diff --git a/cmd/mesh-controller/desk_secret.go b/cmd/mesh-controller/desk_secret.go new file mode 100644 index 00000000..d0441c9d --- /dev/null +++ b/cmd/mesh-controller/desk_secret.go @@ -0,0 +1,167 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/nats-io/nats.go" + + "github.com/novox/mesh-controller/internal/link" + "github.com/novox/mesh-controller/internal/secrets" +) + +// A module's own secret given at the operator's desk (novox/hq ADR 0259 §10). +// +// **The value never passes through whoever asked for it.** An agent, or the operator at the mesh MCP +// server, calls `give` with the machine, the module, the secret's name and the desk — never a value. The +// controller makes a sealing keypair for this one call, asks the desk's `node-launcher.secret` to prompt the +// operator without showing what is typed, and is answered with what was typed **sealed to that key**: no +// plaintext on the bus, in a runtime's log or in any call's record. It opens it here, seals it to the +// module's machine exactly as `secret accept` does, and forgets it. What it answers says only that the +// value was taken, or why not. +// +// **What remains** (ADR 0234's accepted residual risk): on an X11 desk any program of the operator's +// account can read the keys as they are typed. And a program that calls the desk's prompt itself, with a +// key of its own, is answered with what the operator typed into a prompt they did not ask for — as it could +// draw a window of its own. The prompt says who asks and for what, so the operator types only into a +// prompt they started. + +// deskPromptWithin is how long the prompt waits for the operator: below the runtime's thirty seconds for +// one call, as the launcher's menu is. +const deskPromptWithin = 25 + +// deskGive is the desk path, its four reaches given so a test needs no store and no bus. +type deskGive struct { + // declares refuses a module or a secret the mesh would refuse, before anybody is asked to type. + declares func(module, name string) error + // ask asks one machine's node-launcher.secret and answers its result, or the holder's refusal. + ask func(machine string, args map[string]any) (json.RawMessage, error) + // accept seals the value as `secret accept` does, and says whether it lives until the module's start. + accept func(value string) (untilStart bool, err error) + // record writes the act in the hand-act log. + record func(link.HandAct) error +} + +// errNothingGiven is a prompt dismissed, or not answered in time: nothing changes. +var errNothingGiven = errors.New("nothing was given") + +// give asks the desk for the value and seals it; it answers the words said to the caller. +func (d deskGive) give(node, module, name, desk string) (string, error) { + for what, v := range map[string]string{"the machine": node, "the module": module, "the secret": name, "the desk": desk} { + if strings.TrimSpace(v) == "" { + return "", fmt.Errorf("%s is not named", what) + } + } + if err := d.declares(module, name); err != nil { + return "", fmt.Errorf("nobody was asked to type anything: %w", err) + } + public, private, err := secrets.Keypair() + if err != nil { + return "", fmt.Errorf("no key could be made to take the value: %w", err) + } + raw, err := d.ask(desk, map[string]any{ + "prompt": name + " for " + module, + "message": fmt.Sprintf("The mesh asks for %s, the own secret of %s on %s. What you type is not shown, "+ + "and is sealed before it leaves this machine. Type it only if you asked for this.", name, module, node), + "seal_to": public, + "timeout_seconds": deskPromptWithin, + }) + if err != nil { + 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 { + return "", fmt.Errorf("the desk on %s answered something that is not the prompt's answer", desk) + } + switch { + case answer.TimedOut: + return "", fmt.Errorf("%w: the prompt on %s was not answered within %d seconds", errNothingGiven, desk, deskPromptWithin) + case answer.Cancelled: + return "", fmt.Errorf("%w: the prompt on %s was dismissed", errNothingGiven, desk) + case answer.Sealed == "": + return "", fmt.Errorf("the desk on %s answered no sealed value", desk) + } + opened, err := secrets.Open(private, answer.Sealed) + if err != nil { + // Never the value, never what failed to open: only that it was not sealed to this call. + return "", fmt.Errorf("the desk on %s answered a value not sealed to this call; nothing was taken", desk) + } + value := asSupplied(string(opened)) + for i := range opened { + opened[i] = 0 + } + if strings.TrimSpace(value) == "" { + return "", fmt.Errorf("%w: the prompt on %s was answered empty", errNothingGiven, desk) + } + untilStart, err := d.accept(value) + value = "" + if err != nil { + return "", 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", name, module, node, desk), + 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) + } + words := fmt.Sprintf("%s on %s now holds %q, given at the desk on %s and sealed to %s; the mesh cannot read it "+ + "back.\n run `push %s` to send it", module, node, name, desk, node, node) + if untilStart { + words += fmt.Sprintf("\n it lives until %s next starts well under the mesh, and is then replaced with a value "+ + "the mesh makes (ADR 0228)", module) + } + return words + recorded, nil +} + +// giveAtDesk is `secret accept --at-desk `: the desk path, on this +// controller's stores and bus. +func giveAtDesk(ctx context.Context, node, module, name, desk string) error { + open, err := openStores(ctx) + if err != nil { + return err + } + defer open.Close() + d := deskGive{ + declares: func(module, name string) error { return open.inventory.DeclaresOwnSecret(ctx, module, name) }, + ask: func(machine string, args map[string]any) (json.RawMessage, error) { + var result json.RawMessage + err := onTheBus(func(conn *nats.Conn) error { + answer, err := link.AskSeatTool(ctx, conn, "node-launcher", "secret", machine, args, + time.Duration(deskPromptWithin+5)*time.Second) + if err != nil { + return err + } + if answer.Error != "" { + return errors.New(answer.Error) + } + result = answer.Result + return nil + }) + return result, err + }, + accept: func(value string) (bool, error) { + return open.inventory.AcceptGivenSecret(ctx, node, module, name, value) + }, + record: func(act link.HandAct) error { + return onTheBus(func(conn *nats.Conn) error { + _, err := link.RecordHandAct(ctx, conn, act) + return err + }) + }, + } + words, err := d.give(node, module, name, desk) + if err != nil { + return err + } + fmt.Println(words) + return nil +} diff --git a/cmd/mesh-controller/desk_secret_test.go b/cmd/mesh-controller/desk_secret_test.go new file mode 100644 index 00000000..6cd29545 --- /dev/null +++ b/cmd/mesh-controller/desk_secret_test.go @@ -0,0 +1,141 @@ +package main + +import ( + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/broker" + "github.com/novox/mesh-controller/internal/link" + "github.com/novox/mesh-controller/internal/secrets" +) + +const typed = "123456789:AAEhBP0av28P4XFQnIuR-o-7Xnz1kkUzW3g" + +// aDesk is the desk path with a prompt the test answers as the operator would, and what it was asked kept. +func aDesk(t *testing.T, answer func(args map[string]any) (json.RawMessage, error)) (deskGive, *[]string, *[]link.HandAct, *[]map[string]any) { + t.Helper() + var accepted []string + var acts []link.HandAct + var asked []map[string]any + return deskGive{ + declares: func(module, name string) error { + if module != "telegram" || name != "telegram-token" { + return errors.New(module + " does not declare " + name + " as an own secret") + } + return nil + }, + ask: func(machine string, args map[string]any) (json.RawMessage, error) { + asked = append(asked, args) + return answer(args) + }, + accept: func(value string) (bool, error) { accepted = append(accepted, value); return false, nil }, + record: func(a link.HandAct) error { acts = append(acts, a); return nil }, + }, &accepted, &acts, &asked +} + +func sealedTo(t *testing.T, value string) func(args map[string]any) (json.RawMessage, error) { + return func(args map[string]any) (json.RawMessage, error) { + sealed, err := secrets.Seal(args["seal_to"].(string), []byte(value+"\n")) + if err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(map[string]any{"sealed": sealed}) + return raw, nil + } +} + +// novox/hq ADR 0259 §10: the value typed at the desk is sealed as `secret accept` seals it, and is in no +// answer, no prompt argument and no act recorded. +func TestASecretGivenAtTheDeskIsSealedAndSaidNowhere(t *testing.T) { + d, accepted, acts, asked := aDesk(t, sealedTo(t, typed)) + words, err := d.give("anchor", "telegram", "telegram-token", "laptop") + if err != nil { + t.Fatal(err) + } + if len(*accepted) != 1 || (*accepted)[0] != typed { + t.Fatalf("the value sealed is not what was typed, its line ending taken off") + } + if len(*acts) != 1 || (*acts)[0].Verb != "secret accept" || (*acts)[0].Cause != "given-at-the-desk" || + !strings.Contains((*acts)[0].Why, "at the desk on laptop") { + t.Errorf("the act: %+v", *acts) + } + raw, _ := json.Marshal(struct { + Words string + Acts []link.HandAct + Asked []map[string]any + }{words, *acts, *asked}) + if strings.Contains(string(raw), typed) || strings.Contains(string(raw), "AAEhBP0") { + t.Fatal("the value appears in what was said, asked or recorded") + } + if !strings.Contains(words, "push anchor") || !strings.Contains(words, "given at the desk on laptop") { + t.Errorf("%q", words) + } + if p := (*asked)[0]; p["seal_to"] == "" || p["timeout_seconds"] != deskPromptWithin { + t.Errorf("the prompt was asked %v", p) + } +} + +func TestNothingIsAskedForASecretTheMeshWouldRefuse(t *testing.T) { + for _, c := range [][2]string{{"telegram", "chat-id"}, {"nobody", "telegram-token"}} { + d, accepted, _, asked := aDesk(t, sealedTo(t, typed)) + if _, err := d.give("anchor", c[0], c[1], "laptop"); err == nil || !strings.Contains(err.Error(), "nobody was asked") { + t.Errorf("%v: %v", c, err) + } + if len(*asked) != 0 || len(*accepted) != 0 { + t.Errorf("%v: the operator was asked anyway", c) + } + } + d, _, _, _ := aDesk(t, sealedTo(t, typed)) + if _, err := d.give("anchor", "telegram", "telegram-token", ""); err == nil { + t.Error("no desk was refused nowhere") + } +} + +func TestADismissedEmptyLateOrForeignAnswerTakesNothing(t *testing.T) { + for want, answer := range map[string]func(map[string]any) (json.RawMessage, error){ + "not answered within 25 seconds": func(map[string]any) (json.RawMessage, error) { + return json.RawMessage(`{"cancelled":true,"timed_out":true}`), nil + }, + "was dismissed": func(map[string]any) (json.RawMessage, error) { return json.RawMessage(`{"cancelled":true}`), nil }, + "answered empty": sealedTo(t, " "), + "not sealed to this call": func(map[string]any) (json.RawMessage, error) { + other, _, _ := secrets.Keypair() + sealed, _ := secrets.Seal(other, []byte(typed)) + raw, _ := json.Marshal(map[string]any{"sealed": sealed}) + return raw, nil + }, + "could not be asked": func(map[string]any) (json.RawMessage, error) { return nil, errors.New("no session answers") }, + } { + d, accepted, acts, _ := aDesk(t, answer) + _, err := d.give("anchor", "telegram", "telegram-token", "laptop") + if err == nil || !strings.Contains(err.Error(), want) || strings.Contains(err.Error(), typed) { + t.Errorf("want %q, got %v", want, err) + } + if len(*accepted) != 0 || len(*acts) != 0 { + t.Errorf("%s: something was taken or recorded", want) + } + } +} + +func TestTheGiveVerbRunsTheDeskPathAndTheControllerMayAskTheDesk(t *testing.T) { + argv, err := argvFor("give", map[string]any{"node": "anchor", "module": "telegram", "secret": "telegram-token", "at": "laptop"}) + if err != nil || strings.Join(argv, " ") != "secret accept anchor telegram telegram-token --at-desk laptop" { + t.Fatalf("%v %v", argv, err) + } + if _, err := argvFor("give", map[string]any{"node": "anchor", "module": "telegram", "secret": "telegram-token"}); err == nil { + t.Error("give without a desk was taken") + } + perms, err := broker.PermissionsFor(broker.Principal{Kind: broker.KindController}) + if err != nil { + t.Fatal(err) + } + found := false + for _, p := range perms.Publish { + found = found || p == "mesh.seat.node-launcher.tool.secret.*" + } + if !found { + t.Error("the controller may not ask the desk's prompt") + } +} diff --git a/cmd/mesh-controller/handacts.go b/cmd/mesh-controller/handacts.go index 19a662b8..2fca839b 100644 --- a/cmd/mesh-controller/handacts.go +++ b/cmd/mesh-controller/handacts.go @@ -98,6 +98,9 @@ var handActVerbs = []handActVerb{ // to that judgement. Several values rotate for one leak, and a leak that recurs is a defect of the // module that prints them, an issue against it, not a healer that rotates. A rotation for any other // cause — a credential that stopped working — counts: a schedule or a healer could take it over. + // A value given at the desk (novox/hq ADR 0259 §10): an outside party's key, such as a bot token, which + // only a person can give. Their word, never a repair. + {Verb: "secret accept", Decision: "a value an outside party issued is given by a person, at their desk"}, {Verb: "secret rotate", Decision: "a value a person judged disclosed is replaced on their word", DecidedFor: []string{causeLeakedInLogs}}, } diff --git a/cmd/mesh-controller/seatverbs.go b/cmd/mesh-controller/seatverbs.go index 9b4a9beb..a7d06d57 100644 --- a/cmd/mesh-controller/seatverbs.go +++ b/cmd/mesh-controller/seatverbs.go @@ -707,6 +707,11 @@ func (a *verbArguments) commandLine() ([]string, error) { argv = append(argv, "--probe", p) } return append(argv, "--json"), nil + case "give": + if err := need("node", "module", "secret", "at"); err != nil { + return nil, err + } + return []string{"secret", "accept", str("node"), str("module"), str("secret"), "--at-desk", str("at")}, nil case "rotate": if p := str("provision"); p != "" { argv := []string{"rotate", p} diff --git a/cmd/mesh-controller/seatverbs_schema_test.go b/cmd/mesh-controller/seatverbs_schema_test.go index 08ca631c..76351638 100644 --- a/cmd/mesh-controller/seatverbs_schema_test.go +++ b/cmd/mesh-controller/seatverbs_schema_test.go @@ -276,6 +276,13 @@ var accountedFlags = map[string]map[string]string{ "json": "set by the verb: the answer is data", "all": "withheld: every measurement of a fortnight is more than a call should carry; `command` reaches it", }, + // The desk path of `secret accept` (novox/hq ADR 0259 §10): a value is never an argument of a call. + "secret accept": { + "at-desk": "=at", + "from": "withheld: a file of the control node's is read at a shell, never named by a call", + "provider": "withheld: a pair credential's value is given at a shell; give takes a module's own secret", + "local": "withheld: it goes with --provider", + }, "hand-acts": {"json": "set by the verb: the answer is data"}, "conditions": {"json": "set by the verb: the answer is data"}, "retire": {"json": "set by the verb: the answer is data"}, diff --git a/cmd/mesh-controller/secret.go b/cmd/mesh-controller/secret.go index 46321e1f..2d489658 100644 --- a/cmd/mesh-controller/secret.go +++ b/cmd/mesh-controller/secret.go @@ -55,6 +55,9 @@ func secretCommand(ctx context.Context, args []string) error { provider := set.String("provider", "", "the node providing : the value becomes the PAIR credential between on "+ "and that provider, sealed to both — the vault's operator-delivered secret (ADR 0092)") + desk := set.String("at-desk", "", + "ask the operator for the value in a prompt that does not show it, on this machine's desk; the "+ + "answer comes back sealed to this call alone (novox/hq ADR 0259 §10)") local := set.String("local", "", "with --provider: the name the credential goes by inside , where its manifest keeps "+ "several for (ADR 0094)") @@ -65,6 +68,12 @@ func secretCommand(ctx context.Context, args []string) error { return errors.New(secretUsage) } node, module, name := rest[0], rest[1], rest[2] + if *desk != "" { + if *from != "" || *provider != "" { + return errors.New("--at-desk gives a module's own secret, and takes neither --from nor --provider") + } + return giveAtDesk(ctx, node, module, name, *desk) + } value, err := valueFor(node, module, name, *from) if err != nil { @@ -118,7 +127,7 @@ func secretCommand(ctx context.Context, args []string) error { } const secretUsage = "secret rotate [--why [--cause ]]\n" + - "secret accept [--from ] [--provider [--local ]]\n" + + "secret accept [--from | --at-desk ] [--provider [--local ]]\n" + "secret recover --key [--out ] [--from-export ] [--provider ]\n" + "secret export [--out ]" diff --git a/cmd/mesh-controller/signals.go b/cmd/mesh-controller/signals.go index 875c4940..3968b206 100644 --- a/cmd/mesh-controller/signals.go +++ b/cmd/mesh-controller/signals.go @@ -84,7 +84,7 @@ const ( // callBounds are the verbs that may run longer than callDefault, and how long (S7). var callBounds = map[string]time.Duration{ - "push": 30 * time.Minute, "rotate": 30 * time.Minute, "assign": 15 * time.Minute, + "push": 30 * time.Minute, "rotate": 30 * time.Minute, "give": 5 * time.Minute, "assign": 15 * time.Minute, "unassign": 15 * time.Minute, "command": 30 * time.Minute, "doctor": 3 * time.Minute, } diff --git a/internal/broker/nats.go b/internal/broker/nats.go index 4e022964..f1ac6d46 100644 --- a/internal/broker/nats.go +++ b/internal/broker/nats.go @@ -166,6 +166,10 @@ var VerbsTheSelfCheckAsks = []SeatVerb{{Seat: "node-intrusion-prevention", Verb: // the controller's grant that acts, and only through the step a person starts. var VerbsTheBusStepAsks = []SeatVerb{{Seat: "node-backup", Verb: "now"}} +// VerbsTheControllerAsksForASecret are the seat verbs `give` calls (novox/hq ADR 0259 §10): the operator's +// desk opens a prompt that does not show what is typed, and answers it sealed to the controller's call. +var VerbsTheControllerAsksForASecret = []SeatVerb{{Seat: "node-launcher", Verb: "secret"}} + // VerbsTheControllerAsksTheDeliveryOwner are the mesh-delivery seat's verbs the controller calls (novox/hq // ADR 0239): its self-check reads `stalled`, and healer H2 takes the one transition the table allows // through `close`. A mesh seat's verb is flat: no machine in the subject. @@ -366,6 +370,10 @@ func PermissionsFor(p Principal) (Permissions, error) { for _, v := range VerbsTheBusStepAsks { pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb+".*") } + // And the operator's desk, for a secret given there (ADR 0259 §10). + for _, v := range VerbsTheControllerAsksForASecret { + pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb+".*") + } // And the delivery's owner, a mesh seat, asked on its flat subjects (ADR 0239). for _, v := range VerbsTheControllerAsksTheDeliveryOwner { pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb) diff --git a/internal/broker/testdata/composed.conf b/internal/broker/testdata/composed.conf index 2891cc56..ca2ceaab 100644 --- a/internal/broker/testdata/composed.conf +++ b/internal/broker/testdata/composed.conf @@ -24,7 +24,7 @@ accounts { jetstream: enabled users = [ { user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: { - publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>", "$KV.SEAT_NODE_BUILD_AGENT_cancelled.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.assignment.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-build-machine.tool.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.checked", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.doctor-heartbeat", "mesh.seat.mesh-controller.event.healer-acted", "mesh.seat.mesh-controller.event.plan-moved", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.rolled-back", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.mesh-delivery.tool.close", "mesh.seat.mesh-delivery.tool.stalled", "mesh.seat.node-backup.tool.backed-up.*", "mesh.seat.node-backup.tool.now.*", "mesh.seat.node-build-agent.accept.>", "mesh.seat.node-build-agent.tool.>", "mesh.seat.node-intrusion-prevention.tool.banned.*"] } + publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>", "$KV.SEAT_NODE_BUILD_AGENT_cancelled.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.assignment.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-build-machine.tool.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.checked", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.doctor-heartbeat", "mesh.seat.mesh-controller.event.healer-acted", "mesh.seat.mesh-controller.event.plan-moved", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.rolled-back", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.mesh-delivery.tool.close", "mesh.seat.mesh-delivery.tool.stalled", "mesh.seat.node-backup.tool.backed-up.*", "mesh.seat.node-backup.tool.now.*", "mesh.seat.node-build-agent.accept.>", "mesh.seat.node-build-agent.tool.>", "mesh.seat.node-intrusion-prevention.tool.banned.*", "mesh.seat.node-launcher.tool.secret.*"] } subscribe: { allow: ["$JS.API.>", "$JS.EVENT.ADVISORY.CONSUMER.DELETED.>", "$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>", "$SRV.INFO", "$SRV.INFO.mesh-controller", "$SRV.INFO.mesh-controller.>", "$SRV.PING", "$SRV.PING.mesh-controller", "$SRV.PING.mesh-controller.>", "$SRV.STATS", "$SRV.STATS.mesh-controller", "$SRV.STATS.mesh-controller.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.*.event.provisioner.failing", "mesh.mod.*.event.provisioner.recovered", "mesh.mod.*.event.provisioner.retirement", "mesh.mod.gitea.event.pull.merged", "mesh.mod.gitea.event.pull.updated", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built", "mesh.seat.mesh-controller.tool.>", "mesh.seat.node-build-agent.event.built"] } allow_responses: { max: 1, ttl: "1m" } } } diff --git a/internal/catalogue/graphical_session.go b/internal/catalogue/graphical_session.go index a03a5082..09e738ae 100644 --- a/internal/catalogue/graphical_session.go +++ b/internal/catalogue/graphical_session.go @@ -78,6 +78,18 @@ func graphicalSessionSeats() []Seat { "description": "the lines to choose between, in order"}, "prompt": map[string]any{"type": "string", "description": "what the menu asks (optional)"}, }}}, + // A value the operator types and nobody sees (novox/hq ADR 0259 §10): a hidden prompt whose answer + // is sealed to the asker's key, so it is never plaintext on the bus or in any call's record. + // **Optional while its holders catch up** (ADR 0246): rofi serves it once this is live. + {Name: "secret", Optional: true, Description: "Ask the operator for a value in a prompt that " + + "does not show what is typed, and answer it sealed to the key the asker gives — never in " + + "the clear — or cancelled when the prompt was dismissed or not answered in time.", + Input: schema(map[string]string{ + "prompt": "what the prompt asks", + "message": "a line saying who asks and for what (optional)", + "seal_to": "the asker's public sealing key: the answer is sealed to it", + "timeout_seconds": "give up after this long (optional)", + }, []string{"prompt", "seal_to"})}, }}, {Name: NotifierSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{ {Name: "send", Description: "Show the operator a notification.", diff --git a/internal/catalogue/graphical_session_test.go b/internal/catalogue/graphical_session_test.go index d349e1d0..f412e246 100644 --- a/internal/catalogue/graphical_session_test.go +++ b/internal/catalogue/graphical_session_test.go @@ -16,7 +16,7 @@ func TestTheGraphicalSessionsSeatsAreTheMeshsOwnWithTheirVerbs(t *testing.T) { DisplayServerSeat: {"displays", "layout"}, DisplaySessionSeat: {"reload", "workspaces", "windows"}, TerminalEmulatorSeat: {"open"}, - LauncherSeat: {"menu"}, + LauncherSeat: {"menu", "secret"}, NotifierSeat: {"send", "history"}, LockScreenSeat: {"lock"}, ClipboardSeat: {"history", "copy"}, diff --git a/internal/catalogue/verbs.go b/internal/catalogue/verbs.go index edb2c92b..dc4d7640 100644 --- a/internal/catalogue/verbs.go +++ b/internal/catalogue/verbs.go @@ -230,6 +230,18 @@ var ControllerVerbs = []Verb{ "why": "an own secret: why it is rotated — recorded in the hand-act log (optional)", "cause": "with why: the cause in a word, the word a second rotation for the same reason uses (optional)", }, nil)}, + {Name: "give", Description: "Take a module's own secret from the operator at their desk (novox/hq ADR 0259 " + + "§10): a prompt that does not show what is typed opens on the machine named by at, its answer comes " + + "back sealed to this call alone, and is sealed to the module's machine as `secret accept` seals it. " + + "The value is never an argument and never in the answer: the answer says it was taken, or why not. " + + "Recorded in the hand-act log as a value given at the desk. The prompt waits 25 seconds; dismissed " + + "or unanswered, nothing changes. Then push the machine.", + Input: schema(map[string]string{ + "node": "the machine the module runs on, which the secret is sealed to", + "module": "the module's name", + "secret": "the own secret's name in the module's definition", + "at": "the machine the operator sits at, where the prompt opens", + }, []string{"node", "module", "secret", "at"})}, {Name: "issue", Description: "Give a module on a machine its account on the bus: minted, and sealed to the " + "machine as the module's own secret named broker, read at the next push of that machine. For a module " + "whose definition declares that secret; refused with the reason otherwise. Issued again, it replaces the account.", diff --git a/internal/inventory/secrets.go b/internal/inventory/secrets.go index cebe1df4..6e001eeb 100644 --- a/internal/inventory/secrets.go +++ b/internal/inventory/secrets.go @@ -554,6 +554,19 @@ func (i *Inventory) declared(ctx context.Context, module string) (catalogue.Mani return m, nil } +// DeclaresOwnSecret refuses, in words, a module the mesh does not know or an own secret its definition does +// not declare: asked before anybody is asked for a value, so nobody types one the mesh would refuse. +func (i *Inventory) DeclaresOwnSecret(ctx context.Context, module, name string) error { + m, err := i.declared(ctx, module) + if err != nil { + return err + } + if _, ok := m.OwnSecrets[name]; !ok { + return fmt.Errorf("%s does not declare %q as an own secret; %s", module, name, declaresOwn(m)) + } + return nil +} + func declaresOwn(m catalogue.Manifest) string { if len(m.OwnSecrets) == 0 { return "it declares no own secrets" diff --git a/module.json b/module.json index d5390683..97280fc3 100644 --- a/module.json +++ b/module.json @@ -48,6 +48,7 @@ "unpin", "push", "rotate", + "give", "issue", "token", "settings",