diff --git a/modules/mesh-vault/cmd/mesh-vault/lease.go b/modules/mesh-vault/cmd/mesh-vault/lease.go index e9500970..3a399d00 100644 --- a/modules/mesh-vault/cmd/mesh-vault/lease.go +++ b/modules/mesh-vault/cmd/mesh-vault/lease.go @@ -1,16 +1,49 @@ package main +// The switch on the controller's lease (novox/hq ADR 0561 §3): minting is on only while the lease is handed to a holder +// that is no controller, and off for a controller's value, a key deleted or expired, or one that cannot be read. The +// write that hands it away turns it on and mints every wanted account the vault holds no value for; a lease handed back +// turns it off. No clock and no setting turns it. A key's age passing is heard by no watch, so the lease is read afresh +// before every mint (LeaseRead): a lease that expired since mints nothing. + import "git.novox.be/novox/mesh-sdk/go/buscompose" -// LeaseHeard is the watch of the controller's lease (ADR 0561 §3): minting is on only while it is handed to a holder that -// is no controller. +// LeaseHeard is the watch of the controller's lease. When minting turns on or off, every wanted account is judged again: +// minted when on, held back with the new why when off. func (v *Vault) LeaseHeard(raw []byte, found bool, readErr error) { + if !v.setLease(raw, found, readErr) { + return + } + on, why := v.mintingOn() + v.log("minting is %s: %s", map[bool]string{true: "ON", false: "off"}[on], why) + for _, user := range v.wantedUsers() { + if w, ok := v.wanted(user); ok { + if _, err := v.mintWanted(w, "the lease write"); err != nil { + v.log("%s is not minted: %v", user, err) + } + } + } +} + +// setLease records what the lease says, and answers whether minting turned on or off by it. +func (v *Vault) setLease(raw []byte, found bool, readErr error) bool { on, why := buscompose.LeaseHandedAway(raw, found, readErr) m := &v.minting m.mu.Lock() defer m.mu.Unlock() - if on != m.on || m.why == "" { + flipped := on != m.on || m.why == "" + if flipped { m.since = v.Now().UTC() } m.on, m.why = on, why + return flipped +} + +// leaseAfresh reads the lease again before a mint, where the vault can: a key that expired since is heard by no watch. +func (v *Vault) leaseAfresh() { + if v.LeaseRead == nil { + return + } + raw, found, err := v.LeaseRead() + v.setLease(raw, found, err) } diff --git a/modules/mesh-vault/cmd/mesh-vault/main.go b/modules/mesh-vault/cmd/mesh-vault/main.go index 06f3d61c..30ff5819 100644 --- a/modules/mesh-vault/cmd/mesh-vault/main.go +++ b/modules/mesh-vault/cmd/mesh-vault/main.go @@ -51,7 +51,7 @@ 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{}, Haves: readHave} + Now: time.Now, Log: say, Accounts: busAccounts{}, Haves: readHave, LeaseRead: readLease} return env } @@ -112,6 +112,34 @@ func watchWants(env *Env) bool { return true } +// readLease is the controller's lease key, read through the runtime under the vault's own account (its manifest reads it). +func readLease() ([]byte, bool, error) { + e, err := stdio.State(LeaseRead).Get(LeaseKey) + if err != nil || e == nil { + return nil, false, err + } + return e.Value, true, nil +} + +// watchLease turns minting on and off as the controller's lease is written (lease.go, ADR 0561 §3): a watch, never a +// poll; and the lease is read afresh before every mint. It answers whether the watch began. +func watchLease(env *Env) bool { + if env.Vault == nil { + return true + } + env.Vault.LeaseHeard(readLease()) + err := stdio.State(LeaseRead).Watch(LeaseKey, func(c stdio.StateChange) error { + deleted := c.Op == "delete" || c.Op == "purge" || len(c.Value) == 0 + env.Vault.LeaseHeard(c.Value, !deleted, nil) + return nil + }) + if err != nil { + say("the controller's lease is not watched, so minting stays as last read; 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 @@ -166,11 +194,15 @@ func main() { say("hashed %d bus accounts at start; %d could not be, tried again every minute: %v", len(written), len(failed), failed) } watched := watchHandings(env) + lease := watchLease(env) wants := watchWants(env) for { if !watched { watched = watchHandings(env) } + if !lease { + lease = watchLease(env) + } if !wants { wants = watchWants(env) } diff --git a/modules/mesh-vault/cmd/mesh-vault/mint.go b/modules/mesh-vault/cmd/mesh-vault/mint.go index 6808ea2d..c24bae1c 100644 --- a/modules/mesh-vault/cmd/mesh-vault/mint.go +++ b/modules/mesh-vault/cmd/mesh-vault/mint.go @@ -202,6 +202,7 @@ func (v *Vault) mintWanted(w buscompose.WantedAccount, by string) (Minted, error v.holdBack(w.User, why) return Minted{}, errHeldBack{why} } + v.leaseAfresh() if on, why := v.mintingOn(); !on { v.holdBack(w.User, "held back: "+why) return Minted{}, errHeldBack{why} diff --git a/modules/mesh-vault/cmd/mesh-vault/mint_test.go b/modules/mesh-vault/cmd/mesh-vault/mint_test.go index fba566fc..aadd8abe 100644 --- a/modules/mesh-vault/cmd/mesh-vault/mint_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/mint_test.go @@ -105,3 +105,46 @@ func TestTheVaultMintsAWantedAccountOnceAndNothingItHolds(t *testing.T) { t.Errorf("vault_status says %+v: an adopted user is not held back, one account minted", st) } } + +// 774/AC8 (novox/hq ADR 0561 §3): while the controller holds the lease nothing is minted and vault_status says why and +// names each wanted account held back (never an adopted one); the write that hands the lease away mints them; a lease +// handed back, expired or unreadable turns minting off again, read afresh before every mint. +func TestNothingIsMintedUntilTheLeaseIsHandedAway(t *testing.T) { + v, accounts, _ := mintingVault(t, false) + accounts.keys = map[string]AccountHash{"ace.mod-y": {User: "ace.mod-y", Hash: "$2a$04$adopted", Adopted: true}} + for _, m := range []string{"mod-x", "mod-y"} { + w := aWant(m) + v.WantHeard(w.User, wantRaw(t, w), false) + } + st := v.MintState() + if st.On || !strings.Contains(st.Why, "controller holds the lease") || len(st.HeldBack) != 1 || + !strings.Contains(st.HeldBack["ace.mod-x"], "controller holds the lease") { + t.Fatalf("under the controller's lease vault_status says %+v", st) + } + if _, held, _ := v.Store.Read("own/ace/mod-x/broker"); held { + t.Fatal("minted while the controller holds the lease") + } + // Handed away: the wanted account is minted at that write, the adopted one is not. + lease := []byte(`{"handed-to":"the operator's hand-over"}`) + v.LeaseRead = func() ([]byte, bool, error) { return lease, true, nil } + v.LeaseHeard(lease, true, nil) + if _, held, _ := v.Store.Read("own/ace/mod-x/broker"); !held { + t.Fatalf("not minted at the lease write: %+v", v.MintState()) + } + if _, held, _ := v.Store.Read("own/ace/mod-y/broker"); held { + t.Error("an adopted account was minted at the lease write") + } + if st := v.MintState(); !st.On || len(st.HeldBack) != 0 { + t.Errorf("after the lease write vault_status says %+v", st) + } + // The key expired (no watch hears a key's age pass): read afresh before the next mint, nothing is minted. + v.LeaseRead = func() ([]byte, bool, error) { return nil, false, nil } + z := aWant("mod-z") + v.WantHeard(z.User, wantRaw(t, z), false) + if _, held, _ := v.Store.Read("own/ace/mod-z/broker"); held { + t.Error("minted after the lease's key expired") + } + if st := v.MintState(); st.On || !strings.Contains(st.HeldBack["ace.mod-z"], "no lease is written") { + t.Errorf("after the key expired vault_status says %+v", st) + } +} diff --git a/modules/mesh-vault/cmd/mesh-vault/tools.go b/modules/mesh-vault/cmd/mesh-vault/tools.go index 2ef64c8d..b2738d0a 100644 --- a/modules/mesh-vault/cmd/mesh-vault/tools.go +++ b/modules/mesh-vault/cmd/mesh-vault/tools.go @@ -492,7 +492,9 @@ func definitions(env *Env) []toolDef { run: func(string, map[string]any) (any, error) { return env.health(), nil }}, {Name: "vault_status", Description: "What needs a person's eye in the vault: operator copies still sealed to an earlier key, the operator " + - "key the mesh's export holds if the vault refused it, and how often the record of hand-outs was rolled.", + "key the mesh's export holds if the vault refused it, how often the record of hand-outs was rolled, and " + + "minting (novox/hq ADR 0561 §3, §9): whether the vault mints bus accounts now and why (only while the " + + "controller's lease is handed away), every wanted account it holds back with why, and those it minted.", run: func(string, map[string]any) (any, error) { if env.Vault == nil { return map[string]any{"ready": false, "error": fmt.Sprint(env.Broken)}, nil @@ -504,7 +506,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(), - "ok": left == 0 && env.keyRefused() == "" && key != ""}, nil + "minting": env.Vault.MintState(), "ok": left == 0 && env.keyRefused() == "" && key != ""}, nil }}, } } diff --git a/modules/mesh-vault/cmd/mesh-vault/vault.go b/modules/mesh-vault/cmd/mesh-vault/vault.go index da2ee430..3d975e97 100644 --- a/modules/mesh-vault/cmd/mesh-vault/vault.go +++ b/modules/mesh-vault/cmd/mesh-vault/vault.go @@ -56,6 +56,8 @@ type Vault struct { hashes hashing // adoption is the last adoption (adopt.go), under mu. adoption adoptionKept + // LeaseRead reads the controller's lease afresh before a mint (lease.go): nil reads it only as the watch hands it. + LeaseRead func() ([]byte, bool, error) // minting is the switch on the lease and what the vault knows of the wanted bus accounts (mint.go). minting minting // callsMu guards pending: the keys of the calls open now (intake.go), never written anywhere. diff --git a/modules/mesh-vault/cmd/mesh-vault/vault_test.go b/modules/mesh-vault/cmd/mesh-vault/vault_test.go index 91a0a736..a635c535 100644 --- a/modules/mesh-vault/cmd/mesh-vault/vault_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/vault_test.go @@ -131,9 +131,10 @@ 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 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. +// (ADR 0547 clause 9), opened in accounts.go alone. It reads four buckets of others', and only reads them: the +// controller's assignments, for what each node's plan names (askers.go, design 57 V4), the node-engines' haves, where +// the bus node hands the loaded list's hashes sealed to the vault (adopt.go, ADR 0547 clause 9), the grants part's wanted +// accounts and the controller's lease (mint.go, lease.go, ADR 0561 §2, §3), in main.go alone. func TestNoBucketHoldsASecret(t *testing.T) { raw, err := os.ReadFile("../../module.json") if err != nil { @@ -147,7 +148,7 @@ 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","node-engine.have","mesh-grants.accounts"]` { + if r, _ := json.Marshal(manifest["reads"]); string(r) != `["mesh-controller.assignments","node-engine.have","mesh-grants.accounts","mesh-controller.lease"]` { t.Errorf("the manifest reads %s: the vault reads the controller's assignments, the haves and the grants part's "+ "wanted accounts alone", r) } @@ -174,6 +175,9 @@ func TestNoBucketHoldsASecret(t *testing.T) { code = []byte(strings.Replace(string(code), "stdio.State(HaveRead).Get(", "", 1)) code = []byte(strings.Replace(string(code), "stdio.State(HaveRead).Watch(", "", 1)) code = []byte(strings.Replace(string(code), "stdio.State(WantedRead).Watch(", "", 1)) + // And the controller's lease, one read and one watch (ADR 0561 §3). + code = []byte(strings.Replace(string(code), "stdio.State(LeaseRead).Get(", "", 1)) + code = []byte(strings.Replace(string(code), "stdio.State(LeaseRead).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/module.json b/modules/mesh-vault/module.json index 3be11a1c..d2c8783f 100644 --- a/modules/mesh-vault/module.json +++ b/modules/mesh-vault/module.json @@ -21,7 +21,8 @@ "reads": [ "mesh-controller.assignments", "node-engine.have", - "mesh-grants.accounts" + "mesh-grants.accounts", + "mesh-controller.lease" ], "state": [ {