The bus node follows the grants part's sealed user list, and only the grants part reads the vault's hashes (ADR 0547 clauses 7, 9; issue 1945)

The grants part writes its list as an object of its own bucket, sealed to the bus node's key, and names it in
user-list.sealed; the node-engine of the node holding mesh-broker is granted that one key and an object by name, the
grants part its bucket (the controller's copy carries the same grants, byte for byte, golden regenerated there).
A state may name its readers (announce, NamedReaders): mesh-vault.bus-accounts is read by mesh-grants alone.
This commit is contained in:
2026-10-26 11:53:11 +01:00
parent 85fa8511c4
commit 175a5567b8
13 changed files with 659 additions and 11 deletions
+6
View File
@@ -352,6 +352,12 @@ type UserListWritten struct {
Users int `json:"users"`
At time.Time `json:"at"`
Error string `json:"error,omitempty"`
// Follows is which list the node follows now (FollowController or FollowGrants, novox/hq ADR 0547 clause 8), empty
// from a node-engine before the switch.
Follows string `json:"follows,omitempty"`
// Key is the bus node's sealing key, its public half as SPKI PEM: the key the grants part seals its list to
// (clause 7). Public, as the vault holds it.
Key string `json:"key,omitempty"`
}
// HaveNode is a node's own facts, written under HaveNodeKey. Each field is the node-engine's own JSON, carried whole:
+64
View File
@@ -1,12 +1,15 @@
package delivery
import (
"bytes"
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/seal"
)
// 1131/AC1: the have key forms and parses back; bad names are refused, naming them.
@@ -168,6 +171,67 @@ func TestRoundTrip(t *testing.T) {
t.Fatalf("got %+v want %+v", out, in)
}
})
// The grants part's list sealed to the bus node (novox/hq ADR 0547 clause 7): the key's value round-trips and refuses
// what the bus node could not follow, and the object opens with the node's key alone, to the digest the key names.
t.Run("the grants part's sealed user list", func(t *testing.T) {
in := Have{FP: "f1", Phase: PhaseRunning, Health: HealthOK, UserList: &UserListWritten{Source: GrantsUserListSource,
Revision: 3, Wanted: "sha256:ab", Digest: "sha256:ab", Users: 2, Follows: FollowGrants, Key: "PEM"}}
b, err := EncodeHave(in)
if err != nil {
t.Fatal(err)
}
if out, err := DecodeHave(b); err != nil || out.UserList.Follows != FollowGrants || out.UserList.Key != "PEM" {
t.Fatalf("got %+v, %v", out.UserList, err)
}
list := []byte("accounts { MESH { users = [ { user: \"controller\" } ] } }\n")
digest := UserListDigest(list)
node, err := seal.GenerateKeyPair()
if err != nil {
t.Fatal(err)
}
other, _ := seal.GenerateKeyPair()
g := GrantsUserList{Follow: FollowGrants, Digest: digest, Object: UserListObjectName(digest),
SealedTo: SealingKeyID(node.PublicKey), Node: "novox", Revision: 1, Users: 1}
raw, err := EncodeGrantsUserList(g)
if err != nil {
t.Fatal(err)
}
back, err := DecodeGrantsUserList(raw)
if err != nil || back.Digest != digest || back.LeftOut == nil || back.Follow != FollowGrants {
t.Fatalf("decoded %+v, %v", back, err)
}
for _, bad := range []GrantsUserList{{Follow: "both", Digest: digest, Object: g.Object},
{Follow: FollowGrants, Digest: digest, Object: "other"}, {Follow: FollowGrants, Digest: "md5:x", Object: "md5:x"}} {
if _, err := EncodeGrantsUserList(bad); err == nil {
t.Errorf("%+v was written", bad)
}
}
if _, err := EncodeGrantsUserList(GrantsUserList{Follow: FollowController}); err != nil {
t.Errorf("a switch with no list yet refused: %v", err)
}
object, err := SealUserList(list, node.PublicKey)
if err != nil {
t.Fatal(err)
}
if bytes.Contains(object, []byte("controller")) {
t.Fatal("the object carries the list in the clear")
}
opener := func(priv string) func(seal.Box) ([]byte, error) {
return func(b seal.Box) ([]byte, error) { s, err := seal.Open(b, priv); return []byte(s), err }
}
if got, err := OpenUserList(object, digest, opener(node.PrivateKey)); err != nil || !bytes.Equal(got, list) {
t.Fatalf("opened %q, %v", got, err)
}
if _, err := OpenUserList(object, digest, opener(other.PrivateKey)); err == nil {
t.Error("another key opened the list")
}
if _, err := OpenUserList(object, "sha256:00", opener(node.PrivateKey)); err == nil {
t.Error("a list of another digest was taken")
}
if _, err := SealUserList(list, "not a key"); err == nil {
t.Error("a list was sealed to no key")
}
})
t.Run("have node", func(t *testing.T) {
in := HaveNode{Windows: json.RawMessage(`{"a":1}`), Busy: json.RawMessage(`true`)}
b, err := EncodeHaveNode(in)
+149
View File
@@ -0,0 +1,149 @@
package delivery
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/seal"
)
// **The grants part's user list, sealed to the bus node** (novox/hq ADR 0547 clauses 7 and 8, issue 1945): the contract
// between the grants part (mesh-delivery modules/mesh-grants), which writes it, and the node-engine of the node holding
// mesh-broker (mesh-host), which follows it once the operator's take-over says so.
//
// - The grants part composes the bus's user list (the accounts block, buscompose.ComposeAccounts, with the vault's
// hashes), seals it to the bus node's sealing key (seal.Seal, the key the node says in its have of the bus module,
// UserListWritten.Key), and puts the box as one object of its object bucket GrantsUserListObjects, named by the
// list's digest (UserListObjectName).
// - It writes the key GrantsUserListKey of its state `user-list` (bucket GrantsUserListBucket): the GrantsUserList
// below, which names the object, the list's digest, its revision, its user count and the users left out, and which
// source the bus node follows (Follow: FollowGrants from take-over, FollowController in shadow and after hand-back).
// - The node-engine of the bus node watches that key alone. While it says FollowGrants it opens the object, checks
// the digest, writes the bus's user-list file (mode 0600) only when the file's digest differs, and says what it
// loaded in its have of the bus module (UserListWritten, Source GrantsUserListSource); otherwise it follows the
// controller's compositions as before (clause 8). Never both.
// The names of the grants part's user list on the bus.
const (
// GrantsUserListBucket is the grants part's state `user-list`.
GrantsUserListBucket = "mesh-grants_user-list"
// GrantsUserListKey is the one key of it the bus node reads.
GrantsUserListKey = "sealed"
// GrantsUserListObjects is the grants part's object bucket, where each sealed list is one object.
GrantsUserListObjects = "mesh-grants-user-list"
// GrantsUserListObjectStream is the bucket's stream.
GrantsUserListObjectStream = "OBJ_" + GrantsUserListObjects
// GrantsUserListSource is the source a have names for a list taken from the grants part.
GrantsUserListSource = GrantsUserListBucket + "." + GrantsUserListKey
)
// Which list the bus node follows.
const (
FollowController = "controller"
FollowGrants = "grants"
)
// GrantsUserList is the value of the grants part's key `user-list.sealed`.
type GrantsUserList struct {
V int `json:"v"`
// Follow is which list the bus node follows: FollowGrants from take-over, FollowController otherwise.
Follow string `json:"follow"`
// Digest is the list's digest, `sha256:<hex>` of the file's bytes as the bus loads them.
Digest string `json:"digest"`
// Object is the object's name in GrantsUserListObjects (UserListObjectName of Digest).
Object string `json:"object"`
// SealedTo is the fingerprint of the key the object is sealed to (SealingKeyID), and Node the node it holds.
SealedTo string `json:"sealed_to"`
Node string `json:"node"`
// Revision counts the lists the grants part wrote: one more for each new digest.
Revision uint64 `json:"revision"`
Users int `json:"users"`
// LeftOut are the users the list leaves out for want of a hash in the vault.
LeftOut []string `json:"left_out"`
At time.Time `json:"at"`
}
// EncodeGrantsUserList is g as JSON at Version; it refuses a value the bus node could not follow.
func EncodeGrantsUserList(g GrantsUserList) ([]byte, error) {
g.V = Version
if g.LeftOut == nil {
g.LeftOut = []string{}
}
if err := g.check(); err != nil {
return nil, err
}
return json.Marshal(g)
}
// DecodeGrantsUserList reads the key's value; it refuses another version and what Encode refuses.
func DecodeGrantsUserList(data []byte) (GrantsUserList, error) {
var g GrantsUserList
if err := decode(data, &g); err != nil {
return GrantsUserList{}, err
}
if err := g.check(); err != nil {
return GrantsUserList{}, err
}
return g, nil
}
func (g GrantsUserList) check() error {
if g.Follow != FollowGrants && g.Follow != FollowController {
return fmt.Errorf("delivery: the user list's follow %q is not %s or %s", g.Follow, FollowGrants, FollowController)
}
if g.Digest == "" {
return nil // a switch with no list written yet: the bus node keeps what it follows
}
if !strings.HasPrefix(g.Digest, "sha256:") || g.Object != UserListObjectName(g.Digest) {
return fmt.Errorf("delivery: the user list names object %q for digest %q; the object is the digest's hex", g.Object, g.Digest)
}
return nil
}
// UserListDigest is a list's digest as the have and the key say it, `sha256:<hex>` of its bytes.
func UserListDigest(content []byte) string {
sum := sha256.Sum256(content)
return "sha256:" + hex.EncodeToString(sum[:])
}
// UserListObjectName is the object a list of that digest is put under: the digest's hex.
func UserListObjectName(digest string) string { return strings.TrimPrefix(digest, "sha256:") }
// SealingKeyID is a short fingerprint of a public key in PEM, `sha256:<16 hex>`, to say which key a box is sealed to
// without carrying the key.
func SealingKeyID(publicPEM string) string {
sum := sha256.Sum256([]byte(strings.TrimSpace(publicPEM)))
return "sha256:" + hex.EncodeToString(sum[:8])
}
// SealUserList is the object a list is put as: the sealed box (seal.Seal) of its bytes to the bus node's key, as JSON.
func SealUserList(list []byte, nodePEM string) ([]byte, error) {
if !strings.Contains(nodePEM, "PUBLIC KEY") {
return nil, fmt.Errorf("delivery: the bus node's key is not a public key in PEM")
}
box, err := seal.Seal(string(list), nodePEM)
if err != nil {
return nil, fmt.Errorf("delivery: the user list could not be sealed: %w", err)
}
return json.Marshal(box)
}
// OpenUserList opens an object with open (the node's own key) and checks it is the list of digest.
func OpenUserList(object []byte, digest string, open func(seal.Box) ([]byte, error)) ([]byte, error) {
var box seal.Box
if err := json.Unmarshal(object, &box); err != nil {
return nil, fmt.Errorf("delivery: the object is no sealed box: %w", err)
}
list, err := open(box)
if err != nil {
return nil, err
}
if got := UserListDigest(list); got != digest {
return nil, fmt.Errorf("delivery: the opened list's digest is %s, and the key names %s", got, digest)
}
return list, nil
}