Serve node-launcher's hidden prompt, answering sealed to the asker, so a secret typed at the desk is never in the clear (hq ADR 0259 §10)

This commit is contained in:
jochen
2026-10-09 17:08:17 +02:00
committed by jschoubben
parent d902e1550e
commit ad5a85398e
8 changed files with 231 additions and 2 deletions
+6
View File
@@ -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
+24
View File
@@ -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 " +
+1 -1
View File
@@ -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"}) {
+99
View File
@@ -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
}
@@ -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)
}
}
}
+5
View File
@@ -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
)
+4
View File
@@ -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=
+2 -1
View File
@@ -12,7 +12,8 @@
"name": "node-launcher",
"scope": "node",
"serves": [
"menu"
"menu",
"secret"
]
}
],