diff --git a/modules/mesh-vault/cmd/mesh-vault/accounts.go b/modules/mesh-vault/cmd/mesh-vault/accounts.go index e25aff28..9e064931 100644 --- a/modules/mesh-vault/cmd/mesh-vault/accounts.go +++ b/modules/mesh-vault/cmd/mesh-vault/accounts.go @@ -61,6 +61,8 @@ type AccountStore interface { Put(a AccountHash) error // Users is every user with a key. Users() ([]string, error) + // Delete takes a user's key out: an account retired (retire.go). + Delete(user string) error } // busAccounts is AccountStore over the vault's own state through its runtime. @@ -80,6 +82,8 @@ func (busAccounts) Get(user string) (*AccountHash, error) { func (busAccounts) Users() ([]string, error) { return stdio.State(BusAccountsState).Keys() } +func (busAccounts) Delete(user string) error { return stdio.State(BusAccountsState).Delete(user) } + func (busAccounts) Put(a AccountHash) error { _, err := stdio.State(BusAccountsState).Put(a.User, a) return err @@ -181,6 +185,10 @@ func (v *Vault) HashAccount(secret string, force bool) (Hashed, error) { if err != nil { return Hashed{}, err } + 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) + } user, password, err := accountOf(secret, value) if err != nil { return Hashed{}, err @@ -259,7 +267,7 @@ func (v *Vault) HashHeld() (written []Hashed, failed map[string]string) { return nil, failed } for _, r := range records { - if !Covered(r.Name) { + if !Covered(r.Name) || r.Retired != nil { continue } h, err := v.HashAccount(r.Name, false) 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 7625ec8b..41f600e5 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,13 @@ func (f *fakeAccounts) Put(a AccountHash) error { return nil } +func (f *fakeAccounts) Delete(user string) error { + f.mu.Lock() + defer f.mu.Unlock() + delete(f.keys, user) + return nil +} + func (f *fakeAccounts) Users() ([]string, error) { f.mu.Lock() defer f.mu.Unlock() diff --git a/modules/mesh-vault/cmd/mesh-vault/main.go b/modules/mesh-vault/cmd/mesh-vault/main.go index 30ff5819..083cc1a6 100644 --- a/modules/mesh-vault/cmd/mesh-vault/main.go +++ b/modules/mesh-vault/cmd/mesh-vault/main.go @@ -109,6 +109,8 @@ func watchWants(env *Env) bool { say("the grants part's wanted accounts are not watched, so nothing is minted; begun again at the next pass: %v", err) return false } + // The watch returns once every current key was handed: what has no key now is no longer wanted. + env.Vault.WantsRead() return true } @@ -209,6 +211,7 @@ func main() { env.pass() if env.Vault != nil { env.Vault.HashOwed() + env.Vault.RetirePass() } env.pairPass() if env.Custody != nil { diff --git a/modules/mesh-vault/cmd/mesh-vault/mint.go b/modules/mesh-vault/cmd/mesh-vault/mint.go index c24bae1c..b581c3b9 100644 --- a/modules/mesh-vault/cmd/mesh-vault/mint.go +++ b/modules/mesh-vault/cmd/mesh-vault/mint.go @@ -56,6 +56,8 @@ type minting struct { heldBack map[string]string // minted are the accounts this vault minted since it started, by user (vault_status). minted map[string]Minted + // retiring is what the passes keep of the accounts no longer wanted (retire.go). + retiring retiring // mintMu keeps one mint at a time: a want heard, the switch turning on, and issue-account. mintMu sync.Mutex } diff --git a/modules/mesh-vault/cmd/mesh-vault/retire.go b/modules/mesh-vault/cmd/mesh-vault/retire.go index c775df57..ddc47474 100644 --- a/modules/mesh-vault/cmd/mesh-vault/retire.go +++ b/modules/mesh-vault/cmd/mesh-vault/retire.go @@ -1,6 +1,67 @@ package main -// wantGone is a wanted account's key deleted: forgotten here (its retirement is ADR 0230's, retire's). +// A wanted account no longer wanted is retired within ADR 0230's bounds (novox/hq ADR 0561 §2, issue 774). +// +// The grants part deletes a wanted account's key when no input names its user any more. The vault then retires the +// account: it takes its hash out of `bus-accounts`, so the grants part's next list drops the user, and marks the value +// retired in its store, keeping it (a value is deleted only by a person, ADR 0230). As ADR 0230 §15 and §18 have it, the +// same set of accounts gone must stand for five passes and ten minutes first, and a set of more than three, or more than +// half of the accounts held, waits for a person, who retires them with `account_retire`. Wanted again, an account is +// re-enabled at once: the mark taken off and its hash written again. Nothing is retired while the controller holds the +// lease (it forgets the accounts it mints), or before the wanted accounts are read whole. + +import ( + "errors" + "fmt" + "sort" + "strings" + "time" +) + +// retiring is what the passes keep of the accounts no longer wanted, under minting.mu. +type retiring struct { + wantsRead bool + key string + passes int + first time.Time + waiting []string + why string + retired []string +} + +// RetireState is what vault_status says of retirement. +type RetireState struct { + Gone []string `json:"gone"` + Passes int `json:"passes"` + Since time.Time `json:"since,omitzero"` + Waiting []string `json:"waiting_for_a_person"` + Why string `json:"why"` + Retired []string `json:"retired"` +} + +// RetireState answers how retirement stands. +func (v *Vault) RetireState() RetireState { + m := &v.minting + m.mu.Lock() + defer m.mu.Unlock() + r := m.retiring + out := RetireState{Passes: r.passes, Since: r.first, Why: r.why, Waiting: append([]string{}, r.waiting...), + Retired: append([]string{}, r.retired...), Gone: []string{}} + if r.key != "" { + out.Gone = strings.Split(r.key, " ") + } + return out +} + +// WantsRead says the wanted accounts were read whole once (the watch handed every current key): only then is an account +// with no key known to be no longer wanted. +func (v *Vault) WantsRead() { + v.minting.mu.Lock() + defer v.minting.mu.Unlock() + v.minting.retiring.wantsRead = true +} + +// 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() @@ -8,5 +69,184 @@ func (v *Vault) wantGone(user string) { delete(v.minting.heldBack, user) } -// wantBack is a wanted account's key written again. -func (v *Vault) wantBack(string) {} +// wantBack is a wanted account's key written: a retired account of it is re-enabled at once. +func (v *Vault) wantBack(user string) { + w, ok := v.wanted(user) + if !ok { + return + } + secret, err := AccountSecret(w) + if err != nil { + return + } + rec, held, err := v.Store.Read(secret) + if err != nil || !held || rec.Retired == nil { + return + } + rec.Retired = nil + if err := v.Store.Write(rec); err != nil { + v.log("%s is wanted again and NOT RE-ENABLED: its mark could not be taken off: %v", user, err) + return + } + if _, err := v.HashAccount(secret, true); err != nil { + v.hashes.owe(secret, err.Error()) + v.log("%s is wanted again and its hash is not written yet, written at the next pass: %v", user, err) + } + v.minting.mu.Lock() + r := &v.minting.retiring + for i, u := range r.retired { + if u == user { + r.retired = append(r.retired[:i], r.retired[i+1:]...) + break + } + } + v.minting.mu.Unlock() + v.log("%s is wanted again: re-enabled, its hash written again", user) +} + +// goneAccounts are the users whose hash the vault wrote from a value it holds and that no wanted key names, sorted, and +// how many such hashes it holds in all. +func (v *Vault) goneAccounts() ([]string, int, error) { + users, err := v.Accounts.Users() + if err != nil { + return nil, 0, err + } + var gone []string + held := 0 + for _, u := range users { + a, err := v.Accounts.Get(u) + if err != nil { + return nil, 0, err + } + if a == nil || a.Adopted || a.Of == "" { + continue + } + held++ + if _, wanted := v.wanted(u); !wanted { + gone = append(gone, u) + } + } + sort.Strings(gone) + return gone, held, nil +} + +// RetirePass is one pass of retirement, at the vault's minute pass. +func (v *Vault) RetirePass() { + if v.Accounts == nil { + return + } + v.leaseAfresh() + on, leaseWhy := v.mintingOn() + m := &v.minting + m.mu.Lock() + read := m.retiring.wantsRead + m.mu.Unlock() + reset := func(why string) { + m.mu.Lock() + defer m.mu.Unlock() + m.retiring.key, m.retiring.passes, m.retiring.first, m.retiring.waiting, m.retiring.why = "", 0, time.Time{}, nil, why + } + switch { + case !read: + reset("the wanted accounts are not read whole yet, so none is known to be gone") + return + case !on: + reset("nothing is retired: " + leaseWhy) + return + } + gone, held, err := v.goneAccounts() + if err != nil { + reset("the accounts held could not be read: " + err.Error()) + return + } + if len(gone) == 0 { + reset("") + return + } + now := v.Now().UTC() + key := strings.Join(gone, " ") + m.mu.Lock() + r := &m.retiring + if r.key != key { + r.key, r.passes, r.first, r.waiting = key, 0, now, nil + } + r.passes++ + passes, since := r.passes, r.first + m.mu.Unlock() + setWhy := func(why string, waiting []string) { + m.mu.Lock() + defer m.mu.Unlock() + m.retiring.why, m.retiring.waiting = why, waiting + } + if passes < StablePasses || now.Sub(since) < StableFor { + setWhy(fmt.Sprintf("%d no longer wanted (%s): retired once the same set stands for %d passes and %s (ADR 0230)", + len(gone), key, StablePasses, StableFor), nil) + return + } + if len(gone) > RetireAtOnce || float64(len(gone)) > RetireFraction*float64(held) { + why := fmt.Sprintf("%d accounts are no longer wanted, more than %d or more than half of the %d held at once: they "+ + "wait for a person (account_retire), ADR 0230 §18", len(gone), RetireAtOnce, held) + setWhy(why, gone) + v.log("%s: %s", why, key) + return + } + for _, u := range gone { + if err := v.retireOne(u, "no longer wanted by the grants part", "the vault's pass"); err != nil { + v.log("%s is not retired: %v", u, err) + } + } + reset("") +} + +// retireOne marks the value of user's account retired and takes its hash out of bus-accounts. +func (v *Vault) retireOne(user, why, by string) error { + a, err := v.Accounts.Get(user) + if err != nil { + return err + } + if a == nil || a.Adopted || a.Of == "" { + return fmt.Errorf("the vault holds no account of %s it made or was handed: an adopted hash is never retired here", user) + } + rec, held, err := v.Store.Read(a.Of) + if err != nil { + return err + } + if held { + rec.Retired = &RetiredMark{At: v.Now().UTC().Format(time.RFC3339), Why: why + ", by " + by} + if err := v.Store.Write(rec); err != nil { + return fmt.Errorf("its value could not be marked retired: %w", err) + } + } + if err := v.Accounts.Delete(user); err != nil { + return fmt.Errorf("its hash could not be taken out of %s: %w", BusAccountsState, err) + } + v.minting.mu.Lock() + v.minting.retiring.retired = append(v.minting.retiring.retired, user) + v.minting.mu.Unlock() + 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 +} + +// RetireAccounts is the tool account_retire: a person retires accounts no longer wanted, past ADR 0230's bound, with why. +func (v *Vault) RetireAccounts(caller string, users []string, why string) (any, error) { + if strings.TrimSpace(why) == "" { + return nil, errors.New("account_retire says why (`why`)") + } + if len(users) == 0 { + return nil, errors.New("account_retire names the accounts (`users`), as vault_status lists them waiting") + } + for _, u := range users { + if _, wanted := v.wanted(u); wanted { + return nil, fmt.Errorf("%s is still wanted by the grants part: only an account no longer wanted is retired; nothing was retired", u) + } + } + var done []string + for _, u := range users { + if err := v.retireOne(u, strings.TrimSpace(why), someoneOr("", caller)); err != nil { + return map[string]any{"retired": done}, fmt.Errorf("%s: %w", u, err) + } + done = append(done, u) + } + return map[string]any{"retired": done}, nil +} diff --git a/modules/mesh-vault/cmd/mesh-vault/retire_test.go b/modules/mesh-vault/cmd/mesh-vault/retire_test.go new file mode 100644 index 00000000..df6c2721 --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/retire_test.go @@ -0,0 +1,74 @@ +package main + +import ( + "strings" + "testing" + "time" +) + +// 774/AC9 (novox/hq ADR 0561 §2, ADR 0230 §15, §18): a wanted account whose key is deleted is retired (its hash taken +// out of bus-accounts, its value kept, marked) only once the same set has stood for five passes and ten minutes, and never +// more than three, or more than half of those held, at once: past that it waits for a person (account_retire). Wanted +// again, it is re-enabled at once; and a retired account is not hashed again at a start. +func TestAWantGoneRetiresItsAccountOnlyWithinTheBounds(t *testing.T) { + v, accounts, _ := mintingVault(t, true) + clock := time.Date(2026, 10, 28, 9, 0, 0, 0, time.UTC) + v.Now = func() time.Time { return clock } + modules := []string{"m1", "m2", "m3", "m4", "m5", "m6", "m7", "m8"} + for _, m := range modules { + w := aWant(m) + v.WantHeard(w.User, wantRaw(t, w), false) + } + v.WantsRead() + if len(accounts.keys) != 8 { + t.Fatalf("minted %d", len(accounts.keys)) + } + pass := func(n int, every time.Duration) { + for i := 0; i < n; i++ { + clock = clock.Add(every) + v.RetirePass() + } + } + v.WantHeard("ace.m1", nil, true) + pass(5, time.Second) + if _, ok := accounts.keys["ace.m1"]; !ok { + t.Fatal("retired after five passes in five seconds: ADR 0230 waits ten minutes too") + } + pass(1, 10*time.Minute) + if _, ok := accounts.keys["ace.m1"]; ok { + t.Fatalf("not retired after five passes and ten minutes: %+v", v.RetireState()) + } + if rec, held, _ := v.Store.Read("own/ace/m1/broker"); !held || rec.Retired == nil { + t.Fatalf("the retired account's value is %+v (held %v): kept, and marked", rec, held) + } + if written, _ := v.HashHeld(); len(written) != 0 { + t.Errorf("a start hashed a retired account again: %+v", written) + } + // Wanted again: re-enabled at once. + w := aWant("m1") + v.WantHeard(w.User, wantRaw(t, w), false) + if rec, _, _ := v.Store.Read("own/ace/m1/broker"); rec.Retired != nil || accounts.keys["ace.m1"].Hash == "" { + t.Fatalf("wanted again, ace.m1 is %+v with hash %v", rec.Retired, accounts.keys["ace.m1"]) + } + // Four gone at once is over the bound: it waits for a person, who retires them by hand. + for _, m := range modules[4:] { + v.WantHeard("ace."+m, nil, true) + } + pass(6, 3*time.Minute) + st := v.RetireState() + if len(accounts.keys) != 8 || len(st.Waiting) != 4 || !strings.Contains(st.Why, "more than 3") { + t.Fatalf("over the bound: %d keys, %+v", len(accounts.keys), st) + } + if _, err := v.RetireAccounts("person.jochen", []string{"ace.m5"}, ""); err == nil { + t.Error("retired by hand with no why") + } + if _, err := v.RetireAccounts("person.jochen", []string{"ace.m2"}, "test"); err == nil { + t.Error("retired by hand an account still wanted") + } + if _, err := v.RetireAccounts("person.jochen", []string{"ace.m5", "ace.m6"}, "the modules left"); err != nil { + t.Fatal(err) + } + if _, ok := accounts.keys["ace.m5"]; ok || len(accounts.keys) != 6 { + t.Errorf("after a person's retirement %d keys remain", len(accounts.keys)) + } +} diff --git a/modules/mesh-vault/cmd/mesh-vault/store.go b/modules/mesh-vault/cmd/mesh-vault/store.go index dd98a506..803304f0 100644 --- a/modules/mesh-vault/cmd/mesh-vault/store.go +++ b/modules/mesh-vault/cmd/mesh-vault/store.go @@ -48,6 +48,8 @@ type Record struct { // MovedFrom is, for a value the move took from the controller's store (design 57's V3), the node-engine that // handed it in and the controller's generation for it, kept as it was. MovedFrom *MovedFrom `json:"moved-from,omitempty"` + // Retired marks a bus account no longer wanted (retire.go, ADR 0561 §2): its value kept, its hash taken out. + Retired *RetiredMark `json:"retired,omitempty"` } // OperatorCopy is a value sealed to the operator key named in Key. diff --git a/modules/mesh-vault/cmd/mesh-vault/tools.go b/modules/mesh-vault/cmd/mesh-vault/tools.go index b2738d0a..d2149bb5 100644 --- a/modules/mesh-vault/cmd/mesh-vault/tools.go +++ b/modules/mesh-vault/cmd/mesh-vault/tools.go @@ -424,6 +424,28 @@ func definitions(env *Env) []toolDef { } return env.Vault.HashUser(arg(a, "user"), caller) }}, + {Name: "account_retire", + Description: "Retire bus accounts the grants part no longer wants, by a person's act with why (novox/hq ADR 0561 §2, " + + "ADR 0230 §18): the vault takes each one's hash out of its state `bus-accounts`, so the grants part's next user " + + "list drops the user, and keeps its value marked retired. For the set vault_status lists as waiting for a " + + "person (more than three, or more than half, gone at once); refused for an account still wanted, an adopted " + + "hash, and with no why. Wanted again, an account is re-enabled at once.", + Input: map[string]any{"users": map[string]any{"type": "array", "items": str("a bus user")}, + "why": str("why they are retired")}, + run: func(caller string, a map[string]any) (any, error) { + if env.Vault == nil { + return nil, errors.New("the vault is not ready") + } + var users []string + if list, ok := a["users"].([]any); ok { + for _, u := range list { + if s, ok := u.(string); ok { + users = append(users, strings.TrimSpace(s)) + } + } + } + return env.Vault.RetireAccounts(caller, users, arg(a, "why")) + }}, {Name: "secret_vault_export", Description: "Every secret the vault itself holds, as its copy sealed to the operator key, in the shape of " + "`secret export`: saved to a file, `nox secret recover vault mesh-vault --key " + @@ -506,7 +528,7 @@ func definitions(env *Env) []toolDef { key, _ := env.Vault.Store.OperatorKey() return map[string]any{"ready": true, "operator_key": key, "copies_on_an_earlier_key": left, "operator_key_refused": env.keyRefused(), "handed_rolled": env.Vault.Store.Rolled(), - "minting": env.Vault.MintState(), "ok": left == 0 && env.keyRefused() == "" && key != ""}, nil + "minting": env.Vault.MintState(), "retiring": env.Vault.RetireState(), "ok": left == 0 && env.keyRefused() == "" && key != ""}, nil }}, } } diff --git a/modules/mesh-vault/cmd/mesh-vault/vault_test.go b/modules/mesh-vault/cmd/mesh-vault/vault_test.go index a635c535..908affe3 100644 --- a/modules/mesh-vault/cmd/mesh-vault/vault_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/vault_test.go @@ -168,6 +168,7 @@ func TestNoBucketHoldsASecret(t *testing.T) { 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)) + code = []byte(strings.Replace(string(code), "stdio.State(BusAccountsState).Delete(", "", 1)) } // The haves, in main.go alone: one read and one watch, never a write; and one watch of the wanted accounts, the // grants part's, which hold no secret (ADR 0561 §2). diff --git a/modules/mesh-vault/module.json b/modules/mesh-vault/module.json index d2c8783f..66efe9fa 100644 --- a/modules/mesh-vault/module.json +++ b/modules/mesh-vault/module.json @@ -159,6 +159,7 @@ "secret_own", "account_hash", "account_adopt", + "account_retire", "secret_asks", "secret_vault_export", "provisioner_retirement",