diff --git a/cmd/mesh-controller/secret.go b/cmd/mesh-controller/secret.go index a8eb726..295baaf 100644 --- a/cmd/mesh-controller/secret.go +++ b/cmd/mesh-controller/secret.go @@ -49,6 +49,9 @@ func secretCommand(ctx context.Context, args []string) error { set := flag.NewFlagSet("secret accept", flag.ContinueOnError) from := set.String("from", "", "read the value from this file instead of asking (use - for standard input)") + provider := set.String("provider", "", + "the node providing : the value becomes the PAIR credential between on "+ + "and that provider, sealed to both — the vault's operator-delivered secret (ADR 0092)") if err := set.Parse(flags); err != nil { return err } @@ -72,6 +75,18 @@ func secretCommand(ctx context.Context, args []string) error { } defer open.Close() + if *provider != "" { + // Into the pair, not into the module's own secrets: what the provider is asked to create + // and what the consumer reads are the same value, and neither end can be told a different + // one later without the other (novox/hq 04-ISSUES/070). + if err := open.inventory.AcceptSecretForPair(ctx, name, node, module, *provider, value); err != nil { + return err + } + fmt.Printf("%s on %s now holds %q from %s, sealed to both machines.\n", module, node, name, *provider) + fmt.Printf(" the mesh cannot read it back, will not replace it with one of its own, and will not rotate it\n") + fmt.Printf(" run `push %s` and `push %s` to send it\n", *provider, node) + return nil + } if err := open.inventory.AcceptSecretForModule(ctx, node, module, name, value); err != nil { return err } @@ -83,7 +98,7 @@ func secretCommand(ctx context.Context, args []string) error { return nil } -const secretUsage = "secret accept [--from ]\n" + +const secretUsage = "secret accept [--from ] [--provider ]\n" + "secret recover --key [--out ] [--from-export ] [--provider ]\n" + "secret export [--out ]" diff --git a/internal/inventory/migrations/0026-a-pair-credential-says-where-it-came-from.sql b/internal/inventory/migrations/0026-a-pair-credential-says-where-it-came-from.sql new file mode 100644 index 0000000..5d75b2b --- /dev/null +++ b/internal/inventory/migrations/0026-a-pair-credential-says-where-it-came-from.sql @@ -0,0 +1,14 @@ +-- A pair credential records whether the mesh made it or a person supplied it +-- (novox/hq 04-ISSUES/070, ADR 0092). +-- +-- Every pair credential so far was made: generated, sealed to both ends, the plaintext discarded, +-- remade whenever either end's key changed and replaced whole by `rotate`. A module's own secret +-- has carried `origin` since the beginning so an accepted one is never replaced by a minted one; +-- a pair could not be accepted at all, so the vault's third species -- a credential for something +-- outside the mesh, which only a person can supply -- had no entry. +-- +-- An accepted pair is not remade when a key changes (the mesh cannot: it does not hold the +-- value) and is not rotated (there is nothing to rotate to); both are refused aloud, and the +-- remedy is to accept it again. + +alter table secret add column origin text not null default 'made'; diff --git a/internal/inventory/secrets.go b/internal/inventory/secrets.go index 9f7caf7..f605070 100644 --- a/internal/inventory/secrets.go +++ b/internal/inventory/secrets.go @@ -29,8 +29,17 @@ type Secret struct { ForProvider string ConsumerKey string ProviderKey string + // Origin is `made` — the mesh generated it — or `accepted` — a person supplied it, for + // something outside the mesh, and the mesh cannot make another (novox/hq 04-ISSUES/070). + Origin string } +// Where a pair credential came from. +const ( + OriginMade = "made" + OriginAccepted = "accepted" +) + // SecretFor is the credential one module uses for one provision, making it the first time. // // **Made once and kept**, rather than regenerated whenever it is asked for. A secret that changed @@ -64,15 +73,26 @@ func (i *Inventory) SecretFor(ctx context.Context, name, consumer, consumerModul var held Secret err = i.store.Pool().QueryRow(ctx, - `select for_consumer, for_provider, consumer_key, provider_key from secret + `select for_consumer, for_provider, consumer_key, provider_key, origin from secret where name = $1 and consumer = $2 and consumer_module = $3 and provider = $4`, name, consumerNode.ID, consumerModule, providerNode.ID). - Scan(&held.ForConsumer, &held.ForProvider, &held.ConsumerKey, &held.ProviderKey) + Scan(&held.ForConsumer, &held.ForProvider, &held.ConsumerKey, &held.ProviderKey, &held.Origin) if err == nil && held.ConsumerKey == consumerKey && held.ProviderKey == providerKey { held.Name, held.Consumer, held.Provider = name, consumer, provider held.ConsumerModule = consumerModule return held, nil } + if err == nil && held.Origin == OriginAccepted { + // A person supplied this, and the mesh does not hold the value: it cannot seal it to the + // new key. Refused aloud rather than replaced by something the mesh made up, which would + // be delivered, reported as applied, and fail to authenticate somewhere else entirely + // (novox/hq 04-ISSUES/070). + return Secret{}, fmt.Errorf( + "%s's %q credential from %s was accepted from a person, and a sealing key at one end "+ + "has changed since. The mesh cannot re-seal a value it does not hold: accept it "+ + "again with `secret accept %s %s %s --provider %s`", + consumerModule, name, provider, consumer, consumerModule, name, provider) + } // And to the operator, when the mesh has one (novox/hq ADR 0085, amended): the third copy that // makes a vault-provided secret recoverable, and nothing the mesh can open. @@ -102,7 +122,60 @@ func (i *Inventory) SecretFor(ctx context.Context, name, consumer, consumerModul return Secret{Name: name, Consumer: consumer, ConsumerModule: consumerModule, Provider: provider, ForConsumer: made.ForConsumer, ForProvider: made.ForProvider, - ConsumerKey: made.ConsumerKey, ProviderKey: made.ProviderKey}, nil + ConsumerKey: made.ConsumerKey, ProviderKey: made.ProviderKey, Origin: OriginMade}, nil +} + +// AcceptSecretForPair takes a value a person supplied into a pair credential — sealed to the +// consumer's node and to the provider's, and to the operator when the mesh has one — where the +// mesh would otherwise have made one (novox/hq 04-ISSUES/070, ADR 0092). +// +// This is the vault's third species: a credential for something outside the mesh, which only a +// person can supply. It is the counterpart to AcceptSecretForModule for a module's own secret; +// what differs is that both ends of the pair are sealed to, and that the record says `accepted` +// so a later read never replaces it with a minted one. The plaintext is discarded here. +func (i *Inventory) AcceptSecretForPair(ctx context.Context, name, consumer, consumerModule, provider, value string) error { + consumerKey, err := i.SealingKeyOf(ctx, consumer) + if err != nil { + return err + } + providerKey, err := i.SealingKeyOf(ctx, provider) + if err != nil { + return err + } + if consumerKey == "" || providerKey == "" { + return fmt.Errorf( + "both %s and %s need a sealing key before a credential can be sealed to them — a "+ + "node joins to get one", consumer, provider) + } + consumerNode, err := i.NodeByName(ctx, consumer) + if err != nil { + return err + } + providerNode, err := i.NodeByName(ctx, provider) + if err != nil { + return err + } + sealed, err := secrets.Accept(value, consumerKey, providerKey) + if err != nil { + return err + } + forOperator, operatorKey, err := i.operatorSeal(ctx, value) + if err != nil { + return err + } + _, 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, origin) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + on conflict (name, consumer, consumer_module, provider) do update set + for_consumer = excluded.for_consumer, for_provider = excluded.for_provider, + consumer_key = excluded.consumer_key, provider_key = excluded.provider_key, + created_at = now(), origin = excluded.origin, + operator_sealed = excluded.operator_sealed, operator_key = excluded.operator_key`, + name, consumerNode.ID, consumerModule, providerNode.ID, + sealed.ForConsumer, sealed.ForProvider, sealed.ConsumerKey, sealed.ProviderKey, + forOperator, operatorKey, OriginAccepted) + return err } // RotateSecret discards what was there, so the next declaration carries a new one. @@ -113,6 +186,10 @@ func (i *Inventory) SecretFor(ctx context.Context, name, consumer, consumerModul // // The new secret then reaches both ends on the same push, together, which is what makes rotation // a single event rather than a fanout with a window where half the mesh holds a dead credential. +// +// **An accepted credential is not rotated.** The mesh did not make it and cannot make its +// replacement; deleting it would have the next read mint one, which is exactly the wrong value +// delivered with the mesh insisting it was (novox/hq 04-ISSUES/070). Refused, and the remedy named. func (i *Inventory) RotateSecret(ctx context.Context, name, consumer, consumerModule, provider string) error { consumerNode, err := i.NodeByName(ctx, consumer) if err != nil { @@ -122,6 +199,18 @@ func (i *Inventory) RotateSecret(ctx context.Context, name, consumer, consumerMo if err != nil { return err } + var origin string + err = i.store.Pool().QueryRow(ctx, + `select origin from secret where name = $1 and consumer = $2 and consumer_module = $3 + and provider = $4`, + name, consumerNode.ID, consumerModule, providerNode.ID).Scan(&origin) + if err == nil && origin == OriginAccepted { + return fmt.Errorf( + "%s's %q credential from %s was accepted from a person, and the mesh cannot make "+ + "its replacement. Accept the new value instead: `secret accept %s %s %s "+ + "--provider %s --from `", + consumerModule, name, provider, consumer, consumerModule, name, provider) + } _, err = i.store.Pool().Exec(ctx, `delete from secret where name = $1 and consumer = $2 and consumer_module = $3 and provider = $4`, diff --git a/internal/inventory/secrets_test.go b/internal/inventory/secrets_test.go index 9a92f07..07c6a46 100644 --- a/internal/inventory/secrets_test.go +++ b/internal/inventory/secrets_test.go @@ -558,3 +558,69 @@ func TestRotatingGivesBothEndsTheSameNewCredential(t *testing.T) { t.Fatal("rotating one machine's credential changed another machine's") } } + +// **A person can deliver a pair credential** (novox/hq 04-ISSUES/070, ADR 0092): the vault's +// third species, a value for something outside the mesh. It is sealed to both ends like a made +// one; what differs is that the mesh will neither replace it with one of its own nor rotate it, +// because it cannot make the replacement. +func TestAnAcceptedPairCredentialIsKeptAndNeverRemade(t *testing.T) { + inv, ctx := twoNodesWithKeys(t) + if err := inv.AcceptSecretForPair(ctx, "secret", "consumer", "gitea", "provider", "hunter2"); err != nil { + t.Fatal(err) + } + got, err := inv.SecretFor(ctx, "secret", "consumer", "gitea", "provider") + if err != nil { + t.Fatal(err) + } + if got.Origin != OriginAccepted { + t.Fatalf("an accepted credential reads back as %q", got.Origin) + } + again, err := inv.SecretFor(ctx, "secret", "consumer", "gitea", "provider") + if err != nil { + t.Fatal(err) + } + if again.ForConsumer != got.ForConsumer || again.ForProvider != got.ForProvider { + t.Fatal("reading an accepted credential twice produced two different values") + } + + // Rotation is refused, and says what to do instead. + err = inv.RotateSecret(ctx, "secret", "consumer", "gitea", "provider") + if err == nil || !strings.Contains(err.Error(), "secret accept") { + t.Fatalf("rotating an accepted credential was not refused with the remedy: %v", err) + } + // And a made one still rotates. + if _, err := inv.SecretFor(ctx, "postgres-database", "consumer", "gitea", "provider"); err != nil { + t.Fatal(err) + } + if err := inv.RotateSecret(ctx, "postgres-database", "consumer", "gitea", "provider"); err != nil { + t.Fatalf("a made credential no longer rotates: %v", err) + } +} + +// A key that changed at either end makes the accepted value unreadable there, and the mesh cannot +// re-seal what it does not hold: refused aloud, never quietly replaced by a minted one. +func TestAnAcceptedPairCredentialIsNotRemadeWhenAKeyChanges(t *testing.T) { + inv, ctx := twoNodesWithKeys(t) + if err := inv.AcceptSecretForPair(ctx, "secret", "consumer", "gitea", "provider", "hunter2"); err != nil { + t.Fatal(err) + } + node, err := inv.NodeByName(ctx, "consumer") + if err != nil { + t.Fatal(err) + } + fresh, _ := aSealingKey(t) + if err := inv.RecordSealingKey(ctx, node.ID, fresh); err != nil { + t.Fatal(err) + } + _, err = inv.SecretFor(ctx, "secret", "consumer", "gitea", "provider") + if err == nil || !strings.Contains(err.Error(), "accept it again") { + t.Fatalf("an accepted credential was remade, or refused without the remedy: %v", err) + } + // Accepting it again is the remedy, and it works. + if err := inv.AcceptSecretForPair(ctx, "secret", "consumer", "gitea", "provider", "hunter3"); err != nil { + t.Fatal(err) + } + if _, err := inv.SecretFor(ctx, "secret", "consumer", "gitea", "provider"); err != nil { + t.Fatal(err) + } +}