diff --git a/cmd/mesh-controller/desk_secret.go b/cmd/mesh-controller/desk_secret.go index 963f3ac5..5d91a9e1 100644 --- a/cmd/mesh-controller/desk_secret.go +++ b/cmd/mesh-controller/desk_secret.go @@ -39,6 +39,9 @@ const deskPromptWithin = 25 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 + // trusted says a module runs as an account of its own: its secret is never taken at a desk (below). Nil is + // never (a test that does not look). + trusted func(module string) (bool, 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. @@ -63,6 +66,24 @@ func (d deskGive) give(node, module, name, desk string) (string, error) { if err := d.declares(module, name); err != nil { return "", fmt.Errorf("nobody was asked to type anything: %w", err) } + // **A trusted party's secret is not taken at a desk** (the confirmation review of 2026-10-09, N1-give). The + // prompt's answer comes back over the desk machine's bus, on a subject that machine's runtime answers — and + // on a desk machine agents run as the operator, who holds that runtime's credential. An agent could answer + // first, with a bot token of its own sealed to this call's key, and the channel the operator's answers are + // proven on would be the agent's. So the value of a module running as its own account is typed at the + // controller's terminal, where no bus carries it. + if d.trusted != nil { + trusted, err := d.trusted(module) + if err != nil { + return "", fmt.Errorf("nobody was asked to type anything: whether %s is a trusted party could not be read: %w", module, err) + } + if trusted { + return "", fmt.Errorf("nobody was asked to type anything: %s runs as an account of its own and proves the "+ + "operator's answers, so its %s is given at the controller's terminal alone — there, run `mesh-controller "+ + "secret accept %s %s %s` and type it at its prompt. A desk's prompt is answered over the desk machine's "+ + "bus, where an agent may answer first (novox/hq ADR 0259 §10)", module, name, node, module, name) + } + } public, private, err := secrets.Keypair() if err != nil { return "", fmt.Errorf("no key could be made to take the value: %w", err) @@ -143,6 +164,7 @@ func giveAtDesk(ctx context.Context, node, module, name, desk string) error { defer open.Close() d := deskGive{ declares: func(module, name string) error { return open.inventory.DeclaresOwnSecret(ctx, module, name) }, + trusted: func(module string) (bool, error) { return open.inventory.RunsAsItsOwnAccount(ctx, module) }, ask: func(machine string, args map[string]any) (json.RawMessage, error) { var result json.RawMessage err := onTheBus(func(conn *nats.Conn) error { diff --git a/cmd/mesh-controller/desk_secret_test.go b/cmd/mesh-controller/desk_secret_test.go index c63364fc..b3ad1873 100644 --- a/cmd/mesh-controller/desk_secret_test.go +++ b/cmd/mesh-controller/desk_secret_test.go @@ -245,3 +245,51 @@ func TestOnlyTheGiveLinePassesTheTerminalRuleForSecrets(t *testing.T) { } } } + +// The confirmation review of 2026-10-09, N1-give: a desk's prompt is answered over the desk machine's bus, and +// on a desk machine agents run as the operator, who holds its runtime's credential — so a trusted party's +// secret (a module running as an account of its own: the Telegram bot's token) is never taken at a desk. +// Refused before anybody is asked to type, whoever called, naming the terminal's line. +func TestATrustedPartysSecretIsNeverTakenAtADesk(t *testing.T) { + d, accepted, acts, asked := aDesk(t, sealedTo(t, typed)) + d.trusted = func(module string) (bool, error) { return module == "telegram", nil } + _, err := d.give("anchor", "telegram", "telegram-token", "laptop") + if err == nil || !strings.Contains(err.Error(), "controller's terminal alone") || + !strings.Contains(err.Error(), "secret accept anchor telegram telegram-token") { + t.Fatalf("a trusted party's secret was taken at the desk, or refused without the line: %v", err) + } + if len(*asked)+len(*accepted)+len(*acts) != 0 { + t.Errorf("asked %v, accepted %d, recorded %v", *asked, len(*accepted), *acts) + } + d.trusted = func(string) (bool, error) { return false, errors.New("the store did not answer") } + if _, err := d.give("anchor", "telegram", "telegram-token", "laptop"); err == nil || len(*asked) != 0 { + t.Errorf("a module not known to be untrusted was asked at the desk: %v", err) + } +} + +// And who may answer the desk's prompt at all: only the runtime of the machine it is asked on, carrying the +// launcher that holds the seat there — never the controller, another machine's runtime, or a module's own +// account (the confirmation review of 2026-10-09, N1-give). +func TestOnlyTheDeskMachinesLauncherMayAnswerItsPrompt(t *testing.T) { + launcher := broker.Declared{Module: "rofi", Holds: []broker.Seat{{Name: "node-launcher", Scope: "node", + Serves: []string{"run", "secret"}}}} + subject := "mesh.seat.node-launcher.tool.secret.laptop" + for _, c := range []struct { + p broker.Principal + answers bool + }{ + {broker.Principal{Kind: broker.KindNodeTools, Node: "laptop", Module: broker.RuntimeModule, Carries: []broker.Declared{launcher}}, true}, + {broker.Principal{Kind: broker.KindNodeTools, Node: "anchor", Module: broker.RuntimeModule, Carries: []broker.Declared{launcher}}, false}, + {broker.Principal{Kind: broker.KindController}, false}, + {broker.Principal{Kind: broker.KindModule, Node: "laptop", Module: "lab"}, false}, + {broker.Principal{Kind: broker.KindNode, Node: "laptop"}, false}, + } { + perms, err := broker.PermissionsFor(c.p) + if err != nil { + t.Fatal(err) + } + if got := broker.MaySubscribe(perms, subject); got != c.answers { + t.Errorf("%s may answer %s: %v, want %v", c.p.Username(), subject, got, c.answers) + } + } +} diff --git a/cmd/mesh-controller/hidden_input.go b/cmd/mesh-controller/hidden_input.go new file mode 100644 index 00000000..460b8a34 --- /dev/null +++ b/cmd/mesh-controller/hidden_input.go @@ -0,0 +1,26 @@ +package main + +import ( + "os" + + "golang.org/x/sys/unix" +) + +// hideTyping turns a terminal's echo off while a secret is typed at it, and gives back what restores it. On +// anything that is not a terminal (a pipe, a file) it does nothing. +func hideTyping(f *os.File) func() { + fd := int(f.Fd()) + before, err := unix.IoctlGetTermios(fd, unix.TCGETS) + if err != nil { + return func() {} + } + hidden := *before + hidden.Lflag &^= unix.ECHO + if err := unix.IoctlSetTermios(fd, unix.TCSETS, &hidden); err != nil { + return func() {} + } + return func() { + _ = unix.IoctlSetTermios(fd, unix.TCSETS, before) + _, _ = os.Stderr.WriteString("\n") + } +} diff --git a/cmd/mesh-controller/secret.go b/cmd/mesh-controller/secret.go index 8acdbf3d..52430a4d 100644 --- a/cmd/mesh-controller/secret.go +++ b/cmd/mesh-controller/secret.go @@ -108,12 +108,27 @@ func secretCommand(ctx context.Context, args []string) error { fmt.Printf(" run `push %s` and `push %s` to send it\n", *provider, node) return nil } + // A trusted party's secret is announced before it is kept (the confirmation review of 2026-10-09, N1-give): + // on every channel, the one it replaces among them, which still runs on its old value until the next push. + // Not announced, it is not kept: a channel whose token changed unheard of answers for somebody else. + trusted, err := open.inventory.RunsAsItsOwnAccount(ctx, module) + if err != nil { + return err + } + if trusted { + if err := announceSecretGiven(ctx, node, module, name, "at the controller's terminal"); err != nil { + return fmt.Errorf("%s runs as an account of its own, and the change of its %s could not be announced on "+ + "your channels first, so nothing was kept: %w", module, name, err) + } + } untilStart, err := open.inventory.AcceptGivenSecret(ctx, node, module, name, value) if err != nil { return err } - if err := announceSecretGiven(ctx, node, module, name, "at the controller's terminal"); err != nil { - fmt.Printf(" this change could NOT be announced on the operator's channels: %v\n", err) + if !trusted { + if err := announceSecretGiven(ctx, node, module, name, "at the controller's terminal"); err != nil { + fmt.Printf(" this change could NOT be announced on the operator's channels: %v\n", err) + } } // Not printed back, and there is nowhere it could be printed from: it is sealed to that // machine and the mesh cannot read it again. @@ -387,6 +402,8 @@ func valueFor(node, module, name, from string) (string, error) { fmt.Fprintf(os.Stderr, "reading %s's %q for %s from standard input; it is not echoed anywhere\n", module, name, node) + // At a terminal, what is typed is not shown either: echo off while it is read. + defer hideTyping(os.Stdin)() line, err := bufio.NewReader(os.Stdin).ReadString('\n') if err != nil && line == "" { return "", fmt.Errorf("nothing was given on standard input: %w", err) diff --git a/internal/broker/nats.go b/internal/broker/nats.go index 355c8b1d..cfeb34f4 100644 --- a/internal/broker/nats.go +++ b/internal/broker/nats.go @@ -216,6 +216,16 @@ func MayPublish(perms Permissions, subject string) bool { return false } +// MaySubscribe says whether a principal with these permissions may subscribe to (and so answer) a subject. +func MaySubscribe(perms Permissions, subject string) bool { + for _, a := range perms.Subscribe { + if SubjectsOverlap(a, subject) { + return true + } + } + return false +} + // 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. diff --git a/internal/inventory/secrets.go b/internal/inventory/secrets.go index 843ae7db..32534e70 100644 --- a/internal/inventory/secrets.go +++ b/internal/inventory/secrets.go @@ -564,6 +564,17 @@ func (i *Inventory) DeclaresOwnSecret(ctx context.Context, module, name string) return GivableAtDesk(m, name) } +// RunsAsItsOwnAccount says a module runs as an account of its own (novox/hq ADR 0259 §8): a trusted party — the +// router, a channel that proves its sender or shows a link's code — whose own secret is what the operator's +// answers are believed by. Its value is given at the controller's terminal alone. +func (i *Inventory) RunsAsItsOwnAccount(ctx context.Context, module string) (bool, error) { + m, err := i.declared(ctx, module) + if err != nil { + return false, err + } + return m.RunsAs != "", nil +} + // BrokerSecret is the own secret that is a module's bus account, which `issue` mints. const BrokerSecret = "broker"