diff --git a/modules/mesh-vault/cmd/mesh-vault/accounts.go b/modules/mesh-vault/cmd/mesh-vault/accounts.go index 9e064931..c102337e 100644 --- a/modules/mesh-vault/cmd/mesh-vault/accounts.go +++ b/modules/mesh-vault/cmd/mesh-vault/accounts.go @@ -53,6 +53,8 @@ type AccountHash struct { Adopted bool `json:"adopted,omitempty"` From string `json:"from,omitempty"` Node string `json:"node,omitempty"` + // Rotating is a rotation under way over a second login (rotation.go, ADR 0561 §4): absent when none is. + Rotating *Rotating `json:"rotating,omitempty"` } // AccountStore is the vault's state of bus-account hashes: read and written by the vault's instances alone. diff --git a/modules/mesh-vault/cmd/mesh-vault/adopt.go b/modules/mesh-vault/cmd/mesh-vault/adopt.go index 2e6e998b..7b80b103 100644 --- a/modules/mesh-vault/cmd/mesh-vault/adopt.go +++ b/modules/mesh-vault/cmd/mesh-vault/adopt.go @@ -143,6 +143,9 @@ func (v *Vault) adopt(node string, raw []byte, by string, force bool) (Adoption, if err != nil { return Adoption{}, fmt.Errorf("the have of %s on %s is not a have: %w", BusModule, node, err) } + if have.UserList != nil { + v.LoadedListHeard(have.UserList.Revision) + } if have.UserList == nil || have.UserList.Handed == nil { return Adoption{}, fmt.Errorf("the have of %s on %s hands no hashes yet (its node-engine hands them once it "+ "heard the vault's key in its answer to node-key, and writes the list)", BusModule, node) diff --git a/modules/mesh-vault/cmd/mesh-vault/main.go b/modules/mesh-vault/cmd/mesh-vault/main.go index 083cc1a6..85bf3ca7 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, LeaseRead: readLease} + Now: time.Now, Log: say, Accounts: busAccounts{}, Haves: readHave, LeaseRead: readLease, WantRead: readWant} return env } @@ -114,6 +114,15 @@ func watchWants(env *Env) bool { return true } +// readWant is one wanted account of the grants part, read afresh (issue-account). +func readWant(user string) ([]byte, bool, error) { + e, err := stdio.State(WantedRead).Get(user) + if err != nil || e == nil { + return nil, false, err + } + return e.Value, true, nil +} + // 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) diff --git a/modules/mesh-vault/cmd/mesh-vault/mint.go b/modules/mesh-vault/cmd/mesh-vault/mint.go index b581c3b9..04d7f960 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 + // loadedRevision is the highest revision of the user list the bus node said it loaded (rotation.go). + loadedRevision uint64 // 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. @@ -265,6 +267,44 @@ func heldBackOrMinted(m Minted, err error) (any, error) { return map[string]any{"minted": true, "account": m}, nil } +// IssueAccount is the seat's verb issue-account (ADR 0561 §9): one wanted user minted now, its key read afresh from the +// grants part's state. The recovery when a want was not heard: it mints nothing a want would not, and is refused while +// minting is off, for a node's account and the controller's (never minted here, §4), and for a user nobody wants. +func (v *Vault) IssueAccount(caller, user string) (any, error) { + user = strings.TrimSpace(user) + if user == "" { + return nil, errors.New("issue-account names a bus user (`user`), e.g. ace.mesh-build-go") + } + if nodeOrController(user) { + return nil, fmt.Errorf("%s is never minted by the vault: a node's account and the controller's keep the hash they "+ + "have (ADR 0561 §4)", user) + } + v.leaseAfresh() + if on, why := v.mintingOn(); !on { + return nil, fmt.Errorf("not minted: %s", why) + } + raw, found, err := []byte(nil), false, error(nil) + if v.WantRead != nil { + raw, found, err = v.WantRead(user) + } else if w, ok := v.wanted(user); ok { + raw, err = json.Marshal(w) + found = err == nil + } + if err != nil { + return nil, fmt.Errorf("the wanted account %s could not be read: %w", user, err) + } + if !found { + return nil, fmt.Errorf("the grants part holds no wanted account %s: the vault mints only what placement composes "+ + "(grants_accounts lists them)", user) + } + w, err := buscompose.ReadWantedAccount(user, raw) + if err != nil { + return nil, err + } + v.log("issue-account %s, asked by %s", user, someoneOr("", caller)) + return heldBackOrMinted(v.mintWanted(w, "issue-account by "+someoneOr("", caller))) +} + // wantedUsers is every wanted user heard, sorted. func (v *Vault) wantedUsers() []string { v.minting.mu.Lock() diff --git a/modules/mesh-vault/cmd/mesh-vault/mint_test.go b/modules/mesh-vault/cmd/mesh-vault/mint_test.go index aadd8abe..f7a3c7c6 100644 --- a/modules/mesh-vault/cmd/mesh-vault/mint_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/mint_test.go @@ -148,3 +148,76 @@ func TestNothingIsMintedUntilTheLeaseIsHandedAway(t *testing.T) { t.Errorf("after the key expired vault_status says %+v", st) } } + +// 774/AC10 issue-account (novox/hq ADR 0561 §9): the verb mints one wanted user now, read afresh; it is refused while +// minting is off, for a node's account and the controller's, and for a user nobody wants; a user held already is said so. +func TestIssueAccountMintsOneWantedUserNowAndRefusesTheRest(t *testing.T) { + v, _, _ := mintingVault(t, false) + w := aWant("mod-x") + raw := wantRaw(t, w) + v.WantRead = func(user string) ([]byte, bool, error) { + if user == w.User { + return raw, true, nil + } + return nil, false, nil + } + for user, says := range map[string]string{ + "ace.mod-x": "controller holds the lease", + "node.ace": "never minted", + "controller": "never minted", + } { + if _, err := v.IssueAccount("person.jochen", user); err == nil || !strings.Contains(err.Error(), says) { + t.Errorf("%s: %v, want a refusal saying %q", user, err, says) + } + } + v.LeaseHeard([]byte(`{"handed-to":"the operator's hand-over"}`), true, nil) + if _, err := v.IssueAccount("person.jochen", "ace.nobody"); err == nil || !strings.Contains(err.Error(), "no wanted account") { + t.Errorf("a user nobody wants: %v", err) + } + got, err := v.IssueAccount("person.jochen", w.User) + if err != nil || got.(map[string]any)["minted"] != true { + t.Fatalf("issue-account: %v, %v", got, err) + } + if again, err := v.IssueAccount("person.jochen", w.User); err != nil || again.(map[string]any)["minted"] != false || + !strings.Contains(again.(map[string]any)["why"].(string), "held already") { + t.Errorf("issue-account again: %v, %v", again, err) + } +} + +// 774/AC10 the rotation's rule (novox/hq ADR 0561 §4): at most one rotation begins per revision of the loaded user list, +// over the second login `--b`, never of a node's account, the controller's or an adopted hash, and `rotate` of a +// covered account stays refused, so nothing rotates in place. +func TestOneRotationBeginsPerRevisionOfTheLoadedList(t *testing.T) { + v, accounts, _ := mintingVault(t, true) + for _, m := range []string{"mod-x", "mod-y"} { + w := aWant(m) + v.WantHeard(w.User, wantRaw(t, w), false) + } + if _, err := v.BeginRotation("ace.mod-x"); err == nil || !strings.Contains(err.Error(), "not known yet") { + t.Errorf("began with no loaded revision known: %v", err) + } + v.LoadedListHeard(570) + r, err := v.BeginRotation("ace.mod-x") + if err != nil || r.Login != "ace.mod-x--b" || r.Revision != 570 || r.Generation != 2 || accounts.keys["ace.mod-x"].Rotating == nil { + t.Fatalf("the first rotation: %+v, %v", r, err) + } + if _, err := v.BeginRotation("ace.mod-y"); err == nil || !strings.Contains(err.Error(), "one rotation begins per revision") { + t.Errorf("a second rotation in one revision: %v", err) + } + v.LoadedListHeard(571) + if _, err := v.BeginRotation("ace.mod-y"); err != nil { + t.Errorf("the next revision's rotation: %v", err) + } + if _, err := v.BeginRotation("ace.mod-x"); err == nil || !strings.Contains(err.Error(), "rotating already") { + t.Errorf("an account rotating began again: %v", err) + } + accounts.keys["node.ace"] = AccountHash{User: "node.ace", Hash: "$2a$04$n", Adopted: true} + for _, u := range []string{"node.ace", "controller"} { + if _, err := v.BeginRotation(u); err == nil { + t.Errorf("%s began a rotation", u) + } + } + if _, err := v.RotateFor(ControllerCaller, "own/ace/mod-x/broker", Narrow{}, "the test", "a rotation in place"); err == nil { + t.Error("a covered account rotated in place") + } +} diff --git a/modules/mesh-vault/cmd/mesh-vault/rotation.go b/modules/mesh-vault/cmd/mesh-vault/rotation.go new file mode 100644 index 00000000..31244f97 --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/rotation.go @@ -0,0 +1,94 @@ +package main + +// The record of a bus account's rotation, and its one rule (novox/hq ADR 0561 §4; issue 774, the lead's decision of +// 2026-10-27). A module's, a process's or a provisioner client's account rotates over two credentials (ADR 0114 §10): +// a second login, `--b`, carrying the first login's permissions verbatim, which the bus composer composes only while +// the account's key in `bus-accounts` names it in `rotating`. The vault starts at most one rotation per revision of the +// user list the bus loaded. What composes and delivers the second login is a subtask of its own, before the lease write; +// until it is built nothing begins a rotation, and `rotate` of a covered account stays refused, so nothing rotates in +// place. + +import ( + "fmt" + "strings" + "time" +) + +// SecondLoginSuffix makes an account's second login: reserved, so no module or process may be named to end in it. +const SecondLoginSuffix = "--b" + +// Rotating is a rotation under way, as the account's key in bus-accounts says it: the second login, the generation of +// the value it carries, since when, and the revision of the loaded user list it began at. +type Rotating struct { + Login string `json:"login"` + Generation int `json:"generation"` + Since time.Time `json:"since"` + Revision uint64 `json:"revision"` +} + +// LoadedListHeard keeps the revision of the user list the bus node says it loaded (its have of the bus module). +func (v *Vault) LoadedListHeard(revision uint64) { + v.minting.mu.Lock() + defer v.minting.mu.Unlock() + if revision > v.minting.loadedRevision { + v.minting.loadedRevision = revision + } +} + +// BeginRotation records a rotation of user's account at the loaded list's revision, under the one rule: none for a +// node's account or the controller's, none while minting is off, none of an account the vault did not make or was not +// handed, none twice for one account, and none when another began at this revision. +func (v *Vault) BeginRotation(user string) (Rotating, error) { + if nodeOrController(user) { + return Rotating{}, fmt.Errorf("%s does not rotate here: a node's account and the controller's keep the hash they have (ADR 0561 §4)", user) + } + if on, why := v.mintingOn(); !on { + return Rotating{}, fmt.Errorf("no rotation begins: %s", why) + } + v.minting.mu.Lock() + revision := v.minting.loadedRevision + v.minting.mu.Unlock() + if revision == 0 { + return Rotating{}, fmt.Errorf("no rotation begins: the revision of the user list the bus loaded is not known yet") + } + users, err := v.Accounts.Users() + if err != nil { + return Rotating{}, err + } + var mine *AccountHash + began := "" + for _, u := range users { + a, err := v.Accounts.Get(u) + if err != nil { + return Rotating{}, err + } + if a == nil { + continue + } + if a.Rotating != nil && a.Rotating.Revision == revision { + began = u + } + if u == user { + mine = a + } + } + switch { + case mine == nil || mine.Adopted || mine.Of == "": + return Rotating{}, fmt.Errorf("the vault holds no account of %s it made or was handed, so it rotates none", user) + case mine.Rotating != nil: + return Rotating{}, fmt.Errorf("%s is rotating already, since %s (second login %s)", user, mine.Rotating.Since.Format(time.RFC3339), mine.Rotating.Login) + case strings.HasSuffix(user, SecondLoginSuffix): + return Rotating{}, fmt.Errorf("%s is a second login, which never rotates of its own", user) + case began != "": + return Rotating{}, fmt.Errorf("no rotation of %s begins: %s's began at the loaded list's revision %d, and one "+ + "rotation begins per revision (ADR 0561 §4)", user, began, revision) + } + r := Rotating{Login: user + SecondLoginSuffix, Generation: mine.Generation + 1, Since: v.Now().UTC(), Revision: revision} + next := *mine + next.Rotating = &r + if err := v.Accounts.Put(next); err != nil { + return Rotating{}, err + } + v.log("a rotation of %s begins at the loaded list's revision %d: second login %s", user, revision, r.Login) + return r, nil +} diff --git a/modules/mesh-vault/cmd/mesh-vault/tools.go b/modules/mesh-vault/cmd/mesh-vault/tools.go index d2149bb5..70d89f7e 100644 --- a/modules/mesh-vault/cmd/mesh-vault/tools.go +++ b/modules/mesh-vault/cmd/mesh-vault/tools.go @@ -251,6 +251,20 @@ func definitions(env *Env) []toolDef { // clause 9, adopt.go): a public half, never the private one. return map[string]any{"recorded": true, "vault_key": v.Own.PublicKey}, nil }}, + {Name: Seat + ".issue-account", + Description: "Mint one bus account the grants part wants, now (novox/hq ADR 0561 §9): the recovery when the vault did not " + + "hear a want. Reads the wanted key afresh from mesh-grants.accounts and mints it as a want would: only while " + + "the controller's lease is handed away, never for a node's account or the controller's, never a user nobody " + + "wants, and never anew for one whose value the vault holds or whose hash it adopted (it says so). Answers " + + "whether it minted, the secret and generation, never the value or the hash.", + Input: map[string]any{"user": str("the bus user, e.g. ace.mesh-build-go")}, + run: func(caller string, a map[string]any) (any, error) { + v, err := env.ready() + if err != nil { + return nil, err + } + return v.IssueAccount(caller, arg(a, "user")) + }}, {Name: Seat + ".own", Description: "The own secrets of this machine's modules, each sealed to the machine's sealing key (novox/hq ADR 0547 §2, " + "ADR 0550 §5, issue 1126): from the node-engine of the machine alone, about its own machine (the bus proves which), " + diff --git a/modules/mesh-vault/cmd/mesh-vault/vault.go b/modules/mesh-vault/cmd/mesh-vault/vault.go index 3d975e97..4707dff5 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 + // WantRead reads one wanted account afresh (issue-account): nil reads the wants as the watch handed them. + WantRead func(user string) ([]byte, bool, error) // 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). diff --git a/modules/mesh-vault/cmd/mesh-vault/vault_test.go b/modules/mesh-vault/cmd/mesh-vault/vault_test.go index 908affe3..0dfe93df 100644 --- a/modules/mesh-vault/cmd/mesh-vault/vault_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/vault_test.go @@ -176,6 +176,7 @@ 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)) + code = []byte(strings.Replace(string(code), "stdio.State(WantedRead).Get(", "", 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)) diff --git a/modules/mesh-vault/module.json b/modules/mesh-vault/module.json index 66efe9fa..a29c31ae 100644 --- a/modules/mesh-vault/module.json +++ b/modules/mesh-vault/module.json @@ -182,7 +182,8 @@ "move-in", "node-key", "move-asks", - "own" + "own", + "issue-account" ] } ]