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) + } + } +}