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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user