An operator key, a second seal on every own secret, and the vault keeps the export

novox/hq ADR 0085, amended: the mesh's root secrets — the store's superuser,
the broker's administrator, every secret a module holds for itself — were
sealed to a node key and nothing else, so a lost node took them with it.
Now the mesh records an operator's public sealing key and seals every own
secret to it as well, minted or accepted. The private half is written once
by `operator key new` to a file the operator keeps off the mesh; the mesh
holds one more blob per secret that it cannot open.

`secret recover` opens a secret with that key, to a 0600 file, from the
store or from an export; `secret export` writes every operator-sealed copy
as ciphertext. A module that `keeps` (the vault) is handed that export as a
declared file on its own disk, so recovery survives the store.

Secrets made before the key exists have no operator copy and are said so —
the plaintext was discarded — until each is issued again.
This commit is contained in:
2026-09-20 23:56:49 +02:00
parent 1c2e94e1a0
commit e140ed5d0b
13 changed files with 906 additions and 20 deletions
+159 -3
View File
@@ -3,12 +3,17 @@ 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.
@@ -28,8 +33,17 @@ import (
// 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 || args[0] != "accept" {
return errors.New("secret accept <node> <module> <name> [--from <file>]")
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)
@@ -39,7 +53,7 @@ func secretCommand(ctx context.Context, args []string) error {
return err
}
if len(rest) != 3 {
return errors.New("secret accept <node> <module> <name> [--from <file>]")
return errors.New(secretUsage)
}
node, module, name := rest[0], rest[1], rest[2]
@@ -69,6 +83,148 @@ func secretCommand(ctx context.Context, args []string) error {
return nil
}
const secretUsage = "secret accept <node> <module> <name> [--from <file>]\n" +
"secret recover <node> <module> <name> --key <operator-key> [--out <file>] [--from-export <file>]\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")
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)
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)
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 := os.Stat(path); err == nil {
return fmt.Errorf("%s already exists; not overwriting it", path)
}
if err := os.WriteFile(path, value, 0o600); 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")
}
kept, unrecoverable, err := inv.KeptForOperator(ctx)
if err != nil {
return err
}
body, err := json.MarshalIndent(export{
Export: 1, OperatorKey: key, Fingerprint: secrets.Fingerprint(key),
Kept: kept, Unrecoverable: unrecoverable,
}, "", " ")
if err != nil {
return err
}
body = append(body, '\n')
if *out == "" || *out == "-" {
_, err := os.Stdout.Write(body)
return err
}
if err := os.WriteFile(*out, body, 0o600); 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(kept), *out, secrets.Fingerprint(key))
if len(unrecoverable) > 0 {
fmt.Printf(" %d secret(s) are NOT in it: made before the mesh had an operator key\n", len(unrecoverable))
}
return nil
}
func keptFromExport(path, node, module, name 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)
}
for _, k := range e.Kept {
if k.Node == node && k.Module == module && k.Name == name {
return k, nil
}
}
return inventory.Kept{}, fmt.Errorf("%s holds no copy of %s's %q on %s", path, module, name, node)
}
// 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