Bring in the adoption handing (fix/1945-adopt-hashes 17478994): the have's user list carries Handed beside Follows and Key; a state that names readers is not Readable to a composer that knows no readers (review of the switch, issue 1945)

This commit is contained in:
2026-10-26 12:23:07 +01:00
5 changed files with 195 additions and 7 deletions
+3 -2
View File
@@ -47,7 +47,8 @@ type State struct {
Name string `json:"name"`
Readable bool `json:"readable,omitempty"`
// Readers, when named, are the only modules that may read the state (novox/hq ADR 0547 clause 9, issue 1945): a
// state readable by some and not all, such as the vault's bus account hashes. Naming readers makes it readable.
// state readable by some and not all, such as the vault's bus account hashes. Only ReadableBy grants them; Readable,
// which a composer that knows no readers asks, stays false for it, so such a composer grants nobody.
Readers []string `json:"readers,omitempty"`
}
@@ -94,7 +95,7 @@ func (s States) ReadableBy(state, reader string) bool {
func (s States) Readable(state string) bool {
for _, k := range s.States {
if k.Name == state {
return k.Readable || len(k.Readers) > 0
return k.Readable
}
}
return false
+2 -2
View File
@@ -31,8 +31,8 @@ func TestAnAnnouncementRoundTripsSortedAndSaysWhatIsReadable(t *testing.T) {
if !back.Readable("plans-building") || back.Readable("own") || back.Readable("absent") {
t.Fatal("Readable does not say what was announced readable")
}
// Named readers (ADR 0547 clause 9): the state is readable, by them alone.
if !back.Readable("hashes") || !back.ReadableBy("hashes", "mesh-grants") || back.ReadableBy("hashes", "spy") ||
// Named readers (ADR 0547 clause 9): readable by them alone, and not readable to a composer that knows no readers.
if back.Readable("hashes") || !back.ReadableBy("hashes", "mesh-grants") || back.ReadableBy("hashes", "spy") ||
!back.ReadableBy("plans-building", "spy") || back.ReadableBy("own", "spy") {
t.Fatal("ReadableBy does not say who was named")
}
+10 -3
View File
@@ -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 (
@@ -358,6 +361,9 @@ type UserListWritten struct {
// 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"`
// 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:
@@ -508,7 +514,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.
+55
View File
@@ -170,6 +170,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")
}
})
// 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.
@@ -370,6 +411,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")
+125
View File
@@ -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:<hex>` (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:<hex>` 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)
}