From eba300794f8423ed8e5e0ead70aa1e049fc30f20 Mon Sep 17 00:00:00 2001 From: mesh-admin Date: Wed, 14 Oct 2026 19:10:55 +0000 Subject: [PATCH] Serve the vault's intake verbs and say how they work (issue 675) Co-Authored-By: Claude Opus 5.5 --- modules/mesh-vault/README.md | 60 ++++++++- modules/mesh-vault/cmd/mesh-vault/tools.go | 147 ++++++++++++++++++++- 2 files changed, 202 insertions(+), 5 deletions(-) diff --git a/modules/mesh-vault/README.md b/modules/mesh-vault/README.md index b34b94c0..d86f164b 100644 --- a/modules/mesh-vault/README.md +++ b/modules/mesh-vault/README.md @@ -112,6 +112,54 @@ of its own, for what its node's declaration names, and a module under its own ac exists yet, so the running vault refuses every asker; the tests set a stand-in list. The real rule goes behind the `Askers` interface in `askers.go` (design 57's V4 and P3). +## Taking values in: `accept`, `secret-ask`, `issued`, `rotate` + +Design 57's V2 (issue 675; ADR 0349 clauses 8 to 10, ADR 0277 §22–§27 and ADR 0305 with the vault in the +controller's place). `intake.go`. + +**Two steps of one verb, from the controller alone.** The first step names the secret and answers a `call` and +`seal_to`, the public half of an X25519 key the vault made for that one call (raw, base64 — the NaCl anonymous box +the desk's prompt and a provider's tool already seal to). The second step sends `call` and `sealed`, and the vault +opens it with the private half, which lives in this process only and is forgotten once used or after two minutes. +A value sealed to any other key is refused, and a call is answered once. + +**Why the controller carries it.** A desk's prompt (`node-launcher.secret`) and a provider's tool answer the +controller alone: the bus lets only the controller ask them, on the subject that names its caller (issue 365). The +vault cannot call in a name of its own while it runs in its machine's tool runner, whose one bus account carries +that machine's agents' calls too (issue 569, ADR 0305 §3). So the controller asks them with the vault's key and +passes the sealed answer on; it cannot open it. The value opens in the vault and nowhere else. The vault refuses +these steps from any caller but `controller`, as the runtime proved it. + +- `accept` — node, module, name (a module's own secret) or provider, provision (a provision's one credential), + and `by`. The controller's terminal seals the value it read to the call's key before it leaves the terminal. + Kept with origin `accepted`; an accepted provision credential is never remade (`rotate` refuses it); a value a + provider's software issued is not taken by `accept`. +- `secret-ask` — node, module, name, desk and `asked_by` (who asked the controller, as the bus named its caller). + The ask is recorded in `asks.jsonl` before anything else, and refused while another ask for the same secret is + open (two minutes at most) or when three were made in the last hour. The answer names who asked as the record + has it, which the prompt shows. The second step carries what the prompt sealed, or `outcome` (dismissed, + timed-out, empty, refused, failed); how each ask ended is recorded. `secret_asks` reads the record. The hand-act + log stays the controller's: it records the act once the vault has taken the value. +- `issued` — provision, provider (machine), module. The controller asks the provider's tool with the call's key, + as it asks it today; the value is kept as the provision's one credential with origin `issued`. The same value + again writes nothing, says nothing and keeps the generation (ADR 0305 §5). +- `rotate` — a module's own secret or a provision's credential, made anew. Refused for an `issued` value (the + software rotates it, ADR 0305 §9) and an accepted provision credential (clause 8). Who asked is the proven caller, + or the `by` the controller names when it carries the act; both are kept (`changed-by`, `changed-via`). + +Names in the store: `own///` and `provision//`. V3 copies the +controller's rows in under these names. + +**Which modules reach the vault this way.** The controller sends a module's verbs here only when its secrets are +filled the new way (its machine asks the vault for them, V4); every other module keeps the controller's path, so +no value lands where its machine never looks (ADR 0349 clause 17; the release lead's decision on issue 675). Until +V4 that is no module: these verbs are served and tested, and nothing calls them yet. + +**What the issuing provider's test is here.** `TestAnIssuingProviderAnswersOnlyTheControllersCallSealedToTheVault` +uses a stand-in provider that refuses every caller but the controller and answers only sealed to the call's key. +plex's own tool already answers the controller alone; whether it moves with the media catalogue is for that move, +after release 57. + ## Events - `secret-changed` — `name`, `by`, `at`, `generation`: which secret, who changed it, when. Never a value @@ -151,9 +199,9 @@ file. ## What comes later -Taking values in (`secret accept`, `secret-ask`, `issued`, `rotate`) is V2; the controller's secrets move -in with V3; nodes ask with V4; pair credentials are minted here with V5. Until V3 the vault's store is -empty and the controller still serves every secret. +Taking values in is built (above); the controller's secrets move in with V3; nodes ask with V4, which also turns +the controller's switch on for each module it moves; pair credentials are minted here with V5. Until V3 the +vault's store is empty and the controller still serves every secret. ## Tests @@ -161,4 +209,8 @@ empty and the controller still serves every secret. `vault_test.go` is ADR 0349's vault test list as far as this package goes: no bucket holds a secret; no private key is accepted; every secret has an operator-sealed copy; `get` answers only an allowed asker, -sealed to its key; `secret-changed` carries no value. +sealed to its key; `secret-changed` carries no value. `intake_test.go` is ADR 0349's `secret-ask` test as far as +the vault answers it (recorded before the prompt, one open ask per secret and three an hour, the asker named as +the bus named it, the typed value opened only here), the issuing provider's test with a stand-in provider (ADR 0305 +§13's checks that belong to the vault: nothing rewritten for a value held, `rotate` refused, no other way in), and +accept and rotate. `followups_test.go` holds V1's review follow-ups. diff --git a/modules/mesh-vault/cmd/mesh-vault/tools.go b/modules/mesh-vault/cmd/mesh-vault/tools.go index 1bd56770..7e686136 100644 --- a/modules/mesh-vault/cmd/mesh-vault/tools.go +++ b/modules/mesh-vault/cmd/mesh-vault/tools.go @@ -83,6 +83,100 @@ func definitions(env *Env) []toolDef { key, _ := a["public_key"].(string) return env.Vault.Get(caller, strings.TrimSpace(name), key) }}, + {Name: Seat + ".accept", + Description: "Take a value given at the controller's terminal (ADR 0349 clause 8), in two steps from the " + + "controller alone: name the secret (node, module and name for a module's own; provider and provision " + + "for a provision's one credential) and get a call and a key made for it; then send the call and the " + + "value sealed to that key. The vault alone opens it, and never remakes an accepted credential.", + Input: intakeInput(map[string]string{"node": "the machine, for a module's own secret", + "module": "the module, for a module's own secret", "name": "the secret's name in the module", + "provider": "the provider's machine, for a provision's credential", "provision": "the provision", + "by": "who gave it at the terminal, as the controller names them"}), + run: func(caller string, a map[string]any) (any, error) { + v, err := env.ready() + if err != nil { + return nil, err + } + if call := arg(a, "call"); call != "" { + return v.AcceptTake(caller, call, arg(a, "sealed")) + } + secret, err := secretNamed(a) + if err != nil { + return nil, err + } + return v.AcceptOpen(caller, secret, arg(a, "by")) + }}, + {Name: Seat + ".secret-ask", + Description: "Ask the operator for a module's own secret at a desk (ADR 0349 clause 9, ADR 0277 §22–§27), in " + + "two steps from the controller alone, which reaches the desk's prompt: node, module, name, desk and " + + "asked_by open the ask — recorded before the prompt opens, one open ask per secret and three an " + + "hour — and answer the key the prompt seals to and who asked as the record names them; then the call " + + "with what the prompt sealed, or with how it ended (dismissed, timed-out, empty, refused, failed). " + + "What is typed opens in the vault alone.", + Input: intakeInput(map[string]string{"node": "the machine the secret is for", "module": "the module", + "name": "the secret's name in the module", "desk": "the machine whose prompt asks", + "asked_by": "who asked the controller, as the bus named its caller", + "outcome": "instead of sealed: how the prompt ended — dismissed, timed-out, empty, refused or failed"}), + run: func(caller string, a map[string]any) (any, error) { + v, err := env.ready() + if err != nil { + return nil, err + } + if call := arg(a, "call"); call != "" { + return v.AskEnd(caller, call, arg(a, "sealed"), arg(a, "outcome")) + } + return v.AskOpen(caller, AskOf{Node: arg(a, "node"), Module: arg(a, "module"), Name: arg(a, "name"), + Desk: arg(a, "desk"), AskedBy: arg(a, "asked_by")}) + }}, + {Name: Seat + ".issued", + Description: "Take the credential a provider's software issued for a provision (ADR 0349 clause 10, ADR 0305), " + + "in two steps from the controller alone, which asks the provider's tool: provision, provider and module " + + "open a call and answer the key the tool seals to; then the call with what the tool sealed. Kept as the " + + "provision's one credential; the same value again writes nothing.", + Input: intakeInput(map[string]string{"provision": "the provision", "provider": "the provider's machine", + "module": "the provider's module, whose software issued it"}), + run: func(caller string, a map[string]any) (any, error) { + v, err := env.ready() + if err != nil { + return nil, err + } + if call := arg(a, "call"); call != "" { + return v.IssuedTake(caller, call, arg(a, "sealed")) + } + return v.IssuedOpen(caller, arg(a, "provision"), arg(a, "provider"), arg(a, "module")) + }}, + {Name: Seat + ".rotate", + Description: "Make a secret anew (ADR 0114, ADR 0228): node, module and name for a module's own, or provider " + + "and provision for a provision's credential. Refused for a value a provider's software issued, which " + + "the software rotates, and for an accepted credential, which the vault does not remake. Never answers " + + "the value.", + Input: map[string]any{"type": "object", "properties": map[string]any{ + "node": str("the machine, for a module's own secret"), "module": str("the module"), + "name": str("the secret's name in the module"), "provider": str("the provider's machine"), + "provision": str("the provision"), + "by": str("who asked, as the controller names them when it carries the act")}}, + run: func(caller string, a map[string]any) (any, error) { + v, err := env.ready() + if err != nil { + return nil, err + } + secret, err := secretNamed(a) + if err != nil { + return nil, err + } + return v.Rotate(caller, secret, arg(a, "by")) + }}, + {Name: "secret_asks", + Description: "The record of asks for a module's own secret at a desk, newest first: who asked as the bus named " + + "them, for what, at which desk, when, and how each ended. Never a value.", + Input: map[string]any{"limit": map[string]any{"type": "number", "description": "how many lines (default 50)"}}, + run: func(_ string, a map[string]any) (any, error) { + v, err := env.ready() + if err != nil { + return nil, err + } + return v.Store.Asks(limitOf(a)) + }}, {Name: "secret_export", Description: "The mesh's root secrets as this vault keeps them: every secret a module holds for itself, " + "sealed to the operator's key (novox/hq ADR 0085, amended). Ciphertext — nothing here can open a line " + @@ -237,7 +331,8 @@ func actInput(set, one bool) map[string]any { } // actOf is an act's why, by and via. `by` is the person the controller names; with none, the caller the -// runtime proved. +// runtime proved. `by` is an argument, so it is not proven: when it names somebody other than the caller the +// runtime proved, the proven caller is kept beside it in via, so the record says both. func actOf(caller string, a map[string]any) Act { act := Act{} act.Why, _ = a["why"].(string) @@ -246,9 +341,59 @@ func actOf(caller string, a map[string]any) Act { if act.By == "" { act.By = caller } + if act.By != caller { + proven := "called by " + caller + if act.Via == "" { + act.Via = proven + } else { + act.Via += ", " + proven + } + } return act } +// ready is the vault, or why there is none. +func (e *Env) ready() (*Vault, error) { + if e.Vault != nil { + return e.Vault, nil + } + if e.Broken != nil { + return nil, e.Broken + } + return nil, errors.New("the vault is not ready") +} + +func arg(a map[string]any, key string) string { + s, _ := a[key].(string) + return strings.TrimSpace(s) +} + +// secretNamed is the secret a call names: a module's own (node, module, name), or a provision's one credential +// (provider, provision) — one or the other. +func secretNamed(a map[string]any) (string, error) { + own := arg(a, "node") != "" || arg(a, "module") != "" || arg(a, "name") != "" + pair := arg(a, "provider") != "" || arg(a, "provision") != "" + switch { + case own && !pair: + return OwnSecret(arg(a, "node"), arg(a, "module"), arg(a, "name")) + case pair && !own: + return ProvisionSecret(arg(a, "provider"), arg(a, "provision")) + } + return "", errors.New("name a module's own secret (node, module, name) or a provision's credential " + + "(provider, provision), one of the two") +} + +// intakeInput is a two-step verb's arguments: what its first step names, then the call and what was sealed. +func intakeInput(first map[string]string) map[string]any { + props := map[string]any{} + for k, d := range first { + props[k] = str(d) + } + props["call"] = str("the second step: the call the first step answered") + props["sealed"] = str("the second step: the value sealed to the call's key (NaCl anonymous box, base64) — never the value") + return map[string]any{"type": "object", "properties": props} +} + func stringsOf(v any) []string { list, _ := v.([]any) out := []string{}