diff --git a/modules/mesh-vault/cmd/mesh-vault/accounts.go b/modules/mesh-vault/cmd/mesh-vault/accounts.go index 32566f5b..e25aff28 100644 --- a/modules/mesh-vault/cmd/mesh-vault/accounts.go +++ b/modules/mesh-vault/cmd/mesh-vault/accounts.go @@ -14,8 +14,8 @@ package main // bus refused is kept as owed and tried again at the next minute pass, never lost silently. // // The hashes of accounts the vault holds no value for (enrolment users, persons, the controller's own) are adopted once, -// before take-over, from the list the bus loaded (clause 9's adoption): such a key is marked adopted and is replaced by -// the vault's own hash the moment the vault holds the account's value, never the other way round. +// before take-over, from the list the bus loaded (clause 9's adoption, adopt.go): such a key is marked adopted and is +// replaced by the vault's own hash the moment the vault holds the account's value, never the other way round. import ( "encoding/json" @@ -48,14 +48,19 @@ type AccountHash struct { Generation int `json:"generation,omitempty"` Fingerprint string `json:"fingerprint,omitempty"` At time.Time `json:"at"` - // Adopted marks a hash taken from the list the bus loaded, for an account the vault holds no value for. - Adopted bool `json:"adopted,omitempty"` + // Adopted marks a hash taken from the list the bus loaded, for an account the vault holds no value for; From is + // that list's digest and Node the node that loaded it and handed it (adopt.go). + Adopted bool `json:"adopted,omitempty"` + From string `json:"from,omitempty"` + Node string `json:"node,omitempty"` } // AccountStore is the vault's state of bus-account hashes: read and written by the vault's instances alone. type AccountStore interface { Get(user string) (*AccountHash, error) Put(a AccountHash) error + // Users is every user with a key. + Users() ([]string, error) } // busAccounts is AccountStore over the vault's own state through its runtime. @@ -73,6 +78,8 @@ func (busAccounts) Get(user string) (*AccountHash, error) { return &a, nil } +func (busAccounts) Users() ([]string, error) { return stdio.State(BusAccountsState).Keys() } + func (busAccounts) Put(a AccountHash) error { _, err := stdio.State(BusAccountsState).Put(a.User, a) return err diff --git a/modules/mesh-vault/cmd/mesh-vault/adopt.go b/modules/mesh-vault/cmd/mesh-vault/adopt.go new file mode 100644 index 00000000..2e6e998b --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/adopt.go @@ -0,0 +1,276 @@ +package main + +// The vault adopts the hashes of the bus accounts it holds no value for (novox/hq ADR 0547 clause 9, issue 1945). +// +// The controller's own account, the node accounts and the enrolled devices were never handed to the vault, so it cannot +// hash them, and the grants part's list would leave them out. The one part that has their hashes is the node-engine of +// the node holding mesh-broker, in the list the bus loaded: it hands every loaded user's hash in the bus module's have, +// sealed to this vault's key (mesh-sdk delivery.AccountsHanded), which it learns from the vault's answer to node-key. +// The vault reads that have (its manifest `reads` node-engine.have), as a watch of the bus module's have and again by +// hand with the tool `account_adopt`, and opens it with its own key. +// +// Of the users handed it takes only those it holds no value for: a user whose covered account it holds, or whose key in +// bus-accounts is its own hash, is never touched (the hash it made from the value is the one that holds). Each other +// user's hash is written to bus-accounts. marked adopted, with the list it came from, saying account-hashed; an +// adopted key is written again only when the loaded list carries another hash for the user. A handing from any node but +// the one the assignments name as holding the bus module is refused: a node-engine writes only its own node's haves, so +// that node is the one that loaded the list. + +import ( + "errors" + "fmt" + "sort" + "strings" + "time" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// HaveRead is the node-engines' have bucket as the vault reads it (its manifest's `reads`). +const HaveRead = "node-engine.have" + +// BusModule is the module holding mesh-broker, whose have on its node carries the handing. +const BusModule = "nats" + +// HaveReader reads one key of the have bucket: nil when there is none. +type HaveReader func(key string) ([]byte, error) + +// Adoption is what one adoption did: never a hash. +type Adoption struct { + Node string `json:"node"` + // From is the digest of the loaded list the handing is of; Handed how many users it holds. + From string `json:"from,omitempty"` + Handed int `json:"handed"` + // Adopted are the users whose hash was written now; Same those adopted before with the same hash; Own those the + // vault holds a value for, never adopted. + Adopted []string `json:"adopted"` + Same int `json:"same"` + Own int `json:"own"` + Refused map[string]string `json:"refused,omitempty"` + At time.Time `json:"at"` + By string `json:"by"` +} + +// adoptionKept is what the vault keeps of its last adoption, for its tools and to skip a have that says the same. +type adoptionKept struct { + last *Adoption + handed string // the From and To of the handing last taken in a complete pass + failed string // why the last look found nothing to adopt, or "" +} + +// busNode is the node the assignments name as holding the bus module: exactly one. +func (v *Vault) busNode() (string, error) { + if v.Assignments.Keys == nil { + return "", errors.New("the vault cannot read the assignments, so it cannot tell which node holds the bus") + } + keys, err := v.Assignments.Keys() + if err != nil { + return "", fmt.Errorf("the assignments' keys could not be read: %w", err) + } + var nodes []string + for _, k := range keys { + if node, module, ok := strings.Cut(k, "."); ok && module == BusModule { + nodes = append(nodes, node) + } + } + sort.Strings(nodes) + switch len(nodes) { + case 1: + return nodes[0], nil + case 0: + return "", fmt.Errorf("no node is assigned the bus module %s", BusModule) + } + return "", fmt.Errorf("%d nodes are assigned the bus module %s (%s): the vault adopts from one alone", len(nodes), + BusModule, strings.Join(nodes, ", ")) +} + +// AdoptNow is the tool account_adopt: the bus node's have read now and adopted, though it says the same as before. +func (v *Vault) AdoptNow(caller string) (Adoption, error) { + if v.Haves == nil { + return Adoption{}, errors.New("the vault cannot read the have bucket (" + HaveRead + ")") + } + node, err := v.busNode() + if err != nil { + return Adoption{}, err + } + raw, err := v.Haves(node + "." + BusModule) + if err != nil { + return Adoption{}, fmt.Errorf("%s %s.%s could not be read: %w", HaveRead, node, BusModule, err) + } + if raw == nil { + return Adoption{}, fmt.Errorf("%s holds no have of %s on %s", HaveRead, BusModule, node) + } + return v.AdoptFrom(node, raw, someoneOr(caller, "a tool"), true) +} + +// AdoptFrom adopts from the have of the bus module on node: unless force, nothing when it hands what the last complete +// adoption took. +func (v *Vault) AdoptFrom(node string, raw []byte, by string, force bool) (Adoption, error) { + a, err := v.adopt(node, raw, by, force) + v.mu.Lock() + switch { + case err != nil: + v.adoption.failed = err.Error() + case a.At.IsZero(): + default: + v.adoption.failed = "" + v.adoption.last = &a + } + v.mu.Unlock() + if err == nil && len(a.Adopted) > 0 { + v.log("adopted the hashes of %d bus accounts it holds no value for from %s's loaded list %s (%s), asked by %s", + len(a.Adopted), node, a.From, strings.Join(a.Adopted, ", "), by) + } + if len(a.Refused) > 0 { + v.log("ADOPTION REFUSED %d of the users %s handed: %v", len(a.Refused), node, a.Refused) + } + return a, err +} + +func (v *Vault) adopt(node string, raw []byte, by string, force bool) (Adoption, error) { + if v.Accounts == nil { + return Adoption{}, errors.New("the vault has no state of bus accounts to write to") + } + bus, err := v.busNode() + if err != nil { + return Adoption{}, err + } + if node != bus { + return Adoption{}, fmt.Errorf("%s does not hold the bus (the assignments name %s): its handing is not adopted", + node, bus) + } + have, err := delivery.DecodeHave(raw) + if err != nil { + return Adoption{}, fmt.Errorf("the have of %s on %s is not a have: %w", BusModule, node, err) + } + if have.UserList == nil || have.UserList.Handed == nil { + return Adoption{}, fmt.Errorf("the have of %s on %s hands no hashes yet (its node-engine hands them once it "+ + "heard the vault's key in its answer to node-key, and writes the list)", BusModule, node) + } + h := *have.UserList.Handed + handed := h.From + " " + h.To + v.mu.Lock() + seen := v.adoption.handed == handed + v.mu.Unlock() + if seen && !force { + return Adoption{}, nil + } + accounts, err := delivery.OpenHanded(h, v.Own.PublicKey, v.Own.PrivateKey) + if err != nil { + return Adoption{}, err + } + + v.hashMu.Lock() + defer v.hashMu.Unlock() + held, err := v.heldUsers() + if err != nil { + return Adoption{}, err + } + a := Adoption{Node: node, From: h.From, Handed: len(accounts), Adopted: []string{}, At: v.Now().UTC(), By: by} + refuse := func(user, why string) { + if a.Refused == nil { + a.Refused = map[string]string{} + } + a.Refused[user] = why + } + for _, acc := range accounts { + if !delivery.IsBcrypt(acc.Hash) || !namePart.MatchString(acc.User) { + refuse(acc.User, "not a bus user with a bcrypt hash") + continue + } + if held[acc.User] { + a.Own++ + continue + } + was, err := v.Accounts.Get(acc.User) + switch { + case err != nil: + refuse(acc.User, "its key could not be read: "+err.Error()) + continue + case was != nil && !was.Adopted: + a.Own++ + continue + case was != nil && was.Hash == acc.Hash: + a.Same++ + continue + } + if err := v.Accounts.Put(AccountHash{User: acc.User, Hash: acc.Hash, At: a.At, Adopted: true, From: h.From, + Node: node}); err != nil { + refuse(acc.User, "its key could not be written: "+err.Error()) + continue + } + a.Adopted = append(a.Adopted, acc.User) + if v.Emit != nil { + if err := v.Emit(AccountHashedEvent, map[string]any{"user": acc.User, "adopted": true, "from": h.From, + "node": node, "at": a.At.Format(time.RFC3339Nano)}); err != nil { + v.log("the adopted hash of %s is written, but %s could not be said: %v", acc.User, AccountHashedEvent, err) + } + } + } + // A complete pass, refusals and all: the same handing is not opened again at the next change of the have (a + // refused user stays refused until the list changes, and the last adoption names it). + v.mu.Lock() + v.adoption.handed = handed + v.mu.Unlock() + return a, nil +} + +// heldUsers is the bus user of every covered account the vault holds a value for. +func (v *Vault) heldUsers() (map[string]bool, error) { + records, err := v.Store.List() + if err != nil { + return nil, err + } + out := map[string]bool{} + for _, r := range records { + if !Covered(r.Name) { + continue + } + value, err := openAtRest(r, v) + if err != nil { + return nil, fmt.Errorf("the vault could not open its copy of %s, so it cannot tell whose value it holds: %w", + r.Name, err) + } + if user, _, err := accountOf(r.Name, value); err == nil { + out[user] = true + } + } + return out, nil +} + +// adoptedRows is every adopted key of bus-accounts, for secret_own: never a hash. +func (v *Vault) adoptedRows() (rows []map[string]any, err error) { + if v.Accounts == nil { + return nil, nil + } + users, err := v.Accounts.Users() + if err != nil { + return nil, err + } + sort.Strings(users) + for _, u := range users { + a, err := v.Accounts.Get(u) + if err != nil || a == nil || !a.Adopted { + continue + } + rows = append(rows, map[string]any{"user": a.User, "adoptedAt": a.At, "from": a.From, "node": a.Node}) + if len(rows) >= ownRowsMax { + break + } + } + return rows, nil +} + +// adoptionSaid is the last adoption and why the last look adopted nothing, for the tools. +func (v *Vault) adoptionSaid() map[string]any { + v.mu.Lock() + defer v.mu.Unlock() + out := map[string]any{"last": v.adoption.last} + if v.adoption.last != nil && len(v.adoption.last.Refused) > 0 { + out["refused"] = v.adoption.last.Refused + } + if v.adoption.failed != "" { + out["why"] = v.adoption.failed + } + return out +} diff --git a/modules/mesh-vault/cmd/mesh-vault/issued_account_test.go b/modules/mesh-vault/cmd/mesh-vault/issued_account_test.go index fb2f5486..7625ec8b 100644 --- a/modules/mesh-vault/cmd/mesh-vault/issued_account_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/issued_account_test.go @@ -48,6 +48,16 @@ func (f *fakeAccounts) Put(a AccountHash) error { return nil } +func (f *fakeAccounts) Users() ([]string, error) { + f.mu.Lock() + defer f.mu.Unlock() + var out []string + for u := range f.keys { + out = append(out, u) + } + return out, nil +} + // hashOf says whether the key of user is the hash of password at generation, of the vault's own value. func (f *fakeAccounts) hashOf(user, password string, generation int) bool { f.mu.Lock() diff --git a/modules/mesh-vault/cmd/mesh-vault/main.go b/modules/mesh-vault/cmd/mesh-vault/main.go index 6e344591..c74e0449 100644 --- a/modules/mesh-vault/cmd/mesh-vault/main.go +++ b/modules/mesh-vault/cmd/mesh-vault/main.go @@ -11,6 +11,7 @@ import ( "errors" "fmt" "os" + "strings" "time" stdio "git.novox.be/novox/mesh-sdk/go" @@ -50,10 +51,50 @@ func build() *Env { // Where it sees a consumer bind a provider (pair.go): the same mirror, every key. env.Vault = &Vault{Store: store, Own: own, Askers: Declared{Read: readAssignment, Store: store}, Assignments: Mirror{Keys: assignmentKeys, Read: readAssignment}, Emit: emit, Ask: stdio.Ask, - Now: time.Now, Log: say, Accounts: busAccounts{}} + Now: time.Now, Log: say, Accounts: busAccounts{}, Haves: readHave} return env } +// readHave is one key of the node-engines' have bucket, read through the runtime under the vault's own account (its +// manifest `reads` it), or nil when there is none. +func readHave(key string) ([]byte, error) { + e, err := stdio.State(HaveRead).Get(key) + if err != nil || e == nil { + return nil, err + } + return e.Value, nil +} + +// watchHandings adopts from the bus module's have whenever it changes (adopt.go): a watch, never a poll. It answers +// whether the watch began; one that did not is begun again at the next minute pass. +func watchHandings(env *Env) bool { + if env.Vault == nil { + return true + } + // The have changes at every look of the bus module's health: why nothing was adopted is said once, and again when + // it changes. + var said string + err := stdio.State(HaveRead).Watch("*."+BusModule, func(c stdio.StateChange) error { + if c.Op == "delete" || len(c.Value) == 0 { + return nil + } + node := strings.TrimSuffix(c.Key, "."+BusModule) + _, err := env.Vault.AdoptFrom(node, c.Value, "the watch of "+HaveRead, false) + if line := fmt.Sprint(err); err != nil && line != said { + say("adopted nothing from %s: %v", c.Key, err) + said = line + } else if err == nil { + said = "" + } + return nil + }) + if err != nil { + say("the bus module's have is not watched, so no hash is adopted; begun again at the next pass: %v", err) + return false + } + return true +} + // adoptOperatorKey takes the operator key from the export the mesh writes to the vault's root (what // `operator key set` recorded; the controller keeps only a public half there), and seals again any // operator copy not yet sealed to the current key. Run every minute, so a failure partway is finished. It @@ -107,7 +148,11 @@ func main() { written, failed := env.Vault.HashHeld() say("hashed %d bus accounts at start; %d could not be, tried again every minute: %v", len(written), len(failed), failed) } + watched := watchHandings(env) for { + if !watched { + watched = watchHandings(env) + } env.pass() if env.Vault != nil { env.Vault.HashOwed() diff --git a/modules/mesh-vault/cmd/mesh-vault/tools.go b/modules/mesh-vault/cmd/mesh-vault/tools.go index bbf416aa..1bd6fe9f 100644 --- a/modules/mesh-vault/cmd/mesh-vault/tools.go +++ b/modules/mesh-vault/cmd/mesh-vault/tools.go @@ -235,7 +235,8 @@ func definitions(env *Env) []toolDef { Description: "State this machine's sealing key to the vault, in its own name (ADR 0372, issue 786): from the " + "node-engine of the machine alone, at its start and whenever the key changes; the machine is the caller's, " + "as the bus proved it. A first or changed key is recorded and `secret-changed` is raised for the machine's " + - "core secrets, so the controller asks again; the same key again changes nothing.", + "core secrets, so the controller asks again; the same key again changes nothing. Answers the vault's own " + + "public key (`vault_key`, SPKI PEM), which the bus node seals the loaded list's hashes to (ADR 0547 clause 9).", Input: map[string]any{"type": "object", "properties": map[string]any{ "key": str("this machine's sealing key, a raw X25519 public key in base64, as move-in's sealing_key carries it")}}, run: func(caller string, a map[string]any) (any, error) { @@ -246,7 +247,9 @@ func definitions(env *Env) []toolDef { if err := v.StateNodeKey(caller, arg(a, "key")); err != nil { return nil, err } - return map[string]any{"recorded": true}, nil + // And the vault's own public key, which the bus node seals the loaded list's hashes to (ADR 0547 + // clause 9, adopt.go): a public half, never the private one. + return map[string]any{"recorded": true, "vault_key": v.Own.PublicKey}, nil }}, {Name: Seat + ".move-asks", Description: "Copy rows of the controller's record of asks at a desk into the vault's, as they are and once " + @@ -347,14 +350,40 @@ func definitions(env *Env) []toolDef { "(sha256:), when it was made and last changed, by whom, where the value came from, and whether it is a " + "covered bus account; for a bus account, its user and whether the hash of this generation is written in " + "`bus-accounts` (hashed, hashedAt, or why not). Narrow by node, module or name prefix. At most 1000 rows; " + - "says when cut. Never a value or a hash.", + "says when cut. Unnarrowed by module, also `adopted`: every account whose hash the vault adopted from the " + + "bus's loaded list because it holds no value for it (user, adoptedAt, the list's digest, the node), and " + + "`adoption`: the last adoption and why the last look adopted nothing. Never a value or a hash.", Input: map[string]any{"node": str("only this machine's secrets"), "module": str("only this module's secrets"), "name": str("only secrets whose name (the last part) starts with this, e.g. broker")}, run: func(_ string, a map[string]any) (any, error) { if env.Vault == nil { return nil, errors.New("the vault is not ready") } - return ownAnswer(env.Vault, arg(a, "node"), arg(a, "module"), arg(a, "name")) + out, err := ownAnswer(env.Vault, arg(a, "node"), arg(a, "module"), arg(a, "name")) + if err != nil || arg(a, "module") != "" { + return out, err + } + adopted, err := env.Vault.adoptedRows() + if err != nil { + out["adoptedWhy"] = "bus-accounts could not be read: " + err.Error() + } + if adopted == nil { + adopted = []map[string]any{} + } + out["adopted"], out["adoption"] = adopted, env.Vault.adoptionSaid() + return out, nil + }}, + {Name: "account_adopt", + Description: "Adopt now the hashes of the bus accounts the vault holds no value for (ADR 0547 clause 9): read " + + "the have of the bus module on the node holding it, open the loaded list's hashes its node-engine handed " + + "sealed to the vault's key, and write each user's into `bus-accounts` marked adopted, saying " + + "`account-hashed`. Never a user whose value the vault holds or whose key is its own hash. As the watch of " + + "that have does, though the handing is the one adopted before. Answers the users adopted and counted, never a hash.", + run: func(caller string, _ map[string]any) (any, error) { + if env.Vault == nil { + return nil, errors.New("the vault is not ready") + } + return env.Vault.AdoptNow(caller) }}, {Name: "account_hash", Description: "Hash one covered bus account again now (ADR 0547 clause 9): the vault writes the bcrypt hash of " + diff --git a/modules/mesh-vault/cmd/mesh-vault/tools_test.go b/modules/mesh-vault/cmd/mesh-vault/tools_test.go index 4201ac97..415bf5d9 100644 --- a/modules/mesh-vault/cmd/mesh-vault/tools_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/tools_test.go @@ -4,6 +4,7 @@ package main // secret_verify — none returns a value. And the seat's verb `get`, reached the way the runtime calls it. import ( + "encoding/base64" "encoding/json" "os" "path/filepath" @@ -11,6 +12,7 @@ import ( "testing" "time" + "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/seal" ) @@ -218,4 +220,113 @@ func TestSecretOwnListsFingerprintsOfOwnSecretsOnly(t *testing.T) { if _, err := hash.Run(map[string]any{"user": "ace.mesh-build-go"}); err == nil { t.Error("account_hash hashed a value that is no account") } + + // ADR 0547 clause 9: the hashes of the accounts the vault holds no value for are adopted from the bus node's + // handing, sealed to the vault's key (which node-key answers), never over a user whose value it holds, again only + // when the loaded list carries another hash, and only from the node the assignments name as holding the bus. + defs := definitions(&Env{Vault: v.Vault}) + answer, err := toolNamed(t, defs, Seat+".node-key").runAs("node.novox", + map[string]any{"key": base64.StdEncoding.EncodeToString(make([]byte, 32))}) + if err != nil || answer.(map[string]any)["vault_key"] != v.Own.PublicKey { + t.Fatalf("node-key answered %v, %v: no vault key", answer, err) + } + h := func(c string) string { return "$2a$11$" + strings.Repeat(c, 53) } + handing := map[string]string{"controller": h("c"), "node.ace": h("n"), "g14.dunst": h("d")} + sealedTo := v.Own.PublicKey + have := func() []byte { + var accounts []delivery.HandedAccount + for _, u := range []string{"controller", "node.ace", "g14.dunst", "bad.user"} { + if hash, ok := handing[u]; ok { + accounts = append(accounts, delivery.HandedAccount{User: u, Hash: hash}) + } + } + // The list's digest, as the node says it: another list (another hash or user) is another digest. + list, _ := json.Marshal(accounts) + handed, err := delivery.HandAccounts(accounts, sealedTo, Fingerprint(string(list)), time.Now()) + if err != nil { + t.Fatal(err) + } + raw, _ := delivery.EncodeHave(delivery.Have{Phase: delivery.PhaseRunning, Health: delivery.HealthOK, + UserList: &delivery.UserListWritten{Source: "k", Users: 3, Handed: &handed}}) + return raw + } + v.Assignments = Mirror{Keys: func() ([]string, error) { return []string{"novox.nats", "novox.mesh-vault", "ace.dunst"}, nil }} + v.Haves = func(key string) ([]byte, error) { + if key != "novox.nats" { + return nil, nil + } + return have(), nil + } + dunst := acc.keys["g14.dunst"] + adopt := toolNamed(t, defs, "account_adopt") + for _, step := range []struct { + name string + change func() + adopted []string + same, own int + wantErr, said string + }{ + {name: "the users it holds no value for are adopted", adopted: []string{"controller", "node.ace"}, own: 1}, + {name: "again: the same hashes are kept", adopted: []string{}, same: 2, own: 1}, + {name: "another hash in the loaded list is adopted again", change: func() { handing["node.ace"] = h("m") }, + adopted: []string{"node.ace"}, same: 1, own: 1}, + {name: "a handing sealed to another key is refused", change: func() { + other, _ := seal.GenerateKeyPair() + sealedTo = other.PublicKey + }, wantErr: "not to this one"}, + } { + if step.change != nil { + step.change() + } + before := len(v.bus.events) + got, err := adopt.Run(map[string]any{}) + if step.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), step.wantErr) { + t.Fatalf("%s: %v %+v", step.name, err, got) + } + continue + } + a, _ := got.(Adoption) + if err != nil || strings.Join(a.Adopted, ",") != strings.Join(step.adopted, ",") || a.Same != step.same || + a.Own != step.own || a.Handed != 3 || eventsNamed(v, before, AccountHashedEvent) != len(step.adopted) { + t.Fatalf("%s: %+v, %v", step.name, a, err) + } + raw, _ := json.Marshal(got) + for _, hash := range handing { + if strings.Contains(string(raw), hash) { + t.Fatalf("%s: account_adopt said a hash: %s", step.name, raw) + } + } + } + if k := acc.keys["controller"]; !k.Adopted || k.Hash != h("c") || k.Node != "novox" || acc.keys["node.ace"].Hash != h("m") { + t.Errorf("adopted keys %+v %+v", k, acc.keys["node.ace"]) + } + if acc.keys["g14.dunst"] != dunst { + t.Errorf("a user whose value the vault holds was adopted over: %+v", acc.keys["g14.dunst"]) + } + // A refused user is named in the last adoption, and the same handing is not opened again at the next change. + sealedTo, handing["bad.user"] = v.Own.PublicKey, "not-bcrypt" + if a, err := v.AdoptFrom("novox", have(), "the watch", false); err != nil || a.Refused["bad.user"] == "" || a.Handed != 4 { + t.Fatalf("a refused user: %+v, %v", a, err) + } + if a, err := v.AdoptFrom("novox", have(), "the watch", false); err != nil || !a.At.IsZero() { + t.Fatalf("the same handing was opened again after a refusal: %+v, %v", a, err) + } + if said := v.adoptionSaid(); said["refused"] == nil { + t.Errorf("the refusal is not said: %v", said) + } + if _, err := v.AdoptFrom("ace", have(), "the test", true); err == nil || !strings.Contains(err.Error(), "does not hold the bus") { + t.Errorf("a handing from a node not holding the bus: %v", err) + } + listed, err := own.Run(map[string]any{}) + if err != nil { + t.Fatal(err) + } + rows := listed.(map[string]any)["adopted"].([]map[string]any) + if len(rows) != 2 || rows[0]["user"] != "controller" || rows[1]["user"] != "node.ace" || rows[0]["node"] != "novox" { + t.Errorf("secret_own's adopted rows %v", rows) + } + if narrowed, _ := own.Run(map[string]any{"module": "dunst"}); narrowed.(map[string]any)["adopted"] != nil { + t.Errorf("secret_own narrowed to a module lists adopted accounts: %v", narrowed) + } } diff --git a/modules/mesh-vault/cmd/mesh-vault/vault.go b/modules/mesh-vault/cmd/mesh-vault/vault.go index fa3f7129..6734901d 100644 --- a/modules/mesh-vault/cmd/mesh-vault/vault.go +++ b/modules/mesh-vault/cmd/mesh-vault/vault.go @@ -46,11 +46,16 @@ type Vault struct { Accounts AccountStore // HashCost is the bcrypt cost of those hashes: zero is the controller's, bcrypt's default. HashCost int + // Haves reads the node-engines' have bucket, where the bus node hands the loaded list's hashes (adopt.go): nil + // adopts nothing. + Haves HaveReader mu sync.Mutex // hashMu keeps one hashing at a time, and hashes what is owed (accounts.go). hashMu sync.Mutex hashes hashing + // adoption is the last adoption (adopt.go), under mu. + adoption adoptionKept // callsMu guards pending: the keys of the calls open now (intake.go), never written anywhere. callsMu sync.Mutex pending map[string]*callKey diff --git a/modules/mesh-vault/cmd/mesh-vault/vault_test.go b/modules/mesh-vault/cmd/mesh-vault/vault_test.go index c44c270a..fefbff17 100644 --- a/modules/mesh-vault/cmd/mesh-vault/vault_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/vault_test.go @@ -131,8 +131,9 @@ func filesUnder(t *testing.T, dir string) map[string]string { // The manifest declares one bucket, and no secret is in it: the vault's only place for a secret is its own store on its // own disk (ADR 0349 §7, ADR 0201 §13). The one bucket is bus-accounts, the bcrypt hashes of the bus accounts it holds -// (ADR 0547 clause 9), opened in accounts.go alone. It reads one bucket of another's, and only reads it: the -// controller's assignments, for what each node's plan names (askers.go, design 57 V4). +// (ADR 0547 clause 9), opened in accounts.go alone. It reads two buckets of others', and only reads them: the +// controller's assignments, for what each node's plan names (askers.go, design 57 V4), and the node-engines' haves, where +// the bus node hands the loaded list's hashes sealed to the vault (adopt.go, ADR 0547 clause 9), in main.go alone. func TestNoBucketHoldsASecret(t *testing.T) { raw, err := os.ReadFile("../../module.json") if err != nil { @@ -146,8 +147,8 @@ func TestNoBucketHoldsASecret(t *testing.T) { t.Errorf("the manifest declares buckets %s: a secret is in no bucket, sealed or not (ADR 0201 §13), and the one "+ "bucket is the accounts' hashes (ADR 0547 clause 9)", s) } - if r, _ := json.Marshal(manifest["reads"]); string(r) != `["mesh-controller.assignments"]` { - t.Errorf("the manifest reads %s: the vault reads the controller's assignments alone", r) + if r, _ := json.Marshal(manifest["reads"]); string(r) != `["mesh-controller.assignments","node-engine.have"]` { + t.Errorf("the manifest reads %s: the vault reads the controller's assignments and the haves alone", r) } sources, _ := filepath.Glob("*.go") bucket := regexp.MustCompile(`stdio\.State\(|nats-io|jetstream|KeyValue`) @@ -164,6 +165,12 @@ func TestNoBucketHoldsASecret(t *testing.T) { if f == "accounts.go" { code = []byte(strings.Replace(string(code), "stdio.State(BusAccountsState).Get(", "", 1)) code = []byte(strings.Replace(string(code), "stdio.State(BusAccountsState).Put(", "", 1)) + code = []byte(strings.Replace(string(code), "stdio.State(BusAccountsState).Keys(", "", 1)) + } + // The haves, in main.go alone: one read and one watch, never a write. + if f == "main.go" { + code = []byte(strings.Replace(string(code), "stdio.State(HaveRead).Get(", "", 1)) + code = []byte(strings.Replace(string(code), "stdio.State(HaveRead).Watch(", "", 1)) } if m := bucket.Find(code); m != nil { t.Errorf("%s opens a bucket (%s): the vault keeps every secret in its own store only", f, m) diff --git a/modules/mesh-vault/go.mod b/modules/mesh-vault/go.mod index ce928478..f225847f 100644 --- a/modules/mesh-vault/go.mod +++ b/modules/mesh-vault/go.mod @@ -3,7 +3,7 @@ module mesh-vault go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021100209-cb54756c3865 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026112527-c2a410bb6376 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 golang.org/x/crypto v0.57.0 diff --git a/modules/mesh-vault/go.sum b/modules/mesh-vault/go.sum index 639aa719..d4f12692 100644 --- a/modules/mesh-vault/go.sum +++ b/modules/mesh-vault/go.sum @@ -1,5 +1,9 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021100209-cb54756c3865 h1:VLir9NZbyi1LdH1YwWDIVRuL+lDC+AYlm6FZPlvmlm4= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021100209-cb54756c3865/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026102838-174789941936 h1:HGBoWimZXYZp+Qn9VCstYDitOP84t9EUQYd1yM9j2eg= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026102838-174789941936/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026112527-c2a410bb6376 h1:by2zBASLb8h5jA1IiYJWykXwLnHYpuZh/zFNa4NsbOU= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026112527-c2a410bb6376/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-vault/module.json b/modules/mesh-vault/module.json index 8c4f2e04..cdff2835 100644 --- a/modules/mesh-vault/module.json +++ b/modules/mesh-vault/module.json @@ -18,7 +18,8 @@ "account-hashed" ], "reads": [ - "mesh-controller.assignments" + "mesh-controller.assignments", + "node-engine.have" ], "state": [ { @@ -154,6 +155,7 @@ "secret_handed", "secret_own", "account_hash", + "account_adopt", "secret_asks", "secret_vault_export", "provisioner_retirement",