Files
mesh-controller/cmd/mesh-controller/secret.go
T
jochen c9dd07f919
mesh/merge-gate head c9dd07f919a2 merged into trunk 404b4409e70e — pass: builds build-agent, mesh-controller → ace, g14, novox, shanks; no bus step; every …
mesh/repo-check head c9dd07f919a2 merged into trunk 404b4409e70e — pass: its merge-check.sh passed (run 2)
mesh/delivery delivered
Never make a credential only a person can give, and give it once per provider (hq issue 164, ADR 0337)
A Servarr app's API key, an indexer's key and a downloader's password are values the provider's
software holds; the pair path made a random value for every consumer of them, sealed it to both ends
and reported success, and the provider refused it. An offer may now say `"credential": {"issued-by":
"outside"}`: SecretFor never makes a value for it, the plan reads such a pair as given or not given
and sends the consumer no credential file until a person gives it, without refusing its machine;
remakeShared and SharedSecretFor never make a provider's own secret issued outside either.
`secret accept <provider's node> <provider module> <provision>` seals one value to every consumer that
binds the provision from that provider, the provider and the operator, under one generation; the
per-pair form is refused for such a provision, and rotate names the accept. A consumer may wait for a
provision's credential: the controller excuses the wait only when the store bears it out, and its
needs-operator condition names the one accept line.

Adds the replay of issue 164 (TestReplay164), written with only what the controller had before.
2026-10-13 11:37:31 +02:00

563 lines
23 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 "ask":
return secretAsk(ctx, args[1:])
case "issued":
// The credential a provider's software issued, taken from the provider (novox/hq ADR 0305).
return secretIssued(ctx, args[1:])
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]
// A value comes from the terminal or the desk, never through a verb (the review of 2026-10-09, M4): a
// verb's caller may be an agent, and a value it chose would become what a module acts with.
if verb, through := throughAVerb(); through && *desk == "" {
return fmt.Errorf("a secret's value is given at the controller's terminal or at the desk (`give`), never "+
"through a verb (this line came through %q): nothing was read or sealed", verb)
}
// **A provision's credential issued outside the mesh is given once, for its provider** (novox/hq ADR 0337 §3,
// issue 165): `secret accept <provider's machine> <provider module> <provision>`, sealed to every consumer of
// that provider; the per-pair form naming a consumer is refused for it, naming this one.
outsideAt, err := outsideOffer(ctx, module, name, *provider)
if err != nil {
return err
}
if outsideAt == "" && *provider != "" {
if offered, err := offeredOutsideBy(ctx, name); err != nil {
return err
} else if offered != "" {
return fmt.Errorf("%s is issued outside the mesh and given once for its provider, which seals it to "+
"every consumer of it (ADR 0337): `secret accept %s %s %s`; nothing was read or sealed",
name, *provider, offered, name)
}
}
if outsideAt != "" {
if *desk != "" || *local != "" {
return errors.New("a provision's credential issued outside the mesh is given at the controller's " +
"terminal, for every consumer of its provider; it takes neither --at-desk nor --local")
}
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")
}
return acceptForProvision(ctx, node, module, name, value)
}
if *desk != "" {
if *from != "" || *provider != "" {
return errors.New("--at-desk gives a module's own secret, and takes neither --from nor --provider")
}
return askAtDesk(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
}
// A trusted party's secret is announced before it is kept (the confirmation review of 2026-10-09, N1-give):
// on every channel, the one it replaces among them, which still runs on its old value until the next push.
// Not announced, it is not kept: a channel whose token changed unheard of answers for somebody else.
trusted, err := open.inventory.RunsAsItsOwnAccount(ctx, module)
if err != nil {
return err
}
untilStart, unannounced, err := keepGiven(trusted,
func() error { return announceSecretGiven(ctx, node, module, name, "at the controller's terminal") },
func() (bool, error) { return open.inventory.AcceptGivenSecret(ctx, node, module, name, value) })
if err != nil {
if trusted && unannounced != nil {
return fmt.Errorf("%s runs as an account of its own, and the change of its %s could not be announced on "+
"your channels first, so nothing was kept: %w", module, name, err)
}
return err
}
if unannounced != nil {
fmt.Printf(" this change could NOT be announced on the operator's channels: %v\n", unannounced)
}
// 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
}
// secretAsk is `secret ask <node> <module> <name> [--at <machine>]` (novox/hq ADR 0277): the operator is asked
// for a module's own secret in a prompt at the desk, which only they answer. The one `secret` line a verb may
// run beside rotate (givenAtTheDesk): it carries no value and answers none.
func secretAsk(ctx context.Context, args []string) error {
rest, flags := split(args)
set := flag.NewFlagSet("secret ask", flag.ContinueOnError)
at := set.String("at", "", "the machine the operator sits at, where the prompt opens; the module's machine when absent")
if err := set.Parse(flags); err != nil {
return err
}
if len(rest) != 3 {
return errors.New("secret ask <node> <module> <name> [--at <machine>]")
}
return askAtDesk(ctx, rest[0], rest[1], rest[2], *at)
}
const secretUsage = "secret rotate <node> <module> <name> [--why <text> [--cause <word>]]\n" +
"secret ask <node> <module> <name> [--at <machine>]\n" +
"secret issued <provision>\n" +
"secret accept <node> <module> <name> [--from <file> | --at-desk <machine>] [--provider <node> [--local <name>]]\n" +
"secret accept <provider's node> <provider module> <provision> [--from <file>] (a credential issued outside the mesh, for every consumer)\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)
// At a terminal, what is typed is not shown either: echo off while it is read.
defer hideTyping(os.Stdin)()
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"
}
// keepGiven keeps a value given at the controller's terminal, and announces it on the operator's channels
// (the confirmation review of 2026-10-09, N1-give). **A trusted party's — a module running as an account of its
// own: the router, a verified channel — is announced before it is kept, and not kept when the announcement
// fails**: a channel whose token changed unheard of answers for somebody else. Any other module's is kept first
// and announced after, and a failed announcement is said (unannounced) without undoing it.
func keepGiven(trusted bool, announce func() error, keep func() (bool, error)) (untilStart bool, unannounced, err error) {
if trusted {
if err := announce(); err != nil {
return false, err, err
}
untilStart, err = keep()
return untilStart, nil, err
}
untilStart, err = keep()
if err != nil {
return false, nil, err
}
return untilStart, announce(), nil
}