diff --git a/modules/mesh-vault/cmd/mesh-vault/intake.go b/modules/mesh-vault/cmd/mesh-vault/intake.go index b1eb1811..99a272a7 100644 --- a/modules/mesh-vault/cmd/mesh-vault/intake.go +++ b/modules/mesh-vault/cmd/mesh-vault/intake.go @@ -177,6 +177,16 @@ func (v *Vault) takeCall(verb, id string) (*callKey, error) { return c, nil } +// openedBy is the caller that opened a secret-ask's call still open, as the ask's record names it; "" for none. +func (v *Vault) openedBy(id string) string { + v.callsMu.Lock() + defer v.callsMu.Unlock() + if c, ok := v.pending[id]; ok && c.verb == "secret-ask" && c.ask != nil { + return c.ask.Caller + } + return "" +} + // 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{} }() @@ -305,15 +315,33 @@ func (v *Vault) AcceptTake(caller, call, sealed string) (Changed, error) { 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. + // controller alone. A caller asking directly is named by the runtime's proof, never by this (ADR 0563 §8). AskedBy string } +// askedByFor is who asked a secret-ask as the bus says it (novox/hq ADR 0563 §8): for a call relayed by the controller, +// the caller the bus named to the controller, which it takes from the controller alone, as before; for a caller the +// manifest names, that caller as the runtime proved it. Any other caller is refused: no caller names itself. +func (v *Vault) askedByFor(caller string, a AskOf) (string, error) { + switch { + case caller == ControllerCaller: + return askedByName(a.AskedBy), nil + case v.AskCallers.Allows(caller): + return askedByName(caller), nil + } + if caller == "" { + caller = stdio.Unproven + } + return "", fmt.Errorf("secret-ask answers the controller and the callers the vault's manifest names, and %s is "+ + "neither; nobody was asked to type anything", caller) +} + // 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 { + askedBy, err := v.askedByFor(caller, a) + if err != nil { return Opened{}, err } secret, err := OwnSecret(a.Node, a.Module, a.Name) @@ -323,7 +351,6 @@ func (v *Vault) AskOpen(caller string, a AskOf) (Opened, error) { 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() @@ -378,9 +405,13 @@ var askOutcomes = map[string]bool{"dismissed": true, "timed-out": true, "empty": // 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 { + if _, err := v.askedByFor(caller, AskOf{}); err != nil { return Changed{}, err } + // An ask is ended by the caller that opened it, and by no other. + if opener := v.openedBy(call); opener != "" && opener != caller { + return Changed{}, fmt.Errorf("the ask %s was opened by %s, and %s may not end it", call, opener, caller) + } 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") @@ -527,8 +558,8 @@ type Asked struct { Unchanged bool `json:"unchanged,omitempty"` } -// AskAtDesk is `secret-ask` (ADR 0349 clause 9, ADR 0277 §22–§27): asked by the controller alone, which names who -// asked it. The ask is recorded and its bounds held before any prompt opens; the vault asks the desk's prompt +// AskAtDesk is `secret-ask` (ADR 0349 clause 9, ADR 0277 §22–§27): asked by the controller, which names who asked it, +// or by a caller the vault's manifest names, named by the runtime's proof (ADR 0563 §8). The ask is recorded and its bounds held before any prompt opens; the vault asks the desk's prompt // itself, in its own name, naming the asker as recorded and giving a key made for this one call; what is typed // comes back sealed to that key and opens here. How the prompt ended is the answer and the record's. func (v *Vault) AskAtDesk(caller string, a AskOf) (Asked, error) { diff --git a/modules/mesh-vault/cmd/mesh-vault/intake_test.go b/modules/mesh-vault/cmd/mesh-vault/intake_test.go index e795bad7..d9701db6 100644 --- a/modules/mesh-vault/cmd/mesh-vault/intake_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/intake_test.go @@ -13,6 +13,7 @@ import ( "encoding/base64" "encoding/json" "errors" + stdio "git.novox.be/novox/mesh-sdk/go" "os" "path/filepath" "strings" @@ -173,6 +174,29 @@ func TestAnAskIsBoundedToOneOpenPerSecretAndThreeAnHour(t *testing.T) { 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) } + // 774 (ADR 0563 §8): a caller the manifest names asks directly, is named by its proven caller and never by the + // asked_by it gives, and is held to the same bounds; one it does not name is refused. + t.Run("a named caller asks itself, within the bounds", func(t *testing.T) { + x := newVault(t, nil) + x.AskCallers = stdio.Callers{"node-tools"} + if _, err := x.AskOpen("ace.claude-code", telegramAsk("x")); err == nil || !strings.Contains(err.Error(), "ace.claude-code") { + t.Errorf("a caller the manifest does not name opened an ask: %v", err) + } + opened, err := x.AskOpen("g14.node-tools", telegramAsk("person.operator")) + if err != nil || opened.AskedBy != "g14.node-tools" { + t.Fatalf("a named caller's ask: %+v, %v", opened, err) + } + if _, err := x.AskEnd(ControllerCaller, opened.Call, "", "dismissed"); err == nil { + t.Error("another caller ended a named caller's ask") + } + if _, err := x.AskOpen("ace.node-tools", telegramAsk("")); err == nil || !strings.Contains(err.Error(), "still open (asked by g14.node-tools)") { + t.Errorf("a second ask while a named caller's is open: %v", err) + } + recorded, _ := x.Store.Asks(10) + if len(recorded) == 0 || recorded[0].AskedBy != "g14.node-tools" || recorded[0].Caller != "g14.node-tools" { + t.Errorf("recorded as %+v", recorded) + } + }) } // Only the controller reaches a desk's prompt, so only it opens and ends an ask; a value not sealed to the diff --git a/modules/mesh-vault/cmd/mesh-vault/main.go b/modules/mesh-vault/cmd/mesh-vault/main.go index af3c48c1..23321cee 100644 --- a/modules/mesh-vault/cmd/mesh-vault/main.go +++ b/modules/mesh-vault/cmd/mesh-vault/main.go @@ -54,7 +54,8 @@ func build() *Env { env.Vault = &Vault{Store: store, Own: own, Askers: Declared{Read: assigned.Read, Confirm: readAssignment, Store: store}, Assignments: Mirror{Keys: assignmentKeys, Read: readAssignment}, Emit: emit, Ask: stdio.Ask, Now: time.Now, Log: say, Accounts: busAccounts{}, Haves: readHave, LeaseRead: readLease, WantRead: readWant, - HaveKeys: func() ([]string, error) { return stdio.State(HaveRead).Keys() }, Placement: placement, Signers: busSigners{}} + HaveKeys: func() ([]string, error) { return stdio.State(HaveRead).Keys() }, Placement: placement, Signers: busSigners{}, + AskCallers: askCallers(os.Getenv("MESH_VAULT_SECRET_ASK_CALLERS"))} return env } @@ -283,3 +284,15 @@ func main() { os.Exit(1) } } + +// askCallers are the callers the vault's manifest names for secret-ask besides the controller (novox/hq ADR 0563 §8): +// MESH_VAULT_SECRET_ASK_CALLERS, comma-separated, each a module (on any machine), . or person.. +func askCallers(list string) stdio.Callers { + var out stdio.Callers + for _, c := range strings.Split(list, ",") { + if c = strings.TrimSpace(c); c != "" { + out = append(out, c) + } + } + return out +} diff --git a/modules/mesh-vault/cmd/mesh-vault/tools.go b/modules/mesh-vault/cmd/mesh-vault/tools.go index 9a027cee..b6fc006a 100644 --- a/modules/mesh-vault/cmd/mesh-vault/tools.go +++ b/modules/mesh-vault/cmd/mesh-vault/tools.go @@ -122,7 +122,8 @@ func definitions(env *Env) []toolDef { }}, {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), " + - "asked by the controller alone: node, module, name, desk and asked_by. The ask is recorded before any " + + "asked by the controller (naming asked_by) or by a caller the vault's manifest names, itself named by the " + + "bus (ADR 0563 §8): node, module, name and desk. The ask is recorded before any " + "prompt opens — one open ask per secret and three an hour — and the vault asks the desk's prompt itself, " + "in its own name, with a key made for the call; what is typed opens in the vault alone. Answers how the " + "prompt ended (given, dismissed, timed-out, empty, refused or failed) and who asked as recorded; never " + @@ -130,7 +131,7 @@ func definitions(env *Env) []toolDef { Input: map[string]any{"type": "object", "properties": map[string]any{ "node": str("the machine the secret is for"), "module": str("the module"), "name": str("the secret's name in the module"), "desk": str("the machine whose prompt asks"), - "asked_by": str("who asked the controller, as the bus named its caller")}}, + "asked_by": str("from the controller alone: who asked it, as the bus named its caller")}}, run: func(caller string, a map[string]any) (any, error) { v, err := env.ready() if err != nil { diff --git a/modules/mesh-vault/cmd/mesh-vault/vault.go b/modules/mesh-vault/cmd/mesh-vault/vault.go index d52ac302..611ed873 100644 --- a/modules/mesh-vault/cmd/mesh-vault/vault.go +++ b/modules/mesh-vault/cmd/mesh-vault/vault.go @@ -40,8 +40,12 @@ type Vault struct { // account (ADR 0259 §8's runs-as, issue 723): the desks' secret prompt and each issuing provider's tool, which // the bus lets the vault's account alone call in its own name. Nil asks nobody. Ask func(key string, body any) (json.RawMessage, error) - Now func() time.Time - Log func(format string, args ...any) + // AskCallers are the callers besides the controller that may ask secret-ask themselves, as the vault's manifest names + // them (MESH_VAULT_SECRET_ASK_CALLERS; novox/hq ADR 0563 §8): each is named by the runtime's proof. Empty: the + // controller alone. + AskCallers stdio.Callers + Now func() time.Time + Log func(format string, args ...any) // Accounts is the vault's state of bus-account hashes (accounts.go, ADR 0547 clause 9): nil writes none. Accounts AccountStore // HashCost is the bcrypt cost of those hashes: zero is the controller's, bcrypt's default. diff --git a/modules/mesh-vault/module.json b/modules/mesh-vault/module.json index 5fd98c62..de83a234 100644 --- a/modules/mesh-vault/module.json +++ b/modules/mesh-vault/module.json @@ -150,7 +150,8 @@ "MESH_VAULT_LEDGER": "${dir:ledger}", "MESH_VAULT_ROOT": "${dir:root}", "MESH_VAULT_STORE": "${dir:store}", - "MESH_VAULT_STATE": "${dir:state}" + "MESH_VAULT_STATE": "${dir:state}", + "MESH_VAULT_SECRET_ASK_CALLERS": "node-tools" } } ]