From f5cbfd850c11f4f0abad7b0735870a26156164f2 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 9 Oct 2026 12:17:45 +0200 Subject: [PATCH] rofi: write the hidden prompt from names and say the controller asks, never with the caller's words MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review of 2026-10-09 (M4): the prompt and its message were the caller's free text, so whoever could ask the verb could put any words before the operator. The verb now takes the module, the secret and the machine, checked as names, and writes the prompt here, saying the mesh's controller asks — which the bus makes true, denying the verb to every other principal (mesh-controller #156). prompt and message are refused. --- modules/rofi/README.md | 7 ++- modules/rofi/cmd/rofi-tools/main.go | 10 ++-- modules/rofi/cmd/rofi-tools/secret.go | 36 +++++++++++--- modules/rofi/cmd/rofi-tools/secret_test.go | 56 ++++++++++++++++++---- 4 files changed, 88 insertions(+), 21 deletions(-) diff --git a/modules/rofi/README.md b/modules/rofi/README.md index cf96bfba..b83197c3 100644 --- a/modules/rofi/README.md +++ b/modules/rofi/README.md @@ -8,8 +8,11 @@ The launcher as a module (novox/hq ADR 0208, research 026/04 and 05). what is typed (`rofi -dmenu -password`), whose answer is sealed to the key the asker gives before it leaves this process — never in the clear on the bus, in the runtime's log or in a call's record. The controller's `give` asks it to take a module's own secret, such as a bot token, at the desk. It waits at - most 25 seconds. On X11 any program of the operator's account can still read the keys as they are typed - (ADR 0234's accepted residual risk), so the operator types only into a prompt they started. + most 25 seconds. **The prompt is written here** from the module, the secret and the machine — names, checked + as names — and says the mesh's controller asks: the bus denies the verb to every principal but the + controller, however wide its grant. A caller's own words (`prompt`, `message`) are refused. On X11 any + program of the operator's account can still read the keys as they are typed (ADR 0234's accepted residual + risk), so the operator types only into a prompt they started with `give`. - **The seat's dmenu-compatible command.** It places `~/.local/bin/dmenu`: choices on standard input, the chosen one on standard output, exit 1 when nothing was chosen. It runs `rofi -dmenu`, passing on dmenu's `-p`, `-l` and `-i` and dropping its look options. A script, a notifier or a clipboard diff --git a/modules/rofi/cmd/rofi-tools/main.go b/modules/rofi/cmd/rofi-tools/main.go index ee192114..3137e9e7 100644 --- a/modules/rofi/cmd/rofi-tools/main.go +++ b/modules/rofi/cmd/rofi-tools/main.go @@ -47,17 +47,19 @@ func tools() []stdio.Tool { Name: "node-launcher.secret", Description: fmt.Sprintf("Ask the operator for a value in a prompt that shows nothing of what is typed, "+ "and answer it sealed to the key seal_to names — never in the clear (novox/hq ADR 0259 §10): how the "+ - "mesh takes a module's own secret at the desk. Answers sealed, or cancelled when the prompt was "+ + "mesh takes a module's own secret at the desk. The prompt is written here from the names, and says the "+ + "mesh's controller asks: the bus lets nobody else ask it. Answers sealed, or cancelled when the prompt was "+ "dismissed, answered empty or not answered within timeout_seconds (default and at most %d).", menuMost), Input: map[string]any{ "type": "object", "properties": map[string]any{ - "prompt": map[string]any{"type": "string", "description": "what the prompt asks"}, - "message": map[string]any{"type": "string", "description": "a line saying who asks and for what"}, + "module": map[string]any{"type": "string", "description": "the module whose own secret is asked for"}, + "secret": map[string]any{"type": "string", "description": "the own secret's name"}, + "node": map[string]any{"type": "string", "description": "the machine the module runs on"}, "seal_to": map[string]any{"type": "string", "description": "the asker's public sealing key, base64: the answer is sealed to it"}, "timeout_seconds": map[string]any{"type": "integer", "description": fmt.Sprintf("give up after this long (at most %d)", menuMost)}, }, - "required": []string{"prompt", "seal_to"}, + "required": []string{"module", "secret", "node", "seal_to"}, }, Run: func(args map[string]any) (any, error) { q, err := secretQuestion(args) diff --git a/modules/rofi/cmd/rofi-tools/secret.go b/modules/rofi/cmd/rofi-tools/secret.go index 6e6bd63f..535bd681 100644 --- a/modules/rofi/cmd/rofi-tools/secret.go +++ b/modules/rofi/cmd/rofi-tools/secret.go @@ -5,6 +5,7 @@ import ( "encoding/base64" "errors" "fmt" + "regexp" "strings" "time" @@ -18,8 +19,9 @@ import ( // // **What it does not do**: on an X11 desk any program of the operator's account can read the keys as they // are typed, and a program that asks this verb with a key of its own is answered with what the operator -// typed into a prompt they did not ask for. The prompt says who asks and for what, so the operator types -// only into one they started (ADR 0234's accepted residual risk). +// typed into a prompt they did not ask for. The bus lets only the controller ask this verb, and the prompt, +// written here from names, says so and for what, so the operator types only into one they started with give +// (ADR 0234's accepted residual risk is what remains: a program drawing a window of its own). // SecretQuestion is what node-launcher.secret asks. type SecretQuestion struct { @@ -36,15 +38,35 @@ type SecretAnswer struct { TimedOut bool `json:"timed_out,omitempty"` } +// aName is a module's, a secret's or a machine's name as the mesh writes them: nothing else reaches the prompt. +var aName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`) + +// secretQuestion reads the call. The prompt is this holder's words, written from the module, the secret and the +// machine (the review of 2026-10-09, M4): it says the mesh's controller asks, which the bus makes true — it +// denies these subjects to every principal but the controller — and a caller's own words are refused, so +// nobody puts a prompt of its choosing before the operator. func secretQuestion(args map[string]any) (SecretQuestion, error) { var q SecretQuestion - var err error - if q.Prompt, err = text(args, "prompt", true); err != nil { - return q, err + for _, free := range []string{"prompt", "message"} { + if _, there := args[free]; there { + return q, fmt.Errorf("%s is not taken: the prompt is written here from module, secret and node", free) + } } - if q.Message, err = text(args, "message", false); err != nil { - return q, err + names := map[string]string{} + for _, key := range []string{"module", "secret", "node"} { + v, err := text(args, key, true) + if err != nil { + return q, err + } + if !aName.MatchString(v) { + return q, fmt.Errorf("%s %q is not a name the mesh writes", key, v) + } + names[key] = v } + q.Prompt = names["secret"] + " for " + names["module"] + q.Message = fmt.Sprintf("The mesh's controller 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 started this with give.", + names["secret"], names["module"], names["node"]) key, err := text(args, "seal_to", true) if err != nil { return q, err diff --git a/modules/rofi/cmd/rofi-tools/secret_test.go b/modules/rofi/cmd/rofi-tools/secret_test.go index 0621095b..15cb2838 100644 --- a/modules/rofi/cmd/rofi-tools/secret_test.go +++ b/modules/rofi/cmd/rofi-tools/secret_test.go @@ -30,7 +30,7 @@ func TestWhatIsTypedIsAnsweredSealedToTheAskersKeyAndNeverInTheClear(t *testing. bin := fakeBinaries(t, map[string]string{"rofi": `echo "$*" > "$LOG"; echo "` + typedValue + `"`}) t.Setenv("LOG", filepath.Join(bin, "log")) key, pub, priv := aKey(t) - q, err := secretQuestion(map[string]any{"prompt": "telegram-token for telegram", "message": "The mesh asks", "seal_to": key}) + q, err := secretQuestion(map[string]any{"module": "telegram", "secret": "telegram-token", "node": "anchor", "seal_to": key}) if err != nil { t.Fatal(err) } @@ -48,7 +48,8 @@ func TestWhatIsTypedIsAnsweredSealedToTheAskersKeyAndNeverInTheClear(t *testing. t.Fatalf("the answer does not open to what was typed, its line ending taken off") } asked, _ := os.ReadFile(filepath.Join(bin, "log")) - for _, want := range []string{"-dmenu", "-password", "-p telegram-token for telegram", "-mesg The mesh asks"} { + for _, want := range []string{"-dmenu", "-password", "-p telegram-token for telegram", + "-mesg The mesh's controller asks for telegram-token, the own secret of telegram on anchor"} { if !strings.Contains(string(asked), want) { t.Errorf("rofi was not asked %q: %s", want, asked) } @@ -61,7 +62,7 @@ func TestWhatIsTypedIsAnsweredSealedToTheAskersKeyAndNeverInTheClear(t *testing. func TestADismissedEmptyOrLatePromptIsACancelAndAFailureNeverSaysTheValue(t *testing.T) { withSession(t) key, _, _ := aKey(t) - q, _ := secretQuestion(map[string]any{"prompt": "p", "seal_to": key, "timeout_seconds": 1}) + q, _ := secretQuestion(map[string]any{"module": "telegram", "secret": "telegram-token", "node": "anchor", "seal_to": key, "timeout_seconds": 1}) for script, timedOut := range map[string]bool{`exit 1`: false, `echo ""`: false, `sleep 30`: true} { fakeBinaries(t, map[string]string{"rofi": script}) got, err := Secret(q) @@ -76,15 +77,54 @@ func TestADismissedEmptyOrLatePromptIsACancelAndAFailureNeverSaysTheValue(t *tes } func TestASecretIsAskedOnlyWithAKeyToSealItTo(t *testing.T) { + key := base64.StdEncoding.EncodeToString(make([]byte, 32)) + named := func(extra map[string]any) map[string]any { + a := map[string]any{"module": "telegram", "secret": "telegram-token", "node": "anchor"} + for k, v := range extra { + a[k] = v + } + return a + } for _, args := range []map[string]any{ - {"prompt": "p"}, - {"prompt": "p", "seal_to": "not a key"}, - {"prompt": "p", "seal_to": base64.StdEncoding.EncodeToString([]byte("short"))}, - {"seal_to": base64.StdEncoding.EncodeToString(make([]byte, 32))}, - {"prompt": "p", "seal_to": base64.StdEncoding.EncodeToString(make([]byte, 32)), "timeout_seconds": 60}, + named(nil), + named(map[string]any{"seal_to": "not a key"}), + named(map[string]any{"seal_to": base64.StdEncoding.EncodeToString([]byte("short"))}), + named(map[string]any{"seal_to": key, "timeout_seconds": 60}), + {"seal_to": key}, } { if _, err := secretQuestion(args); err == nil { t.Errorf("%v was taken", args) } } } + +// The prompt is this holder's words from names, never the caller's (the review of 2026-10-09, M4): it says the +// mesh's controller asks — the bus lets nobody else ask it — and for which secret of which module on which +// machine; words of the caller's, and a name that is not one, are refused. +func TestThePromptIsWrittenHereFromNamesAndSaysTheControllerAsks(t *testing.T) { + key := base64.StdEncoding.EncodeToString(make([]byte, 32)) + base := map[string]any{"module": "telegram", "secret": "telegram-token", "node": "anchor", "seal_to": key} + q, err := secretQuestion(base) + if err != nil { + t.Fatal(err) + } + if q.Prompt != "telegram-token for telegram" || !strings.HasPrefix(q.Message, "The mesh's controller asks for telegram-token") { + t.Errorf("prompt %q, message %q", q.Prompt, q.Message) + } + for key, value := range map[string]any{ + "prompt": "Your bank's PIN", + "message": "Your bank asks", + "module": "telegram\nYour bank asks", + "secret": "a token", + "node": "anchor", + } { + args := map[string]any{} + for k, v := range base { + args[k] = v + } + args[key] = value + if _, err := secretQuestion(args); err == nil { + t.Errorf("%s=%q was taken", key, value) + } + } +}