From 698657176da7e8c211a1bc176376a74457663aa3 Mon Sep 17 00:00:00 2001 From: mesh-admin Date: Wed, 14 Oct 2026 19:07:54 +0000 Subject: [PATCH] Carry a value into the vault sealed to its call, and prove both paths of the switch (issue 675) Co-Authored-By: Claude Opus 5.5 --- cmd/mesh-controller/vault_secrets.go | 281 +++++++++++++++++++++++++++ internal/link/seat_as_test.go | 47 +++++ 2 files changed, 328 insertions(+) create mode 100644 cmd/mesh-controller/vault_secrets.go create mode 100644 internal/link/seat_as_test.go diff --git a/cmd/mesh-controller/vault_secrets.go b/cmd/mesh-controller/vault_secrets.go new file mode 100644 index 00000000..99319ef3 --- /dev/null +++ b/cmd/mesh-controller/vault_secrets.go @@ -0,0 +1,281 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/nats-io/nats.go" + + "github.com/novox/mesh-controller/internal/broker" + "github.com/novox/mesh-controller/internal/link" + "github.com/novox/mesh-controller/internal/secrets" +) + +// The vault's verbs, reached from the controller's (novox/hq ADR 0349 clauses 8 to 10; design 57's V2, issue 675). +// +// **Each module is filled the old way or the new way, by its form** (ADR 0349 clause 17, the release lead's +// decision of 2026-10-14 on issue 675). `secret accept`, `secret ask` (and `accept --at-desk`), `secret issued` +// and `secret rotate` go to the vault only for a module whose secrets are filled the new way — its machine asks +// the vault for them — and keep today's path, the controller's store and the declaration it seals, for every other +// module. Forwarding a module still filled the old way would report success and change nothing on its machine: +// the vault hands it nothing, and its machine never asks. +// +// **What the controller carries, it cannot open.** The vault takes a value in two steps of one verb: the first +// names the secret and answers a key the vault made for that one call; the second carries the value sealed to it. +// The value given at this terminal is sealed here before it leaves the process (clause 8); what a desk's prompt or +// a provider's tool answers is sealed there to the vault's key, and the controller only passes it on (clauses 9 +// and 10). The controller still asks the desk and the provider, because the bus lets only the controller ask +// them, and asks the vault in its own name, so the vault knows the call is the controller's. + +// secretsOfTheVault says whether a module's secrets are filled the new way: the switch the controller's secret +// verbs turn on. **No module's are until design 57's V4** (issue 647), which fills a moved module's +// `${secret:}` from the vault and sets this; until then it is false for every module, so every verb keeps today's +// path. +func secretsOfTheVault(module string) bool { return false } + +// bySwitch runs the vault's path for a module whose secrets are filled the new way, and today's for every other. +func bySwitch(module string, ofTheVault func(string) bool, today, vault func() error) error { + if ofTheVault != nil && ofTheVault(module) { + return vault() + } + return today() +} + +// vaultAsker asks one of the vault's verbs in the controller's name and answers its result, or its refusal. +type vaultAsker func(verb string, args map[string]any) (json.RawMessage, error) + +// vaultWithin is how long the vault is given to answer one step. +const vaultWithin = 20 * time.Second + +// theVault asks the vault's seat on this controller's bus, on the subject that names the controller. +func theVault(ctx context.Context) vaultAsker { + return func(verb string, args map[string]any) (json.RawMessage, error) { + var result json.RawMessage + err := onTheBus(func(conn *nats.Conn) error { + answer, err := link.AskMeshSeatToolAs(ctx, conn, broker.ControllerName, "mesh-vault", verb, args, vaultWithin) + if err != nil { + return err + } + if answer.Error != "" { + return errors.New(answer.Error) + } + result = answer.Result + return nil + }) + return result, err + } +} + +// vaultOpened is a first step's answer: the call, the key made for it, the secret's name in the vault, and who +// asked as the vault recorded it. Never a value. +type vaultOpened struct { + Call string `json:"call"` + SealTo string `json:"seal_to"` + Secret string `json:"secret"` + AskedBy string `json:"asked_by"` +} + +// vaultChanged is what the vault kept: never the value. +type vaultChanged struct { + Name string `json:"name"` + Generation int `json:"generation"` + By string `json:"by"` + Unchanged bool `json:"unchanged"` +} + +func openVaultCall(ask vaultAsker, verb string, args map[string]any) (vaultOpened, error) { + raw, err := ask(verb, args) + if err != nil { + return vaultOpened{}, fmt.Errorf("the vault did not open the call: %w", err) + } + var o vaultOpened + if err := json.Unmarshal(raw, &o); err != nil || o.Call == "" || o.SealTo == "" { + return vaultOpened{}, errors.New("the vault answered no call and no key to seal to") + } + return o, nil +} + +func endVaultCall(ask vaultAsker, verb string, args map[string]any) (vaultChanged, error) { + raw, err := ask(verb, args) + if err != nil { + return vaultChanged{}, err + } + var c vaultChanged + if err := json.Unmarshal(raw, &c); err != nil { + return vaultChanged{}, errors.New("the vault answered something that is not what it kept") + } + return c, nil +} + +// ownSecretArgs names a module's own secret on one machine, as the vault's verbs take it. +func ownSecretArgs(node, module, name string) map[string]any { + return map[string]any{"node": node, "module": module, "name": name} +} + +// provisionArgs names the one credential of a provider assignment and provision. +func provisionArgs(provider, provision string) map[string]any { + return map[string]any{"provider": provider, "provision": provision} +} + +// acceptIntoVault is `secret accept` for a module filled the new way: the value read at this terminal is sealed +// here to the key the vault made for this call, and only the sealed box crosses the bus (ADR 0349 clause 8). +func acceptIntoVault(ask vaultAsker, target map[string]any, value, by string) (string, error) { + first := map[string]any{"by": by} + for k, v := range target { + first[k] = v + } + opened, err := openVaultCall(ask, "accept", first) + if err != nil { + return "", fmt.Errorf("nothing was read or sealed: %w", err) + } + sealed, err := secrets.Seal(opened.SealTo, []byte(value)) + value = "" + if err != nil { + return "", fmt.Errorf("the vault's key for this call is not a sealing key; nothing was sent: %w", err) + } + changed, err := endVaultCall(ask, "accept", map[string]any{"call": opened.Call, "sealed": sealed}) + if err != nil { + return "", fmt.Errorf("the vault did not take it: %w", err) + } + if changed.Unchanged { + return fmt.Sprintf("the vault already holds this value as %s (generation %d); nothing changed", opened.Secret, + changed.Generation), nil + } + return fmt.Sprintf("the vault now holds %s (generation %d), accepted: sealed at this terminal to the vault's key "+ + "for this call, opened in the vault alone and never remade by it; its machine takes it from the vault", + opened.Secret, changed.Generation), nil +} + +// rotateInVault is `secret rotate` for a module filled the new way: the vault makes the value anew and says it +// changed; the module's machine takes it from the vault, with no declaration sent. +func rotateInVault(ask vaultAsker, target map[string]any, by string) (string, error) { + args := map[string]any{"by": by} + for k, v := range target { + args[k] = v + } + changed, err := endVaultCall(ask, "rotate", args) + if err != nil { + return "", fmt.Errorf("not rotated: %w", err) + } + return fmt.Sprintf("rotated %s in the vault (generation %d), asked by %s; the value is sealed and not shown, and "+ + "its machine takes it from the vault", changed.Name, changed.Generation, changed.By), nil +} + +// giveToVault is the desk's path for a module filled the new way (ADR 0349 clause 9, ADR 0277 §22–§27 in the +// vault): the vault records the ask and holds its bounds before the prompt opens and answers the key the prompt +// seals to and who asked as it recorded them; the prompt names that asker; what is typed comes back sealed to +// the vault's key and is passed on unopened; how the ask ended is the vault's record. +func (d deskGive) giveToVault(node, module, name, desk string) (string, error) { + opened, err := openVaultCall(d.vault, "secret-ask", map[string]any{"node": node, "module": module, "name": name, + "desk": desk, "asked_by": askedByName(d.askedBy)}) + if err != nil { + return "", fmt.Errorf("nobody was asked to type anything: %w", err) + } + end := func(outcome string) { + _, _ = endVaultCall(d.vault, "secret-ask", map[string]any{"call": opened.Call, "outcome": outcome}) + } + raw, err := d.ask(desk, map[string]any{ + "module": module, + "secret": name, + "node": node, + "asked_by": opened.AskedBy, + "seal_to": opened.SealTo, + "timeout_seconds": deskPromptWithin, + }) + if err != nil { + end("refused") + return "", fmt.Errorf("the desk on %s could not be asked: %w", desk, err) + } + var answer struct { + Sealed string `json:"sealed"` + Cancelled bool `json:"cancelled"` + TimedOut bool `json:"timed_out"` + } + if err := json.Unmarshal(raw, &answer); err != nil { + end("failed") + return "", fmt.Errorf("the desk on %s answered something that is not the prompt's answer", desk) + } + switch { + case answer.TimedOut: + end("timed-out") + return "", fmt.Errorf("%w: the prompt on %s was not answered within %d seconds", errNothingGiven, desk, deskPromptWithin) + case answer.Cancelled: + end("dismissed") + return "", fmt.Errorf("%w: the prompt on %s was dismissed", errNothingGiven, desk) + case answer.Sealed == "": + end("failed") + return "", fmt.Errorf("the desk on %s answered no sealed value", desk) + } + changed, err := endVaultCall(d.vault, "secret-ask", map[string]any{"call": opened.Call, "sealed": answer.Sealed}) + if err != nil { + return "", fmt.Errorf("the vault did not take what was typed at the desk on %s: %w", desk, err) + } + act := link.HandAct{Verb: "secret accept", Args: []string{node, module, name, "--at-desk", desk}, + Why: fmt.Sprintf("the operator gave %s for %s on %s at the desk on %s, asked by %s; kept in the vault", name, + module, node, desk, opened.AskedBy), + Cause: "given-at-the-desk"} + recorded := "" + if err := d.record(act); err != nil { + recorded = fmt.Sprintf("\n this act could NOT be recorded in the hand-act log, and is done anyway: %v", err) + } + if d.announce != nil { + if err := d.announce(node, module, name, "at the desk on "+desk); err != nil { + recorded += fmt.Sprintf("\n this change could NOT be announced on the operator's channels: %v", err) + } + } + return fmt.Sprintf("%s on %s now holds %q, given at the desk on %s and kept in the vault as %s (generation %d); "+ + "it opened in the vault alone, and %s takes it from there", module, node, name, desk, opened.Secret, + changed.Generation, node) + recorded, nil +} + +// takeIntoVault is `secret issued` for a provider filled the new way (ADR 0349 clause 10): the vault opens a call; +// the provider's tool, asked by the controller as today — the one caller the bus lets ask it — seals its +// software's value to the vault's key; the controller passes it on unopened. Nothing is sealed or sent here: each +// holder takes it from the vault. +func (t issuedTaker) takeIntoVault(provision string, p issuedProvider) (string, error) { + opened, err := openVaultCall(t.vault, "issued", map[string]any{"provision": provision, "provider": p.Node, + "module": p.Module}) + if err != nil { + return "", err + } + answer, err := t.ask(p, map[string]any{"provision": provision, "seal_to": opened.SealTo}) + if err != nil { + return "", fmt.Errorf("its tool %s could not be asked: %w", p.Tool, err) + } + if answer.Error != "" { + return "", fmt.Errorf("its tool %s refused: %s", p.Tool, answer.Error) + } + var given struct { + Sealed string `json:"sealed"` + } + if err := unmarshalAnswer(answer, &given); err != nil || given.Sealed == "" { + return "", fmt.Errorf("its tool %s answered no sealed value", p.Tool) + } + changed, err := endVaultCall(t.vault, "issued", map[string]any{"call": opened.Call, "sealed": given.Sealed}) + if err != nil { + return "", fmt.Errorf("the vault did not take what its tool %s answered: %w", p.Tool, err) + } + if changed.Unchanged { + return fmt.Sprintf("%s from %s on %s: the vault already holds the value its software issued (generation %d); "+ + "nothing was written", provision, p.Module, p.Node, changed.Generation), nil + } + recorded := "" + if err := t.record(link.HandAct{Verb: handActIssued, Args: []string{provision}, + Why: fmt.Sprintf("%s on %s gave the credential its software issued for %s; kept in the vault as %s", p.Module, + p.Node, provision, opened.Secret), + Cause: "issued-by-software"}); err != nil { + recorded = fmt.Sprintf("\n this was NOT recorded in the hand-act log, and is done anyway: %v", err) + } + return fmt.Sprintf("%s from %s on %s: the value its software issued is kept in the vault as %s (generation %d); "+ + "each holder takes it from there%s", provision, p.Module, p.Node, opened.Secret, changed.Generation, + recorded), nil +} + +// actingAs is who acts in this process, as the vault records it beside the controller that carries the act. +func actingAs() string { + return strings.TrimSpace(askedByName(link.Caller())) +} diff --git a/internal/link/seat_as_test.go b/internal/link/seat_as_test.go new file mode 100644 index 00000000..c25ca8d0 --- /dev/null +++ b/internal/link/seat_as_test.go @@ -0,0 +1,47 @@ +package link + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/nats-io/nats.go" + + "github.com/novox/mesh-controller/internal/broker" + "github.com/novox/mesh-controller/internal/testbus" +) + +// A mesh seat's verb asked in the caller's own name goes on the subject that names its caller (novox/hq issue +// 365), so the holder knows who asks from the subject alone: how the vault takes a value from the controller +// and nobody else (design 57's V2, issue 675). +func TestAMeshSeatsVerbIsAskedInTheCallersOwnName(t *testing.T) { + conn, err := nats.Connect(testbus.URL(t)) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + heard := make(chan string, 1) + sub, err := conn.Subscribe(broker.CalledPattern(SeatToolSubject("mesh-vault", "accept")), func(m *nats.Msg) { + heard <- m.Subject + raw, _ := json.Marshal(Answer{Result: json.RawMessage(`{"call":"c1"}`)}) + _ = m.Respond(raw) + }) + if err != nil { + t.Fatal(err) + } + defer sub.Unsubscribe() + answer, err := AskMeshSeatToolAs(context.Background(), conn, broker.ControllerName, "mesh-vault", "accept", + map[string]any{"provider": "novox", "provision": "postgres"}, 5*time.Second) + if err != nil || string(answer.Result) != `{"call":"c1"}` { + t.Fatalf("%+v %v", answer, err) + } + subject := <-heard + if subject != "mesh.seat.mesh-vault.call.accept.controller" || !strings.HasSuffix(subject, "."+broker.ControllerName) { + t.Errorf("asked on %s", subject) + } + if _, err := AskMeshSeatToolAs(context.Background(), conn, "not a user!", "mesh-vault", "accept", nil, time.Second); err == nil { + t.Error("a caller that is no bus user was asked in") + } +}