From 174789941936c401d07240eb1891a9fd38a107f0 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 26 Oct 2026 11:28:38 +0100 Subject: [PATCH] delivery: the bus node hands the loaded list's hashes to the vault, sealed, in the bus module's have (ADR 0547 clause 9, issue 1945) The vault holds no value for the controller's own account, the node accounts and the enrolled devices, so it cannot hash them; the one part that has their hashes is the node-engine of the bus node, in the list the bus loaded. The have of the bus module now carries that list's hashes sealed to the vault's key (UserListWritten.Handed), which every reader of the have bucket sees as a count, a list digest and a key fingerprint, and only the vault opens. HandedAccountsOf reads them from a composed list, HandAccounts seals, OpenHanded opens and refuses a box sealed to another key. --- go/delivery/delivery.go | 13 +++- go/delivery/delivery_test.go | 57 ++++++++++++++++ go/delivery/handed.go | 125 +++++++++++++++++++++++++++++++++++ 3 files changed, 192 insertions(+), 3 deletions(-) create mode 100644 go/delivery/handed.go diff --git a/go/delivery/delivery.go b/go/delivery/delivery.go index 8fccfaa..cea8059 100644 --- a/go/delivery/delivery.go +++ b/go/delivery/delivery.go @@ -34,9 +34,12 @@ // **The release 4 layouts** (issues 1356 to 1359; ADR 0453 clauses 4, 6 and 16): the new fingerprint form (fingerprint2.go), // the indexer's manifests by digest and the new fields of its modules and candidates (manifests.go, source.go), the // builder's build keys (builder.go) and the checker's verdicts and kept verdicts (checker.go). A manifests value is the -// one exception to the size above: a manifest is up to about 21 KB, whole, byte for byte. +// one exception to the size above: a manifest is up to about 21 KB, whole, byte for byte. The bus module's have on the +// node holding mesh-broker is the other: its user list's handing carries a sealed box of every loaded user's hash, some +// kilobytes (handed.go). // -// Standard library only, and this module's own asks and declaration packages (a composition holds declaration resources). +// Standard library only, and this module's own asks, declaration and seal packages (a composition holds declaration +// resources; the adoption handing is a sealed box, handed.go). package delivery import ( @@ -352,6 +355,9 @@ type UserListWritten struct { Users int `json:"users"` At time.Time `json:"at"` Error string `json:"error,omitempty"` + // Handed is the hashes of the users of the list on the machine, sealed to the vault, for its adoption of the + // accounts it holds no value for (ADR 0547 clause 9, handed.go); nil while the vault's key is not known. + Handed *AccountsHanded `json:"handed,omitempty"` } // HaveNode is a node's own facts, written under HaveNodeKey. Each field is the node-engine's own JSON, carried whole: @@ -502,7 +508,8 @@ func SameHave(a, b Have) bool { } x, y := *ua, *ub x.At, y.At = time.Time{}, time.Time{} - return x == y && ua.At.Equal(ub.At) + x.Handed, y.Handed = nil, nil + return x == y && ua.At.Equal(ub.At) && sameHanded(ua.Handed, ub.Handed) } // SameHaveNode says whether two node facts are the same value, comparing each carried JSON without its spacing. diff --git a/go/delivery/delivery_test.go b/go/delivery/delivery_test.go index a0aba8d..3ed5072 100644 --- a/go/delivery/delivery_test.go +++ b/go/delivery/delivery_test.go @@ -7,6 +7,8 @@ import ( "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. @@ -167,6 +169,47 @@ func TestRoundTrip(t *testing.T) { if !reflect.DeepEqual(out, in) || !out.UserList.At.Equal(at) { t.Fatalf("got %+v want %+v", out, in) } + // And with the hashes handed to the vault (ADR 0547 clause 9): sealed to its key, opened by it alone, every + // listed user with a bcrypt hash once, and refused to another key. + hash := "$2a$11$" + strings.Repeat("a", 53) + list := []byte("{ user: \"controller\", password: \"" + hash + "\", permissions: {\n" + + "{ user: \"node.ace\", password: \"" + hash + "\", permissions: {\n" + + "{ user: \"controller\", password: \"" + hash + "\" }\n{ user: \"nkey\", password: \"plain\" }\n") + accounts := HandedAccountsOf(list) + if len(accounts) != 2 || accounts[0].User != "controller" || accounts[1].User != "node.ace" || accounts[1].Hash != hash { + t.Fatalf("handed accounts: %+v", accounts) + } + vault, err := seal.GenerateKeyPair() + if err != nil { + t.Fatal(err) + } + other, _ := seal.GenerateKeyPair() + handed, err := HandAccounts(accounts, vault.PublicKey, "sha256:ab", at) + if err != nil { + t.Fatal(err) + } + in.UserList.Handed = &handed + b, err = EncodeHave(in) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(b), "node.ace") || strings.Contains(string(b), hash) { + t.Fatalf("a user or a hash is in the clear: %s", b) + } + out, err = DecodeHave(b) + if err != nil || !SameHave(out, in) { + t.Fatalf("handed round trip: %v %+v", err, out.UserList) + } + opened, err := OpenHanded(*out.UserList.Handed, vault.PublicKey, vault.PrivateKey) + if err != nil || !reflect.DeepEqual(opened, accounts) { + t.Fatalf("opened %+v, %v", opened, err) + } + if _, err := OpenHanded(*out.UserList.Handed, other.PublicKey, other.PrivateKey); err == nil { + t.Fatal("a handing opened for another key") + } + if _, err := HandAccounts(accounts, "", "sha256:ab", at); err == nil { + t.Fatal("handed with no vault key") + } }) t.Run("have node", func(t *testing.T) { in := HaveNode{Windows: json.RawMessage(`{"a":1}`), Busy: json.RawMessage(`true`)} @@ -306,6 +349,20 @@ func TestSame(t *testing.T) { if SameHave(ua, ub) { t.Error("different user lists same") } + // A handing is compared by what it says: one said and one not differ, as do two to other keys. + ub.UserList.Revision = 1 + ua.UserList.Handed = &AccountsHanded{From: "sha256:a", To: "sha256:k", Users: 2, At: at} + if SameHave(ua, ub) { + t.Error("a handing said and one not are the same") + } + ub.UserList.Handed = &AccountsHanded{From: "sha256:a", To: "sha256:k", Users: 2, At: at.In(time.Local)} + if !SameHave(ua, ub) { + t.Error("equal handings differ") + } + ub.UserList.Handed.To = "sha256:j" + if SameHave(ua, ub) { + t.Error("handings to other keys same") + } n := HaveNode{V: 1, Busy: json.RawMessage(`true`)} if !SameHaveNode(n, HaveNode{V: 1, Busy: json.RawMessage(`true`)}) || SameHaveNode(n, HaveNode{V: 1}) { t.Error("have node") diff --git a/go/delivery/handed.go b/go/delivery/handed.go new file mode 100644 index 0000000..af0bc3c --- /dev/null +++ b/go/delivery/handed.go @@ -0,0 +1,125 @@ +package delivery + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "regexp" + "strings" + "time" + + "git.novox.be/novox/mesh-sdk/go/seal" +) + +// The adoption handing of novox/hq ADR 0547 clause 9 (issue 1945): the vault adopts, once and before `take-over`, the +// bcrypt hashes of the bus accounts it holds no value for (the controller's own, the node accounts, the enrolled +// devices) from the list the bus has loaded. The node-engine of the node holding mesh-broker is the one part that has +// that list, so it hands the hashes in the bus module's have, beside what it says of the list it wrote: sealed to the +// vault's own key, which it learns from the vault's answer to `node-key`, so that of every reader of the have bucket +// the vault alone can open them. The have says in the clear only how many users it hands, from which list and to which +// key; the users' names stay sealed, as the list itself is (ADR 0547 clause 7: it names every account). +// +// It hands every user of the loaded list; the vault takes from it only the users it holds no value for, and never +// replaces a hash it made from a value it holds, so the node-engine never needs to read the vault's state (which the +// grants part alone is to read, clause 9). + +// AccountsHanded is the hashes of the loaded list's users, sealed to the vault, in UserListWritten. +type AccountsHanded struct { + // From is the digest of the list on the machine the hashes are taken from, `sha256:` (UserListWritten.Digest). + From string `json:"from"` + // To is the fingerprint of the vault's key the box is sealed to (KeyFingerprint). + To string `json:"to"` + // Users is how many users the box holds. + Users int `json:"users"` + // Box is the JSON of []HandedAccount, sealed to the vault's key (ADR 0206). + Box seal.Box `json:"box"` + At time.Time `json:"at"` +} + +// HandedAccount is one user of an AccountsHanded box, opened: the bus user and the bcrypt hash the loaded list +// carries for it. +type HandedAccount struct { + User string `json:"user"` + Hash string `json:"hash"` +} + +// KeyFingerprint is a public key's fingerprint as a handing names it: `sha256:` of its PEM, without the space +// around it. +func KeyFingerprint(publicPEM string) string { + sum := sha256.Sum256([]byte(strings.TrimSpace(publicPEM))) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +// listedAccount is one user of a bus user list as the composer writes it (buscompose), with its password's hash. +var listedAccount = regexp.MustCompile(`\{\s*user:\s*"([^"]+)",\s*password:\s*"([^"]+)"`) + +// HandedAccountsOf is every user of a bus user list that carries a bcrypt hash, in the list's order. A user listed +// twice is handed once, as the list first names it. +func HandedAccountsOf(list []byte) []HandedAccount { + var out []HandedAccount + seen := map[string]bool{} + for _, m := range listedAccount.FindAllSubmatch(list, -1) { + user, hash := string(m[1]), string(m[2]) + if seen[user] || !IsBcrypt(hash) { + continue + } + seen[user] = true + out = append(out, HandedAccount{User: user, Hash: hash}) + } + return out +} + +// bcryptHash is the form of a bcrypt hash: version, cost, and 53 characters of salt and digest. +var bcryptHash = regexp.MustCompile(`^\$2[abxy]?\$[0-9]{2}\$[./A-Za-z0-9]{53}$`) + +// IsBcrypt says whether s has the form of a bcrypt hash. +func IsBcrypt(s string) bool { return bcryptHash.MatchString(s) } + +// HandAccounts seals the accounts to the vault's public key (SPKI PEM) as the handing of the list whose digest is from. +func HandAccounts(accounts []HandedAccount, vaultPEM, from string, at time.Time) (AccountsHanded, error) { + if strings.TrimSpace(vaultPEM) == "" { + return AccountsHanded{}, errors.New("delivery: no vault key to seal the handed accounts to") + } + body, err := json.Marshal(accounts) + if err != nil { + return AccountsHanded{}, err + } + box, err := seal.Seal(string(body), vaultPEM) + if err != nil { + return AccountsHanded{}, fmt.Errorf("delivery: the handed accounts could not be sealed to the vault's key: %w", err) + } + return AccountsHanded{From: from, To: KeyFingerprint(vaultPEM), Users: len(accounts), Box: box, At: at.UTC()}, nil +} + +// OpenHanded opens a handing with the vault's private key (PEM), refusing one sealed to another key than publicPEM's +// and a box that does not hold as many users as it says. +func OpenHanded(h AccountsHanded, publicPEM, privatePEM string) ([]HandedAccount, error) { + if h.To != KeyFingerprint(publicPEM) { + return nil, fmt.Errorf("delivery: the handing is sealed to the key %s, not to this one (%s)", h.To, + KeyFingerprint(publicPEM)) + } + plain, err := seal.Open(h.Box, privatePEM) + if err != nil { + return nil, fmt.Errorf("delivery: the handing does not open: %w", err) + } + var out []HandedAccount + if err := json.Unmarshal([]byte(plain), &out); err != nil { + return nil, fmt.Errorf("delivery: the handing holds no accounts: %w", err) + } + if len(out) != h.Users { + return nil, fmt.Errorf("delivery: the handing says %d users and holds %d", h.Users, len(out)) + } + return out, nil +} + +// sameHanded says whether two handings say the same: the same list, key, count and box, whatever zone their time is in. +func sameHanded(a, b *AccountsHanded) bool { + if a == nil || b == nil { + return a == b + } + x, y := *a, *b + x.At, y.At = time.Time{}, time.Time{} + return x == y && a.At.Equal(b.At) +}