diff --git a/internal/broker/controller_buckets_test.go b/internal/broker/controller_buckets_test.go index 804f1c5e..d2211bd3 100644 --- a/internal/broker/controller_buckets_test.go +++ b/internal/broker/controller_buckets_test.go @@ -96,3 +96,47 @@ func TestWhatTheControllerAskedIsBounded(t *testing.T) { t.Errorf("history %d, age %s, bytes %d", status.History(), status.TTL(), info.Config.MaxBytes) } } + +// **The controller may ask the vault's intake verbs in its own name** (design 57's V2, issue 675): the vault +// takes a value only from the controller, which it knows from the subject that names its caller — so the +// controller is granted that subject for the verbs the vault's holder serves, and no more of the vault's seat. +// Composed from the records, as the issued tools are: the installer's first user list carries none. +func TestTheControllerMayAskTheVaultInItsOwnName(t *testing.T) { + vault := Declared{Module: "mesh-vault", Holds: []Seat{{Name: "mesh-vault", + Serves: []string{"get", "accept", "secret-ask", "issued", "rotate"}}}} + users, err := Users(Records{Nodes: []string{"novox"}, Assigned: map[string][]Declared{"novox": {vault}}}) + if err != nil { + t.Fatal(err) + } + controller := users[0] + if strings.Join(controller.AsksTheVault, ",") != "accept,issued,rotate,secret-ask" { + t.Fatalf("composed %v", controller.AsksTheVault) + } + p, err := PermissionsFor(controller) + if err != nil { + t.Fatal(err) + } + for _, verb := range []string{"accept", "secret-ask", "issued", "rotate"} { + if !MayPublish(p, CalledSubject("mesh.seat.mesh-vault.tool."+verb, ControllerName)) { + t.Errorf("the controller may not ask the vault's %s in its own name", verb) + } + if MayPublish(p, CalledSubject("mesh.seat.mesh-vault.tool."+verb, "anchor.node-tools")) { + t.Errorf("the controller may ask the vault's %s in another's name", verb) + } + } + if MayPublish(p, CalledSubject("mesh.seat.mesh-vault.tool.get", ControllerName)) { + t.Error("the controller may ask the vault for a secret: it seals nothing the vault holds") + } + // A vault that serves none of them yet — the running one — composes no grant, nor does genesis. + older := Declared{Module: "mesh-vault", Holds: []Seat{{Name: "mesh-vault", Serves: []string{"get"}}}} + users, _ = Users(Records{Nodes: []string{"novox"}, Assigned: map[string][]Declared{"novox": {older}}}) + if len(users[0].AsksTheVault) != 0 { + t.Errorf("a vault serving none composed %v", users[0].AsksTheVault) + } + genesis, _ := PermissionsFor(Principal{Kind: KindController, PasswordHash: "x"}) + for _, s := range genesis.Publish { + if strings.Contains(s, "mesh-vault") { + t.Errorf("the installer's controller may publish %s", s) + } + } +} diff --git a/internal/broker/nats.go b/internal/broker/nats.go index 3e5a42ca..bd2b1102 100644 --- a/internal/broker/nats.go +++ b/internal/broker/nats.go @@ -16,6 +16,7 @@ import ( "errors" "fmt" "regexp" + "slices" "sort" "strings" "time" @@ -130,6 +131,11 @@ type Principal struct { // grants. AsksIssued []string + // AsksTheVault are the vault's verbs the controller asks in its own name (VerbsTheControllerAsksTheVault, + // issue 675), those the vault's holder serves: on the subject that names its caller, so the vault knows the + // controller asks. Composed from the records like AsksIssued, so the installer's first user list carries none. + AsksTheVault []string + // State is the local names of the state this principal's module keeps, and Reads the state of // others it reads as `.` (novox/hq ADR 0201): a bucket each, kept by the owner's // instances and read by whoever declares it. @@ -269,6 +275,14 @@ var VerbsTheControllerAsksTheDeliveryOwner = []SeatVerb{{Seat: "mesh-delivery", {Seat: "mesh-delivery", Verb: "close"}, {Seat: "mesh-delivery", Verb: "release"}, {Seat: "mesh-delivery", Verb: "stop"}, {Seat: "mesh-delivery", Verb: "times"}, {Seat: "mesh-delivery", Verb: "groups"}} +// VerbsTheControllerAsksTheVault are the mesh-vault seat's verbs the controller calls in its own name (novox/hq +// ADR 0349 clauses 8 to 10, design 57's V2, issue 675): it carries a value given at its terminal, typed at a desk +// or issued by a provider's software into the vault, sealed to a key the vault made for the call, and asks a +// rotation. The vault takes these from the controller alone, which it knows from the subject that names its +// caller; granted as Principal.AsksTheVault says. +var VerbsTheControllerAsksTheVault = []SeatVerb{{Seat: "mesh-vault", Verb: "accept"}, + {Seat: "mesh-vault", Verb: "secret-ask"}, {Seat: "mesh-vault", Verb: "issued"}, {Seat: "mesh-vault", Verb: "rotate"}} + // VerbsTheControllerActsOnAWarrant are the other seat verbs the controller calls when the operator's warrant // chooses them (novox/hq ADR 0259): a machine's service restarted, and a walk started or stopped through the // controller's own `plans`. Named one by one; a node seat's on any machine, a mesh seat's flat. @@ -930,6 +944,13 @@ func PermissionsFor(p Principal) (Permissions, error) { } pub = append(pub, "mesh.mod."+module+"."+CallKind+"."+tool+".*."+CallerToken(p.Username())) } + // **And the vault's intake verbs** (issue 675): the vault takes a value from the controller alone, known + // from the subject's last token — the verbs its holder serves, flat, and nothing else of its seat. + for _, v := range VerbsTheControllerAsksTheVault { + if slices.Contains(p.AsksTheVault, v.Verb) { + pub = append(pub, "mesh.seat."+v.Seat+"."+CallKind+"."+v.Verb+"."+CallerToken(p.Username())) + } + } } sort.Strings(pub) diff --git a/internal/broker/users.go b/internal/broker/users.go index 1768b028..1af80c50 100644 --- a/internal/broker/users.go +++ b/internal/broker/users.go @@ -2,6 +2,7 @@ package broker import ( "fmt" + "slices" "sort" ) @@ -87,7 +88,7 @@ type Records struct { // is a mesh that cannot be told anything, and there is no state of the records in which that is // correct. func Users(r Records) ([]Principal, error) { - out := []Principal{{Kind: KindController, Uses: asksSeatOf(r), AsksIssued: issuedOf(r)}} + out := []Principal{{Kind: KindController, Uses: asksSeatOf(r), AsksIssued: issuedOf(r), AsksTheVault: vaultVerbsOf(r)}} for _, node := range sortedCopy(r.Nodes) { witness := false @@ -242,3 +243,24 @@ func asksSeatOf(r Records) []Seat { } return nil } + +// vaultVerbsOf are the vault's intake verbs its holder serves, as the records say (issue 675): what the controller +// is granted to ask it in its own name. None while no holder serves them. +func vaultVerbsOf(r Records) []string { + seen := map[string]bool{} + var out []string + for _, node := range sortedCopy(r.Nodes) { + for _, d := range r.Assigned[node] { + for _, s := range d.Holds { + for _, v := range VerbsTheControllerAsksTheVault { + if s.Name == v.Seat && slices.Contains(s.Serves, v.Verb) && !seen[v.Verb] { + seen[v.Verb] = true + out = append(out, v.Verb) + } + } + } + } + } + sort.Strings(out) + return out +} diff --git a/internal/catalogue/seats.go b/internal/catalogue/seats.go index 1c9478e5..38cec473 100644 --- a/internal/catalogue/seats.go +++ b/internal/catalogue/seats.go @@ -129,9 +129,11 @@ var defaultSeats = append([]Seat{ // test for a seat of the mesh's own (novox/hq ADR 0161) — a second provider of `secret` is a // second claimant, refused by name, rather than a candidate for a pin. // - // Its one verb, `get`, hands a secret on request and reply, sealed to the key the asker sends and only - // to an asker the vault allows (novox/hq ADR 0349 §11, issue 674). Optional until the vault that runs - // serves it (design 33 §7). + // Its verb `get` hands a secret on request and reply, sealed to the key the asker sends and only to an + // asker the vault allows (novox/hq ADR 0349 §11, issue 674). `accept`, `secret-ask`, `issued` and `rotate` + // take a value in and make one anew (ADR 0349 clauses 8 to 10, issue 675): the first three in two steps from + // the controller alone, which carries the value sealed to a key the vault made for the call and cannot open + // it. All optional until the vault that runs serves them (design 33 §7). {Name: "mesh-vault", Scope: ScopeMesh, Delivers: "secret", Decision: "novox/hq ADR 0161", Serves: []Verb{ {Name: "get", Optional: true, Description: "One secret, sealed to the public key the asker sends (the " + @@ -140,6 +142,30 @@ var defaultSeats = append([]Seat{ Input: schema(map[string]string{"name": "the secret's name", "public_key": "the asker's X25519 public key as SPKI PEM — never a private key"}, []string{"name", "public_key"})}, + {Name: "accept", Optional: true, Description: "Take a value given at the controller's terminal, from the " + + "controller alone: name the secret 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: schema(vaultIntake(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"}), nil)}, + {Name: "secret-ask", Optional: true, Description: "Ask the operator for a module's own secret at a desk, " + + "from the controller alone: the ask is recorded in the vault's store and bounded before the prompt " + + "opens, and what is typed is sealed to a key the vault made for the call and opens there alone.", + Input: schema(vaultIntake(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, as the bus named its caller", + "outcome": "instead of sealed: how the prompt ended"}), nil)}, + {Name: "issued", Optional: true, Description: "Take the credential a provider's software issued for a " + + "provision, from the controller alone, which asks the provider's tool with a key the vault made for " + + "the call; kept as the provision's one credential, and the same value again writes nothing.", + Input: schema(vaultIntake(map[string]string{"provision": "the provision", + "provider": "the provider's machine", "module": "the provider's module"}), nil)}, + {Name: "rotate", Optional: true, Description: "Make a secret anew. Refused for a value a provider's " + + "software issued and for an accepted credential. Never answers the value.", + Input: schema(map[string]string{"node": "the machine, for a module's own secret", "module": "the module", + "name": "the secret's name in the module", "provider": "the provider's machine", + "provision": "the provision", "by": "who asked, when the controller carries the act"}, nil)}, }}, // Named for its scope since 2026-09-30 (novox/hq ADR 0156); `the-artifact-store` resolves to it as // an alias on a mesh that predates the rename. It serves artifacts of every kind a build makes — @@ -822,3 +848,13 @@ func buildAgentVerbs() []Verb { // BrokerSeat is the mesh's bus. Its holder is the bus, and its account — when it declares one — may // snapshot the bus's streams and do nothing else (novox/hq ADR 0235). const BrokerSeat = "mesh-broker" + +// vaultIntake is a two-step vault verb's arguments: what its first step names, then the call and what was sealed. +func vaultIntake(first map[string]string) map[string]string { + out := map[string]string{"call": "the second step: the call the first step answered", + "sealed": "the second step: the value sealed to the call's key — never the value"} + for k, v := range first { + out[k] = v + } + return out +} diff --git a/internal/catalogue/vault_seat_test.go b/internal/catalogue/vault_seat_test.go index 92a31b1b..c1749068 100644 --- a/internal/catalogue/vault_seat_test.go +++ b/internal/catalogue/vault_seat_test.go @@ -54,3 +54,46 @@ func TestTheVaultsSeatPromisesGet(t *testing.T) { t.Fatalf("the vault that does not serve get yet was refused: %v", err) } } + +// The vault takes values in and rotates them (novox/hq ADR 0349 clauses 8 to 10, issue 675): the seat promises +// accept, secret-ask, issued and rotate, each with the arguments of its steps; a holder serving them is accepted, +// and, optional while the vault that runs does not, so is one that does not yet. +func TestTheVaultsSeatPromisesItsIntakeVerbs(t *testing.T) { + seat, _ := SeatNamed("mesh-vault") + want := map[string][]string{ + "accept": {"node", "module", "name", "provider", "provision", "by", "call", "sealed"}, + "secret-ask": {"node", "module", "name", "desk", "asked_by", "outcome", "call", "sealed"}, + "issued": {"provision", "provider", "module", "call", "sealed"}, + "rotate": {"node", "module", "name", "provider", "provision", "by"}, + } + for name, args := range want { + var verb *Verb + for i := range seat.Serves { + if seat.Serves[i].Name == name { + verb = &seat.Serves[i] + } + } + if verb == nil { + t.Errorf("the vault's seat does not promise %s", name) + continue + } + if !verb.Optional { + t.Errorf("%s is not optional while the vault that runs does not serve it", name) + } + props, _ := verb.Input["properties"].(map[string]any) + for _, arg := range args { + if props[arg] == nil { + t.Errorf("%s takes no %s", name, arg) + } + } + if props["value"] != nil { + t.Errorf("%s takes a value in the clear", name) + } + } + serving := Manifest{Module: "mesh-vault", Provides: []Offer{{Name: "secret", Scope: ScopeMesh}}, + Claims: []Claim{{Name: "mesh-vault", Scope: ScopeMesh, + Serves: []string{"get", "accept", "secret-ask", "issued", "rotate"}}}} + if err := CanHold(serving, seat); err != nil { + t.Fatalf("the vault serving its verbs was refused: %v", err) + } +} diff --git a/internal/link/queue.go b/internal/link/queue.go index 3bbbb8a2..9172e02c 100644 --- a/internal/link/queue.go +++ b/internal/link/queue.go @@ -609,13 +609,29 @@ func PausedSaid(js *broker.JetStream, seat string, nodes []string) (map[string]H // (design 33 §4: a mesh seat's verb carries no machine), and reads its answer (novox/hq ADR 0239: the // delivery's owner's `stalled` and `close`). func AskMeshSeatTool(ctx context.Context, conn *nats.Conn, seat, verb string, args any, timeout time.Duration) (Answer, error) { + return askMeshSeatToolAt(ctx, conn, SeatToolSubject(seat, verb), seat, verb, args, timeout) +} + +// AskMeshSeatToolAs is AskMeshSeatTool in the caller's own name: on the subject that names its caller (novox/hq +// issue 365), which the bus lets only that user publish, 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 AskMeshSeatToolAs(ctx context.Context, conn *nats.Conn, caller, seat, verb string, args any, + timeout time.Duration) (Answer, error) { + subject := broker.CalledSubject(SeatToolSubject(seat, verb), caller) + if subject == "" { + return Answer{}, fmt.Errorf("%q cannot ask %s.%s in its own name: it is not a bus user's name", caller, seat, verb) + } + return askMeshSeatToolAt(ctx, conn, subject, seat, verb, args, timeout) +} + +func askMeshSeatToolAt(ctx context.Context, conn *nats.Conn, subject, seat, verb string, args any, + timeout time.Duration) (Answer, error) { body, err := json.Marshal(args) if err != nil { return Answer{}, err } asking, cancel := context.WithTimeout(ctx, timeout) defer cancel() - subject := SeatToolSubject(seat, verb) refused, stop := refusalsOf(conn, subject) defer stop() type replied struct {