From review: the export counted any operator-sealed row as recoverable, so a secret sealed to a replaced key was reported as openable with the current one; replacing the key counted orphans in one table of two; and a pair credential held from two providers was recovered as whichever row came first. The export now lists what the current key opens, what an earlier key opens, and what has no copy; `secret recover` takes --provider and refuses ambiguity; files that must not exist are created exclusively; one constructor builds the export for the operator's file and the vault's disk alike.
343 lines
12 KiB
Go
343 lines
12 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)")
|
|
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 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>]\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")
|
|
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)
|
|
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
|
|
}
|
|
}
|