diff --git a/cmd/mesh-controller/operator.go b/cmd/mesh-controller/operator.go index d46d60b..af037eb 100644 --- a/cmd/mesh-controller/operator.go +++ b/cmd/mesh-controller/operator.go @@ -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) } diff --git a/cmd/mesh-controller/plan.go b/cmd/mesh-controller/plan.go index a5867b7..17eaa30 100644 --- a/cmd/mesh-controller/plan.go +++ b/cmd/mesh-controller/plan.go @@ -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 } diff --git a/cmd/mesh-controller/secret.go b/cmd/mesh-controller/secret.go index 74a5ece..a8eb726 100644 --- a/cmd/mesh-controller/secret.go +++ b/cmd/mesh-controller/secret.go @@ -84,7 +84,7 @@ func secretCommand(ctx context.Context, args []string) error { } const secretUsage = "secret accept [--from ]\n" + - "secret recover --key [--out ] [--from-export ]\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. @@ -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 ...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. diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index ed635ce..c6cf0f3 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -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: // every operator-sealed copy, and the honest list of what has none. type KeptExport struct { - Export int `json:"export"` - OperatorKey string `json:"operator-key"` - Fingerprint string `json:"fingerprint"` + Export int `json:"export"` + OperatorKey string `json:"operator-key"` + 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"` + EarlierKey []Kept `json:"sealed-to-earlier-key,omitempty"` Unrecoverable []Kept `json:"unrecoverable,omitempty"` } diff --git a/internal/inventory/operator.go b/internal/inventory/operator.go index 848b77a..7e6b0b5 100644 --- a/internal/inventory/operator.go +++ b/internal/inventory/operator.go @@ -4,10 +4,12 @@ import ( "context" "errors" "fmt" + "strings" "github.com/jackc/pgx/v5" "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. @@ -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 // 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. func (i *Inventory) OperatorKey(ctx context.Context) (string, error) { var key string @@ -44,9 +69,12 @@ func (i *Inventory) SetOperatorKey(ctx context.Context, public string) (orphaned return 0, err } 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, - `select count(*) from module_secret - where operator_key is not null and operator_key <> $1`, public).Scan(&orphaned); err != nil { + `select (select count(*) from module_secret where operator_key is not null and operator_key <> $1) + + (select count(*) from secret where operator_key is not null and operator_key <> $1)`, + public).Scan(&orphaned); err != nil { return 0, err } 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. 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 -// operator-sealed copy and cannot get one — the plaintext was discarded. Naming those 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 []Kept, unrecoverable []Kept, err error) { +// 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; one sealed to a key the +// mesh has since replaced is not opened by the current key, however the export is labelled. Naming +// 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, `select 'own', n.name, s.module, s.name, '', s.origin, coalesce(s.operator_sealed, ''), 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 order by 1, 2, 3, 4`) if err != nil { - return nil, nil, err + return nil, nil, nil, err } defer rows.Close() for rows.Next() { 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 { - return nil, nil, err + return nil, nil, nil, err } - if k.Sealed == "" { + switch { + case k.Sealed == "": 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. -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 - // secret and requirement share a name is refused at resolution, so the two cannot both answer. +// +// An own secret first, then a pair credential by the provision's name — a module whose own secret +// 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 err := i.store.Pool().QueryRow(ctx, `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). 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) { - 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, ''), 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 - where c.name = $1 and s.consumer_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) + where c.name = $1 and s.consumer_module = $2 and s.name = $3 and ($4 = '' or p.name = $4) + 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) { 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) diff --git a/internal/inventory/operator_test.go b/internal/inventory/operator_test.go index 6ead7c5..4db167a 100644 --- a/internal/inventory/operator_test.go +++ b/internal/inventory/operator_test.go @@ -2,6 +2,7 @@ package inventory import ( "context" + "strings" "testing" "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 { 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") } - kept, unrecoverable, err := inv.KeptForOperator(ctx) + kept, _, unrecoverable, err := inv.KeptForOperator(ctx) if err != nil { 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 { t.Fatal(err) } - kept, unrecoverable, err = inv.KeptForOperator(ctx) + kept, _, unrecoverable, err = inv.KeptForOperator(ctx) if err != nil { t.Fatal(err) } if len(kept) != 2 || len(unrecoverable) != 1 { 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 { t.Fatal(err) } @@ -67,7 +68,7 @@ func TestAnOwnSecretIsSealedToTheOperatorToo(t *testing.T) { if got.Origin != "accepted" || got.Key != pub { 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 { 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) } - // 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 { 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") } + // 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() orphaned, err := inv.SetOperatorKey(ctx, pub2) if err != nil { t.Fatal(err) } - if orphaned != 2 { - t.Fatalf("replacing the key orphaned %d, and two were sealed to it", orphaned) + if orphaned != 3 { + t.Fatalf("replacing the key orphaned %d, and three were sealed to it", orphaned) } if now, _ := inv.OperatorKey(ctx); now != pub2 { 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 @@ -134,13 +168,42 @@ func TestAPairCredentialIsSealedToTheOperatorToo(t *testing.T) { if err != nil { t.Fatal(err) } - kept, err := inv.KeptSecret(ctx, "consumer", "gitea", "secret") + kept, err := inv.KeptSecret(ctx, "consumer", "gitea", "secret", "") if err != nil { t.Fatal(err) } if kept.Kind != "pair" || kept.Provider != "provider" { 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) if err != nil { t.Fatal(err) @@ -153,17 +216,4 @@ func TestAPairCredentialIsSealedToTheOperatorToo(t *testing.T) { if string(fromOperator) != string(fromNode) { 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) - } } diff --git a/internal/inventory/secrets.go b/internal/inventory/secrets.go index 182f0c7..9f7caf7 100644 --- a/internal/inventory/secrets.go +++ b/internal/inventory/secrets.go @@ -80,18 +80,11 @@ func (i *Inventory) SecretFor(ctx context.Context, name, consumer, consumerModul if err != nil { return Secret{}, err } - var also []string - if operator != "" { - also = append(also, operator) - } - made, more, err := secrets.MakeAlso(consumerKey, providerKey, also...) + made, blob, err := secrets.MakeWithOperator(consumerKey, providerKey, operator) if err != nil { return Secret{}, err } - var forOperator, operatorKey *string - if operator != "" { - forOperator, operatorKey = &more[0], &operator - } + forOperator, operatorKey := operatorColumns(operator, blob) _, err = i.store.Pool().Exec(ctx, `insert into secret (name, consumer, consumer_module, provider, for_consumer, for_provider, 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 { 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 // 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. - var forOperator, operatorKey *string - if operator != "" { - forOperator, operatorKey = &more[0], &operator + made, blob, err := secrets.MakeWithOperator(key, key, operator) + if err != nil { + return "", err } + forOperator, operatorKey := operatorColumns(operator, blob) if _, err := i.store.Pool().Exec(ctx, `insert into module_secret (node, module, name, sealed, node_key, origin, operator_sealed, operator_key) 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 // 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 { 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, `insert into module_secret (node, module, name, sealed, node_key, origin, operator_sealed, operator_key) values ($1, $2, $3, $4, $5, 'accepted', $6, $7) diff --git a/internal/secrets/operator_test.go b/internal/secrets/operator_test.go index 41336e3..e6b1996 100644 --- a/internal/secrets/operator_test.go +++ b/internal/secrets/operator_test.go @@ -12,18 +12,21 @@ func TestAThirdRecipientOpensWithItsOwnKeyOnly(t *testing.T) { if err != nil { t.Fatal(err) } - sealed, more, err := MakeAlso(nodePub, nodePub, opPub) + sealed, forOperator, err := MakeWithOperator(nodePub, nodePub, opPub) if err != nil { t.Fatal(err) } - if len(more) != 1 { - t.Fatalf("%d extra blobs for one extra key", len(more)) + if forOperator == "" { + 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) if err != nil { t.Fatal(err) } - fromOperator, err := Open(opPriv, more[0]) + fromOperator, err := Open(opPriv, forOperator) if err != nil { t.Fatal(err) } @@ -33,7 +36,7 @@ func TestAThirdRecipientOpensWithItsOwnKeyOnly(t *testing.T) { if len(fromNode) != 40 { 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") } if _, err := Open(opPriv, sealed.ForConsumer); err == nil { diff --git a/internal/secrets/seal.go b/internal/secrets/seal.go index 3dff1de..a26f3f5 100644 --- a/internal/secrets/seal.go +++ b/internal/secrets/seal.go @@ -51,22 +51,22 @@ type Sealed struct { // rather than reading the old one back — the only version of rotation that is honest about what // the mesh knows. func Make(consumerKey, providerKey string) (Sealed, error) { - sealed, _, err := MakeAlso(consumerKey, providerKey) + sealed, _, err := MakeWithOperator(consumerKey, providerKey, "") return sealed, err } -// MakeAlso is Make with further recipients: the same fresh value, sealed once more to each key in -// `also`, returned in that order. +// MakeWithOperator is Make with a third recipient: the same fresh value, sealed once more to the +// 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 -// holds for itself is sealed to its node and, when the mesh has an operator key, to that as well — -// so a person holding the key can recover it when the node cannot. The plaintext still exists only -// inside this call; a third blob is one more thing the mesh cannot open, not one more copy it can. -func MakeAlso(consumerKey, providerKey string, also ...string) (Sealed, []string, error) { +// The operator is the one holder that is not a node (novox/hq ADR 0085, amended): a person with a +// key that never entered the mesh, who can recover a secret when the node cannot. The plaintext +// still exists only inside this call; a third blob is one more thing the mesh cannot open, not one +// more copy it can. +func MakeWithOperator(consumerKey, providerKey, operatorKey string) (Sealed, string, error) { if consumerKey == "" || providerKey == "" { // 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. - 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 @@ -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. value := make([]byte, 30) 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 // 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)) if err != nil { - return Sealed{}, nil, err + return Sealed{}, "", err } forProvider, err := Seal(providerKey, []byte(password)) if err != nil { - return Sealed{}, nil, err + return Sealed{}, "", err } - more := make([]string, 0, len(also)) - for _, key := range also { - blob, err := Seal(key, []byte(password)) - if err != nil { - return Sealed{}, nil, err + var forOperator string + if operatorKey != "" { + if forOperator, err = Seal(operatorKey, []byte(password)); err != nil { + return Sealed{}, "", err } - more = append(more, blob) } return Sealed{ ForConsumer: forConsumer, ForProvider: forProvider, ConsumerKey: consumerKey, ProviderKey: providerKey, - }, more, nil + }, forOperator, nil } // Accept seals a value somebody supplied, rather than one the mesh made.