Files
mesh-catalog/modules/rofi/cmd/rofi-tools/secret.go
T
jschoubben f5cbfd850c
mesh/merge-gate pass: builds rofi → g14, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
rofi: write the hidden prompt from names and say the controller asks, never with the caller's words
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.
2026-10-09 17:08:17 +02:00

122 lines
4.5 KiB
Go

package main
import (
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"regexp"
"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 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 {
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"`
}
// 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
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)
}
}
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
}
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
}