Recoverable means sealed to the current operator key; recovery names the provider

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.
This commit is contained in:
2026-09-21 01:16:32 +02:00
parent 565f144a20
commit 77e6c1a684
9 changed files with 308 additions and 141 deletions
+11 -6
View File
@@ -52,14 +52,13 @@ func operatorKeyMake(args []string) error {
if err := set.Parse(args); err != nil {
return err
}
if _, err := os.Stat(*out); err == nil {
return fmt.Errorf("%s already exists; this will not overwrite a key somebody may still need", *out)
}
public, private, err := secrets.Keypair()
if err != nil {
return err
}
if err := os.WriteFile(*out, []byte(private+"\n"), 0o600); err != nil {
// Create-exclusive: a key somebody may still need is never overwritten, and there is no window
// between checking and writing in which one could appear.
if err := writeNew(*out, []byte(private+"\n")); err != nil {
return err
}
fmt.Printf("operator key %s\n", secrets.Fingerprint(public))
@@ -129,14 +128,20 @@ func operatorKeyShow(ctx context.Context) error {
fmt.Println("the mesh has no operator key; `operator key make` then `operator key set` gives it one")
return nil
}
kept, unrecoverable, err := inv.KeptForOperator(ctx)
kept, earlier, unrecoverable, err := inv.KeptForOperator(ctx)
if err != nil {
return err
}
fmt.Printf("operator key %s\n %s\n", secrets.Fingerprint(key), key)
fmt.Printf(" %d secret(s) recoverable with it\n", len(kept))
if len(earlier) > 0 {
fmt.Printf(" %d secret(s) sealed to an earlier operator key — recoverable with that key only, until issued again:\n", len(earlier))
for _, k := range earlier {
fmt.Printf(" %s %s %s (%s)\n", k.Node, k.Module, k.Name, secrets.Fingerprint(k.Key))
}
}
if len(unrecoverable) > 0 {
fmt.Printf(" %d secret(s) not recoverable — made before it, or sealed to an earlier key:\n", len(unrecoverable))
fmt.Printf(" %d secret(s) not recoverable — made before the mesh had an operator key:\n", len(unrecoverable))
for _, k := range unrecoverable {
fmt.Printf(" %s %s %s\n", k.Node, k.Module, k.Name)
}
+5 -15
View File
@@ -13,7 +13,6 @@ import (
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/licences"
"github.com/novox/mesh-controller/internal/secrets"
"net"
"strconv"
)
@@ -464,27 +463,18 @@ func declarationWith(ctx context.Context, open *stores, node string,
}
// And, for a module that keeps them, every operator-sealed secret in the mesh — the vault's
// copy, outside the store (novox/hq ADR 0085, amended). Read only; nothing here mints.
// copy, outside the store (novox/hq ADR 0085, amended). Read only; nothing here mints. The
// export changes whenever any secret in the mesh is made or rotated, so the vault's declaration
// changes with it and the vault node is sent again: that is what keeps its copy current, and
// the cost is one two-table read per composition of the vault's node, in every mode.
var kept *catalogue.KeptExport
for _, m := range plan.Modules {
if m.Keeps == "" {
continue
}
operator, err := inv.OperatorKey(ctx)
if err != nil {
if kept, err = inv.OperatorExport(ctx); err != nil {
return nil, err
}
if operator == "" {
break // nothing is sealed to an operator, so there is nothing to keep yet
}
recoverable, unrecoverable, err := inv.KeptForOperator(ctx)
if err != nil {
return nil, err
}
kept = &catalogue.KeptExport{
Export: 1, OperatorKey: operator, Fingerprint: secrets.Fingerprint(operator),
Kept: recoverable, Unrecoverable: unrecoverable,
}
break
}
+71 -21
View File
@@ -84,7 +84,7 @@ func secretCommand(ctx context.Context, args []string) error {
}
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 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.
@@ -104,6 +104,7 @@ func secretRecover(ctx context.Context, args []string) error {
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
}
@@ -118,7 +119,7 @@ func secretRecover(ctx context.Context, args []string) error {
var kept inventory.Kept
if *fromExport != "" {
kept, err = keptFromExport(*fromExport, node, module, name)
kept, err = keptFromExport(*fromExport, node, module, name, *provider)
if err != nil {
return err
}
@@ -128,7 +129,7 @@ func secretRecover(ctx context.Context, args []string) error {
return err
}
defer open.Close()
kept, err = open.inventory.KeptSecret(ctx, node, module, name)
kept, err = open.inventory.KeptSecret(ctx, node, module, name, *provider)
if err != nil {
return err
}
@@ -147,10 +148,7 @@ func secretRecover(ctx context.Context, args []string) error {
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 {
if err := writeNew(path, value); err != nil {
return err
}
fmt.Printf("%s on %s: %q recovered to %s (0600) — %d bytes, origin %s\n",
@@ -181,14 +179,11 @@ func secretExport(ctx context.Context, args []string) error {
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)
doc, err := inv.OperatorExport(ctx)
if err != nil {
return err
}
body, err := json.MarshalIndent(export{
Export: 1, OperatorKey: key, Fingerprint: secrets.Fingerprint(key),
Kept: kept, Unrecoverable: unrecoverable,
}, "", " ")
body, err := json.MarshalIndent(doc, "", " ")
if err != nil {
return err
}
@@ -197,18 +192,58 @@ func secretExport(ctx context.Context, args []string) error {
_, err := os.Stdout.Write(body)
return err
}
if err := os.WriteFile(*out, body, 0o600); err != nil {
// 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(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))
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
}
func keptFromExport(path, node, module, name string) (inventory.Kept, error) {
// 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
@@ -217,12 +252,27 @@ func keptFromExport(path, node, module, name string) (inventory.Kept, error) {
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
// 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)
}
}
return inventory.Kept{}, fmt.Errorf("%s holds no copy of %s's %q on %s", path, module, name, node)
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.