From ad5a85398ee3411a4f5e091a172f6b9fbfcbdf2c Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 8 Oct 2026 17:11:59 +0200 Subject: [PATCH] =?UTF-8?q?Serve=20node-launcher's=20hidden=20prompt,=20an?= =?UTF-8?q?swering=20sealed=20to=20the=20asker,=20so=20a=20secret=20typed?= =?UTF-8?q?=20at=20the=20desk=20is=20never=20in=20the=20clear=20(hq=20ADR?= =?UTF-8?q?=200259=20=C2=A710)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- modules/rofi/README.md | 6 ++ modules/rofi/cmd/rofi-tools/main.go | 24 +++++ modules/rofi/cmd/rofi-tools/manifest_test.go | 2 +- modules/rofi/cmd/rofi-tools/secret.go | 99 ++++++++++++++++++++ modules/rofi/cmd/rofi-tools/secret_test.go | 90 ++++++++++++++++++ modules/rofi/go.mod | 5 + modules/rofi/go.sum | 4 + modules/rofi/module.json | 3 +- 8 files changed, 231 insertions(+), 2 deletions(-) create mode 100644 modules/rofi/cmd/rofi-tools/secret.go create mode 100644 modules/rofi/cmd/rofi-tools/secret_test.go diff --git a/modules/rofi/README.md b/modules/rofi/README.md index 59fba221..cf96bfba 100644 --- a/modules/rofi/README.md +++ b/modules/rofi/README.md @@ -4,6 +4,12 @@ The launcher as a module (novox/hq ADR 0208, research 026/04 and 05). - Installs `rofi`, claims the mesh's `node-launcher` seat and serves its verb `menu`. Requires `x11-display` on its own machine. +- **A value nobody sees** (`node-launcher.secret`, novox/hq ADR 0259 §10): a prompt that shows nothing of + 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. - **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 fd7dbb87..ee192114 100644 --- a/modules/rofi/cmd/rofi-tools/main.go +++ b/modules/rofi/cmd/rofi-tools/main.go @@ -43,6 +43,30 @@ func tools() []stdio.Tool { return Menu(q) }, }, + { + 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 "+ + "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"}, + "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"}, + }, + Run: func(args map[string]any) (any, error) { + q, err := secretQuestion(args) + if err != nil { + return nil, err + } + return Secret(q) + }, + }, { Name: "rofi_applications", Description: "The desktop applications the launcher offers on this machine, from every desktop entry " + diff --git a/modules/rofi/cmd/rofi-tools/manifest_test.go b/modules/rofi/cmd/rofi-tools/manifest_test.go index 45fd21ab..bc5eff4f 100644 --- a/modules/rofi/cmd/rofi-tools/manifest_test.go +++ b/modules/rofi/cmd/rofi-tools/manifest_test.go @@ -16,7 +16,7 @@ func TestItClaimsTheLauncherSeatServingMenuAndRequiresTheXDisplay(t *testing.T) if m.Module != "rofi" || m.Seats != nil { t.Fatalf("module %q declares seats %v", m.Module, m.Seats) } - if !reflect.DeepEqual(m.Claims, []claim{{Name: "node-launcher", Scope: "node", Serves: []string{"menu"}}}) { + if !reflect.DeepEqual(m.Claims, []claim{{Name: "node-launcher", Scope: "node", Serves: []string{"menu", "secret"}}}) { t.Fatalf("claims: %+v", m.Claims) } if !reflect.DeepEqual(m.Requires, []string{"x11-display"}) { diff --git a/modules/rofi/cmd/rofi-tools/secret.go b/modules/rofi/cmd/rofi-tools/secret.go new file mode 100644 index 00000000..6e6bd63f --- /dev/null +++ b/modules/rofi/cmd/rofi-tools/secret.go @@ -0,0 +1,99 @@ +package main + +import ( + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "strings" + "time" + + "golang.org/x/crypto/nacl/box" +) + +// node-launcher's verb `secret` (novox/hq ADR 0259 §10): a prompt that does not show what is typed, 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 any call's record. The controller asks it to take a module's own secret, +// such as a bot token, from the operator at their desk. +// +// **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). + +// SecretQuestion is what node-launcher.secret asks. +type SecretQuestion struct { + Prompt string + Message string + SealTo [32]byte + Timeout time.Duration +} + +// SecretAnswer is what node-launcher.secret answers: the value sealed, or why there is none. Never the value. +type SecretAnswer struct { + Sealed string `json:"sealed,omitempty"` + Cancelled bool `json:"cancelled"` + TimedOut bool `json:"timed_out,omitempty"` +} + +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 + } + if q.Message, err = text(args, "message", false); err != nil { + return q, err + } + key, err := text(args, "seal_to", true) + if err != nil { + return q, err + } + raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(key)) + if err != nil || len(raw) != 32 { + return q, errors.New("seal_to is not a sealing key: 32 bytes, base64") + } + copy(q.SealTo[:], raw) + q.Timeout, err = seconds(args, "timeout_seconds", menuMost, menuMost) + return q, err +} + +// Secret asks the operator in a prompt that shows nothing of what is typed, and seals the answer. +func Secret(q SecretQuestion) (SecretAnswer, error) { + s, err := findSession() + if err != nil { + return SecretAnswer{}, err + } + args := []string{"-dmenu", "-password", "-p", q.Prompt, "-theme-str", "listview { enabled: false; }"} + if q.Message != "" { + args = append(args, "-mesg", q.Message) + } + r, err := s.run(q.Timeout, "", "rofi", args...) + if errors.Is(err, ErrTimedOut) { + return SecretAnswer{Cancelled: true, TimedOut: true}, nil + } + if err != nil { + return SecretAnswer{}, err + } + switch r.Code { + case 0: + case 1: + return SecretAnswer{Cancelled: true}, nil + default: + // rofi's own words, never what was typed: that is on standard output, which is not said. + return SecretAnswer{}, fmt.Errorf("rofi exited %d: %s", r.Code, strings.TrimSpace(r.Stderr)) + } + value := []byte(strings.TrimRight(r.Stdout, "\r\n")) + r.Stdout = "" + if len(value) == 0 { + return SecretAnswer{Cancelled: true}, nil + } + sealed, err := box.SealAnonymous(nil, value, &q.SealTo, rand.Reader) + for i := range value { + value[i] = 0 + } + if err != nil { + return SecretAnswer{}, errors.New("what was typed could not be sealed; nothing is answered") + } + return SecretAnswer{Sealed: base64.StdEncoding.EncodeToString(sealed)}, nil +} diff --git a/modules/rofi/cmd/rofi-tools/secret_test.go b/modules/rofi/cmd/rofi-tools/secret_test.go new file mode 100644 index 00000000..0621095b --- /dev/null +++ b/modules/rofi/cmd/rofi-tools/secret_test.go @@ -0,0 +1,90 @@ +package main + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "golang.org/x/crypto/nacl/box" +) + +const typedValue = "123456789:AAEhBP0av28P4XFQnIuR-o-7Xnz1kkUzW3g" + +func aKey(t *testing.T) (string, *[32]byte, *[32]byte) { + t.Helper() + pub, priv, err := box.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + return base64.StdEncoding.EncodeToString(pub[:]), pub, priv +} + +// novox/hq ADR 0259 §10: what the operator types in the hidden prompt is answered sealed to the asker's +// key, and never in the clear — not in the answer, not in an error. +func TestWhatIsTypedIsAnsweredSealedToTheAskersKeyAndNeverInTheClear(t *testing.T) { + withSession(t) + 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}) + if err != nil { + t.Fatal(err) + } + got, err := Secret(q) + if err != nil || got.Cancelled || got.Sealed == "" { + t.Fatalf("%+v %v", got, err) + } + raw, _ := json.Marshal(got) + if strings.Contains(string(raw), typedValue) || strings.Contains(string(raw), "AAEhBP0") { + t.Fatal("the answer carries what was typed in the clear") + } + blob, _ := base64.StdEncoding.DecodeString(got.Sealed) + opened, ok := box.OpenAnonymous(nil, blob, pub, priv) + if !ok || string(opened) != typedValue { + 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"} { + if !strings.Contains(string(asked), want) { + t.Errorf("rofi was not asked %q: %s", want, asked) + } + } + if strings.Contains(string(asked), "-no-custom") { + t.Error("the prompt takes no typed answer") + } +} + +func TestADismissedEmptyOrLatePromptIsACancelAndAFailureNeverSaysTheValue(t *testing.T) { + withSession(t) + key, _, _ := aKey(t) + q, _ := secretQuestion(map[string]any{"prompt": "p", "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) + if err != nil || !got.Cancelled || got.TimedOut != timedOut || got.Sealed != "" { + t.Errorf("%s: %+v %v", script, got, err) + } + } + fakeBinaries(t, map[string]string{"rofi": `echo "` + typedValue + `"; echo "rofi broke" >&2; exit 3`}) + if _, err := Secret(q); err == nil || strings.Contains(err.Error(), typedValue) || !strings.Contains(err.Error(), "rofi broke") { + t.Errorf("a failure said %v", err) + } +} + +func TestASecretIsAskedOnlyWithAKeyToSealItTo(t *testing.T) { + 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}, + } { + if _, err := secretQuestion(args); err == nil { + t.Errorf("%v was taken", args) + } + } +} diff --git a/modules/rofi/go.mod b/modules/rofi/go.mod index db7646b1..ca15f8ce 100644 --- a/modules/rofi/go.mod +++ b/modules/rofi/go.mod @@ -3,3 +3,8 @@ module rofi go 1.22 require git.novox.be/novox/mesh-sdk/go v0.1.7 + +require ( + golang.org/x/crypto v0.31.0 + golang.org/x/sys v0.28.0 // indirect +) diff --git a/modules/rofi/go.sum b/modules/rofi/go.sum index b474419e..dff94e3d 100644 --- a/modules/rofi/go.sum +++ b/modules/rofi/go.sum @@ -1,2 +1,6 @@ git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w= git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/modules/rofi/module.json b/modules/rofi/module.json index f58f5704..d603a199 100644 --- a/modules/rofi/module.json +++ b/modules/rofi/module.json @@ -12,7 +12,8 @@ "name": "node-launcher", "scope": "node", "serves": [ - "menu" + "menu", + "secret" ] } ],