diff --git a/modules/mesh-vault/README.md b/modules/mesh-vault/README.md index b34b94c0f..d86f164b5 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/followups_test.go b/modules/mesh-vault/cmd/mesh-vault/followups_test.go new file mode 100644 index 000000000..aa5fbb8db --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/followups_test.go @@ -0,0 +1,162 @@ +package main + +// The four follow-ups V1's third review (review 263 on mesh-catalog#286) left for this package: the tool layer +// passes an act's words through; a rolled record of hand-outs is said in the journal; the export's fingerprint +// and vault_status are what they say; and an act's unproven `by` is kept beside the proven caller. + +import ( + "fmt" + "sort" + "strings" + "sync" + "testing" + + "git.novox.be/novox/mesh-sdk/go/seal" +) + +func anyOf(list []string) []any { + out := make([]any, len(list)) + for i, s := range list { + out[i] = s + } + return out +} + +// (1) and (4): the retirement tools pass consumers, why, by, via and confirm through to the custody ledger, and +// a `by` naming somebody other than the proven caller is kept beside it. +func TestTheRetirementToolsPassTheActThrough(t *testing.T) { + c := newCustody(t) + c.ask(apps(5)) + c.passes(1) + c.ask(keepOnly(apps(5), 1)) + c.stable() + set := waitingNames(c.Retirement()) + sort.Strings(set) + defs := definitions(&Env{Custody: c.Custody}) + approve := toolNamed(t, defs, "provisioner_retire_approve") + if _, err := approve.runAs(ControllerCaller, map[string]any{"consumers": anyOf(set[:2]), "why": "x"}); err == nil { + t.Error("the tool took a set other than the one waiting") + } + got, err := approve.runAs(ControllerCaller, map[string]any{"consumers": anyOf(set), "why": "the apps are gone", + "by": "person.jochen", "via": "telegram"}) + if err != nil { + t.Fatal(err) + } + if done := got.(map[string]any)["retired"].([]string); len(done) != 4 { + t.Errorf("retired %v", done) + } + last := c.bus.events[len(c.bus.events)-5]["body"].(map[string]any) + for _, e := range c.bus.events { + if b := e["body"].(map[string]any); b["change"] == "approved" { + last = b + } + } + if last["why"] != "the apps are gone" || last["by"] != "person.jochen" || last["via"] != "telegram, called by controller" { + t.Errorf("the approval said %v", last) + } + + // Delete: consumer and confirm through, the act's words with them. + r := retiredC(t) + del := toolNamed(t, definitions(&Env{Custody: r.Custody}), "provisioner_delete") + if _, err := del.runAs("person.jochen", map[string]any{"consumer": "d-app", "confirm": "c-app", "why": "x"}); err == nil { + t.Error("a confirm naming another was taken through the tool") + } + if _, err := del.runAs("person.jochen", map[string]any{"consumer": "d-app", "confirm": "d-app", "why": "gone"}); err != nil { + t.Fatal(err) + } + body := r.bus.events[len(r.bus.events)-1]["body"].(map[string]any) + if body["change"] != "deleted" || body["by"] != "person.jochen" || body["via"] != nil || body["why"] != "gone" { + t.Errorf("the deletion said %v", body) + } + + // Reject, by the proven caller alone: nothing more is added to via. + k := newCustody(t) + k.ask(apps(5)) + k.passes(1) + k.ask(keepOnly(apps(5), 1)) + k.stable() + waiting := waitingNames(k.Retirement()) + if _, err := toolNamed(t, definitions(&Env{Custody: k.Custody}), "provisioner_retire_reject").runAs("person.jochen", + map[string]any{"consumers": anyOf(waiting), "why": "they come back"}); err != nil { + t.Fatal(err) + } + if body := k.bus.events[len(k.bus.events)-1]["body"].(map[string]any); body["by"] != "person.jochen" || body["via"] != nil { + t.Errorf("the rejection said %v", body) + } +} + +// (2): a roll of the record of hand-outs is said in the journal, loudly. +func TestARolledRecordOfHandOutsIsSaid(t *testing.T) { + v := newVault(t, nil) + var mu sync.Mutex + var said []string + v.Log = func(format string, args ...any) { + mu.Lock() + defer mu.Unlock() + said = append(said, fmt.Sprintf(format, args...)) + } + v.Store.HandedMax = 600 + asker, _ := seal.GenerateKeyPair() + for i := 0; i < 20; i++ { + _, _ = v.Get("g14.flood", "pair/postgres/gitea", asker.PublicKey) + } + rolled := 0 + for _, s := range said { + if strings.Contains(s, "THE RECORD OF HAND-OUTS WAS ROLLED") { + rolled++ + } + } + if rolled == 0 || rolled != v.Store.Rolled() { + t.Errorf("rolled %d times, said %d times", v.Store.Rolled(), rolled) + } +} + +// (3): the vault's export names the operator key's fingerprint as the controller's secrets.Fingerprint says it, +// and vault_status says what needs a person. +func TestTheExportsFingerprintAndVaultStatus(t *testing.T) { + v := newVault(t, nil) + if _, err := v.Put("pair/postgres/gitea", "x", "the test"); err != nil { + t.Fatal(err) + } + answer, err := toolNamed(t, definitions(&Env{Vault: v.Vault}), "secret_vault_export").Run(map[string]any{}) + if err != nil { + t.Fatal(err) + } + doc := answer.(map[string]any) + // sha256 of the key, its first eight bytes in hex: the controller's secrets.Fingerprint. + if fp := doc["fingerprint"].(string); !strings.HasPrefix(fp, "sha256:") || len(fp) != len("sha256:")+16 || + fp != keyFingerprint(v.operatorPublic) || doc["operator-key"] != v.operatorPublic { + t.Errorf("the export says %v", doc) + } + if keyFingerprint("") != "" { + t.Error("no key has a fingerprint") + } + + env := &Env{Vault: v.Vault} + status := func() map[string]any { + got, err := toolNamed(t, definitions(env), "vault_status").Run(map[string]any{}) + if err != nil { + t.Fatal(err) + } + return got.(map[string]any) + } + if s := status(); s["ok"] != true || s["copies_on_an_earlier_key"] != 0 || s["operator_key"] != v.operatorPublic { + t.Errorf("a vault in order says %v", s) + } + env.refuseKey("a private key was offered") + if s := status(); s["ok"] != false || s["operator_key_refused"] != "a private key was offered" { + t.Errorf("a refused key says %v", s) + } + env.refuseKey("") + // A new operator key whose resealing fails leaves copies on the earlier key, and the status says so. + pub, _ := operatorKey(t) + v.Store.failWrite = func(Record) error { return fmt.Errorf("disk full") } + _, _ = v.SetOperatorKey(asPEM(t, pub), "the test") + v.Store.failWrite = nil + if s := status(); s["ok"] != false || s["copies_on_an_earlier_key"] != 1 { + t.Errorf("a copy on an earlier key says %v", s) + } + if s, _ := toolNamed(t, definitions(&Env{}), "vault_status").Run(map[string]any{}); s.(map[string]any)["ready"] != false { + t.Errorf("no vault says %v", s) + } +} diff --git a/modules/mesh-vault/cmd/mesh-vault/intake.go b/modules/mesh-vault/cmd/mesh-vault/intake.go new file mode 100644 index 000000000..feb3cbc09 --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/intake.go @@ -0,0 +1,472 @@ +package main + +// What the vault takes in, and how it rotates (novox/hq ADR 0349 clauses 8 to 10; ADR 0277 §22–§27 and ADR 0305 +// with the vault in the controller's place; design 57's V2, issue 675). +// +// Four verbs of the seat: `accept` (a value given at the controller's terminal), `secret-ask` (a value the +// operator types at a desk), `issued` (a value a provider's software issued) and `rotate`. +// +// **A value comes in sealed to a key the vault made for that one call, and opens only here.** The first three +// are two steps of one verb. The first names what is taken and answers a call and the public half of a key made +// for it (`seal_to`, a raw X25519 key in base64, the NaCl anonymous box the desk's prompt and a provider's tool +// already seal to); the second hands back what was sealed to that key, and the vault opens it with the private +// half, which never leaves this process and is forgotten once used or after two minutes. +// +// **The controller carries the call, and cannot open what it carries.** The desk's prompt 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 on +// the vault's behalf with the vault's key, and passes on the sealed answer: the value is opened in the vault and +// nowhere else (ADR 0349 clause 9, ADR 0277 §23), and the vault takes these steps from the controller alone. +// +// Which secret each one is, by name in the vault's store: +// +// own/// a module's own secret on one machine +// provision// the one credential of a provider assignment and provision (clause 8) + +import ( + "crypto/rand" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "regexp" + "strings" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" + "golang.org/x/crypto/nacl/box" +) + +// ControllerCaller is the controller's bus user: the one caller that carries a value in (broker.ControllerName). +const ControllerCaller = "controller" + +// Where a value came from. +const ( + OriginMade = "made" + OriginAccepted = "accepted" + OriginTyped = "typed" + OriginIssued = "issued" +) + +// CallOpenFor is how long a call's key waits for its sealed value: longer than any prompt or tool waits. +const CallOpenFor = 2 * time.Minute + +// The bounds of asking for one secret (ADR 0277 §24, the controller's inventory.SecretAsk* until now). +const ( + // SecretAskOpenFor is how long an ask that has not ended counts as open. + SecretAskOpenFor = 2 * time.Minute + // SecretAsksPerHour is how many asks for one secret an hour takes. + SecretAsksPerHour = 3 +) + +var namePart = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,62}$`) + +// OwnSecret is the vault's name for a module's own secret on one machine. +func OwnSecret(node, module, name string) (string, error) { + for what, v := range map[string]string{"the machine": node, "the module": module, "the secret": name} { + if !namePart.MatchString(v) { + return "", fmt.Errorf("%s %q is not a name the mesh writes", what, v) + } + } + return "own/" + node + "/" + module + "/" + name, nil +} + +// ProvisionSecret is the vault's name for the one credential of a provider assignment and provision. +func ProvisionSecret(provider, provision string) (string, error) { + for what, v := range map[string]string{"the provider's machine": provider, "the provision": provision} { + if !namePart.MatchString(v) { + return "", fmt.Errorf("%s %q is not a name the mesh writes", what, v) + } + } + return "provision/" + provider + "/" + provision, nil +} + +// callKey is one call's key and what the call takes in. Never written anywhere. +type callKey struct { + verb, secret, by string + // ask is the ask's record for `secret-ask`, which the call's id is. + ask *AskRecord + public [32]byte + private [32]byte + until time.Time +} + +// Opened is a first step's answer: the call, and the key to seal its value to. Never a value. +type Opened struct { + Call string `json:"call"` + SealTo string `json:"seal_to"` + Secret string `json:"secret"` + AskedBy string `json:"asked_by,omitempty"` +} + +// fromTheController refuses every caller but the controller: only it reaches a desk's prompt and a provider's +// tool, and only it carries a value given at its terminal. +func fromTheController(caller, verb string) error { + if caller != ControllerCaller { + if caller == "" { + caller = stdio.Unproven + } + return fmt.Errorf("%s takes a value from the controller alone, which carries it sealed to this vault's key "+ + "for one call; %s is not the controller", verb, caller) + } + return nil +} + +// openCall makes a key for one call and keeps its private half here until the call is taken or two minutes +// have passed. +func (v *Vault) openCall(verb, secret, by string, ask *AskRecord) (string, string, error) { + pub, priv, err := box.GenerateKey(rand.Reader) + if err != nil { + return "", "", err + } + idRaw := make([]byte, 12) + if _, err := rand.Read(idRaw); err != nil { + return "", "", err + } + id := hex.EncodeToString(idRaw) + if ask != nil { + id = ask.ID + } + v.callsMu.Lock() + defer v.callsMu.Unlock() + if v.pending == nil { + v.pending = map[string]*callKey{} + } + now := v.Now() + for k, c := range v.pending { + if now.After(c.until) { + delete(v.pending, k) + } + } + v.pending[id] = &callKey{verb: verb, secret: secret, by: by, ask: ask, public: *pub, private: *priv, + until: now.Add(CallOpenFor)} + return id, base64.StdEncoding.EncodeToString(pub[:]), nil +} + +// takeCall ends a call: its key is forgotten whatever follows. +func (v *Vault) takeCall(verb, id string) (*callKey, error) { + v.callsMu.Lock() + defer v.callsMu.Unlock() + c, ok := v.pending[id] + if !ok || c.verb != verb { + return nil, fmt.Errorf("no %s call %q is open in the vault: it ended, it was never opened, or the vault "+ + "started again; open it anew", verb, id) + } + delete(v.pending, id) + if v.Now().After(c.until) { + return nil, fmt.Errorf("the %s call %q waited longer than %s and is closed; open it anew", verb, id, CallOpenFor) + } + return c, nil +} + +// open opens what was sealed to the call's key, and forgets the key. +func (c *callKey) open(sealed string) ([]byte, error) { + defer func() { c.private = [32]byte{} }() + blob, err := base64.StdEncoding.DecodeString(strings.TrimSpace(sealed)) + if err != nil { + return nil, errors.New("the value is not sealed to this call; nothing was taken") + } + out, ok := box.OpenAnonymous(nil, blob, &c.public, &c.private) + if !ok { + // Never the value, never what failed to open: only that it was not sealed to this call. + return nil, errors.New("the value is not sealed to this call; nothing was taken") + } + return out, nil +} + +func zero(b []byte) { + for i := range b { + b[i] = 0 + } +} + +// ---- accept ------------------------------------------------------------------------------------------------- + +// AcceptOpen opens a call to take a value given at the controller's terminal for secret, which the terminal +// seals to the call's key before it leaves it (ADR 0349 clause 8: the vault alone opens an accepted value). +func (v *Vault) AcceptOpen(caller, secret, by string) (Opened, error) { + if err := fromTheController(caller, "accept"); err != nil { + return Opened{}, err + } + if err := validName(secret); err != nil { + return Opened{}, err + } + if rec, held, err := v.Store.Read(secret); err != nil { + return Opened{}, err + } else if held && rec.Origin == OriginIssued { + return Opened{}, fmt.Errorf("%s is issued by its provider's software, and `secret issued` is the only way "+ + "it enters the vault (ADR 0305 §10); nothing is taken", secret) + } + id, key, err := v.openCall("accept", secret, by, nil) + if err != nil { + return Opened{}, err + } + return Opened{Call: id, SealTo: key, Secret: secret}, nil +} + +// AcceptTake opens the value sealed to the call's key and keeps it, accepted: never remade by the vault. +func (v *Vault) AcceptTake(caller, call, sealed string) (Changed, error) { + if err := fromTheController(caller, "accept"); err != nil { + return Changed{}, err + } + c, err := v.takeCall("accept", call) + if err != nil { + return Changed{}, err + } + value, err := c.open(sealed) + if err != nil { + return Changed{}, err + } + defer zero(value) + if strings.TrimSpace(string(value)) == "" { + return Changed{}, errors.New("the value given is empty; nothing was taken") + } + return v.keep(c.secret, string(value), someoneOr(c.by, caller), caller, OriginAccepted) +} + +// ---- secret-ask --------------------------------------------------------------------------------------------- + +// AskOf is what an ask is for, and where. +type AskOf struct { + Node, Module, Name, Desk string + // AskedBy is who asked the controller, as the bus named its caller; the controller's word, taken from the + // controller alone. + AskedBy string +} + +// AskOpen records an ask for a module's own secret before any prompt opens, holding its bounds — one open ask +// per secret, three an hour (ADR 0277 §24, §25) — and answers the key the desk's prompt seals what is typed to, +// and who asked as the record says it, which the prompt names (§26). +func (v *Vault) AskOpen(caller string, a AskOf) (Opened, error) { + if err := fromTheController(caller, "secret-ask"); err != nil { + return Opened{}, err + } + secret, err := OwnSecret(a.Node, a.Module, a.Name) + if err != nil { + return Opened{}, fmt.Errorf("nobody was asked to type anything: %w", err) + } + if !namePart.MatchString(a.Desk) { + return Opened{}, fmt.Errorf("nobody was asked to type anything: the desk %q is not a machine's name", a.Desk) + } + askedBy := askedByName(a.AskedBy) + v.Store.asksMu.Lock() + defer v.Store.asksMu.Unlock() + now := v.Now().UTC() + recent, err := v.Store.AsksSince(now.Add(-time.Hour)) + if err != nil { + return Opened{}, fmt.Errorf("nobody was asked to type anything: the record of asks cannot be read: %w", err) + } + ended := map[string]bool{} + for _, r := range recent { + if r.Event == AskEnded { + ended[r.ID] = true + } + } + lastHour, openBy := 0, "" + for _, r := range recent { + if r.Event != AskOpened || r.Secret != secret { + continue + } + lastHour++ + if !ended[r.ID] && r.At.After(now.Add(-SecretAskOpenFor)) { + openBy = r.AskedBy + } + } + if openBy != "" { + return Opened{}, fmt.Errorf("an ask for %s of %s on %s is still open (asked by %s): one prompt at a time for a "+ + "secret, and this one ends within %s", a.Name, a.Module, a.Node, openBy, SecretAskOpenFor) + } + if lastHour >= SecretAsksPerHour { + return Opened{}, fmt.Errorf("%s of %s on %s was asked for %d times in the last hour, and an hour takes %d asks "+ + "for one secret: the operator is not kept at a prompt", a.Name, a.Module, a.Node, lastHour, SecretAsksPerHour) + } + idRaw := make([]byte, 12) + if _, err := rand.Read(idRaw); err != nil { + return Opened{}, err + } + rec := AskRecord{ID: hex.EncodeToString(idRaw), At: now, Event: AskOpened, Secret: secret, Node: a.Node, + Module: a.Module, Name: a.Name, Desk: a.Desk, AskedBy: askedBy, Caller: caller} + if err := v.recordAsk(rec); err != nil { + return Opened{}, fmt.Errorf("nobody was asked to type anything: the ask could not be recorded: %w", err) + } + id, key, err := v.openCall("secret-ask", secret, askedBy, &rec) + if err != nil { + _ = v.recordAsk(AskRecord{ID: rec.ID, At: v.Now().UTC(), Event: AskEnded, Secret: secret, Outcome: "failed"}) + return Opened{}, err + } + return Opened{Call: id, SealTo: key, Secret: secret, AskedBy: askedBy}, nil +} + +// The outcomes an ask ends with, as the controller's record had them. +var askOutcomes = map[string]bool{"dismissed": true, "timed-out": true, "empty": true, "refused": true, "failed": true} + +// AskEnd ends an ask: with what the desk sealed to its key, which the vault opens and keeps as typed, or with +// the outcome the desk answered instead. How it ended is recorded whatever happens. +func (v *Vault) AskEnd(caller, call, sealed, outcome string) (Changed, error) { + if err := fromTheController(caller, "secret-ask"); err != nil { + return Changed{}, err + } + if sealed == "" && !askOutcomes[outcome] { + return Changed{}, fmt.Errorf("an ask ends with what was sealed to its key, or with one of dismissed, " + + "timed-out, empty, refused or failed") + } + c, err := v.takeCall("secret-ask", call) + if err != nil { + return Changed{}, err + } + end := func(how string) { + if err := v.recordAsk(AskRecord{ID: c.ask.ID, At: v.Now().UTC(), Event: AskEnded, Secret: c.secret, + Outcome: how}); err != nil { + v.log("the end of ask %s (%s) could not be recorded: %v", c.ask.ID, how, err) + } + } + if sealed == "" { + end(outcome) + return Changed{Name: c.secret, Unchanged: true}, nil + } + opened, err := c.open(sealed) + if err != nil { + end("failed") + return Changed{}, err + } + value := strings.TrimRight(string(opened), "\r\n") + zero(opened) + if strings.TrimSpace(value) == "" { + end("empty") + return Changed{}, fmt.Errorf("nothing was given: the prompt on %s was answered empty", c.ask.Desk) + } + changed, err := v.keep(c.secret, value, c.ask.AskedBy, caller, OriginTyped) + value = "" + if err != nil { + end("failed") + return Changed{}, err + } + end("given") + return changed, nil +} + +func (v *Vault) recordAsk(r AskRecord) error { + rolled, err := v.Store.RecordAsk(r) + if rolled { + v.log("THE RECORD OF ASKS WAS ROLLED: it reached its bound of %d bytes; the oldest of %d kept files is "+ + "dropped. Many asks in little time is worth a look: secret_asks", v.Store.HandedBound(), HandedKept) + } + return err +} + +// askedByName is who asked as the prompt and the record name it: the first clause of what the bus said, in the +// characters a name has, at most 80 (the controller's own rule, ADR 0277 §26), so no words of the caller's own +// reach the prompt. +func askedByName(caller string) string { + first, _, _ := strings.Cut(caller, ",") + var b strings.Builder + for _, r := range strings.TrimSpace(first) { + if b.Len() >= 80 { + break + } + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '_', r == '/', r == '@', + r == '-', r == ' ': + b.WriteRune(r) + default: + b.WriteRune('-') + } + } + name := strings.TrimSpace(b.String()) + if name == "" || strings.HasPrefix(name, "-") { + return "an unnamed caller" + } + return name +} + +// ---- issued ------------------------------------------------------------------------------------------------- + +// IssuedOpen opens a call to take the credential a provider's software issued for a provision (ADR 0349 clause +// 10, ADR 0305 with the vault in the controller's place): the controller asks the provider's tool, on the +// provider's machine, with the call's key, and passes on what it answered. +func (v *Vault) IssuedOpen(caller, provision, provider, module string) (Opened, error) { + if err := fromTheController(caller, "issued"); err != nil { + return Opened{}, err + } + secret, err := ProvisionSecret(provider, provision) + if err != nil { + return Opened{}, err + } + if !namePart.MatchString(module) { + return Opened{}, fmt.Errorf("the provider's module %q is not a name the mesh writes", module) + } + id, key, err := v.openCall("issued", secret, module+" on "+provider, nil) + if err != nil { + return Opened{}, err + } + return Opened{Call: id, SealTo: key, Secret: secret}, nil +} + +// IssuedTake opens what the provider's tool sealed to the call's key and keeps it as the provision's one +// credential. The same value again writes nothing (ADR 0305 §5): every holder that has it is left as it is. +func (v *Vault) IssuedTake(caller, call, sealed string) (Changed, error) { + if err := fromTheController(caller, "issued"); err != nil { + return Changed{}, err + } + c, err := v.takeCall("issued", call) + if err != nil { + return Changed{}, err + } + opened, err := c.open(sealed) + if err != nil { + return Changed{}, err + } + value := strings.TrimSpace(string(opened)) + zero(opened) + if value == "" { + return Changed{}, fmt.Errorf("%s answered an empty value; nothing was taken", c.by) + } + return v.keep(c.secret, value, c.by, caller, OriginIssued) +} + +// ---- rotate ------------------------------------------------------------------------------------------------- + +// Rotate makes a secret anew (ADR 0114, ADR 0228). Refused: a secret the vault does not hold; a value a +// provider's software issued, which the software rotates (ADR 0305 §9); and a provision's accepted credential, +// which the vault does not remake (ADR 0349 clause 8, ADR 0158 §8). A module's own value given by hand is +// rotated like a made one (ADR 0228). Who asked is the caller the runtime proved, or the person the controller +// names when the controller carries the act — both are kept. +func (v *Vault) Rotate(caller, secret, by string) (Changed, error) { + if caller == "" || caller == stdio.Unproven || !busUser.MatchString(caller) { + return Changed{}, errors.New("the vault rotates only for a caller the runtime proved") + } + if err := validName(secret); err != nil { + return Changed{}, err + } + rec, held, err := v.Store.Read(secret) + if err != nil { + return Changed{}, err + } + switch { + case !held: + return Changed{}, fmt.Errorf("not rotated: the vault holds no secret named %s", secret) + case rec.Origin == OriginIssued: + return Changed{}, fmt.Errorf("not rotated: %s is issued by its provider's software, which the mesh cannot "+ + "make or rotate (ADR 0305 §9): change it in that software, and `secret issued` takes the value it holds now", + secret) + case rec.Origin == OriginAccepted && strings.HasPrefix(secret, "provision/"): + return Changed{}, fmt.Errorf("not rotated: %s was accepted, and an accepted credential is not remade by the "+ + "vault (ADR 0349 clause 8): accept its new value instead", secret) + } + if caller != ControllerCaller || by == "" { + by = caller + } + raw := make([]byte, 30) + if _, err := rand.Read(raw); err != nil { + return Changed{}, err + } + return v.keep(secret, base64.RawURLEncoding.EncodeToString(raw), by, caller, OriginMade) +} + +func someoneOr(by, caller string) string { + if strings.TrimSpace(by) == "" { + return caller + } + return by +} diff --git a/modules/mesh-vault/cmd/mesh-vault/intake_test.go b/modules/mesh-vault/cmd/mesh-vault/intake_test.go new file mode 100644 index 000000000..80bb2ddf9 --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/intake_test.go @@ -0,0 +1,495 @@ +package main + +// ADR 0349's tests for what the vault takes in (design 57's V2, issue 675): the vault's `secret-ask` test, the +// issuing provider's test with a stand-in provider (plex's own tool moves with the media catalogue, after +// release 57), an accepted value opened in the vault alone and never remade, and rotate. +// +// In each, "the controller" is a test function that holds no private key: it only carries what the desk or the +// provider sealed to the key the vault made for the call, as the controller does on the bus. + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/seal" + "golang.org/x/crypto/nacl/box" +) + +// sealToCall seals value to a call's key as the desk's prompt and a provider's tool do: the NaCl anonymous box. +func sealToCall(t *testing.T, sealTo, value string) string { + t.Helper() + raw, err := base64.StdEncoding.DecodeString(sealTo) + if err != nil || len(raw) != 32 { + t.Fatalf("seal_to %q is not a key", sealTo) + } + var key [32]byte + copy(key[:], raw) + sealed, err := box.SealAnonymous(nil, []byte(value), &key, rand.Reader) + if err != nil { + t.Fatal(err) + } + return base64.StdEncoding.EncodeToString(sealed) +} + +// handedTo opens what `get` hands an allowed asker: the value the vault holds. +func handedTo(t *testing.T, v testVault, asker, name string) string { + t.Helper() + key, _ := seal.GenerateKeyPair() + a, err := v.Get(asker, name, key.PublicKey) + if err != nil { + t.Fatal(err) + } + value, err := seal.Open(a.Sealed, key.PrivateKey) + if err != nil { + t.Fatal(err) + } + return value +} + +// everything is all the vault said and wrote during a test, as text. +func everything(t *testing.T, v testVault, more ...any) string { + t.Helper() + var b strings.Builder + for _, said := range v.bus.raw { + b.WriteString(said) + } + for _, content := range filesUnder(t, v.dir) { + b.WriteString(content) + } + for _, m := range more { + raw, _ := json.Marshal(m) + b.Write(raw) + } + return b.String() +} + +const typedAtTheDesk = "123456789:AAEhBP0av28P4XFQnIuR-o-7Xnz1kkUzW3g" + +func telegramAsk(by string) AskOf { + return AskOf{Node: "anchor", Module: "telegram", Name: "telegram-token", Desk: "laptop", AskedBy: by} +} + +// ---- the vault's secret-ask test (ADR 0349 "How it is checked") --------------------------------------------- + +// An ask is recorded in the vault's store before the prompt opens; its prompt names the asker as the bus named +// it; the typed value opens only in the vault; the controller carries ciphertext only. +func TestAnAskIsRecordedBeforeThePromptAndTheTypedValueOpensOnlyInTheVault(t *testing.T) { + v := newVault(t, AllowList{"anchor.node-provisioner": {"own/anchor/telegram/telegram-token"}}) + opened, err := v.AskOpen(ControllerCaller, telegramAsk("g14/claude-code, via the console")) + if err != nil { + t.Fatal(err) + } + // Recorded before any prompt opens: the record holds the ask now, with no end. + asks, _ := v.Store.Asks(10) + if len(asks) != 1 || asks[0].Event != AskOpened || asks[0].ID != opened.Call || asks[0].AskedBy != "g14/claude-code" || + asks[0].Caller != ControllerCaller || asks[0].Desk != "laptop" || asks[0].Secret != "own/anchor/telegram/telegram-token" { + t.Fatalf("the record before the prompt: %+v", asks) + } + // The prompt names the asker as the bus named it, cut to a name: what the vault answers is what it recorded. + if opened.AskedBy != "g14/claude-code" { + t.Errorf("the prompt would name %q", opened.AskedBy) + } + // The desk seals what is typed to the call's key; the controller passes it on. + changed, err := v.AskEnd(ControllerCaller, opened.Call, sealToCall(t, opened.SealTo, typedAtTheDesk+"\n"), "") + if err != nil { + t.Fatal(err) + } + if changed.Generation != 1 || changed.By != "g14/claude-code" { + t.Errorf("kept as %+v", changed) + } + if got := handedTo(t, v, "anchor.node-provisioner", "own/anchor/telegram/telegram-token"); got != typedAtTheDesk { + t.Errorf("the vault holds %q, not what was typed with its line ending taken off", got) + } + rec, _, _ := v.Store.Read("own/anchor/telegram/telegram-token") + if rec.Origin != OriginTyped || rec.ChangedBy != "g14/claude-code" || rec.ChangedVia != ControllerCaller { + t.Errorf("the record says origin %q, by %q, via %q", rec.Origin, rec.ChangedBy, rec.ChangedVia) + } + asks, _ = v.Store.Asks(10) + if len(asks) != 2 || asks[0].Event != AskEnded || asks[0].Outcome != "given" || asks[0].ID != opened.Call { + t.Errorf("how it ended: %+v", asks) + } + if strings.Contains(everything(t, v, opened, changed, asks), typedAtTheDesk) { + t.Fatal("the typed value is in something the vault said, answered or wrote in the clear") + } +} + +// One open ask per secret, and three an hour (ADR 0277 §24), read from the vault's own record. +func TestAnAskIsBoundedToOneOpenPerSecretAndThreeAnHour(t *testing.T) { + v := newVault(t, nil) + first, err := v.AskOpen(ControllerCaller, telegramAsk("g14.claude-code")) + if err != nil { + t.Fatal(err) + } + if _, err := v.AskOpen(ControllerCaller, telegramAsk("ace.claude-code")); err == nil || + !strings.Contains(err.Error(), "still open (asked by g14.claude-code)") { + t.Fatalf("a second ask while one is open: %v", err) + } + // Another secret is not held back by it. + other := telegramAsk("ace.claude-code") + other.Name = "webhook-secret" + if _, err := v.AskOpen(ControllerCaller, other); err != nil { + t.Fatalf("another secret's ask was held back: %v", err) + } + if _, err := v.AskEnd(ControllerCaller, first.Call, "", "dismissed"); err != nil { + t.Fatal(err) + } + for i := 0; i < 2; i++ { + o, err := v.AskOpen(ControllerCaller, telegramAsk("g14.claude-code")) + if err != nil { + t.Fatalf("ask %d of the hour: %v", i+2, err) + } + if _, err := v.AskEnd(ControllerCaller, o.Call, "", "timed-out"); err != nil { + t.Fatal(err) + } + } + if _, err := v.AskOpen(ControllerCaller, telegramAsk("g14.claude-code")); err == nil || + !strings.Contains(err.Error(), "3 times in the last hour") { + t.Fatalf("a fourth ask in the hour: %v", err) + } + asks, _ := v.Store.Asks(50) + outcomes := map[string]int{} + for _, a := range asks { + outcomes[a.Outcome]++ + } + if outcomes["dismissed"] != 1 || outcomes["timed-out"] != 2 { + t.Errorf("the record's outcomes: %v", outcomes) + } + // An ask left open counts as open for SecretAskOpenFor only: a prompt that died does not hold the secret. + w := newVault(t, nil) + clock := time.Date(2026, 10, 14, 9, 0, 0, 0, time.UTC) + w.Now = func() time.Time { return clock } + if _, err := w.AskOpen(ControllerCaller, telegramAsk("g14.claude-code")); err != nil { + t.Fatal(err) + } + clock = clock.Add(SecretAskOpenFor + time.Second) + if _, err := w.AskOpen(ControllerCaller, telegramAsk("g14.claude-code")); err != nil { + t.Errorf("an ask left open past its time still held the secret: %v", err) + } +} + +// Only the controller reaches a desk's prompt, so only it opens and ends an ask; a value not sealed to the +// call's key, or a call that waited too long, keeps nothing. +func TestAnAskIsTakenFromTheControllerAloneAndOnlySealedToItsCall(t *testing.T) { + v := newVault(t, nil) + for _, caller := range []string{"g14.node-tools", "unproven", "", "person.jochen"} { + if _, err := v.AskOpen(caller, telegramAsk("x")); err == nil { + t.Errorf("%q opened an ask", caller) + } + } + if asks, _ := v.Store.Asks(10); len(asks) != 0 { + t.Fatalf("a refused caller filled the record: %+v", asks) + } + opened, err := v.AskOpen(ControllerCaller, telegramAsk("g14.claude-code")) + if err != nil { + t.Fatal(err) + } + if _, err := v.AskEnd("g14.node-tools", opened.Call, sealToCall(t, opened.SealTo, "x"), ""); err == nil { + t.Error("another caller ended the ask") + } + other, _, _ := box.GenerateKey(rand.Reader) + if _, err := v.AskEnd(ControllerCaller, opened.Call, sealToCall(t, base64.StdEncoding.EncodeToString(other[:]), "x"), ""); err == nil || + !strings.Contains(err.Error(), "not sealed to this call") { + t.Errorf("a value sealed to another key: %v", err) + } + if _, held, _ := v.Store.Read("own/anchor/telegram/telegram-token"); held { + t.Error("a value not sealed to the call was kept") + } + // The call's key is forgotten once used: the same call cannot be answered twice. + if _, err := v.AskEnd(ControllerCaller, opened.Call, sealToCall(t, opened.SealTo, "x"), ""); err == nil { + t.Error("a call was answered twice") + } + asks, _ := v.Store.Asks(1) + if asks[0].Outcome != "failed" { + t.Errorf("the ask that failed ended %+v", asks[0]) + } + // A call that waited longer than its time is closed. + clock := time.Date(2026, 10, 14, 9, 0, 0, 0, time.UTC) + v.Now = func() time.Time { return clock } + late, err := v.IssuedOpen(ControllerCaller, "plex-api", "anchor", "plex") + if err != nil { + t.Fatal(err) + } + clock = clock.Add(CallOpenFor + time.Second) + if _, err := v.IssuedTake(ControllerCaller, late.Call, sealToCall(t, late.SealTo, "x")); err == nil { + t.Error("a call that waited too long was taken") + } + // An empty answer gives nothing and says so. + clock = clock.Add(time.Hour) + empty, _ := v.AskOpen(ControllerCaller, telegramAsk("g14.claude-code")) + if _, err := v.AskEnd(ControllerCaller, empty.Call, sealToCall(t, empty.SealTo, " \n"), ""); err == nil { + t.Error("an empty answer was kept") + } + if asks, _ := v.Store.Asks(1); asks[0].Outcome != "empty" { + t.Errorf("an empty answer ended %+v", asks[0]) + } +} + +// ---- the issuing provider's test, with a stand-in provider ------------------------------------------------- + +// standInProvider is a provider's tool for an `issued-by: software` offer, as plex's must answer (ADR 0305 §2 +// with the vault in the controller's place): it refuses every caller but the controller — the one caller the +// bus lets ask it, carrying the vault's call — and answers only the software's value sealed to the call's key. +func standInProvider(software *string) func(caller string, args map[string]any) (map[string]any, error) { + return func(caller string, args map[string]any) (map[string]any, error) { + if caller != ControllerCaller { + return nil, errors.New("this tool answers the controller alone") + } + key, _ := args["seal_to"].(string) + raw, err := base64.StdEncoding.DecodeString(key) + if err != nil || len(raw) != 32 { + return nil, errors.New("seal_to is not a sealing key") + } + var to [32]byte + copy(to[:], raw) + sealed, err := box.SealAnonymous(nil, []byte(*software), &to, rand.Reader) + if err != nil { + return nil, err + } + return map[string]any{"sealed": base64.StdEncoding.EncodeToString(sealed)}, nil + } +} + +// issue is the controller's `secret issued` for one provider, carried as the controller carries it: open the +// vault's call, ask the provider's tool with the call's key, pass on what it sealed. +func issue(t *testing.T, v testVault, provider func(string, map[string]any) (map[string]any, error)) (Changed, error) { + t.Helper() + opened, err := v.IssuedOpen(ControllerCaller, "plex-api", "anchor", "plex") + if err != nil { + return Changed{}, err + } + answer, err := provider(ControllerCaller, map[string]any{"provision": "plex-api", "seal_to": opened.SealTo}) + if err != nil { + return Changed{}, err + } + return v.IssuedTake(ControllerCaller, opened.Call, answer["sealed"].(string)) +} + +func TestAnIssuingProviderAnswersOnlyTheControllersCallSealedToTheVault(t *testing.T) { + v := newVault(t, AllowList{"anchor.node-provisioner": {"provision/anchor/plex-api"}}) + software := "plex-token-from-the-vendor" + provider := standInProvider(&software) + + // The provider's tool refuses every caller but the controller, whatever it asks. + for _, caller := range []string{"anchor.node-tools", "g14.claude-code", "unproven", ""} { + if _, err := provider(caller, map[string]any{"provision": "plex-api", "seal_to": "x"}); err == nil { + t.Errorf("the provider's tool answered %q", caller) + } + } + // The vault takes the steps from the controller alone. + if _, err := v.IssuedOpen("anchor.node-tools", "plex-api", "anchor", "plex"); err == nil { + t.Error("the vault opened an issued call for another caller") + } + + changed, err := issue(t, v, provider) + if err != nil { + t.Fatal(err) + } + if changed.Name != "provision/anchor/plex-api" || changed.Generation != 1 || changed.By != "plex on anchor" { + t.Errorf("kept as %+v", changed) + } + if got := handedTo(t, v, "anchor.node-provisioner", "provision/anchor/plex-api"); got != software { + t.Errorf("the vault holds %q", got) + } + rec, _, _ := v.Store.Read("provision/anchor/plex-api") + if rec.Origin != OriginIssued || rec.ChangedVia != ControllerCaller { + t.Errorf("the record: origin %q via %q", rec.Origin, rec.ChangedVia) + } + + // Nothing rewritten for a value every holder has (ADR 0305 §5): no new generation, no write, nothing said. + path := filepath.Join(v.dir, "store", "secrets", "provision%2Fanchor%2Fplex-api.json") + before, _ := os.ReadFile(path) + said := len(v.bus.raw) + again, err := issue(t, v, provider) + if err != nil { + t.Fatal(err) + } + after, _ := os.ReadFile(path) + if !again.Unchanged || again.Generation != 1 || string(before) != string(after) || len(v.bus.raw) != said { + t.Errorf("the same value again rewrote something: %+v", again) + } + + // The software changes its value: the next ask keeps it as a new generation, and says so without it. + software = "plex-token-renewed" + renewed, err := issue(t, v, provider) + if err != nil || renewed.Generation != 2 { + t.Fatalf("%+v %v", renewed, err) + } + if got := handedTo(t, v, "anchor.node-provisioner", "provision/anchor/plex-api"); got != software { + t.Errorf("after the change the vault holds %q", got) + } + + // A value sealed to any other key is refused, and nothing changes. + opened, _ := v.IssuedOpen(ControllerCaller, "plex-api", "anchor", "plex") + stranger, _, _ := box.GenerateKey(rand.Reader) + if _, err := v.IssuedTake(ControllerCaller, opened.Call, sealToCall(t, base64.StdEncoding.EncodeToString(stranger[:]), "forged")); err == nil { + t.Error("a value not sealed to the call was taken") + } + + // The software rotates it, not the mesh (ADR 0305 §9); and no other path puts it into the vault (§10). + if _, err := v.Rotate(ControllerCaller, "provision/anchor/plex-api", "person.jochen"); err == nil || + !strings.Contains(err.Error(), "software") { + t.Errorf("rotate of an issued value: %v", err) + } + if _, err := v.AcceptOpen(ControllerCaller, "provision/anchor/plex-api", "person.jochen"); err == nil { + t.Error("an issued value was taken by accept") + } + if strings.Contains(everything(t, v), "plex-token") { + t.Fatal("the issued value is in something the vault said or wrote in the clear") + } +} + +// ---- accept and rotate ------------------------------------------------------------------------------------- + +// A value given at the controller's terminal is sealed there to the vault's call and opened here alone; an +// accepted provision's credential is never remade; a module's own given value rotates as a made one (ADR 0228). +func TestAnAcceptedValueOpensInTheVaultAloneAndIsNotRemade(t *testing.T) { + v := newVault(t, AllowList{"g14.node-provisioner": {"provision/novox/postgres", "own/g14/dunst/token"}}) + if _, err := v.AcceptOpen("g14.claude-code", "provision/novox/postgres", ""); err == nil { + t.Error("accept was opened for a caller that is not the controller") + } + for name, value := range map[string]string{"provision/novox/postgres": "pw-of-last-year", "own/g14/dunst/token": "tok"} { + opened, err := v.AcceptOpen(ControllerCaller, name, "jochen") + if err != nil { + t.Fatal(err) + } + changed, err := v.AcceptTake(ControllerCaller, opened.Call, sealToCall(t, opened.SealTo, value)) + if err != nil { + t.Fatal(err) + } + if changed.By != "jochen" { + t.Errorf("%s accepted by %q", name, changed.By) + } + if got := handedTo(t, v, "g14.node-provisioner", name); got != value { + t.Errorf("%s holds %q", name, got) + } + rec, _, _ := v.Store.Read(name) + if rec.Origin != OriginAccepted || rec.ChangedVia != ControllerCaller { + t.Errorf("%s: origin %q via %q", name, rec.Origin, rec.ChangedVia) + } + } + if _, err := v.Rotate(ControllerCaller, "provision/novox/postgres", "jochen"); err == nil || + !strings.Contains(err.Error(), "not remade") { + t.Errorf("rotate of an accepted credential: %v", err) + } + rotated, err := v.Rotate(ControllerCaller, "own/g14/dunst/token", "jochen") + if err != nil || rotated.Generation != 2 { + t.Fatalf("rotate of a module's own given value: %+v %v", rotated, err) + } + if got := handedTo(t, v, "g14.node-provisioner", "own/g14/dunst/token"); got == "tok" || len(got) != 40 { + t.Errorf("rotated to %q", got) + } + if strings.Contains(everything(t, v), "pw-of-last-year") { + t.Fatal("an accepted value is in something the vault said or wrote in the clear") + } +} + +// Rotate: refused for a secret not held and for an unproven caller; who asked is the proven caller, or the +// person the controller names when it carries the act — both kept. +func TestRotateKeepsWhoAskedAndWhoCarriedIt(t *testing.T) { + v := newVault(t, nil) + if _, err := v.Rotate(ControllerCaller, "own/g14/dunst/token", "jochen"); err == nil { + t.Error("a secret not held was rotated") + } + if _, err := v.Mint("own/g14/dunst/token", "the test"); err != nil { + t.Fatal(err) + } + if _, err := v.Rotate("unproven", "own/g14/dunst/token", ""); err == nil { + t.Error("an unproven caller rotated") + } + if _, err := v.Rotate(ControllerCaller, "own/g14/dunst/token", "jochen"); err != nil { + t.Fatal(err) + } + rec, _, _ := v.Store.Read("own/g14/dunst/token") + if rec.ChangedBy != "jochen" || rec.ChangedVia != ControllerCaller || rec.Generation != 2 { + t.Errorf("by %q via %q generation %d", rec.ChangedBy, rec.ChangedVia, rec.Generation) + } + // A `by` from any caller but the controller names nobody: the proven caller is who asked. + if _, err := v.Rotate("g14.claude-code", "own/g14/dunst/token", "jochen"); err != nil { + t.Fatal(err) + } + rec, _, _ = v.Store.Read("own/g14/dunst/token") + if rec.ChangedBy != "g14.claude-code" || rec.ChangedVia != "" { + t.Errorf("by %q via %q", rec.ChangedBy, rec.ChangedVia) + } +} + +// The names the vault keeps them under are names, never paths. +func TestTheNamesOfWhatIsTakenIn(t *testing.T) { + if n, err := OwnSecret("g14", "dunst", "token"); err != nil || n != "own/g14/dunst/token" { + t.Errorf("%q %v", n, err) + } + if n, err := ProvisionSecret("novox", "postgres"); err != nil || n != "provision/novox/postgres" { + t.Errorf("%q %v", n, err) + } + for _, bad := range [][3]string{{"..", "x", "y"}, {"g14", "a/b", "y"}, {"g14", "x", ""}, {"G14", "x", "y"}} { + if _, err := OwnSecret(bad[0], bad[1], bad[2]); err == nil { + t.Errorf("%v was taken", bad) + } + } +} + +// ---- the verbs as the runtime calls them ------------------------------------------------------------------ + +// Each verb takes its caller from the runtime, never from its arguments, and names its secret one way only. +func TestTheVerbsTakeTheirCallerFromTheRuntime(t *testing.T) { + v := newVault(t, nil) + env := &Env{Vault: v.Vault} + defs := definitions(env) + ask := toolNamed(t, defs, Seat+".secret-ask") + args := map[string]any{"node": "anchor", "module": "telegram", "name": "telegram-token", "desk": "laptop", + "asked_by": "g14.claude-code", "caller": ControllerCaller} + if _, err := ask.Run(args); err == nil { + t.Error("an ask opened for a caller named in the arguments") + } + answer, err := ask.runAs(ControllerCaller, args) + if err != nil { + t.Fatal(err) + } + opened := answer.(Opened) + if _, err := ask.runAs(ControllerCaller, map[string]any{"call": opened.Call, "outcome": "dismissed"}); err != nil { + t.Fatal(err) + } + listed, err := toolNamed(t, defs, "secret_asks").Run(map[string]any{}) + if err != nil || len(listed.([]AskRecord)) != 2 { + t.Fatalf("secret_asks: %v %v", listed, err) + } + + accept := toolNamed(t, defs, Seat+".accept") + if _, err := accept.runAs(ControllerCaller, map[string]any{"node": "g14", "provision": "postgres"}); err == nil { + t.Error("accept took a name both ways") + } + answer, err = accept.runAs(ControllerCaller, map[string]any{"provider": "novox", "provision": "postgres", "by": "jochen"}) + if err != nil { + t.Fatal(err) + } + o := answer.(Opened) + if _, err := accept.runAs(ControllerCaller, map[string]any{"call": o.Call, "sealed": sealToCall(t, o.SealTo, "pw")}); err != nil { + t.Fatal(err) + } + rotate := toolNamed(t, defs, Seat+".rotate") + if _, err := rotate.runAs("g14.claude-code", map[string]any{"node": "g14", "module": "dunst", "name": "token"}); err == nil { + t.Error("a secret not held was rotated through the verb") + } + + issued := toolNamed(t, defs, Seat+".issued") + answer, err = issued.runAs(ControllerCaller, map[string]any{"provision": "plex-api", "provider": "anchor", "module": "plex"}) + if err != nil { + t.Fatal(err) + } + o = answer.(Opened) + if _, err := issued.runAs(ControllerCaller, map[string]any{"call": o.Call, "sealed": sealToCall(t, o.SealTo, "tok")}); err != nil { + t.Fatal(err) + } + broken := &Env{Broken: errors.New("no store")} + if _, err := toolNamed(t, definitions(broken), Seat+".rotate").runAs(ControllerCaller, map[string]any{}); err == nil || + err.Error() != "no store" { + t.Errorf("a broken vault said %v", err) + } +} diff --git a/modules/mesh-vault/cmd/mesh-vault/store.go b/modules/mesh-vault/cmd/mesh-vault/store.go index 192c60fe2..3ed40ebc0 100644 --- a/modules/mesh-vault/cmd/mesh-vault/store.go +++ b/modules/mesh-vault/cmd/mesh-vault/store.go @@ -39,6 +39,12 @@ type Record struct { MadeAt time.Time `json:"made-at"` ChangedAt time.Time `json:"changed-at"` ChangedBy string `json:"changed-by"` + // ChangedVia is the caller the runtime proved, when it is not who ChangedBy names: the controller + // carrying a person's act (design 57's V2). Empty when they are the same. + ChangedVia string `json:"changed-via,omitempty"` + // Origin is where the value came from: made by the vault, accepted at the controller's terminal, typed at + // a desk, or issued by a provider's software (ADR 0349 clauses 8 to 10). Empty is made, as V1 wrote it. + Origin string `json:"origin,omitempty"` } // OperatorCopy is a value sealed to the operator key named in Key. @@ -58,6 +64,8 @@ type Store struct { handedMu sync.Mutex rolled int + // asksMu serialises the record of asks, so two asks for one secret at once do not both pass its bounds. + asksMu sync.Mutex } // The record of hand-outs is bounded: rolled at HandedMaxDefault into handed.jsonl.1 … .HandedKept, the @@ -160,6 +168,7 @@ func (s *Store) RecordHanding(h Handing) (rolled bool, err error) { if err := s.roll(path); err != nil { return false, err } + s.rolled++ rolled = true } return rolled, appendSynced(path, append(raw, '\n')) @@ -176,7 +185,6 @@ func (s *Store) roll(path string) error { if err := os.Rename(path, path+".1"); err != nil { return err } - s.rolled++ return syncDir(s.dir) } @@ -334,3 +342,99 @@ func syncDir(dir string) error { defer d.Close() return d.Sync() } + +// The two lines an ask leaves in the record: when it opened, and how it ended. +const ( + AskOpened = "opened" + AskEnded = "ended" +) + +// AskRecord is one line of the record of asks for a module's own secret at a desk (ADR 0277 §25, in the vault's +// store since ADR 0349 clause 9): who asked, as the bus named them, for what, at which desk, and how it ended. +// Never a value. +type AskRecord struct { + ID string `json:"id"` + At time.Time `json:"at"` + Event string `json:"event"` + Secret string `json:"secret"` + Node string `json:"node,omitempty"` + Module string `json:"module,omitempty"` + Name string `json:"name,omitempty"` + Desk string `json:"desk,omitempty"` + AskedBy string `json:"asked_by,omitempty"` + Caller string `json:"caller,omitempty"` + Outcome string `json:"outcome,omitempty"` +} + +// RecordAsk appends one line to the record of asks, synced before it returns; rolled as the record of +// hand-outs is, and it says whether it was. +func (s *Store) RecordAsk(r AskRecord) (rolled bool, err error) { + raw, err := json.Marshal(r) + if err != nil { + return false, err + } + path := filepath.Join(s.dir, "asks.jsonl") + if info, err := os.Stat(path); err == nil && info.Mode().IsRegular() && info.Size()+int64(len(raw))+1 > s.HandedBound() { + if err := s.roll(path); err != nil { + return false, err + } + rolled = true + } + return rolled, appendSynced(path, append(raw, '\n')) +} + +// asks is every line of the record of asks, oldest first, the rolled files before the current one. +func (s *Store) asks() ([]AskRecord, error) { + path := filepath.Join(s.dir, "asks.jsonl") + var out []AskRecord + for i := HandedKept; i >= 0; i-- { + file := path + if i > 0 { + file = fmt.Sprintf("%s.%d", path, i) + } + raw, err := os.ReadFile(file) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return nil, err + } + for _, line := range strings.Split(strings.TrimSpace(string(raw)), "\n") { + var r AskRecord + if line != "" && json.Unmarshal([]byte(line), &r) == nil { + out = append(out, r) + } + } + } + return out, nil +} + +// AsksSince is every line of the record of asks at or after since, oldest first. +func (s *Store) AsksSince(since time.Time) ([]AskRecord, error) { + all, err := s.asks() + if err != nil { + return nil, err + } + var out []AskRecord + for _, r := range all { + if !r.At.Before(since) { + out = append(out, r) + } + } + return out, nil +} + +// Asks is the latest lines of the record of asks, newest first, at most limit. +func (s *Store) Asks(limit int) ([]AskRecord, error) { + s.asksMu.Lock() + defer s.asksMu.Unlock() + all, err := s.asks() + if err != nil { + return nil, err + } + out := []AskRecord{} + for i := len(all) - 1; i >= 0 && len(out) < limit; i-- { + out = append(out, all[i]) + } + return out, nil +} diff --git a/modules/mesh-vault/cmd/mesh-vault/tools.go b/modules/mesh-vault/cmd/mesh-vault/tools.go index 1bd567700..7e6861367 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{} diff --git a/modules/mesh-vault/cmd/mesh-vault/vault.go b/modules/mesh-vault/cmd/mesh-vault/vault.go index 2ba4ec530..429978d27 100644 --- a/modules/mesh-vault/cmd/mesh-vault/vault.go +++ b/modules/mesh-vault/cmd/mesh-vault/vault.go @@ -35,6 +35,9 @@ type Vault struct { Log func(format string, args ...any) mu sync.Mutex + // callsMu guards pending: the keys of the calls open now (intake.go), never written anywhere. + callsMu sync.Mutex + pending map[string]*callKey } // Changed is what one change to a secret was. Never its value. @@ -65,6 +68,12 @@ func (v *Vault) log(format string, args ...any) { // Refused: a private key (it stays where it was made), and any value while the vault has no operator key — // a secret only the vault could open is what ADR 0113 §22 rules out. func (v *Vault) Put(name, value, by string) (Changed, error) { + return v.keep(name, value, by, "", OriginMade) +} + +// keep is Put with where the value came from (origin) and the caller the runtime proved when it is not who +// by names (via). +func (v *Vault) keep(name, value, by, via, origin string) (Changed, error) { if err := validName(name); err != nil { return Changed{}, err } @@ -104,9 +113,12 @@ func (v *Vault) Put(name, value, by string) (Changed, error) { return Changed{}, err } now := v.Now().UTC() + if via == by { + via = "" + } rec := Record{Name: name, Generation: 1, AtRest: atRest, Operator: OperatorCopy{Box: OperatorBox, Key: operator, Sealed: forOperator}, - MadeAt: now, ChangedAt: now, ChangedBy: by} + MadeAt: now, ChangedAt: now, ChangedBy: by, ChangedVia: via, Origin: origin} if held { rec.Generation, rec.MadeAt = before.Generation+1, before.MadeAt } diff --git a/modules/mesh-vault/module.json b/modules/mesh-vault/module.json index 29c631f53..d47bb29d5 100644 --- a/modules/mesh-vault/module.json +++ b/modules/mesh-vault/module.json @@ -112,6 +112,7 @@ "secret_holders", "secret_verify", "secret_handed", + "secret_asks", "secret_vault_export", "provisioner_retirement", "provisioner_retire_approve", @@ -124,7 +125,11 @@ "name": "mesh-vault", "scope": "mesh", "serves": [ - "get" + "get", + "accept", + "secret-ask", + "issued", + "rotate" ] } ]