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 <noreply@anthropic.com>
This commit is contained in:
@@ -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()))
|
||||
}
|
||||
Reference in New Issue
Block a user