Files
mesh-controller/cmd/mesh-controller/secret.go
T
jochen aa7836140f
mesh/delivery-group group feat/a-secret-given-at-the-desk rejected: a member's own check failed
mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check fail: its merge-check.sh failed: --- FAIL: TestTheInstallersFirstUserListIsWhatTheControllerWouldCompose (0.82s)
mesh/delivery rejected: the gate failed or could not run, or the repository's own check failed
Take a module's own secret through a hidden prompt on the operator's desk, so a bot token never passes through an agent's session (hq ADR 0259 §10)
2026-10-08 17:10:22 +02:00

464 lines
18 KiB
Go

package main
import (
"bufio"
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"strings"
"time"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/secrets"
)
// secretCommand gives the mesh a value it must carry and could not have invented.
//
// **Every other secret in this mesh is one the mesh made** — generated, sealed to the machine that
// will use it, and never readable again. That is right for something coming into existence, and
// wrong for something that already exists: a database created last year has the password it was
// created with, and generating a new one puts 32 random bytes where a working credential was.
// The machine applies it, reports success, and whatever reads it fails to authenticate somewhere
// else entirely — with the mesh insisting the secret was delivered, which it was.
//
// So this is the entry point for **adopting** something already running. The store has carried
// the distinction since the beginning: a module secret records whether it was `made` or
// `accepted`, and refuses to invent a replacement for the second. Nothing until now could write
// one, so the only accepted secret in the mesh was the broker account issued to a build machine.
//
// The value is sealed on the way in and the plaintext discarded, exactly as a generated one is.
// **The only difference between the two is where the value came from.**
func secretCommand(ctx context.Context, args []string) error {
if len(args) == 0 {
return errors.New(secretUsage)
}
switch args[0] {
case "accept":
case "rotate":
return secretRotate(ctx, args[1:])
case "recover":
return secretRecover(ctx, args[1:])
case "export":
return secretExport(ctx, args[1:])
default:
return errors.New(secretUsage)
}
rest, flags := split(args[1:])
set := flag.NewFlagSet("secret accept", flag.ContinueOnError)
from := set.String("from", "",
"read the value from this file instead of asking (use - for standard input)")
provider := set.String("provider", "",
"the node providing <name>: the value becomes the PAIR credential between <module> on <node> "+
"and that provider, sealed to both — the vault's operator-delivered secret (ADR 0092)")
desk := set.String("at-desk", "",
"ask the operator for the value in a prompt that does not show it, on this machine's desk; the "+
"answer comes back sealed to this call alone (novox/hq ADR 0259 §10)")
local := set.String("local", "",
"with --provider: the name the credential goes by inside <module>, where its manifest keeps "+
"several for <name> (ADR 0094)")
if err := set.Parse(flags); err != nil {
return err
}
if len(rest) != 3 {
return errors.New(secretUsage)
}
node, module, name := rest[0], rest[1], rest[2]
if *desk != "" {
if *from != "" || *provider != "" {
return errors.New("--at-desk gives a module's own secret, and takes neither --from nor --provider")
}
return giveAtDesk(ctx, node, module, name, *desk)
}
value, err := valueFor(node, module, name, *from)
if err != nil {
return err
}
value = asSupplied(value)
if value == "" {
return errors.New("there is nothing to seal")
}
open, err := openStores(ctx)
if err != nil {
return err
}
defer open.Close()
if *provider != "" {
// Into the pair, not into the module's own secrets: what the provider is asked to create
// and what the consumer reads are the same value, and neither end can be told a different
// one later without the other (novox/hq 04-ISSUES/070).
if err := open.inventory.AcceptSecretForPair(ctx, name, node, module, *provider, *local, value); err != nil {
return err
}
fmt.Printf("%s on %s now holds %q from %s%s, sealed to both machines.\n", module, node, name, *provider, asLocal(*local))
fmt.Printf(" the mesh cannot read it back, will not replace it with one of its own, and will not rotate it\n")
fmt.Printf(" run `push %s` and `push %s` to send it\n", *provider, node)
return nil
}
untilStart, err := open.inventory.AcceptGivenSecret(ctx, node, module, name, value)
if err != nil {
return err
}
// Not printed back, and there is nowhere it could be printed from: it is sealed to that
// machine and the mesh cannot read it again.
fmt.Printf("%s on %s now holds %q, sealed to that machine.\n", module, node, name)
if untilStart {
// Accepted, and said what it is for (novox/hq ADR 0228): a value given by hand adopts
// something that already holds it, and lives only until the module has started on it.
fmt.Printf(" it lives until %s next starts well under the mesh on it, and is then replaced with a value\n"+
" the mesh makes, sealed and sent (ADR 0228): a value given by hand is for adopting something\n"+
" already running that holds it\n", module)
if record, err := open.inventory.NodeByName(ctx, node); err == nil && !record.Adopted {
fmt.Printf(" %s is not an adopted machine: if %s is installed fresh there, it needs no given value —\n"+
" the mesh makes one at the first push\n", node, module)
}
} else {
fmt.Printf(" the mesh cannot read it back, and will not replace it with one of its own\n")
}
fmt.Printf(" run `push %s` to send it\n", node)
return nil
}
const secretUsage = "secret rotate <node> <module> <name> [--why <text> [--cause <word>]]\n" +
"secret accept <node> <module> <name> [--from <file> | --at-desk <machine>] [--provider <node> [--local <name>]]\n" +
"secret recover <node> <module> <name> --key <operator-key> [--out <file>] [--from-export <file>] [--provider <node>]\n" +
"secret export [--out <file>]"
// secretRecover is break-glass: a secret opened with the operator's key, written to a file.
//
// **The mesh cannot show a secret back, and this does not make it able to.** What is opened here
// is the copy sealed to the operator key (novox/hq ADR 0085, amended); the mesh holds that blob and
// no key for it, and this program holds the key for the length of the call and no blob until given
// one. Recovery needs both, which is what keeps the sealing meaningful.
//
// The value goes to a file at 0600, never to the terminal unless asked for with `--out -` — the
// source mesh's secret tools were written after a secret was printed into a transcript, and that
// rule is theirs. `--from-export` reads the blob from a file `secret export` wrote, so recovery
// works with the store gone, which is the case it exists for.
func secretRecover(ctx context.Context, args []string) error {
rest, flags := split(args)
set := flag.NewFlagSet("secret recover", flag.ContinueOnError)
keyFile := set.String("key", "", "the operator's private key, from `operator key make`")
out := set.String("out", "", "where to write the value (0600); - for standard output. Default <node>.<module>.<name>.secret")
fromExport := set.String("from-export", "", "read the sealed copy from this `secret export` file instead of the store")
provider := set.String("provider", "", "for a pair credential held from more than one provider: which one")
local := set.String("local", "", "for a pair credential the module keeps under a local name (ADR 0094): which one")
if err := set.Parse(flags); err != nil {
return err
}
if len(rest) != 3 {
return errors.New(secretUsage)
}
node, module, name := rest[0], rest[1], rest[2]
private, err := readPrivateKey(*keyFile)
if err != nil {
return err
}
var kept inventory.Kept
if *fromExport != "" {
kept, err = keptFromExport(*fromExport, node, module, name, *provider)
if err != nil {
return err
}
} else {
open, err := openStores(ctx)
if err != nil {
return err
}
defer open.Close()
kept, err = open.inventory.KeptSecret(ctx, node, module, name, *provider, *local)
if err != nil {
return err
}
}
value, err := secrets.Open(private, kept.Sealed)
if err != nil {
return fmt.Errorf("%s on %s: %q is sealed to operator key %s, and that key does not open it: %w",
module, node, name, secrets.Fingerprint(kept.Key), err)
}
if *out == "-" {
_, err := os.Stdout.Write(append(value, '\n'))
return err
}
path := *out
if path == "" {
path = node + "." + module + "." + name + ".secret"
}
if err := writeNew(path, value); err != nil {
return err
}
fmt.Printf("%s on %s: %q recovered to %s (0600) — %d bytes, origin %s\n",
module, node, name, path, len(value), kept.Origin)
return nil
}
// An export is what a person keeps beside the operator key — the catalogue's shape, so the vault
// keeps the same document on its disk (Manifest.Keeps).
type export = catalogue.KeptExport
func secretExport(ctx context.Context, args []string) error {
set := flag.NewFlagSet("secret export", flag.ContinueOnError)
out := set.String("out", "", "where to write the export (0600); - or empty for standard output")
if err := set.Parse(args); err != nil {
return err
}
open, err := openStores(ctx)
if err != nil {
return err
}
defer open.Close()
inv := open.inventory
key, err := inv.OperatorKey(ctx)
if err != nil {
return err
}
if key == "" {
return errors.New("the mesh has no operator key, so nothing is sealed to one; `operator key make` and `operator key set` first")
}
doc, err := inv.OperatorExport(ctx)
if err != nil {
return err
}
body, err := json.MarshalIndent(doc, "", " ")
if err != nil {
return err
}
body = append(body, '\n')
if *out == "" || *out == "-" {
_, err := os.Stdout.Write(body)
return err
}
// Replaced whole, and made 0600 whether or not it existed: an export is ciphertext and a public
// key, but it is also the list of every secret the mesh has, and a file left at an earlier mode
// while the command says 0600 is a lie in the one place a person checks.
if err := writeReplacing(*out, body); err != nil {
return err
}
fmt.Printf("%d secret(s) exported to %s (0600), sealed to operator key %s — ciphertext, keep it with the key\n",
len(doc.Kept), *out, doc.Fingerprint)
if len(doc.EarlierKey) > 0 {
fmt.Printf(" %d secret(s) are sealed to an EARLIER operator key: recoverable with that key only\n", len(doc.EarlierKey))
}
if len(doc.Unrecoverable) > 0 {
fmt.Printf(" %d secret(s) are NOT in it: made before the mesh had an operator key\n", len(doc.Unrecoverable))
}
return nil
}
// writeNew writes a file that must not exist yet, atomically: create-exclusive, 0600. A check
// followed by a write is a window in which a key somebody still needs can be overwritten.
func writeNew(path string, content []byte) error {
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
if os.IsExist(err) {
return fmt.Errorf("%s already exists; not overwriting it", path)
}
return err
}
if _, err := f.Write(content); err != nil {
f.Close()
return err
}
return f.Close()
}
// writeReplacing writes a file whole, creating or truncating it, and leaves it at 0600 either way.
func writeReplacing(path string, content []byte) error {
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
if err != nil {
return err
}
if _, err := f.Write(content); err != nil {
f.Close()
return err
}
if err := f.Chmod(0o600); err != nil {
f.Close()
return err
}
return f.Close()
}
func keptFromExport(path, node, module, name, provider string) (inventory.Kept, error) {
raw, err := os.ReadFile(path)
if err != nil {
return inventory.Kept{}, err
}
var e export
if err := json.Unmarshal(raw, &e); err != nil {
return inventory.Kept{}, fmt.Errorf("%s is not a secret export: %w", path, err)
}
// Sealed to the current key or to an earlier one: both are copies the given key might open,
// and Open says which. Not the unrecoverable list, which holds no copy at all.
var found []inventory.Kept
for _, k := range append(append([]inventory.Kept{}, e.Kept...), e.EarlierKey...) {
if k.Node == node && k.Module == module && k.Name == name && (provider == "" || k.Provider == provider) {
found = append(found, k)
}
}
switch len(found) {
case 0:
return inventory.Kept{}, fmt.Errorf("%s holds no copy of %s's %q on %s", path, module, name, node)
case 1:
return found[0], nil
default:
providers := make([]string, 0, len(found))
for _, f := range found {
providers = append(providers, f.Provider)
}
return inventory.Kept{}, fmt.Errorf("%s holds %s's %q on %s from more than one provider (%s); say which with --provider",
path, module, name, node, strings.Join(providers, ", "))
}
}
// split separates what this command is about from how it was asked.
//
// **Because the standard library stops parsing at the first non-flag argument.** With the
// positionals first — which is the order that reads correctly — everything after them is left
// sitting in the arguments, so `secret accept a b c --from -` arrives as five positionals and the
// flag is never seen. The host's own parser carries the same note, and the fault it names is
// worse than this one: there, a flag somebody passed was silently ignored and the command
// succeeded anyway.
func split(args []string) (positional, flags []string) {
for i, arg := range args {
if strings.HasPrefix(arg, "-") {
return args[:i], args[i:]
}
}
return args, nil
}
// asSupplied is the value with its line ending removed and nothing else.
//
// **A file has a trailing newline and a password does not**, so the ending goes — a credential
// wrong by one byte fails in a way nobody connects to how it was supplied.
//
// **And only the ending.** Trimming both ends is the obvious thing and it is wrong: a password
// chosen with a leading space is one the mesh would then deliver as a different password, silently,
// with the operator certain they had supplied it correctly.
func asSupplied(raw string) string {
return strings.TrimRight(raw, "\r\n")
}
// valueFor gets the secret without putting it somewhere it can be read afterwards.
//
// **Not an argument, and there is no flag that takes one.** A value on the command line is in the
// shell's history, in the process list for as long as it runs, and in whatever collects either.
// The paths here are a file the operator already has, or a prompt that does not echo — the same
// two ways a model-access key is supplied (novox/hq ADR 0024).
func valueFor(node, module, name, from string) (string, error) {
switch {
case from == "-":
body, err := io.ReadAll(os.Stdin)
if err != nil {
return "", err
}
return string(body), nil
case from != "":
body, err := os.ReadFile(from)
if err != nil {
return "", err
}
return string(body), nil
default:
// The same path a model-access key takes, and for the same reason: a value given as an
// argument is in the shell's history and in the process list. Read from standard input,
// echoed nowhere by this program.
fmt.Fprintf(os.Stderr,
"reading %s's %q for %s from standard input; it is not echoed anywhere\n",
module, name, node)
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
if err != nil && line == "" {
return "", fmt.Errorf("nothing was given on standard input: %w", err)
}
return line, nil
}
}
// secretRotate makes a module's own secret anew and sends the machine, so the module starts again on
// the new value (novox/hq ADR 0114, issue 180). A pair credential rotates with `rotate <provision>`;
// this is the secret with one party. Said in the log with who asked and when, never the value.
//
// **A value given to the mesh rotates the same way** (novox/hq ADR 0228): what a module reads at start
// is held by nobody else, so the old value is not needed to replace it. Refused for a value an
// outside party issued, which no value of the mesh's would replace. Why it was rotated is recorded in
// the hand-act log when given — a rotation asked by a person is an act by hand, and a leak is a cause
// worth counting.
func secretRotate(ctx context.Context, args []string) error {
rest, flags := split(args)
set := flag.NewFlagSet("secret rotate", flag.ContinueOnError)
why := addHandActFlags(set)
if err := set.Parse(flags); err != nil {
return err
}
if len(rest) != 3 || set.NArg() != 0 {
return errors.New(secretUsage)
}
node, module, name := rest[0], rest[1], rest[2]
open, err := openStores(ctx)
if err != nil {
return err
}
defer open.Close()
origin, given, err := open.inventory.OwnSecretOrigin(ctx, node, module, name)
if err != nil {
return err
}
if err := open.inventory.RotateModuleSecret(ctx, node, module, name); err != nil {
var refused inventory.ErrNotRotatable
if errors.As(err, &refused) {
return fmt.Errorf("not rotated: %s", refused.Why)
}
return err
}
why.record(ctx, "secret rotate", []string{node, module, name})
fmt.Printf("rotated %q of %s on %s at %s, asked by %s; the value is sealed and not shown\n",
name, module, node, time.Now().UTC().Format(time.RFC3339), whoAsked())
if origin == inventory.OriginAccepted {
fmt.Printf(" it replaces the value given to the mesh on %s: the mesh made this one, so `secret rotate` "+
"replaces it again whenever asked (ADR 0228)\n", given.UTC().Format("2006-01-02"))
}
// A shared credential (ADR 0158) has as many holders as the provision has consumers, and all
// of them are sent in one act, so no machine is left reading a value the provider no longer takes.
machines, err := open.inventory.SharedHolders(ctx, node, module, name)
if err != nil {
return err
}
if len(machines) == 0 {
machines = []string{node}
}
if len(machines) == 1 {
fmt.Printf("sending %s, so %s starts again on the new value:\n", node, module)
} else {
fmt.Printf("shared with every consumer; sending %s together:\n", strings.Join(machines, ", "))
}
if err := sendTo(ctx, open, machines); err != nil {
return fmt.Errorf("%w\n\nThe new value is sealed and not yet delivered; what runs keeps the old "+
"one until the machines next apply. Fix the cause and run `push --behind`", err)
}
return nil
}
// whoAsked names the caller for the log: the account the command runs as, which for a tool call
// through the console is the mesh's own.
func whoAsked() string {
if u := os.Getenv("SUDO_USER"); u != "" {
return u
}
if u := os.Getenv("USER"); u != "" {
return u
}
return "the mesh"
}