diff --git a/modules/mesh-vault/cmd/mesh-vault/intake.go b/modules/mesh-vault/cmd/mesh-vault/intake.go index f37d4378..b8a03715 100644 --- a/modules/mesh-vault/cmd/mesh-vault/intake.go +++ b/modules/mesh-vault/cmd/mesh-vault/intake.go @@ -224,6 +224,9 @@ const ProcessSecretPrefix = "broker-" // `broker-`. The vault cannot tell core or moved from a module's manifest; the controller, which can, is the // only caller that may hand one in, and the name keeps what it hands to these. func Covered(secret string) bool { + if _, _, ok := ledgerAccountOf(secret); ok { + return true + } parts := strings.Split(secret, "/") if len(parts) != 4 || parts[0] != "own" { return false diff --git a/modules/mesh-vault/cmd/mesh-vault/ledgers.go b/modules/mesh-vault/cmd/mesh-vault/ledgers.go index 287019f1..e329ed5b 100644 --- a/modules/mesh-vault/cmd/mesh-vault/ledgers.go +++ b/modules/mesh-vault/cmd/mesh-vault/ledgers.go @@ -11,7 +11,9 @@ package main import ( "encoding/json" + "fmt" "sort" + "strings" "git.novox.be/novox/mesh-sdk/go/buscompose" ) @@ -67,3 +69,71 @@ func (v *Vault) ensureLedgers(node string) { } v.log("made %s as {}: no ledger account is held for its machine yet", secret) } + +// ledgerAccountPrefix starts the name a ledger account is kept under: `ledger//`. It is the account's +// own record, never an own secret (so `own` hands no module its ledger account); the machine's `ledgers` is made of them. +const ledgerAccountPrefix = "ledger/" + +// LedgerAccountSecret is the vault's name of the credential of the ledger account of module on node. +func LedgerAccountSecret(node, module string) (string, error) { + for what, v := range map[string]string{"the machine": node, "the module": module} { + if !namePart.MatchString(v) { + return "", fmt.Errorf("%s %q is not a name the mesh writes", what, v) + } + } + return ledgerAccountPrefix + node + "/" + module, nil +} + +// ledgerAccountOf says secret is a ledger account's name, with its machine and module. +func ledgerAccountOf(secret string) (node, module string, ok bool) { + parts := strings.Split(secret, "/") + if len(parts) != 3 || parts[0] != "ledger" || !namePart.MatchString(parts[1]) || !namePart.MatchString(parts[2]) { + return "", "", false + } + return parts[1], parts[2], true +} + +// rewriteLedgers makes node's `ledgers` of the ledger accounts that stand: held, not retired, and wanted (once the wanted +// accounts are read whole; before that every one held stands, so a start never drops what it has not heard yet). It +// says `secret-changed` only when the map changed. +func (v *Vault) rewriteLedgers(node string) { + v.minting.ledgersMu.Lock() + defer v.minting.ledgersMu.Unlock() + secret, err := ledgersSecret(node) + if err != nil { + v.log("%s's ledgers are not rewritten: %v", node, err) + return + } + records, err := v.Store.ListPrefix(ledgerAccountPrefix + node + "/") + if err != nil { + v.log("%s is not rewritten, the ledger accounts of %s could not be read: %v", secret, node, err) + return + } + v.minting.mu.Lock() + read := v.minting.retiring.wantsRead + v.minting.mu.Unlock() + standing := map[string]json.RawMessage{} + for _, r := range records { + _, module, ok := ledgerAccountOf(r.Name) + if !ok || r.Retired != nil { + continue + } + if _, wanted := v.wanted("ledger." + node + "." + module); read && !wanted { + continue + } + value, err := openAtRest(r, v) + if err != nil || !BusAccountValue(r.Name, value) { + v.log("%s is not an account the vault can open, so it is left out of %s", r.Name, secret) + continue + } + standing[module] = json.RawMessage(value) + } + raw, err := json.Marshal(standing) + if err != nil { + v.log("%s is not rewritten: %v", secret, err) + return + } + if _, err := v.keepLocked(secret, string(raw), "the vault", "", OriginMade, keepOptions{}); err != nil { + v.log("%s is NOT REWRITTEN (%d ledger accounts stand): %v", secret, len(standing), err) + } +} diff --git a/modules/mesh-vault/cmd/mesh-vault/ledgers_test.go b/modules/mesh-vault/cmd/mesh-vault/ledgers_test.go index ec8e424c..3ded3b55 100644 --- a/modules/mesh-vault/cmd/mesh-vault/ledgers_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/ledgers_test.go @@ -53,9 +53,20 @@ func ledgersOf(t *testing.T, v testVault, priv string) (map[string]json.RawMessa func changesOf(v testVault, since int, name string) int { n := 0 for _, e := range v.bus.events[since:] { - if e["event"] == ChangedEvent && e["name"] == name { + if body, _ := e["body"].(map[string]any); e["event"] == ChangedEvent && body["name"] == name { n++ } } return n } + +// redacted says a credential's shape without its password, for a failure message. +func redacted(raw json.RawMessage) string { + var c map[string]any + if json.Unmarshal(raw, &c) != nil { + return "not JSON" + } + delete(c, "password") + out, _ := json.Marshal(c) + return string(out) +} diff --git a/modules/mesh-vault/cmd/mesh-vault/mint.go b/modules/mesh-vault/cmd/mesh-vault/mint.go index 21ed60cb..c550b8c9 100644 --- a/modules/mesh-vault/cmd/mesh-vault/mint.go +++ b/modules/mesh-vault/cmd/mesh-vault/mint.go @@ -59,6 +59,8 @@ type minting struct { retiring retiring // mintMu keeps one mint at a time: a want heard, the switch turning on, and issue-account. mintMu sync.Mutex + // ledgersMu keeps one rewrite of a machine's `ledgers` at a time (ledgers.go). + ledgersMu sync.Mutex } // Minted is what one mint did: the account and its secret, never its value or its hash. @@ -169,7 +171,11 @@ func (v *Vault) busAccount(secret, value string) bool { } // AccountSecret is the vault's name of the own secret a wanted account is held in. +// A ledger account is the exception: it is kept as LedgerAccountSecret, and its machine's `ledgers` is made of it. func AccountSecret(w buscompose.WantedAccount) (string, error) { + if w.Kind == buscompose.KindLedger { + return LedgerAccountSecret(w.Node, w.Module) + } return OwnSecret(w.Node, w.Module, w.OwnSecret()) } @@ -199,6 +205,10 @@ func (v *Vault) WantHeard(user string, raw []byte, deleted bool) { if _, err := v.mintWanted(w, "the grants part's want of "+user); err != nil { v.log("%s is not minted: %v", user, err) } + if w.Kind == buscompose.KindLedger { + // Wanted again, or held already: the machine's ledgers say what is wanted now (nothing said when unchanged). + v.rewriteLedgers(w.Node) + } } // errHeldBack is a want the vault does not mint now, with why: not an error of the vault. @@ -306,7 +316,12 @@ func (v *Vault) mintOnce(w buscompose.WantedAccount, by string) (Minted, error) } } } - v.announce(c) + if w.Kind == buscompose.KindLedger { + // A ledger account is no own secret of its module: its machine's `ledgers` is rewritten and says secret-changed. + v.rewriteLedgers(w.Node) + } else { + v.announce(c) + } v.minting.mu.Lock() if v.minting.minted == nil { v.minting.minted = map[string]Minted{} diff --git a/modules/mesh-vault/cmd/mesh-vault/mint_test.go b/modules/mesh-vault/cmd/mesh-vault/mint_test.go index edd36ba1..e380c69d 100644 --- a/modules/mesh-vault/cmd/mesh-vault/mint_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/mint_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "strings" "testing" + "time" "git.novox.be/novox/mesh-sdk/go/buscompose" "git.novox.be/novox/mesh-sdk/go/seal" @@ -102,10 +103,10 @@ func TestTheVaultMintsAWantedAccountOnceAndNothingItHolds(t *testing.T) { t.Run("ledgers", func(t *testing.T) { for _, c := range []struct { name string - run func(t *testing.T, v testVault, priv string) + run func(t *testing.T, v testVault, accounts *fakeAccounts, priv string) }{ // 1966/AC24 (issue 1966 V1): a machine carrying node-tools, with no ledger account, has `ledgers` as {}. - {"an empty ledgers on a machine with node-tools", func(t *testing.T, v testVault, priv string) { + {"an empty ledgers on a machine with node-tools", func(t *testing.T, v testVault, accounts *fakeAccounts, priv string) { if _, ok := ledgersOf(t, v, priv); ok { t.Fatal("ledgers is there before node-tools is wanted") } @@ -122,10 +123,83 @@ func TestTheVaultMintsAWantedAccountOnceAndNothingItHolds(t *testing.T) { t.Errorf("a second pass said secret-changed %d times for a value that did not change", n) } }}, + // 1966/AC25 (issue 1966 V2): wanted ledger accounts for m and k on a machine are minted and hashed like every + // kind, kept together in the machine's `ledgers`, each mint said once as a change of `ledgers` and never of + // the account's own record. + {"ledger accounts minted into ledgers", func(t *testing.T, v testVault, accounts *fakeAccounts, priv string) { + tools := nodeToolsWant("ace") + v.WantHeard(tools.User, wantRaw(t, tools), false) + v.LedgersPass() + for i, module := range []string{"m", "k"} { + w := ledgerWant("ace", module) + before := len(v.bus.events) + v.WantHeard(w.User, wantRaw(t, w), false) + if n := changesOf(v, before, "own/ace/node-tools/ledgers"); n != 1 { + t.Errorf("minting %s said secret-changed %d times for ledgers, want once", module, n) + } + if n := eventsNamed(v, before, ChangedEvent); n != 1 { + t.Errorf("minting %s said %d secret-changed in all, want the one of ledgers", module, n) + } + got, _ := ledgersOf(t, v, priv) + if len(got) != i+1 { + t.Fatalf("after %s ledgers holds %d credentials, want %d", module, len(got), i+1) + } + } + got, _ := ledgersOf(t, v, priv) + for _, module := range []string{"m", "k"} { + var cred struct{ User, Password string } + if err := json.Unmarshal(got[module], &cred); err != nil || cred.User != "ledger.ace."+module || len(cred.Password) != 40 { + t.Fatalf("ledgers[%s] is %s (%v)", module, redacted(got[module]), err) + } + if !accounts.hashOf(cred.User, cred.Password, 1) { + t.Errorf("%s's hash is not in bus-accounts or not of the minted password", cred.User) + } + if _, held, _ := v.Store.Read("own/ace/" + module + "/ledgers"); held { + t.Errorf("the ledger account of %s is also kept as an own secret of its module", module) + } + } + }}, + // 1966/AC26: m's wanted key deleted: m leaves ledgers at once, its hash is retired within ADR 0230's bounds, + // k stays; wanted again, m is back in ledgers. + {"a ledger account no longer wanted leaves ledgers", func(t *testing.T, v testVault, accounts *fakeAccounts, priv string) { + clock := time.Date(2026, 11, 3, 9, 0, 0, 0, time.UTC) + v.Now = func() time.Time { return clock } + for _, w := range []buscompose.WantedAccount{nodeToolsWant("ace"), ledgerWant("ace", "m"), ledgerWant("ace", "k")} { + v.WantHeard(w.User, wantRaw(t, w), false) + } + v.WantsRead() + before := len(v.bus.events) + v.WantHeard("ledger.ace.m", nil, true) + got, _ := ledgersOf(t, v, priv) + if _, in := got["m"]; in || len(got) != 1 { + t.Fatalf("after m's want went, ledgers holds %d credentials, m among them: %v", len(got), in) + } + if n := changesOf(v, before, "own/ace/node-tools/ledgers"); n != 1 { + t.Errorf("m's retirement said secret-changed %d times for ledgers, want once", n) + } + for i := 0; i < 6; i++ { + clock = clock.Add(3 * time.Minute) + v.RetirePass() + } + if _, ok := accounts.keys["ledger.ace.m"]; ok { + t.Errorf("m's hash is still in bus-accounts: %+v", v.RetireState()) + } + if _, ok := accounts.keys["ledger.ace.k"]; !ok { + t.Error("k's hash was retired with m's") + } + if got, _ := ledgersOf(t, v, priv); len(got) != 1 { + t.Errorf("the retirement changed ledgers to %d credentials", len(got)) + } + w := ledgerWant("ace", "m") + v.WantHeard(w.User, wantRaw(t, w), false) + if got, _ := ledgersOf(t, v, priv); len(got) != 2 || accounts.keys["ledger.ace.m"].Hash == "" { + t.Errorf("wanted again, m is not back in ledgers (%d) with its hash", len(got)) + } + }}, } { t.Run(c.name, func(t *testing.T) { - v, _, priv := mintingVault(t) - c.run(t, v, priv) + v, accounts, priv := mintingVault(t) + c.run(t, v, accounts, priv) }) } }) diff --git a/modules/mesh-vault/cmd/mesh-vault/retire.go b/modules/mesh-vault/cmd/mesh-vault/retire.go index 6cdb489a..ce1007b7 100644 --- a/modules/mesh-vault/cmd/mesh-vault/retire.go +++ b/modules/mesh-vault/cmd/mesh-vault/retire.go @@ -16,6 +16,8 @@ import ( "sort" "strings" "time" + + "git.novox.be/novox/mesh-sdk/go/buscompose" ) // retiring is what the passes keep of the accounts no longer wanted, under minting.mu. @@ -64,9 +66,14 @@ func (v *Vault) WantsRead() { // wantGone is a wanted account's key deleted: forgotten as wanted, its retirement left to the passes. func (v *Vault) wantGone(user string) { v.minting.mu.Lock() - defer v.minting.mu.Unlock() + gone, was := v.minting.wants[user] delete(v.minting.wants, user) delete(v.minting.heldBack, user) + v.minting.mu.Unlock() + if was && gone.Kind == buscompose.KindLedger { + // Its credential leaves the machine's ledgers at once; its hash is retired by the passes, as every kind's. + v.rewriteLedgers(gone.Node) + } } // wantBack is a wanted account's key written: a retired account of it is re-enabled at once. @@ -221,6 +228,9 @@ func (v *Vault) retireOne(user, why, by string) error { v.minting.mu.Lock() v.minting.retiring.retired = append(v.minting.retiring.retired, user) v.minting.mu.Unlock() + if node, _, ok := ledgerAccountOf(a.Of); ok { + v.rewriteLedgers(node) + } v.log("retired the bus account %s (%s): its hash is out of %s and its value kept, marked; %s", user, a.Of, BusAccountsState, why) return nil