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 : the value becomes the PAIR credential between on "+ "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 , where its manifest keeps "+ "several for (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 [--from ] [--provider [--local ]]\n" + "secret recover --key [--out ] [--from-export ] [--provider ]\n" + "secret export [--out ]" // 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 ...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 } }