Judge a bus account by its declaration, never by a broker- prefix, so the controller's address is kept (issue 774)

683fd547 and b7f87822 took every `broker-<id>` own secret for a bus account. mesh-controller's broker-address is the
bus's address (MESH_BROKER_ADDRESS_FILE, read by broker.FromEnvironment to issue enrolment tokens and credentials):
c1 would have refused to keep it, and it was counted among the "owed bus-account hashes" every minute.

A name is now a bus account when it is declared: `broker` or `provisioner-broker`, or the own secret of an account the
grants part wants, which it composes from the manifests' bus processes (what catalogue.ToolsBusSecret names). A
`broker-<id>` holding an account the controller issued before its want was heard is still hashed. Anything else is
kept as given, never refused, never minted over, never hashed and never owed. The mint path replaced only wanted
names already, so the address was never at risk of replacement there; the test now proves it.

Quick fix: except broker-address by name. Thorough fix: the declaration decides, for every name. Chose thorough.
Table rows: broker-address kept as given, not replaced, owed nothing; broker-nats-tools replaced by a minted account
once wanted and refused a plain value after. They fail on the previous code.
This commit is contained in:
2026-10-30 18:38:59 +01:00
parent 21c86c819c
commit 434214e615
6 changed files with 111 additions and 10 deletions
+20 -1
View File
@@ -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-<id> 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()
}
}
@@ -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-<id>` 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).
//
+27
View File
@@ -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-<id>` 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())
+4 -2
View File
@@ -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)
+1 -1
View File
@@ -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)
@@ -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)
}
}
}