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
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.
122 lines
4.5 KiB
Go
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
|
|
}
|