diff --git a/modules/mesh-vault/cmd/mesh-vault/followups_test.go b/modules/mesh-vault/cmd/mesh-vault/followups_test.go index aa5fbb8d..8e2b69f7 100644 --- a/modules/mesh-vault/cmd/mesh-vault/followups_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/followups_test.go @@ -34,10 +34,10 @@ func TestTheRetirementToolsPassTheActThrough(t *testing.T) { 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 { + if _, err := approve.runAs("person.operator", 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", + got, err := approve.runAs("person.operator", map[string]any{"consumers": anyOf(set), "why": "the apps are gone", "by": "person.jochen", "via": "telegram"}) if err != nil { t.Fatal(err) @@ -51,7 +51,7 @@ func TestTheRetirementToolsPassTheActThrough(t *testing.T) { last = b } } - if last["why"] != "the apps are gone" || last["by"] != "person.jochen" || last["via"] != "telegram, called by controller" { + if last["why"] != "the apps are gone" || last["by"] != "person.jochen" || last["via"] != "telegram, called by person.operator" { t.Errorf("the approval said %v", last) } @@ -118,7 +118,7 @@ func TestTheExportsFingerprintAndVaultStatus(t *testing.T) { 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{}) + answer, err := toolNamed(t, definitions(&Env{Vault: v.Vault}), "secret_vault_export").runAs("person.jochen", map[string]any{}) if err != nil { t.Fatal(err) } diff --git a/modules/mesh-vault/cmd/mesh-vault/intake_test.go b/modules/mesh-vault/cmd/mesh-vault/intake_test.go index 6d844cf0..6f25174d 100644 --- a/modules/mesh-vault/cmd/mesh-vault/intake_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/intake_test.go @@ -536,7 +536,7 @@ func TestTheVerbsTakeTheirCallerFromTheRuntime(t *testing.T) { t.Fatalf("issued: %+v %v", answer, err) } broken := &Env{Broken: errors.New("no store")} - if _, err := toolNamed(t, definitions(broken), Seat+".rotate").runAs(ControllerCaller, map[string]any{}); err == nil || + if _, err := toolNamed(t, definitions(broken), Seat+".rotate").runAs("person.jochen", map[string]any{"node": "g14", "module": "dunst", "name": "token"}); err == nil || err.Error() != "no store" { t.Errorf("a broken vault said %v", err) } diff --git a/modules/mesh-vault/cmd/mesh-vault/sensitive.go b/modules/mesh-vault/cmd/mesh-vault/sensitive.go new file mode 100644 index 00000000..a9c3e486 --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/sensitive.go @@ -0,0 +1,89 @@ +package main + +// **Each sensitive verb judges its own caller** (novox/hq ADR 0590 §8, issue 1126). The controller judged the +// operator's account for these verbs (ADR 0272 §6, §9) and relayed who asked at its terminal; it is not placed, and +// nox and every local user reach the vault through the mesh MCP server on their machine. So each verb that approves or +// deletes a retirement, rotates a secret or exports sealed copies answers only: +// +// - the operator's account: a caller the runtime proves as `person.`; or +// - a recorded hand act: any other proven caller names (`act`) the hand act it recorded through mesh-hand-acts for +// this verb, read back here by its id: recorded by that same caller, within HandActWithin, for this verb, and +// naming what it acts on when the verb acts on something named. +// +// Nothing else passes: an unproven caller, a module with no act, an act of another caller, verb or subject. A `by` +// argument is a word, never the proof (actOf keeps it beside the proven caller). + +import ( + "fmt" + "slices" + "strings" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// PersonPrefix begins the bus user of a person's account: `person.`. +const PersonPrefix = "person." + +// The sensitive verbs, as a hand act names them. +const ( + SensitiveRotate = "rotate" + SensitiveRetireApprove = "provisioner_retire_approve" + SensitiveDelete = "provisioner_delete" + SensitiveExport = "secret_export" + SensitiveVaultExport = "secret_vault_export" +) + +// JudgeSensitive answers whether caller may do verb on about (the names it acts on, each of which a hand act must name; +// none for an export): nil for the operator's account, or for a hand act of caller's own that warrants it. +func JudgeSensitive(read ActReader, now func() time.Time, caller, verb, act string, about ...string) error { + caller = strings.TrimSpace(caller) + if caller == "" || caller == stdio.Unproven { + return fmt.Errorf("%s answers a caller the runtime proves, and it proved none (novox/hq ADR 0590 §8)", verb) + } + if strings.HasPrefix(caller, PersonPrefix) && len(caller) > len(PersonPrefix) { + return nil + } + if strings.TrimSpace(act) == "" { + return fmt.Errorf("%s answers the operator's account (person.), or a caller naming the hand act it "+ + "recorded for it through mesh-hand-acts (`act`); %s named none (novox/hq ADR 0590 §8)", verb, caller) + } + if read == nil { + return fmt.Errorf("%s: the vault reads no hand acts, so it answers only the operator's account", verb) + } + a, err := read(act) + if err != nil { + return fmt.Errorf("%s: the hand act %s could not be read back from mesh-hand-acts: %w", verb, act, err) + } + switch { + case a == nil: + return fmt.Errorf("%s: mesh-hand-acts holds no hand act %s: record one first", verb, act) + case a.By != caller: + return fmt.Errorf("%s: the hand act %s was recorded by %s, not by %s who asks", verb, act, a.By, caller) + case now().Sub(a.At) > HandActWithin: + return fmt.Errorf("%s: the hand act %s is older than %s: record a new one", verb, act, HandActWithin) + case a.Verb != verb && !strings.HasSuffix(a.Verb, "."+verb): + return fmt.Errorf("%s: the hand act %s is %q: it warrants no %s", verb, act, a.Verb, verb) + } + for _, name := range about { + if !slices.Contains(a.Args, name) { + return fmt.Errorf("%s: the hand act %s names %s, not %s", verb, act, strings.Join(a.Args, " "), name) + } + } + return nil +} + +// judge is JudgeSensitive with the vault's reader of hand acts and its clock; with no vault ready, only the operator's +// account passes. +func (e *Env) judge(caller, verb string, a map[string]any, about ...string) error { + var read ActReader + now := time.Now + if e != nil && e.Vault != nil { + read = e.Vault.ReadAct + if e.Vault.Now != nil { + now = e.Vault.Now + } + } + act, _ := a["act"].(string) + return JudgeSensitive(read, now, caller, verb, act, about...) +} diff --git a/modules/mesh-vault/cmd/mesh-vault/sensitive_test.go b/modules/mesh-vault/cmd/mesh-vault/sensitive_test.go new file mode 100644 index 00000000..d0e53eca --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/sensitive_test.go @@ -0,0 +1,72 @@ +package main + +import ( + "strings" + "testing" + "time" +) + +// ADR 0590 §8 (issue 1126): approving or deleting a retirement, rotating a secret and exporting sealed copies each +// answer the operator's account, or a caller naming the hand act it recorded for that verb and that subject, and +// nothing else: the controller that judged for them is not placed. +func TestEachSensitiveVerbJudgesItsOwnCaller(t *testing.T) { + now := time.Date(2026, 11, 2, 12, 0, 0, 0, time.UTC) + acts := map[string]HandActSeen{ + "mine": {ID: "mine", By: "novox.mesh-healers", At: now.Add(-time.Hour), Verb: "mesh-vault.rotate", Args: []string{"own/g14/app/key"}}, + "old": {ID: "old", By: "novox.mesh-healers", At: now.Add(-25 * time.Hour), Verb: "rotate", Args: []string{"own/g14/app/key"}}, + "other": {ID: "other", By: "novox.mesh-config", At: now.Add(-time.Hour), Verb: "rotate", Args: []string{"own/g14/app/key"}}, + "del": {ID: "del", By: "novox.mesh-healers", At: now.Add(-time.Hour), Verb: "provisioner_delete", Args: []string{"d-app"}}, + } + read := func(id string) (*HandActSeen, error) { + if a, ok := acts[id]; ok { + return &a, nil + } + return nil, nil + } + clock := func() time.Time { return now } + for _, row := range []struct { + name, caller, verb, act string + about []string + want string // empty: allowed + }{ + {"the operator's account", "person.jochen", SensitiveRotate, "", []string{"own/g14/app/key"}, ""}, + {"the operator's account exports", "person.jochen", SensitiveExport, "", nil, ""}, + {"unproven", "unproven", SensitiveVaultExport, "", nil, "proved none"}, + {"a bare person prefix", "person.", SensitiveExport, "", nil, "named none"}, + {"a module with no act", "novox.mesh-healers", SensitiveRotate, "", []string{"own/g14/app/key"}, "named none"}, + {"a module's own act", "novox.mesh-healers", SensitiveRotate, "mine", []string{"own/g14/app/key"}, ""}, + {"an act of another subject", "novox.mesh-healers", SensitiveRotate, "mine", []string{"own/ace/app/key"}, "not own/ace/app/key"}, + {"an act of another verb", "novox.mesh-healers", SensitiveRotate, "del", []string{"own/g14/app/key"}, "warrants no rotate"}, + {"an act of another caller", "novox.mesh-healers", SensitiveRotate, "other", []string{"own/g14/app/key"}, "recorded by novox.mesh-config"}, + {"an act too old", "novox.mesh-healers", SensitiveRotate, "old", []string{"own/g14/app/key"}, "older than"}, + {"an act not recorded", "novox.mesh-healers", SensitiveDelete, "none", []string{"d-app"}, "holds no hand act"}, + {"a delete's own act", "novox.mesh-healers", SensitiveDelete, "del", []string{"d-app"}, ""}, + } { + err := JudgeSensitive(read, clock, row.caller, row.verb, row.act, row.about...) + if (row.want == "") != (err == nil) || (err != nil && !strings.Contains(err.Error(), row.want)) { + t.Errorf("%s: %v, want %q", row.name, err, row.want) + } + } +} + +// The tools themselves judge: a module's call with no act is refused before anything is read or changed, and the +// operator's account goes through. +func TestTheSensitiveToolsRefuseACallerTheyDoNotJudgeTheirs(t *testing.T) { + env := &Env{Root: t.TempDir()} + defs := definitions(env) + for _, name := range []string{Seat + ".rotate", "provisioner_retire_approve", "provisioner_delete", "secret_export", + "secret_vault_export"} { + if _, err := toolNamed(t, defs, name).runAs("novox.some-module", map[string]any{"why": "x", "consumer": "c", + "confirm": "c", "consumers": []any{"c"}, "node": "g14", "module": "app", "name": "key"}); err == nil || + !strings.Contains(err.Error(), "ADR 0590") { + t.Errorf("%s answered a module with no act: %v", name, err) + } + } + if _, err := toolNamed(t, defs, "secret_export").runAs("person.jochen", map[string]any{}); err != nil { + t.Errorf("the operator's export was refused: %v", err) + } + // Listing holders alone is no sealed copy: any proven caller reads it. + if _, err := toolNamed(t, defs, "secret_export").runAs("novox.some-module", map[string]any{"sealed": false}); err != nil { + t.Errorf("a listing without sealed copies was refused: %v", err) + } +} diff --git a/modules/mesh-vault/cmd/mesh-vault/tools.go b/modules/mesh-vault/cmd/mesh-vault/tools.go index bdf87dde..f384f15d 100644 --- a/modules/mesh-vault/cmd/mesh-vault/tools.go +++ b/modules/mesh-vault/cmd/mesh-vault/tools.go @@ -171,7 +171,7 @@ func definitions(env *Env) []toolDef { 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. For a caller the runtime proves, its manifest granted the verb, and with a why.", + "the value. Answers the operator's account, or a caller naming its own hand act for this rotation (`act`; novox/hq ADR 0590 §8), with a why.", 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"), @@ -179,14 +179,18 @@ func definitions(env *Env) []toolDef { "consumer": str("a provision's credential of a provider whose consumers keep a login each: only " + "this consumer's machine's own"), "consumer_module": str("with it or alone: only this consuming module's own login"), - "by": str("who asked, as the controller names them when it carries the act"), - "why": str("why it is rotated: required")}}, + "by": str("who asked, a word kept beside the proven caller"), + "why": str("why it is rotated: required"), + "act": str("the id of the hand act the caller recorded for this through mesh-hand-acts; not needed by the operator's account (person.)")}}, run: func(caller string, a map[string]any) (any, error) { - v, err := env.ready() + secret, err := secretNamed(a) if err != nil { return nil, err } - secret, err := secretNamed(a) + if err := env.judge(caller, SensitiveRotate, a, secret); err != nil { + return nil, err + } + v, err := env.ready() if err != nil { return nil, err } @@ -384,11 +388,18 @@ func definitions(env *Env) []toolDef { "sealed to the operator's key (novox/hq ADR 0085, amended). Ciphertext — nothing here can open a line " + "of it; the operator, holding the private key off the mesh, recovers one with " + "`nox secret recover --from-export` on the control-node. Also lists what is NOT recoverable: secrets " + - "made before the mesh had an operator key.", + "made before the mesh had an operator key. The sealed copies answer the operator's account, or a caller naming its own hand act for the export (`act`; novox/hq ADR 0590 §8).", Input: map[string]any{"sealed": map[string]any{"type": "boolean", - "description": "include the sealed blobs (default true); false lists holders and the key only"}}, - run: func(_ string, a map[string]any) (any, error) { - return exportAnswer(env.Root, a["sealed"] != false), nil + "description": "include the sealed blobs (default true); false lists holders and the key only"}, + "act": str("the id of the hand act the caller recorded for this through mesh-hand-acts; not needed by the operator's account (person.)")}, + run: func(caller string, a map[string]any) (any, error) { + sealed := a["sealed"] != false + if sealed { + if err := env.judge(caller, SensitiveExport, a); err != nil { + return nil, err + } + } + return exportAnswer(env.Root, sealed), nil }}, {Name: "secret_holders", Description: "Who holds a secret from this vault: each consumer's login, node and module, when it was " + @@ -521,8 +532,13 @@ func definitions(env *Env) []toolDef { {Name: "secret_vault_export", Description: "Every secret the vault itself holds, as its copy sealed to the operator key, in the shape of " + "`secret export`: saved to a file, `nox secret recover vault mesh-vault --key " + - "--from-export --out ` opens one with the operator's private key. Ciphertext only.", - run: func(string, map[string]any) (any, error) { + "--from-export --out ` opens one with the operator's private key. Ciphertext only. Answers the " + + "operator's account, or a caller naming its own hand act for it (`act`; novox/hq ADR 0590 §8).", + Input: map[string]any{"act": str("the id of the hand act the caller recorded for this through mesh-hand-acts; not needed by the operator's account (person.)")}, + run: func(caller string, a map[string]any) (any, error) { + if err := env.judge(caller, SensitiveVaultExport, a); err != nil { + return nil, err + } if env.Vault == nil { return nil, errors.New("the vault is not ready") } @@ -539,9 +555,12 @@ func definitions(env *Env) []toolDef { return env.Custody.Retirement(), nil }}, {Name: "provisioner_retire_approve", - Description: "Retire exactly the set waiting for a person (or rejected earlier). Repeat the set in consumers; why is required.", + Description: "Retire exactly the set waiting for a person (or rejected earlier). Repeat the set in consumers; why is required. Answers the operator's account, or a caller naming its own hand act naming each consumer (`act`; novox/hq ADR 0590 §8).", Input: actInput(true, false), run: func(caller string, a map[string]any) (any, error) { + if err := env.judge(caller, SensitiveRetireApprove, a, stringsOf(a["consumers"])...); err != nil { + return nil, err + } if env.Custody == nil { return nil, errors.New("the vault has no custody ledger") } @@ -566,13 +585,17 @@ func definitions(env *Env) []toolDef { }}, {Name: "provisioner_delete", Description: "Delete one RETIRED holder from the custody ledger, its rotation history with it — never an active one. " + - "Repeat its name in confirm; why is required.", + "Repeat its name in confirm; why is required. Answers the operator's account, or a caller naming its own hand " + + "act naming the consumer (`act`; novox/hq ADR 0590 §8).", Input: actInput(false, true), run: func(caller string, a map[string]any) (any, error) { + consumer, _ := a["consumer"].(string) + if err := env.judge(caller, SensitiveDelete, a, consumer); err != nil { + return nil, err + } if env.Custody == nil { return nil, errors.New("the vault has no custody ledger") } - consumer, _ := a["consumer"].(string) confirm, _ := a["confirm"].(string) if err := env.Custody.Delete(consumer, confirm, actOf(caller, a)); err != nil { return nil, err @@ -665,7 +688,7 @@ func definitions(env *Env) []toolDef { // actInput is the arguments of an act on retirement, as the SDK's harness takes them. func actInput(set, one bool) map[string]any { text := map[string]any{"type": "string"} - in := map[string]any{"why": text, "by": text, "via": text} + in := map[string]any{"why": text, "by": text, "via": text, "act": text} if set { in["consumers"] = map[string]any{"type": "array", "items": text} } diff --git a/modules/mesh-vault/cmd/mesh-vault/tools_test.go b/modules/mesh-vault/cmd/mesh-vault/tools_test.go index 00612307..89a1194e 100644 --- a/modules/mesh-vault/cmd/mesh-vault/tools_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/tools_test.go @@ -70,18 +70,18 @@ func TestSecretExportAnswersTheMeshsExport(t *testing.T) { root := t.TempDir() env := &Env{Root: root} export := toolNamed(t, definitions(env), "secret_export") - got, _ := export.Run(map[string]any{}) + got, _ := export.runAs("person.jochen", map[string]any{}) if got.(map[string]any)["available"] != false { t.Errorf("no export said %v", got) } doc := `{"export":1,"operator-key":"b3A=","fingerprint":"sha256:f","kept":[{"node":"g14","module":"dunst","name":"token","kind":"own","origin":"made","sealed":"c2VhbGVk","key":"b3A=","made-at":"2026-10-01T00:00:00Z"}],"unrecoverable":[{"node":"ace","module":"x","name":"y"}]}` _ = os.WriteFile(filepath.Join(root, "export.json"), []byte(doc), 0o600) - got, _ = export.Run(map[string]any{}) + got, _ = export.runAs("person.jochen", map[string]any{}) a := got.(map[string]any) if a["available"] != true || a["count"] != 1 || a["operator-key"] != "b3A=" { t.Errorf("the export said %v", a) } - got, _ = export.Run(map[string]any{"sealed": false}) + got, _ = export.runAs("person.jochen", map[string]any{"sealed": false}) kept := got.(map[string]any)["kept"].([]map[string]any) if _, ok := kept[0]["sealed"]; ok { t.Error("sealed=false still returned the blobs") diff --git a/modules/mesh-vault/cmd/mesh-vault/vault_test.go b/modules/mesh-vault/cmd/mesh-vault/vault_test.go index e380ed56..13f75496 100644 --- a/modules/mesh-vault/cmd/mesh-vault/vault_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/vault_test.go @@ -796,7 +796,7 @@ func TestTheOperatorRecoversAVaultSecretFromItsExport(t *testing.T) { if _, err := v.Put("pair/postgres/gitea", "recover-me", "the test"); err != nil { t.Fatal(err) } - answer, err := toolNamed(t, definitions(&Env{Vault: v.Vault}), "secret_vault_export").Run(map[string]any{}) + answer, err := toolNamed(t, definitions(&Env{Vault: v.Vault}), "secret_vault_export").runAs("person.jochen", map[string]any{}) if err != nil { t.Fatal(err) }