diff --git a/modules/mesh-vault/cmd/mesh-vault/lease.go b/modules/mesh-vault/cmd/mesh-vault/lease.go new file mode 100644 index 00000000..e9500970 --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/lease.go @@ -0,0 +1,16 @@ +package main + +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. +func (v *Vault) LeaseHeard(raw []byte, found bool, readErr error) { + on, why := buscompose.LeaseHandedAway(raw, found, readErr) + m := &v.minting + m.mu.Lock() + defer m.mu.Unlock() + if on != m.on || m.why == "" { + m.since = v.Now().UTC() + } + m.on, m.why = on, why +} diff --git a/modules/mesh-vault/cmd/mesh-vault/main.go b/modules/mesh-vault/cmd/mesh-vault/main.go index c74e0449..06f3d61c 100644 --- a/modules/mesh-vault/cmd/mesh-vault/main.go +++ b/modules/mesh-vault/cmd/mesh-vault/main.go @@ -95,6 +95,23 @@ func watchHandings(env *Env) bool { return true } +// watchWants mints from the grants part's wanted accounts as they change (mint.go, ADR 0561 §2): a watch, never a poll. +// It answers whether the watch began; one that did not is begun again at the next minute pass. +func watchWants(env *Env) bool { + if env.Vault == nil { + return true + } + err := stdio.State(WantedRead).Watch("", func(c stdio.StateChange) error { + env.Vault.WantHeard(c.Key, c.Value, c.Op == "delete" || c.Op == "purge" || len(c.Value) == 0) + return nil + }) + if err != nil { + say("the grants part's wanted accounts are not watched, so nothing is minted; 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 @@ -149,10 +166,14 @@ 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) + wants := watchWants(env) for { if !watched { watched = watchHandings(env) } + if !wants { + wants = watchWants(env) + } env.pass() if env.Vault != nil { env.Vault.HashOwed() diff --git a/modules/mesh-vault/cmd/mesh-vault/mint.go b/modules/mesh-vault/cmd/mesh-vault/mint.go new file mode 100644 index 00000000..6808ea2d --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/mint.go @@ -0,0 +1,287 @@ +package main + +// The vault mints every new bus account (novox/hq ADR 0561 §1 to §4, issue 774). +// +// The grants part says which accounts the mesh wants, in its state `accounts` that the vault alone reads (§2): one key +// per bus user, holding what the controller's credential carries beside the password. The vault watches it, and for a +// user it holds no value for and whose hash it did not adopt it makes a password, keeps the account whole (built by +// mesh-sdk's HeldCredential, byte for byte the controller's shape) as the module's own secret on that node +// (`own///|provisioner-broker>`), sealed, with the operator's copy; writes the +// bcrypt hash into `bus-accounts`; and says `account-issued`, then `secret-changed`. The node's engine asks `own` again +// on `secret-changed` and composes. +// +// **One issuer at every moment** (§3): minting is on only while the controller's lease is handed to a holder that is no +// controller (mesh-sdk LeaseHandedAway). Until then the controller mints, the vault keeps hashing what it is handed, and +// vault_status names every wanted account it holds back and why. **Nothing is minted anew** (§4): an account whose value +// the vault holds, or whose hash it adopted, is never minted; the node accounts and the controller's own are never wanted. +// **Never two values for one user** (§7): a user whose hash the vault already wrote from another of its secrets is held +// back, said, and never replaced but by a rotation. + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "sync" + "time" + + "git.novox.be/novox/mesh-sdk/go/buscompose" +) + +// WantedRead is the grants part's state of wanted accounts, as the vault's manifest reads it. +const WantedRead = buscompose.GrantsAccounts + +// LeaseRead is the controller's lease, as the vault's manifest reads it, and LeaseKey its one key. +const ( + LeaseRead = buscompose.ControllerLease + LeaseKey = buscompose.ControllerLeaseKey +) + +// AccountIssuedEvent is what the vault says when it minted an account (mesh-sdk buscompose.AccountIssued). +const AccountIssuedEvent = buscompose.AccountIssuedEvent + +// minting is the switch and what the vault knows of the wanted accounts, kept in memory and read again from the bus at +// every start: the grants part's state and the lease are the record, never this. +type minting struct { + mu sync.Mutex + on bool + why string + since time.Time + // wants are the wanted accounts, by user, as last heard. + wants map[string]buscompose.WantedAccount + // heldBack are the wanted users the vault did not mint, with why (vault_status). + heldBack map[string]string + // minted are the accounts this vault minted since it started, by user (vault_status). + minted map[string]Minted + // mintMu keeps one mint at a time: a want heard, the switch turning on, and issue-account. + mintMu sync.Mutex +} + +// Minted is what one mint did: the account and its secret, never its value or its hash. +type Minted struct { + User string `json:"user"` + Secret string `json:"secret"` + Generation int `json:"generation"` + At time.Time `json:"at"` + // Hashed says its hash is written; false leaves it owed, written at the next pass. + Hashed bool `json:"hashed"` +} + +// MintState is what vault_status says of minting. +type MintState struct { + On bool `json:"on"` + Why string `json:"why"` + Since time.Time `json:"since,omitzero"` + Wanted int `json:"wanted"` + HeldBack map[string]string `json:"held_back"` + Minted []Minted `json:"minted"` +} + +// MintState answers how minting stands. +func (v *Vault) MintState() MintState { + m := &v.minting + m.mu.Lock() + defer m.mu.Unlock() + out := MintState{On: m.on, Why: m.why, Since: m.since, Wanted: len(m.wants), HeldBack: map[string]string{}, Minted: []Minted{}} + if out.Why == "" { + out.Why = "the controller's lease has not been read yet, so nothing is minted" + } + for u, why := range m.heldBack { + out.HeldBack[u] = why + } + for _, x := range m.minted { + out.Minted = append(out.Minted, x) + } + sort.Slice(out.Minted, func(i, j int) bool { return out.Minted[i].User < out.Minted[j].User }) + return out +} + +// mintingOn says whether the vault mints now, and why. +func (v *Vault) mintingOn() (bool, string) { + v.minting.mu.Lock() + defer v.minting.mu.Unlock() + if v.minting.why == "" { + return false, "the controller's lease has not been read yet, so nothing is minted" + } + return v.minting.on, v.minting.why +} + +func (v *Vault) holdBack(user, why string) { + m := &v.minting + m.mu.Lock() + defer m.mu.Unlock() + if m.heldBack == nil { + m.heldBack = map[string]string{} + } + m.heldBack[user] = why +} + +func (v *Vault) noLongerHeld(user string) { + m := &v.minting + m.mu.Lock() + defer m.mu.Unlock() + delete(m.heldBack, user) +} + +// 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()) +} + +// WantHeard is the watch of the grants part's wanted accounts: a key put is a want, minted when minting is on; a key +// deleted is a want gone (retire.go). +func (v *Vault) WantHeard(user string, raw []byte, deleted bool) { + if deleted { + v.wantGone(user) + return + } + w, err := buscompose.ReadWantedAccount(user, raw) + if err != nil { + v.log("the wanted account %s is not read, so nothing is minted for it: %v", user, err) + v.holdBack(user, "its wanted key is not readable: "+err.Error()) + return + } + v.minting.mu.Lock() + if v.minting.wants == nil { + v.minting.wants = map[string]buscompose.WantedAccount{} + } + v.minting.wants[user] = w + v.minting.mu.Unlock() + v.wantBack(user) + if _, err := v.mintWanted(w, "the grants part's want of "+user); err != nil { + v.log("%s is not minted: %v", user, err) + } +} + +// errHeldBack is a want the vault does not mint now, with why: not an error of the vault. +type errHeldBack struct{ why string } + +func (e errHeldBack) Error() string { return e.why } + +// mintWanted mints w when minting is on, the vault holds no value for it and adopted no hash of it; otherwise it says +// why not and, where that is a hold, keeps it for vault_status. +func (v *Vault) mintWanted(w buscompose.WantedAccount, by string) (Minted, error) { + v.minting.mintMu.Lock() + defer v.minting.mintMu.Unlock() + if err := w.Valid(); err != nil { + v.holdBack(w.User, err.Error()) + return Minted{}, err + } + secret, err := AccountSecret(w) + if err != nil { + v.holdBack(w.User, err.Error()) + return Minted{}, err + } + if _, held, err := v.Store.Read(secret); err != nil { + return Minted{}, err + } else if held { + // Its value is held (the controller's, handed in, or one the vault minted): never minted anew (§4). + v.noLongerHeld(w.User) + return Minted{}, errHeldBack{secret + " is held already: an account the vault holds a value for is never minted anew (ADR 0561 §4)"} + } + if v.Accounts == nil { + v.holdBack(w.User, "the vault has no state of bus accounts to write the hash to") + return Minted{}, errors.New("the vault has no state of bus accounts to write the hash to") + } + was, err := v.Accounts.Get(w.User) + if err != nil { + v.holdBack(w.User, "its key in "+BusAccountsState+" cannot be read: "+err.Error()) + return Minted{}, err + } + if was != nil && was.Adopted { + // The hash the bus loaded is kept (§4): not minted, and not held back either. + v.noLongerHeld(w.User) + return Minted{}, errHeldBack{w.User + "'s hash was adopted from the list the bus loaded: it keeps it, and is never minted (ADR 0561 §4)"} + } + if was != nil && was.Of != "" && was.Of != secret { + why := fmt.Sprintf("%s holds the hash of %s for %s, not of %s: a user has one value, so nothing is minted until "+ + "a person sees why (ADR 0561 §7)", BusAccountsState, was.Of, w.User, secret) + v.holdBack(w.User, why) + return Minted{}, errHeldBack{why} + } + if on, why := v.mintingOn(); !on { + v.holdBack(w.User, "held back: "+why) + return Minted{}, errHeldBack{why} + } + raw := make([]byte, 30) + if _, err := rand.Read(raw); err != nil { + return Minted{}, err + } + value, err := buscompose.HeldCredential(w, base64.RawURLEncoding.EncodeToString(raw)) + if err != nil { + v.holdBack(w.User, err.Error()) + return Minted{}, err + } + c, err := v.keepLocked(secret, string(value), "the vault", "", OriginMade, keepOptions{neverReplace: true, quiet: true}) + if err != nil { + v.holdBack(w.User, "its value could not be kept: "+err.Error()) + return Minted{}, err + } + out := Minted{User: w.User, Secret: secret, Generation: c.Generation, At: c.At} + // The hash first, then account-issued, then secret-changed: the grants part composes the user before the node asks. + if _, err := v.HashAccount(secret, false); err != nil { + v.hashes.owe(secret, err.Error()) + v.log("%s is minted and kept, and ITS HASH IS NOT WRITTEN, written at the next pass: %v", w.User, err) + } else { + out.Hashed = true + if v.Emit != nil { + e := buscompose.AccountIssued{User: w.User, Kind: w.Kind, Node: w.Node, Module: w.Module, Process: w.Process, + Generation: c.Generation, At: c.At} + body := map[string]any{} + b, _ := json.Marshal(e) + _ = json.Unmarshal(b, &body) + if err := v.Emit(AccountIssuedEvent, body); err != nil { + v.log("%s is minted and hashed, but %s could not be said: %v", w.User, AccountIssuedEvent, err) + } + } + } + v.announce(c) + v.minting.mu.Lock() + if v.minting.minted == nil { + v.minting.minted = map[string]Minted{} + } + v.minting.minted[w.User] = out + v.minting.mu.Unlock() + v.noLongerHeld(w.User) + v.log("minted the bus account %s as %s (generation %d), for %s", w.User, secret, c.Generation, by) + return out, nil +} + +// heldBackOrMinted is a mint's answer as a tool gives it. +func heldBackOrMinted(m Minted, err error) (any, error) { + var held errHeldBack + if errors.As(err, &held) { + return map[string]any{"minted": false, "why": held.why}, nil + } + if err != nil { + return nil, err + } + return map[string]any{"minted": true, "account": m}, nil +} + +// wantedUsers is every wanted user heard, sorted. +func (v *Vault) wantedUsers() []string { + v.minting.mu.Lock() + defer v.minting.mu.Unlock() + out := make([]string, 0, len(v.minting.wants)) + for u := range v.minting.wants { + out = append(out, u) + } + sort.Strings(out) + return out +} + +func (v *Vault) wanted(user string) (buscompose.WantedAccount, bool) { + v.minting.mu.Lock() + defer v.minting.mu.Unlock() + w, ok := v.minting.wants[user] + return w, ok +} + +// nodeOrController says user is a node's account or the controller's: never minted here (ADR 0561 §4, §9). +func nodeOrController(user string) bool { + return user == ControllerCaller || strings.HasPrefix(user, "node.") +} diff --git a/modules/mesh-vault/cmd/mesh-vault/mint_test.go b/modules/mesh-vault/cmd/mesh-vault/mint_test.go new file mode 100644 index 00000000..fba566fc --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/mint_test.go @@ -0,0 +1,107 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/buscompose" + "git.novox.be/novox/mesh-sdk/go/seal" +) + +// aWant is a wanted account of a module on ace, as the grants part writes it. +func aWant(module string) buscompose.WantedAccount { + return buscompose.WantedAccount{User: "ace." + module, Kind: buscompose.KindModule, Node: "ace", Module: module, + URL: "nats://novox.mesh:4222", Fingerprint: "ab:cd", Claims: []buscompose.ClaimedSeat{{Seat: "s", Scope: "node"}}} +} + +func wantRaw(t *testing.T, w buscompose.WantedAccount) []byte { + t.Helper() + raw, err := json.Marshal(w) + if err != nil { + t.Fatal(err) + } + return raw +} + +// mintingVault is a vault with a state of bus accounts, ace's sealing key stated, ace's modules assigned, and minting +// turned on or off as on says. +func mintingVault(t *testing.T, on bool) (testVault, *fakeAccounts, string) { + t.Helper() + plans := assignments{"ace.mod-x": `{"node":"ace","module":"mod-x"}`, "ace.mod-y": `{"node":"ace","module":"mod-y"}`} + v := newVault(t, nil) + v.Askers = Declared{Read: plans.read, Store: v.Store} + accounts := &fakeAccounts{} + v.Accounts = accounts + pub, priv := aSealingKey(t) + if err := v.recordNodeKey("ace", pub, "node.ace"); err != nil { + t.Fatal(err) + } + if on { + v.LeaseHeard([]byte(`{"handed-to":"the operator's hand-over"}`), true, nil) + } else { + v.LeaseHeard([]byte(`{"instance":"novox/1/2026-10-27","epoch":57}`), true, nil) + } + return v, accounts, priv +} + +// 774/AC7 (novox/hq ADR 0561 §1, §2, §4): a want with no value, minting on, is minted once: kept whole in the controller's +// shape as the module's broker, its hash in bus-accounts, said account-issued before secret-changed, and answered by own +// sealed to the node; a value held or a hash adopted is never minted. +func TestTheVaultMintsAWantedAccountOnceAndNothingItHolds(t *testing.T) { + v, accounts, nodePriv := mintingVault(t, true) + w := aWant("mod-x") + before := len(v.bus.events) + v.WantHeard(w.User, wantRaw(t, w), false) + rec, held, err := v.Store.Read("own/ace/mod-x/broker") + if err != nil || !held || rec.Generation != 1 || rec.Origin != OriginMade { + t.Fatalf("the minted account is kept as %+v (held %v, %v)", rec, held, err) + } + value, err := seal.Open(rec.AtRest, v.Own.PrivateKey) + if err != nil { + t.Fatal(err) + } + var got struct{ Password string } + _ = json.Unmarshal([]byte(value), &got) + shape, _ := buscompose.HeldCredential(w, got.Password) + if len(got.Password) != 40 || value != string(shape) { + t.Errorf("the minted value is %q, not the controller's shape %q", value, shape) + } + if !accounts.hashOf(w.User, got.Password, 1) { + t.Errorf("%s's hash is not the minted password's: %+v", w.User, accounts.keys[w.User]) + } + var order []string + for _, e := range v.bus.events[before:] { + order = append(order, e["event"].(string)) + } + if strings.Join(order, ",") != AccountHashedEvent+","+AccountIssuedEvent+","+ChangedEvent { + t.Errorf("said %v, want the hash, account-issued, then secret-changed", order) + } + for _, raw := range v.bus.raw[before:] { + if strings.Contains(raw, got.Password) || strings.Contains(raw, "$2a$") { + t.Errorf("a value or a hash was said: %s", raw) + } + } + own, err := v.OwnOfNode("node.ace") + if err != nil || len(own.Own) != 1 { + t.Fatalf("own answered %+v, %v", own, err) + } + if opened, err := seal.Open(own.Own[0].Sealed, nodePriv); err != nil || opened != value { + t.Errorf("own's sealed value opens to %q, %v", opened, err) + } + + // Heard again, or for a value held, or for a hash adopted: nothing is minted, nothing is held back. + v.WantHeard(w.User, wantRaw(t, w), false) + if rec, _, _ := v.Store.Read("own/ace/mod-x/broker"); rec.Generation != 1 { + t.Errorf("a want heard again minted generation %d", rec.Generation) + } + accounts.keys["ace.mod-y"] = AccountHash{User: "ace.mod-y", Hash: "$2a$04$adopted", Adopted: true} + y := aWant("mod-y") + v.WantHeard(y.User, wantRaw(t, y), false) + if _, held, _ := v.Store.Read("own/ace/mod-y/broker"); held || accounts.keys["ace.mod-y"].Hash != "$2a$04$adopted" { + t.Errorf("an adopted user was minted") + } + if st := v.MintState(); len(st.HeldBack) != 0 || len(st.Minted) != 1 { + t.Errorf("vault_status says %+v: an adopted user is not held back, one account minted", st) + } +} diff --git a/modules/mesh-vault/cmd/mesh-vault/retire.go b/modules/mesh-vault/cmd/mesh-vault/retire.go new file mode 100644 index 00000000..c775df57 --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/retire.go @@ -0,0 +1,12 @@ +package main + +// wantGone is a wanted account's key deleted: forgotten here (its retirement is ADR 0230's, retire's). +func (v *Vault) wantGone(user string) { + v.minting.mu.Lock() + defer v.minting.mu.Unlock() + delete(v.minting.wants, user) + delete(v.minting.heldBack, user) +} + +// wantBack is a wanted account's key written again. +func (v *Vault) wantBack(string) {} diff --git a/modules/mesh-vault/cmd/mesh-vault/vault.go b/modules/mesh-vault/cmd/mesh-vault/vault.go index 6734901d..da2ee430 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 + // 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. callsMu sync.Mutex pending map[string]*callKey @@ -104,8 +106,10 @@ func (v *Vault) keep(name, value, by, via, origin string) (Changed, error) { // was first made, and where it was moved from. type keepOptions struct { neverReplace bool - madeAt time.Time - movedFrom *MovedFrom + // quiet keeps secret-changed for the caller to say once the rest is done (a minted account: mint.go). + quiet bool + madeAt time.Time + movedFrom *MovedFrom } // keepWith is keep with the move's options. A covered bus account kept anew has its hash written once it is kept @@ -186,7 +190,9 @@ func (v *Vault) keepLocked(name, value, by, via, origin string, opt keepOptions) return Changed{}, err } c := Changed{Name: name, Generation: rec.Generation, At: now, By: by} - v.announce(c) + if !opt.quiet { + v.announce(c) + } return c, nil } diff --git a/modules/mesh-vault/cmd/mesh-vault/vault_test.go b/modules/mesh-vault/cmd/mesh-vault/vault_test.go index fefbff17..91a0a736 100644 --- a/modules/mesh-vault/cmd/mesh-vault/vault_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/vault_test.go @@ -147,8 +147,9 @@ 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"]` { - t.Errorf("the manifest reads %s: the vault reads the controller's assignments and the haves alone", r) + if r, _ := json.Marshal(manifest["reads"]); string(r) != `["mesh-controller.assignments","node-engine.have","mesh-grants.accounts"]` { + t.Errorf("the manifest reads %s: the vault reads the controller's assignments, the haves and the grants part's "+ + "wanted accounts alone", r) } sources, _ := filepath.Glob("*.go") bucket := regexp.MustCompile(`stdio\.State\(|nats-io|jetstream|KeyValue`) @@ -167,10 +168,12 @@ func TestNoBucketHoldsASecret(t *testing.T) { code = []byte(strings.Replace(string(code), "stdio.State(BusAccountsState).Put(", "", 1)) code = []byte(strings.Replace(string(code), "stdio.State(BusAccountsState).Keys(", "", 1)) } - // The haves, in main.go alone: one read and one watch, never a write. + // 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). if f == "main.go" { 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)) } 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 304b95df..3be11a1c 100644 --- a/modules/mesh-vault/module.json +++ b/modules/mesh-vault/module.json @@ -15,11 +15,13 @@ "provisioned", "rotated", "secret-changed", - "account-hashed" + "account-hashed", + "account-issued" ], "reads": [ "mesh-controller.assignments", - "node-engine.have" + "node-engine.have", + "mesh-grants.accounts" ], "state": [ {