An offer may say `"credential": {"own": "<secret>"}`: the provider's own secret is the credential
every consumer of that provision receives, in the shape of a pair credential. The vault keeps one
value, sealed to the provider, to every consumer that holds the provision and to the operator, all
under one generation stamp; a consumer binding later, or `secret rotate` on the provider's secret,
makes a fresh value and seals it to every holder in one act, and the rotate command sends every
holding machine together. An accepted value is sealed to the consumers of the moment and never
remade: a consumer binding after it is refused with the way out (ADR 0113). The named own secret
must say how it is taken (issue 180), so the provider's start applies the file.
A need carries the shared secret's name from either side of the machine boundary; the plan mints a
consumer's copy from the provider's value. Registered manifests keep their bytes.
422 lines
16 KiB
Go
422 lines
16 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)")
|
|
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 rotate <node> <module> <name>\n" +
|
|
"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
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
func secretRotate(ctx context.Context, args []string) error {
|
|
rest, _ := split(args)
|
|
if len(rest) != 3 {
|
|
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()
|
|
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
|
|
}
|
|
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())
|
|
// 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"
|
|
}
|