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 { if err := set.Parse(args); err != nil {
return err 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() public, private, err := secrets.Keypair()
if err != nil { if err != nil {
return err 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 return err
} }
fmt.Printf("operator key %s\n", secrets.Fingerprint(public)) 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") fmt.Println("the mesh has no operator key; `operator key make` then `operator key set` gives it one")
return nil return nil
} }
kept, unrecoverable, err := inv.KeptForOperator(ctx) kept, earlier, unrecoverable, err := inv.KeptForOperator(ctx)
if err != nil { if err != nil {
return err return err
} }
fmt.Printf("operator key %s\n %s\n", secrets.Fingerprint(key), key) fmt.Printf("operator key %s\n %s\n", secrets.Fingerprint(key), key)
fmt.Printf(" %d secret(s) recoverable with it\n", len(kept)) 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 { 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 { for _, k := range unrecoverable {
fmt.Printf(" %s %s %s\n", k.Node, k.Module, k.Name) 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/catalogue"
"github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/licences" "github.com/novox/mesh-controller/internal/licences"
"github.com/novox/mesh-controller/internal/secrets"
"net" "net"
"strconv" "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 // 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 var kept *catalogue.KeptExport
for _, m := range plan.Modules { for _, m := range plan.Modules {
if m.Keeps == "" { if m.Keeps == "" {
continue continue
} }
operator, err := inv.OperatorKey(ctx) if kept, err = inv.OperatorExport(ctx); err != nil {
if err != nil {
return nil, err 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 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" + 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>]" "secret export [--out <file>]"
// secretRecover is break-glass: a secret opened with the operator's key, written to a 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`") 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") 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") 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 { if err := set.Parse(flags); err != nil {
return err return err
} }
@@ -118,7 +119,7 @@ func secretRecover(ctx context.Context, args []string) error {
var kept inventory.Kept var kept inventory.Kept
if *fromExport != "" { if *fromExport != "" {
kept, err = keptFromExport(*fromExport, node, module, name) kept, err = keptFromExport(*fromExport, node, module, name, *provider)
if err != nil { if err != nil {
return err return err
} }
@@ -128,7 +129,7 @@ func secretRecover(ctx context.Context, args []string) error {
return err return err
} }
defer open.Close() 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 { if err != nil {
return err return err
} }
@@ -147,10 +148,7 @@ func secretRecover(ctx context.Context, args []string) error {
if path == "" { if path == "" {
path = node + "." + module + "." + name + ".secret" path = node + "." + module + "." + name + ".secret"
} }
if _, err := os.Stat(path); err == nil { if err := writeNew(path, value); err != nil {
return fmt.Errorf("%s already exists; not overwriting it", path)
}
if err := os.WriteFile(path, value, 0o600); err != nil {
return err return err
} }
fmt.Printf("%s on %s: %q recovered to %s (0600) — %d bytes, origin %s\n", 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 == "" { 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") 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 { if err != nil {
return err return err
} }
body, err := json.MarshalIndent(export{ body, err := json.MarshalIndent(doc, "", " ")
Export: 1, OperatorKey: key, Fingerprint: secrets.Fingerprint(key),
Kept: kept, Unrecoverable: unrecoverable,
}, "", " ")
if err != nil { if err != nil {
return err return err
} }
@@ -197,18 +192,58 @@ func secretExport(ctx context.Context, args []string) error {
_, err := os.Stdout.Write(body) _, err := os.Stdout.Write(body)
return err 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 return err
} }
fmt.Printf("%d secret(s) exported to %s (0600), sealed to operator key %s — ciphertext, keep it with the key\n", 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)) len(doc.Kept), *out, doc.Fingerprint)
if len(unrecoverable) > 0 { if len(doc.EarlierKey) > 0 {
fmt.Printf(" %d secret(s) are NOT in it: made before the mesh had an operator key\n", len(unrecoverable)) 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 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) raw, err := os.ReadFile(path)
if err != nil { if err != nil {
return inventory.Kept{}, err 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 { if err := json.Unmarshal(raw, &e); err != nil {
return inventory.Kept{}, fmt.Errorf("%s is not a secret export: %w", path, err) return inventory.Kept{}, fmt.Errorf("%s is not a secret export: %w", path, err)
} }
for _, k := range e.Kept { // Sealed to the current key or to an earlier one: both are copies the given key might open,
if k.Node == node && k.Module == module && k.Name == name { // and Open says which. Not the unrecoverable list, which holds no copy at all.
return k, nil 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. // split separates what this command is about from how it was asked.
+7 -3
View File
@@ -763,10 +763,14 @@ type Kept struct {
// KeptExport is what a person keeps beside the operator key, and what a vault keeps on its disk: // KeptExport is what a person keeps beside the operator key, and what a vault keeps on its disk:
// every operator-sealed copy, and the honest list of what has none. // every operator-sealed copy, and the honest list of what has none.
type KeptExport struct { type KeptExport struct {
Export int `json:"export"` Export int `json:"export"`
OperatorKey string `json:"operator-key"` OperatorKey string `json:"operator-key"`
Fingerprint string `json:"fingerprint"` Fingerprint string `json:"fingerprint"`
// Kept is sealed to OperatorKey. EarlierKey is sealed to a key the mesh has since replaced —
// recoverable with that key, if the person still has it, and with nothing else. Unrecoverable
// has no operator copy at all.
Kept []Kept `json:"kept"` Kept []Kept `json:"kept"`
EarlierKey []Kept `json:"sealed-to-earlier-key,omitempty"`
Unrecoverable []Kept `json:"unrecoverable,omitempty"` Unrecoverable []Kept `json:"unrecoverable,omitempty"`
} }
+108 -19
View File
@@ -4,10 +4,12 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"strings"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/novox/mesh-controller/internal/catalogue" "github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/secrets"
) )
// The operator's sealing key: the one holder of secrets that is not a node. // The operator's sealing key: the one holder of secrets that is not a node.
@@ -18,6 +20,29 @@ import (
// What is recorded here is the public half, which is all the mesh needs to seal to it; what it // What is recorded here is the public half, which is all the mesh needs to seal to it; what it
// yields is one more blob per secret that the mesh cannot open. // yields is one more blob per secret that the mesh cannot open.
// operatorColumns is the pair of nullable columns a secret row carries for its operator copy:
// both null when the mesh has no operator key, so a row says plainly that no such copy exists.
func operatorColumns(operator, blob string) (sealed, key *string) {
if operator == "" || blob == "" {
return nil, nil
}
return &blob, &operator
}
// operatorSeal seals a value somebody supplied to the operator key, when the mesh has one.
func (i *Inventory) operatorSeal(ctx context.Context, value string) (sealed, key *string, err error) {
operator, err := i.OperatorKey(ctx)
if err != nil || operator == "" {
return nil, nil, err
}
blob, err := secrets.Seal(operator, []byte(value))
if err != nil {
return nil, nil, err
}
sealed, key = operatorColumns(operator, blob)
return sealed, key, nil
}
// OperatorKey is the public key secrets are also sealed to, or empty when the mesh has none. // OperatorKey is the public key secrets are also sealed to, or empty when the mesh has none.
func (i *Inventory) OperatorKey(ctx context.Context) (string, error) { func (i *Inventory) OperatorKey(ctx context.Context) (string, error) {
var key string var key string
@@ -44,9 +69,12 @@ func (i *Inventory) SetOperatorKey(ctx context.Context, public string) (orphaned
return 0, err return 0, err
} }
defer tx.Rollback(ctx) defer tx.Rollback(ctx)
// Both tables: a module's own secrets and the pair credentials. A count over one of them said
// "nothing orphaned" about a mesh whose every vault-provided secret had just been.
if err := tx.QueryRow(ctx, if err := tx.QueryRow(ctx,
`select count(*) from module_secret `select (select count(*) from module_secret where operator_key is not null and operator_key <> $1)
where operator_key is not null and operator_key <> $1`, public).Scan(&orphaned); err != nil { + (select count(*) from secret where operator_key is not null and operator_key <> $1)`,
public).Scan(&orphaned); err != nil {
return 0, err return 0, err
} }
if _, err := tx.Exec(ctx, `delete from operator_key where public <> $1`, public); err != nil { if _, err := tx.Exec(ctx, `delete from operator_key where public <> $1`, public); err != nil {
@@ -62,12 +90,38 @@ func (i *Inventory) SetOperatorKey(ctx context.Context, public string) (orphaned
// Kept is the catalogue's: one secret as the operator can recover it. // Kept is the catalogue's: one secret as the operator can recover it.
type Kept = catalogue.Kept type Kept = catalogue.Kept
// KeptForOperator is every secret the operator can recover, and which cannot. // OperatorExport is the export as the operator and the vault both keep it: every secret sealed to
// the mesh's current operator key, every one sealed to an earlier key (recoverable with that key,
// if the person still has it), and every one with no operator copy at all. Nil when the mesh has
// no operator key. One constructor, so the file `secret export` writes and the file the mesh puts
// on the vault's disk cannot drift apart.
func (i *Inventory) OperatorExport(ctx context.Context) (*catalogue.KeptExport, error) {
operator, err := i.OperatorKey(ctx)
if err != nil || operator == "" {
return nil, err
}
kept, earlier, unrecoverable, err := i.KeptForOperator(ctx)
if err != nil {
return nil, err
}
return &catalogue.KeptExport{
Export: 1, OperatorKey: operator, Fingerprint: secrets.Fingerprint(operator),
Kept: kept, EarlierKey: earlier, Unrecoverable: unrecoverable,
}, nil
}
// KeptForOperator is every secret by what can open it: the mesh's current operator key, an
// earlier operator key, or nothing.
// //
// The second list is the honest half: a secret minted before the mesh had an operator key has no // The last two are the honest half. A secret minted before the mesh had an operator key has no
// operator-sealed copy and cannot get one — the plaintext was discarded. Naming those is what lets // operator-sealed copy and cannot get one — the plaintext was discarded; one sealed to a key the
// an export say what it does not cover, rather than being taken for complete. // mesh has since replaced is not opened by the current key, however the export is labelled. Naming
func (i *Inventory) KeptForOperator(ctx context.Context) (kept []Kept, unrecoverable []Kept, err error) { // both is what lets an export say what it does not cover, rather than being taken for complete.
func (i *Inventory) KeptForOperator(ctx context.Context) (kept, earlier, unrecoverable []Kept, err error) {
current, err := i.OperatorKey(ctx)
if err != nil {
return nil, nil, nil, err
}
rows, err := i.store.Pool().Query(ctx, rows, err := i.store.Pool().Query(ctx,
`select 'own', n.name, s.module, s.name, '', s.origin, coalesce(s.operator_sealed, ''), `select 'own', n.name, s.module, s.name, '', s.origin, coalesce(s.operator_sealed, ''),
coalesce(s.operator_key, ''), s.made_at coalesce(s.operator_key, ''), s.made_at
@@ -78,27 +132,34 @@ func (i *Inventory) KeptForOperator(ctx context.Context) (kept []Kept, unrecover
from secret s join node c on c.id = s.consumer join node p on p.id = s.provider from secret s join node c on c.id = s.consumer join node p on p.id = s.provider
order by 1, 2, 3, 4`) order by 1, 2, 3, 4`)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, nil, err
} }
defer rows.Close() defer rows.Close()
for rows.Next() { for rows.Next() {
var k Kept var k Kept
if err := rows.Scan(&k.Kind, &k.Node, &k.Module, &k.Name, &k.Provider, &k.Origin, &k.Sealed, &k.Key, &k.MadeAt); err != nil { if err := rows.Scan(&k.Kind, &k.Node, &k.Module, &k.Name, &k.Provider, &k.Origin, &k.Sealed, &k.Key, &k.MadeAt); err != nil {
return nil, nil, err return nil, nil, nil, err
} }
if k.Sealed == "" { switch {
case k.Sealed == "":
unrecoverable = append(unrecoverable, k) unrecoverable = append(unrecoverable, k)
continue case k.Key != current:
earlier = append(earlier, k)
default:
kept = append(kept, k)
} }
kept = append(kept, k)
} }
return kept, unrecoverable, rows.Err() return kept, earlier, unrecoverable, rows.Err()
} }
// KeptSecret is one secret's operator-sealed copy, for recovery. // KeptSecret is one secret's operator-sealed copy, for recovery.
func (i *Inventory) KeptSecret(ctx context.Context, node, module, name string) (Kept, error) { //
// An own secret first, then a pair credential by the provision's name. A module whose own // An own secret first, then a pair credential by the provision's name — a module whose own secret
// secret and requirement share a name is refused at resolution, so the two cannot both answer. // and requirement share a name is refused at resolution, so the two cannot both answer. A pair
// credential is keyed by provider as well, and a consumer whose provision moved leaves the old
// provider's row behind: two rows is refused with both providers named, never answered with
// whichever came first, unless `provider` says which.
func (i *Inventory) KeptSecret(ctx context.Context, node, module, name, provider string) (Kept, error) {
var k Kept var k Kept
err := i.store.Pool().QueryRow(ctx, err := i.store.Pool().QueryRow(ctx,
`select 'own', n.name, s.module, s.name, '', s.origin, coalesce(s.operator_sealed, ''), `select 'own', n.name, s.module, s.name, '', s.origin, coalesce(s.operator_sealed, ''),
@@ -107,12 +168,40 @@ func (i *Inventory) KeptSecret(ctx context.Context, node, module, name string) (
where n.name = $1 and s.module = $2 and s.name = $3`, node, module, name). where n.name = $1 and s.module = $2 and s.name = $3`, node, module, name).
Scan(&k.Kind, &k.Node, &k.Module, &k.Name, &k.Provider, &k.Origin, &k.Sealed, &k.Key, &k.MadeAt) Scan(&k.Kind, &k.Node, &k.Module, &k.Name, &k.Provider, &k.Origin, &k.Sealed, &k.Key, &k.MadeAt)
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
err = i.store.Pool().QueryRow(ctx, rows, qerr := i.store.Pool().Query(ctx,
`select 'pair', c.name, s.consumer_module, s.name, p.name, 'made', coalesce(s.operator_sealed, ''), `select 'pair', c.name, s.consumer_module, s.name, p.name, 'made', coalesce(s.operator_sealed, ''),
coalesce(s.operator_key, ''), s.created_at coalesce(s.operator_key, ''), s.created_at
from secret s join node c on c.id = s.consumer join node p on p.id = s.provider from secret s join node c on c.id = s.consumer join node p on p.id = s.provider
where c.name = $1 and s.consumer_module = $2 and s.name = $3`, node, module, name). where c.name = $1 and s.consumer_module = $2 and s.name = $3 and ($4 = '' or p.name = $4)
Scan(&k.Kind, &k.Node, &k.Module, &k.Name, &k.Provider, &k.Origin, &k.Sealed, &k.Key, &k.MadeAt) order by p.name`, node, module, name, provider)
if qerr != nil {
return Kept{}, qerr
}
defer rows.Close()
var found []Kept
for rows.Next() {
var row Kept
if err := rows.Scan(&row.Kind, &row.Node, &row.Module, &row.Name, &row.Provider, &row.Origin, &row.Sealed, &row.Key, &row.MadeAt); err != nil {
return Kept{}, err
}
found = append(found, row)
}
if err := rows.Err(); err != nil {
return Kept{}, err
}
switch len(found) {
case 0:
err = pgx.ErrNoRows
case 1:
k, err = found[0], nil
default:
providers := make([]string, 0, len(found))
for _, f := range found {
providers = append(providers, f.Provider)
}
return Kept{}, fmt.Errorf("%s on %s holds a %q credential from more than one provider (%s); say which with --provider",
module, node, name, strings.Join(providers, ", "))
}
} }
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return Kept{}, fmt.Errorf("%s on %s holds nothing called %q — neither a secret of its own nor a credential for a provision", module, node, name) return Kept{}, fmt.Errorf("%s on %s holds nothing called %q — neither a secret of its own nor a credential for a provision", module, node, name)
+74 -24
View File
@@ -2,6 +2,7 @@ package inventory
import ( import (
"context" "context"
"strings"
"testing" "testing"
"github.com/novox/mesh-controller/internal/catalogue" "github.com/novox/mesh-controller/internal/catalogue"
@@ -20,10 +21,10 @@ func TestAnOwnSecretIsSealedToTheOperatorToo(t *testing.T) {
if _, err := inv.SecretForModule(ctx, "consumer", "postgres", "superuser"); err != nil { if _, err := inv.SecretForModule(ctx, "consumer", "postgres", "superuser"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := inv.KeptSecret(ctx, "consumer", "postgres", "superuser"); err == nil { if _, err := inv.KeptSecret(ctx, "consumer", "postgres", "superuser", ""); err == nil {
t.Fatal("a secret made before the operator key was reported recoverable") t.Fatal("a secret made before the operator key was reported recoverable")
} }
kept, unrecoverable, err := inv.KeptForOperator(ctx) kept, _, unrecoverable, err := inv.KeptForOperator(ctx)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -46,14 +47,14 @@ func TestAnOwnSecretIsSealedToTheOperatorToo(t *testing.T) {
if err := inv.AcceptSecretForModule(ctx, "provider", "postgres", "replication", "given-by-a-person"); err != nil { if err := inv.AcceptSecretForModule(ctx, "provider", "postgres", "replication", "given-by-a-person"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
kept, unrecoverable, err = inv.KeptForOperator(ctx) kept, _, unrecoverable, err = inv.KeptForOperator(ctx)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(kept) != 2 || len(unrecoverable) != 1 { if len(kept) != 2 || len(unrecoverable) != 1 {
t.Fatalf("after a key: %d kept, %d unrecoverable", len(kept), len(unrecoverable)) t.Fatalf("after a key: %d kept, %d unrecoverable", len(kept), len(unrecoverable))
} }
got, err := inv.KeptSecret(ctx, "provider", "postgres", "replication") got, err := inv.KeptSecret(ctx, "provider", "postgres", "replication", "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -67,7 +68,7 @@ func TestAnOwnSecretIsSealedToTheOperatorToo(t *testing.T) {
if got.Origin != "accepted" || got.Key != pub { if got.Origin != "accepted" || got.Key != pub {
t.Fatalf("kept as %+v", got) t.Fatalf("kept as %+v", got)
} }
minted, err := inv.KeptSecret(ctx, "provider", "postgres", "superuser") minted, err := inv.KeptSecret(ctx, "provider", "postgres", "superuser", "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -75,26 +76,59 @@ func TestAnOwnSecretIsSealedToTheOperatorToo(t *testing.T) {
t.Fatalf("the minted secret did not open to a 40-character value: %v", err) t.Fatalf("the minted secret did not open to a 40-character value: %v", err)
} }
// The old secret, remade for a rejoined node, becomes recoverable — it was issued again. // The old secret is kept, not resealed: asking again is a read, the plaintext is gone, and it
// stays honestly unrecoverable.
if _, err := inv.SecretForModule(ctx, "consumer", "postgres", "superuser"); err != nil { if _, err := inv.SecretForModule(ctx, "consumer", "postgres", "superuser"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := inv.KeptSecret(ctx, "consumer", "postgres", "superuser"); err == nil { if _, err := inv.KeptSecret(ctx, "consumer", "postgres", "superuser", ""); err == nil {
t.Fatal("asking again did not remake, yet it became recoverable") t.Fatal("asking again did not remake, yet it became recoverable")
} }
// Until the node rejoins with a new sealing key: then the secret is remade, and the remake is
// sealed to the operator — the one scenario the vault exists for.
rejoined, err := inv.NodeByName(ctx, "consumer")
if err != nil {
t.Fatal(err)
}
newKey, _ := aSealingKey(t)
if err := inv.RecordSealingKey(ctx, rejoined.ID, newKey); err != nil {
t.Fatal(err)
}
if _, err := inv.SecretForModule(ctx, "consumer", "postgres", "superuser"); err != nil {
t.Fatal(err)
}
remade, err := inv.KeptSecret(ctx, "consumer", "postgres", "superuser", "")
if err != nil {
t.Fatalf("the remade secret is not recoverable: %v", err)
}
if _, err := secrets.Open(priv, remade.Sealed); err != nil {
t.Fatal(err)
}
// Replacing the key says how many secrets stay sealed to the old one. // Replacing the key says how many secrets stay sealed to the old one — and those move out of
// the recoverable list, whatever the export is labelled with.
pub2, _, _ := secrets.Keypair() pub2, _, _ := secrets.Keypair()
orphaned, err := inv.SetOperatorKey(ctx, pub2) orphaned, err := inv.SetOperatorKey(ctx, pub2)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if orphaned != 2 { if orphaned != 3 {
t.Fatalf("replacing the key orphaned %d, and two were sealed to it", orphaned) t.Fatalf("replacing the key orphaned %d, and three were sealed to it", orphaned)
} }
if now, _ := inv.OperatorKey(ctx); now != pub2 { if now, _ := inv.OperatorKey(ctx); now != pub2 {
t.Fatal("the new key is not the mesh's key") t.Fatal("the new key is not the mesh's key")
} }
kept, earlier, _, err := inv.KeptForOperator(ctx)
if err != nil {
t.Fatal(err)
}
if len(kept) != 0 || len(earlier) != 3 {
t.Fatalf("after replacing the key: %d recoverable with it, %d sealed to the earlier key", len(kept), len(earlier))
}
doc, err := inv.OperatorExport(ctx)
if err != nil || doc == nil || len(doc.EarlierKey) != 3 || len(doc.Kept) != 0 {
t.Fatalf("the export does not say what the current key cannot open: %+v %v", doc, err)
}
} }
// A pair credential — what the vault provides a module — is sealed to the operator too, and the // A pair credential — what the vault provides a module — is sealed to the operator too, and the
@@ -134,13 +168,42 @@ func TestAPairCredentialIsSealedToTheOperatorToo(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
kept, err := inv.KeptSecret(ctx, "consumer", "gitea", "secret") kept, err := inv.KeptSecret(ctx, "consumer", "gitea", "secret", "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if kept.Kind != "pair" || kept.Provider != "provider" { if kept.Kind != "pair" || kept.Provider != "provider" {
t.Fatalf("kept as %+v", kept) t.Fatalf("kept as %+v", kept)
} }
all, _, _, err := inv.KeptForOperator(ctx)
if err != nil {
t.Fatal(err)
}
var pairs int
for _, k := range all {
if k.Kind == "pair" {
pairs++
}
}
if pairs != 1 {
t.Fatalf("%d pair credential(s) recoverable, expected 1", pairs)
}
// A second provider of the same provision: two rows, refused rather than the first one taken,
// unless the provider is named. And replacing the key counts pair credentials as orphaned.
if _, err := inv.SecretFor(ctx, "secret", "consumer", "gitea", "consumer"); err != nil {
t.Fatal(err)
}
if _, err := inv.KeptSecret(ctx, "consumer", "gitea", "secret", ""); err == nil || !strings.Contains(err.Error(), "--provider") {
t.Fatalf("two providers were not refused: %v", err)
}
if byName, err := inv.KeptSecret(ctx, "consumer", "gitea", "secret", "provider"); err != nil || byName.Provider != "provider" {
t.Fatalf("naming the provider did not select it: %+v %v", byName, err)
}
pub2, _, _ := secrets.Keypair()
if orphaned, err := inv.SetOperatorKey(ctx, pub2); err != nil || orphaned != 2 {
t.Fatalf("replacing the key orphaned %d pair credential(s), and two were sealed to it (%v)", orphaned, err)
}
fromOperator, err := secrets.Open(priv, kept.Sealed) fromOperator, err := secrets.Open(priv, kept.Sealed)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -153,17 +216,4 @@ func TestAPairCredentialIsSealedToTheOperatorToo(t *testing.T) {
if string(fromOperator) != string(fromNode) { if string(fromOperator) != string(fromNode) {
t.Fatal("the operator's copy of the pair credential differs from the consumer's") t.Fatal("the operator's copy of the pair credential differs from the consumer's")
} }
all, _, err := inv.KeptForOperator(ctx)
if err != nil {
t.Fatal(err)
}
var pairs int
for _, k := range all {
if k.Kind == "pair" {
pairs++
}
}
if pairs != 1 {
t.Fatalf("%d pair credential(s) in the export, expected 1", pairs)
}
} }
+7 -29
View File
@@ -80,18 +80,11 @@ func (i *Inventory) SecretFor(ctx context.Context, name, consumer, consumerModul
if err != nil { if err != nil {
return Secret{}, err return Secret{}, err
} }
var also []string made, blob, err := secrets.MakeWithOperator(consumerKey, providerKey, operator)
if operator != "" {
also = append(also, operator)
}
made, more, err := secrets.MakeAlso(consumerKey, providerKey, also...)
if err != nil { if err != nil {
return Secret{}, err return Secret{}, err
} }
var forOperator, operatorKey *string forOperator, operatorKey := operatorColumns(operator, blob)
if operator != "" {
forOperator, operatorKey = &more[0], &operator
}
_, err = i.store.Pool().Exec(ctx, _, err = i.store.Pool().Exec(ctx,
`insert into secret (name, consumer, consumer_module, provider, for_consumer, for_provider, `insert into secret (name, consumer, consumer_module, provider, for_consumer, for_provider,
consumer_key, provider_key, operator_sealed, operator_key) consumer_key, provider_key, operator_sealed, operator_key)
@@ -246,21 +239,14 @@ func (i *Inventory) SecretForModule(ctx context.Context, node, module, name stri
if err != nil { if err != nil {
return "", err return "", err
} }
var also []string
if operator != "" {
also = append(also, operator)
}
made, more, err := secrets.MakeAlso(key, key, also...)
if err != nil {
return "", err
}
// Sealed once to the machine — Make seals to two ends because a provision has two; here both // Sealed once to the machine — Make seals to two ends because a provision has two; here both
// are the same machine, and only one copy is kept — and once more to the operator when the // are the same machine, and only one copy is kept — and once more to the operator when the
// mesh has one (novox/hq ADR 0085, amended), which is the copy a person can recover from. // mesh has one (novox/hq ADR 0085, amended), which is the copy a person can recover from.
var forOperator, operatorKey *string made, blob, err := secrets.MakeWithOperator(key, key, operator)
if operator != "" { if err != nil {
forOperator, operatorKey = &more[0], &operator return "", err
} }
forOperator, operatorKey := operatorColumns(operator, blob)
if _, err := i.store.Pool().Exec(ctx, if _, err := i.store.Pool().Exec(ctx,
`insert into module_secret (node, module, name, sealed, node_key, origin, operator_sealed, operator_key) `insert into module_secret (node, module, name, sealed, node_key, origin, operator_sealed, operator_key)
values ($1, $2, $3, $4, $5, 'made', $6, $7) values ($1, $2, $3, $4, $5, 'made', $6, $7)
@@ -304,18 +290,10 @@ func (i *Inventory) AcceptSecretForModule(ctx context.Context, node, module, nam
} }
// And to the operator, when the mesh has one: a value a person supplied is the one a person // And to the operator, when the mesh has one: a value a person supplied is the one a person
// most needs to get back, since the mesh cannot make another (novox/hq ADR 0085, amended). // most needs to get back, since the mesh cannot make another (novox/hq ADR 0085, amended).
operator, err := i.OperatorKey(ctx) forOperator, operatorKey, err := i.operatorSeal(ctx, value)
if err != nil { if err != nil {
return err return err
} }
var forOperator, operatorKey *string
if operator != "" {
blob, err := secrets.Seal(operator, []byte(value))
if err != nil {
return err
}
forOperator, operatorKey = &blob, &operator
}
_, err = i.store.Pool().Exec(ctx, _, err = i.store.Pool().Exec(ctx,
`insert into module_secret (node, module, name, sealed, node_key, origin, operator_sealed, operator_key) `insert into module_secret (node, module, name, sealed, node_key, origin, operator_sealed, operator_key)
values ($1, $2, $3, $4, $5, 'accepted', $6, $7) values ($1, $2, $3, $4, $5, 'accepted', $6, $7)
+8 -5
View File
@@ -12,18 +12,21 @@ func TestAThirdRecipientOpensWithItsOwnKeyOnly(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
sealed, more, err := MakeAlso(nodePub, nodePub, opPub) sealed, forOperator, err := MakeWithOperator(nodePub, nodePub, opPub)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(more) != 1 { if forOperator == "" {
t.Fatalf("%d extra blobs for one extra key", len(more)) t.Fatal("no blob for the operator")
}
if _, none, err := MakeWithOperator(nodePub, nodePub, ""); err != nil || none != "" {
t.Fatalf("no operator key, yet a blob %q (%v)", none, err)
} }
fromNode, err := Open(nodePriv, sealed.ForConsumer) fromNode, err := Open(nodePriv, sealed.ForConsumer)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
fromOperator, err := Open(opPriv, more[0]) fromOperator, err := Open(opPriv, forOperator)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -33,7 +36,7 @@ func TestAThirdRecipientOpensWithItsOwnKeyOnly(t *testing.T) {
if len(fromNode) != 40 { if len(fromNode) != 40 {
t.Fatalf("a minted value is %d characters, not 40", len(fromNode)) t.Fatalf("a minted value is %d characters, not 40", len(fromNode))
} }
if _, err := Open(nodePriv, more[0]); err == nil { if _, err := Open(nodePriv, forOperator); err == nil {
t.Fatal("the node's key opened the operator's blob") t.Fatal("the node's key opened the operator's blob")
} }
if _, err := Open(opPriv, sealed.ForConsumer); err == nil { if _, err := Open(opPriv, sealed.ForConsumer); err == nil {
+17 -19
View File
@@ -51,22 +51,22 @@ type Sealed struct {
// rather than reading the old one back — the only version of rotation that is honest about what // rather than reading the old one back — the only version of rotation that is honest about what
// the mesh knows. // the mesh knows.
func Make(consumerKey, providerKey string) (Sealed, error) { func Make(consumerKey, providerKey string) (Sealed, error) {
sealed, _, err := MakeAlso(consumerKey, providerKey) sealed, _, err := MakeWithOperator(consumerKey, providerKey, "")
return sealed, err return sealed, err
} }
// MakeAlso is Make with further recipients: the same fresh value, sealed once more to each key in // MakeWithOperator is Make with a third recipient: the same fresh value, sealed once more to the
// `also`, returned in that order. // operator's key, returned beside the two node blobs — or "" when the mesh has no operator key.
// //
// **For the operator key, and nothing else so far** (novox/hq ADR 0085, amended). A secret a module // The operator is the one holder that is not a node (novox/hq ADR 0085, amended): a person with a
// holds for itself is sealed to its node and, when the mesh has an operator key, to that as well — // key that never entered the mesh, who can recover a secret when the node cannot. The plaintext
// so a person holding the key can recover it when the node cannot. The plaintext still exists only // still exists only inside this call; a third blob is one more thing the mesh cannot open, not one
// inside this call; a third blob is one more thing the mesh cannot open, not one more copy it can. // more copy it can.
func MakeAlso(consumerKey, providerKey string, also ...string) (Sealed, []string, error) { func MakeWithOperator(consumerKey, providerKey, operatorKey string) (Sealed, string, error) {
if consumerKey == "" || providerKey == "" { if consumerKey == "" || providerKey == "" {
// Sealing to an empty key would produce a blob nobody can open, stored as though it were // Sealing to an empty key would produce a blob nobody can open, stored as though it were
// a working credential. The caller knows which node is which and says so. // a working credential. The caller knows which node is which and says so.
return Sealed{}, nil, fmt.Errorf("both ends need a sealing key before a secret can be made") return Sealed{}, "", fmt.Errorf("both ends need a sealing key before a secret can be made")
} }
// 30 bytes, not 32: base64url of 30 is exactly 40 characters, and 40 is the longest secret an // 30 bytes, not 32: base64url of 30 is exactly 40 characters, and 40 is the longest secret an
@@ -74,7 +74,7 @@ func MakeAlso(consumerKey, providerKey string, also ...string) (Sealed, []string
// "fit the tightest backend" rule ADR 0049 sets for the login, on the secret. 240 bits is ample. // "fit the tightest backend" rule ADR 0049 sets for the login, on the secret. 240 bits is ample.
value := make([]byte, 30) value := make([]byte, 30)
if _, err := rand.Read(value); err != nil { if _, err := rand.Read(value); err != nil {
return Sealed{}, nil, err return Sealed{}, "", err
} }
// Base64 without padding, because it lands in a configuration file something else parses and // Base64 without padding, because it lands in a configuration file something else parses and
// a password containing a newline or a quote is a support call. // a password containing a newline or a quote is a support call.
@@ -82,24 +82,22 @@ func MakeAlso(consumerKey, providerKey string, also ...string) (Sealed, []string
forConsumer, err := Seal(consumerKey, []byte(password)) forConsumer, err := Seal(consumerKey, []byte(password))
if err != nil { if err != nil {
return Sealed{}, nil, err return Sealed{}, "", err
} }
forProvider, err := Seal(providerKey, []byte(password)) forProvider, err := Seal(providerKey, []byte(password))
if err != nil { if err != nil {
return Sealed{}, nil, err return Sealed{}, "", err
} }
more := make([]string, 0, len(also)) var forOperator string
for _, key := range also { if operatorKey != "" {
blob, err := Seal(key, []byte(password)) if forOperator, err = Seal(operatorKey, []byte(password)); err != nil {
if err != nil { return Sealed{}, "", err
return Sealed{}, nil, err
} }
more = append(more, blob)
} }
return Sealed{ return Sealed{
ForConsumer: forConsumer, ForProvider: forProvider, ForConsumer: forConsumer, ForProvider: forProvider,
ConsumerKey: consumerKey, ProviderKey: providerKey, ConsumerKey: consumerKey, ProviderKey: providerKey,
}, more, nil }, forOperator, nil
} }
// Accept seals a value somebody supplied, rather than one the mesh made. // Accept seals a value somebody supplied, rather than one the mesh made.