Files
mesh-controller/cmd/mesh-controller/secret.go
T
jschoubben 9f3790dcda Review: one local name is still a local name; a local name is unique; recovery knows it; recipes read as instructions; a tag before a digest; ask fails at once when nothing serves
A secrets object with one local name delivered no file. Two requirements could share
a local name. secret recover and the export could not tell two locals apart. The
recipe check missed continued lines and read heredoc bodies as bases. repo:tag@digest
kept the tag in the repository. ask now publishes mandatory, so a tool nothing serves
is said at once rather than after the wait.
2026-09-21 21:03:22 +02:00

362 lines
14 KiB
Go

package main
import (
"bufio"
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"strings"
"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 "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)")
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]
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
}
if err := open.inventory.AcceptSecretForModule(ctx, node, module, name, value); 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)
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 accept <node> <module> <name> [--from <file>] [--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
}
}