diff --git a/modules/mesh-vault/cmd/mesh-vault/accounts.go b/modules/mesh-vault/cmd/mesh-vault/accounts.go index c102337e..2a024ccb 100644 --- a/modules/mesh-vault/cmd/mesh-vault/accounts.go +++ b/modules/mesh-vault/cmd/mesh-vault/accounts.go @@ -187,6 +187,12 @@ func (v *Vault) HashAccount(secret string, force bool) (Hashed, error) { if err != nil { return Hashed{}, err } + if !v.busAccount(secret, value) { + // A broker- no process declares with a bus, holding no account (mesh-controller's broker-address): nothing + // to hash, and nothing owed. + v.hashes.paid(secret) + return Hashed{}, errNotABusAccount{secret} + } if rec.Retired != nil { v.hashes.paid(secret) return Hashed{}, fmt.Errorf("%s is retired (%s): its hash is written again only when it is wanted again", secret, rec.Retired.Why) @@ -245,12 +251,22 @@ func (v *Vault) openHeld(secret string) (string, Record, error) { return value, rec, nil } +// errNotABusAccount is a covered name that holds no bus account and names no declared one. +type errNotABusAccount struct{ secret string } + +func (e errNotABusAccount) Error() string { + return e.secret + " is no bus account: no process declares it with a bus, and its value is no account" +} + // hashKept is what keeping a covered account does after it is kept: its hash written, or the failure said and owed. func (v *Vault) hashKept(secret string) { if v.Accounts == nil || !Covered(secret) { return } if _, err := v.HashAccount(secret, false); err != nil { + if errors.As(err, new(errNotABusAccount)) { + return + } v.hashes.owe(secret, err.Error()) v.log("THE HASH OF %s IS NOT WRITTEN: %v", secret, err) } @@ -273,6 +289,9 @@ func (v *Vault) HashHeld() (written []Hashed, failed map[string]string) { continue } h, err := v.HashAccount(r.Name, false) + if errors.As(err, new(errNotABusAccount)) { + continue + } if err != nil { v.hashes.owe(r.Name, err.Error()) failed[r.Name] = err.Error() @@ -299,7 +318,7 @@ func (v *Vault) HashOwed() { v.hashes.paid(n) continue } - if _, err := v.HashAccount(n, false); err != nil { + if _, err := v.HashAccount(n, false); err != nil && !errors.As(err, new(errNotABusAccount)) { still[n] = err.Error() } } diff --git a/modules/mesh-vault/cmd/mesh-vault/intake.go b/modules/mesh-vault/cmd/mesh-vault/intake.go index b887916b..42bb41cd 100644 --- a/modules/mesh-vault/cmd/mesh-vault/intake.go +++ b/modules/mesh-vault/cmd/mesh-vault/intake.go @@ -250,6 +250,14 @@ func Covered(secret string) bool { return name == "broker" || name == "provisioner-broker" } +// NamedAccount says secret is a bus account by its name alone: a module's own `broker` or a provisioner client's +// `provisioner-broker`. A `broker-` is one only when a process of that id is declared with a bus, which its name +// cannot say (mesh-controller's broker-address is the bus's address, no account): Vault.DeclaredAccount. +func NamedAccount(secret string) bool { + parts := strings.Split(secret, "/") + return len(parts) == 4 && parts[0] == "own" && (parts[3] == "broker" || parts[3] == "provisioner-broker") +} + // AcceptOpen opens a call to take a value given at the controller's terminal for secret, which the terminal // seals to the call's key before it leaves it (ADR 0349 clause 8: the vault alone opens an accepted value). // diff --git a/modules/mesh-vault/cmd/mesh-vault/mint.go b/modules/mesh-vault/cmd/mesh-vault/mint.go index ebfd3aa8..a56b71d1 100644 --- a/modules/mesh-vault/cmd/mesh-vault/mint.go +++ b/modules/mesh-vault/cmd/mesh-vault/mint.go @@ -132,6 +132,33 @@ func (v *Vault) noLongerHeld(user string) { delete(m.heldBack, user) } +// DeclaredAccount says secret is a bus account the mesh declares: one by its name (NamedAccount), or the own secret of +// an account the grants part wants, which it composes from the manifests' bus processes (catalogue.BusAccounts, as +// catalogue.ToolsBusSecret names them). Never by a `broker-` prefix alone (the sweep of 2026-10-30, issue 774: +// mesh-controller's broker-address is the bus's address, read by the controller to issue tokens). +func (v *Vault) DeclaredAccount(secret string) bool { + if NamedAccount(secret) { + return true + } + if !Covered(secret) { + return false + } + v.minting.mu.Lock() + defer v.minting.mu.Unlock() + for _, w := range v.minting.wants { + if s, err := AccountSecret(w); err == nil && s == secret { + return true + } + } + return false +} + +// busAccount says the held secret is a bus account: declared, or a `broker-` whose value is an account (one the +// controller issued before its want was heard). Anything else is not hashed and not owed. +func (v *Vault) busAccount(secret, value string) bool { + return v.DeclaredAccount(secret) || (Covered(secret) && BusAccountValue(secret, value)) +} + // AccountSecret is the vault's name of the own secret a wanted account is held in. func AccountSecret(w buscompose.WantedAccount) (string, error) { return OwnSecret(w.Node, w.Module, w.OwnSecret()) diff --git a/modules/mesh-vault/cmd/mesh-vault/tools.go b/modules/mesh-vault/cmd/mesh-vault/tools.go index 416e63cf..0f575e8f 100644 --- a/modules/mesh-vault/cmd/mesh-vault/tools.go +++ b/modules/mesh-vault/cmd/mesh-vault/tools.go @@ -924,14 +924,16 @@ func ownAnswer(v *Vault, node, module, prefix string) (map[string]any, error) { } fp := "" var note any + account := v.DeclaredAccount(r.Name) if value, err := seal.Open(r.AtRest, v.Own.PrivateKey); err != nil { note = "the vault could not open its copy: " + err.Error() } else { fp = Fingerprint(value) + account = v.busAccount(r.Name, value) } row := map[string]any{"name": r.Name, "node": parts[1], "module": parts[2], "secret": parts[3], "generation": r.Generation, "fingerprint": fp, "made": r.MadeAt, "changed": r.ChangedAt, - "changedBy": r.ChangedBy, "origin": r.Origin, "busAccount": Covered(r.Name)} + "changedBy": r.ChangedBy, "origin": r.Origin, "busAccount": account} if r.ChangedVia != "" { row["changedVia"] = r.ChangedVia } @@ -941,7 +943,7 @@ func ownAnswer(v *Vault, node, module, prefix string) (map[string]any, error) { if note != nil { row["error"] = note } - if Covered(r.Name) && fp != "" { + if account && fp != "" { hashedRow(v, r, fp, row) } rows = append(rows, row) diff --git a/modules/mesh-vault/cmd/mesh-vault/vault.go b/modules/mesh-vault/cmd/mesh-vault/vault.go index 6613c1c0..b9f7585e 100644 --- a/modules/mesh-vault/cmd/mesh-vault/vault.go +++ b/modules/mesh-vault/cmd/mesh-vault/vault.go @@ -186,7 +186,7 @@ func (v *Vault) keepLocked(name, value, by, via, origin string, opt keepOptions) // A bus account is kept only as one (the sweep of 2026-10-30, issue 774): the controller had moved in random values // for broker-nats-tools, broker-address and broker-management, which no hash could be taken of, so the grants part // left those users out of the bus. Such a name is never kept with a value that is no account: not issued. - if Covered(name) && !BusAccountValue(name, value) { + if v.DeclaredAccount(name) && !BusAccountValue(name, value) { return Changed{}, fmt.Errorf("%s is not issued: it names a bus account, and the value handed in is no account "+ "(the JSON the controller hands or the vault mints, with a user and a password); the vault keeps a bus "+ "account only as one, so nothing was kept", name) diff --git a/modules/mesh-vault/cmd/mesh-vault/vault_test.go b/modules/mesh-vault/cmd/mesh-vault/vault_test.go index f19f6747..d1ab87c9 100644 --- a/modules/mesh-vault/cmd/mesh-vault/vault_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/vault_test.go @@ -22,6 +22,7 @@ import ( "time" stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/buscompose" "git.novox.be/novox/mesh-sdk/go/seal" "golang.org/x/crypto/bcrypt" "golang.org/x/crypto/nacl/box" @@ -906,18 +907,22 @@ func TestTheRecordOfHandOutsIsBounded(t *testing.T) { } } -// A bus account is kept only as one (the sweep of 2026-10-30, issue 774: random values for broker-nats-tools, -// broker-address and broker-management were kept, no hash could be taken, and the users were left off the bus). +// A bus account is kept only as one, and a name is one only when it is declared (the sweep of 2026-10-30, issue 774): +// broker, provisioner-broker, or the own secret of an account the grants part wants. mesh-controller's broker-address is +// the bus's address, never an account: kept as given, never refused, never replaced, never owed a hash. nats's +// broker-nats-tools, wanted as a process account, has the random value the controller moved in replaced by a minted +// account, and a value that is no account is refused for it from then on. func TestABusAccountIsKeptOnlyAsAnAccount(t *testing.T) { - v := newVault(t, nil) + v, accounts, _ := mintingVault(t, true) + const tools, address = "own/novox/nats/broker-nats-tools", "own/novox/mesh-controller/broker-address" for _, row := range []struct { name, value string kept bool }{ - {"own/novox/nats/broker-nats-tools", "Xq3-a-random-value-the-controller-made", false}, - {"own/novox/mesh-controller/broker-address", `{"password":"no user and no name that gives one"}`, false}, + {tools, "Xq3-a-random-value-the-controller-made", true}, // not wanted yet: no declared account + {address, "nats://novox.mesh:4222", true}, + {"own/novox/nats/broker", "Xq3-a-random-value-the-controller-made", false}, {"own/novox/nats/broker", `{"password":"pw"}`, true}, - {"own/novox/nats/broker-nats-tools", `{"user":"novox.nats.nats-tools","password":"pw"}`, true}, {"own/novox/postgres/superuser-password", "Xq3-a-random-value-the-controller-made", true}, } { _, err := v.Put(row.name, row.value, "the test") @@ -925,4 +930,44 @@ func TestABusAccountIsKeptOnlyAsAnAccount(t *testing.T) { t.Errorf("%s: kept %v, err %v", row.name, err == nil, err) } } + w := buscompose.WantedAccount{User: "novox.nats.nats-tools", Kind: buscompose.KindProcess, Node: "novox", + Module: "nats", Process: "nats-tools", URL: "nats://novox.mesh:4222", Fingerprint: "ab:cd"} + if err := w.Valid(); err != nil { + t.Fatal(err) + } + v.WantHeard(w.User, wantRaw(t, w), false) + v.HashHeld() + v.HashOwed() + for _, row := range []struct { + name string + generation int + account bool + }{ + {tools, 2, true}, + {address, 1, false}, + } { + rec, _, _ := v.Store.Read(row.name) + value, err := openAtRest(rec, v.Vault) + if err != nil || rec.Generation != row.generation || BusAccountValue(row.name, value) != row.account { + t.Errorf("%s: generation %d, an account %v, %v", row.name, rec.Generation, BusAccountValue(row.name, value), err) + } + } + if owed := v.hashes.list(); len(owed) != 0 { + t.Errorf("hashes owed for names that are no accounts: %v", owed) + } + if _, hashed := accounts.keys["novox.nats.nats-tools"]; !hashed { + t.Errorf("the minted nats-tools account is not hashed") + } + for _, row := range []struct { + name, value string + kept bool + }{ + {tools, "another-random-value", false}, + {address, "nats://novox.mesh:4223", true}, + } { + _, err := v.Put(row.name, row.value, "the test") + if (err == nil) != row.kept { + t.Errorf("%s again: kept %v, err %v", row.name, err == nil, err) + } + } }