diff --git a/cmd/mesh-controller/plan.go b/cmd/mesh-controller/plan.go index b228ed6..46d269b 100644 --- a/cmd/mesh-controller/plan.go +++ b/cmd/mesh-controller/plan.go @@ -163,7 +163,14 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso } continue } - secret, err := inv.SecretFor(ctx, n.Name, nodeName, n.For, n.From, n.Local) + var secret inventory.Secret + var err error + if n.SharedOwn != "" { + // The provider's one credential, sealed to this consumer too (novox/hq ADR 0158). + secret, err = inv.SharedSecretFor(ctx, n.Name, nodeName, n.For, n.From, providerModuleOf(resolved, open, ctx, n), n.Local, n.SharedOwn) + } else { + secret, err = inv.SecretFor(ctx, n.Name, nodeName, n.For, n.From, n.Local) + } if err != nil { // Said rather than skipped. A machine that resolves cleanly and receives no // credential is one that will fail to authenticate at some later, less obvious @@ -1338,3 +1345,23 @@ func foundationPortsFor(brokerPort int, modules []catalogue.Manifest) []int { } return nil } + +// providerModuleOf is which module answers a need on the providing node: the one in this node's +// own set when the provider is here, else the one the catalogue says offers it. +func providerModuleOf(resolved catalogue.Resolution, open *stores, ctx context.Context, n catalogue.Needed) string { + for _, m := range resolved.Modules { + if _, shared := m.SharedCredentialOf(n.Name); shared { + return m.Module + } + } + shelf, err := open.inventory.Catalogue(ctx) + if err != nil { + return "" + } + for name, m := range shelf { + if _, shared := m.SharedCredentialOf(n.Name); shared { + return name + } + } + return "" +} diff --git a/cmd/mesh-controller/secret.go b/cmd/mesh-controller/secret.go index d7f3064..3f5c6de 100644 --- a/cmd/mesh-controller/secret.go +++ b/cmd/mesh-controller/secret.go @@ -387,10 +387,23 @@ func secretRotate(ctx context.Context, args []string) error { } fmt.Printf("rotated %q of %s on %s at %s, asked by %s; the value is sealed and not shown\n", name, module, node, time.Now().UTC().Format(time.RFC3339), whoAsked()) - fmt.Printf("sending %s, so %s starts again on the new value:\n", node, module) - if err := sendTo(ctx, open, []string{node}); err != nil { + // A shared credential (ADR 0158) has as many holders as the provision has consumers, and all + // of them are sent in one act, so no machine is left reading a value the provider no longer takes. + machines, err := open.inventory.SharedHolders(ctx, node, module, name) + if err != nil { + return err + } + if len(machines) == 0 { + machines = []string{node} + } + if len(machines) == 1 { + fmt.Printf("sending %s, so %s starts again on the new value:\n", node, module) + } else { + fmt.Printf("shared with every consumer; sending %s together:\n", strings.Join(machines, ", ")) + } + if err := sendTo(ctx, open, machines); err != nil { return fmt.Errorf("%w\n\nThe new value is sealed and not yet delivered; what runs keeps the old "+ - "one until the machine next applies. Fix the cause and run `push %s`", err, node) + "one until the machines next apply. Fix the cause and run `push --behind`", err) } return nil } diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index a14c453..60e926f 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -127,6 +127,38 @@ type Offer struct { Name string `json:"name"` // Scope defaults to the node, which is where most things must be to be usable. Scope string `json:"scope,omitempty"` + // Credential, when set, says this provision's credential is one of the provider's own secrets, + // shared by every consumer (novox/hq ADR 0158): software that holds one password or one key + // cannot give each consumer a login of its own. The named secret must say how it is taken. + Credential *OfferCredential `json:"credential,omitempty"` +} + +// OfferCredential names which of the provider's own secrets a provision's consumers receive. +type OfferCredential struct { + Own string `json:"own"` +} + +// SharedCredentialOf is the own secret an offer of this module names as the provision's credential, +// and whether it names one. +func (m Manifest) SharedCredentialOf(provision string) (string, bool) { + for _, o := range m.Provides { + if o.Name == provision && o.Credential != nil && o.Credential.Own != "" { + return o.Credential.Own, true + } + } + return "", false +} + +// ProvisionsSharing is every provision of this module whose credential is the named own secret. +func (m Manifest) ProvisionsSharing(own string) []string { + var out []string + for _, o := range m.Provides { + if o.Credential != nil && o.Credential.Own == own { + out = append(out, o.Name) + } + } + sort.Strings(out) + return out } // At is this offer's scope, with the default applied. @@ -145,26 +177,30 @@ func (o *Offer) UnmarshalJSON(raw []byte) error { return nil } var full struct { - Name string `json:"name"` - Scope string `json:"scope,omitempty"` + Name string `json:"name"` + Scope string `json:"scope,omitempty"` + Credential *OfferCredential `json:"credential,omitempty"` } - if err := json.Unmarshal(raw, &full); err != nil { - return fmt.Errorf("a provided name is either a string or {name, scope}: %w", err) + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&full); err != nil { + return fmt.Errorf("a provided name is either a string or {name, scope, credential}: %w", err) } - o.Name, o.Scope = full.Name, full.Scope + o.Name, o.Scope, o.Credential = full.Name, full.Scope, full.Credential return nil } // MarshalJSON writes back the short form when there is nothing else to say, so a manifest that // went through the mesh comes out looking like the one that went in. func (o Offer) MarshalJSON() ([]byte, error) { - if o.Scope == "" { + if o.Scope == "" && o.Credential == nil { return json.Marshal(o.Name) } return json.Marshal(struct { - Name string `json:"name"` - Scope string `json:"scope"` - }{o.Name, o.Scope}) + Name string `json:"name"` + Scope string `json:"scope,omitempty"` + Credential *OfferCredential `json:"credential,omitempty"` + }{o.Name, o.Scope, o.Credential}) } // Manifest is everything a module says about itself. @@ -1142,6 +1178,23 @@ func ParseManifest(raw []byte) (Manifest, error) { if !name.MatchString(p) { problems = append(problems, fmt.Sprintf("%q is not a usable name to provide", p)) } + if offer.Credential != nil { + own, declared := m.OwnSecrets[offer.Credential.Own] + switch { + case offer.Credential.Own == "": + problems = append(problems, fmt.Sprintf( + "%s provides %q with a credential that names no own secret", m.Module, p)) + case !declared: + problems = append(problems, fmt.Sprintf( + "%s provides %q with its own secret %q as the credential, and declares no such secret", + m.Module, p, offer.Credential.Own)) + case own.Taken == "": + problems = append(problems, fmt.Sprintf( + "%s provides %q with its own secret %q as the credential every consumer receives, so "+ + "the secret must say how the module takes it: \"taken\": \"at-start\" or \"applied\" (ADR 0158)", + m.Module, p, offer.Credential.Own)) + } + } if instead, generic := engineGeneric[p]; generic { // A consumer is written against an engine, not a role (novox/hq ADR 0027). Providing // the role means a requirement for it matches any engine, resolves as satisfied, and diff --git a/internal/catalogue/resolve.go b/internal/catalogue/resolve.go index 6bda01f..db82d16 100644 --- a/internal/catalogue/resolve.go +++ b/internal/catalogue/resolve.go @@ -179,6 +179,10 @@ type Needed struct { // for one requirement (ADR 0094); empty for the ordinary one. Part of what identifies the pair // credential, so two secrets from one provider to one module are two secrets. Local string + // SharedOwn is set when the provision's credential is one of the provider's own secrets, shared + // by every consumer (novox/hq ADR 0158): the name of that secret in the provider's definition. + // The plan mints the pair's copy from the provider's value rather than a value of its own. + SharedOwn string // Manager is set when this holder is a refreshable-grant licence's MANAGER, delivered the refresh // token rather than an access token (novox/hq ADR 0050). It changes one thing downstream: an empty // Sealed is tolerated — the manager has not adopted a refresh token yet, which is a real waiting @@ -322,7 +326,8 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world } needs = append(needs, Needed{ Name: want, From: node.Name, At: at, - Serves: servedHere(catalogue, chosen, want), For: because[want]}) + Serves: servedHere(catalogue, chosen, want), For: because[want], + SharedOwn: sharedHere(catalogue, chosen, want)}) } else if served := servedHere(catalogue, chosen, want); len(served) > 0 { // Answered here with no credential to mint, but the provider serves facts the // consumer cannot guess — a port, a model name — and so still needs a binding. @@ -369,8 +374,12 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world node.Name, want, p.Node, meshNetwork)) return } + shared := "" + if pm, known := catalogue[p.Module]; known { + shared, _ = pm.SharedCredentialOf(want) + } needs = append(needs, Needed{Name: want, From: p.Node, At: p.At, - Serves: p.Serves, For: because[want]}) + Serves: p.Serves, For: because[want], SharedOwn: shared}) } switch { case world.Unchecked: @@ -588,6 +597,20 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world // need that is never created is a binding the consumer never gets. It is right about that from the // manifest alone, which is why walking the catalogue mid-resolution is enough here and is not // enough for the values. +// sharedHere is the own secret the provider of a provision on this same machine names as its +// credential (ADR 0158), or "" when the provider gives each consumer its own. +func sharedHere(catalogue map[string]Manifest, chosen map[string]bool, want string) string { + for name, m := range catalogue { + if !chosen[name] { + continue + } + if own, shared := m.SharedCredentialOf(want); shared { + return own + } + } + return "" +} + func servedHere(catalogue map[string]Manifest, chosen map[string]bool, want string) map[string]any { for name, m := range catalogue { if !chosen[name] { diff --git a/internal/catalogue/shared_credential_test.go b/internal/catalogue/shared_credential_test.go new file mode 100644 index 0000000..1965fd9 --- /dev/null +++ b/internal/catalogue/shared_credential_test.go @@ -0,0 +1,79 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// A provider with one credential shares it (novox/hq ADR 0158): the offer names the own secret, the +// secret says how it is taken, and a consumer's need carries the name so the plan mints its copy +// from the provider's value. +func TestAnOfferMayNameAnOwnSecretAsItsCredential(t *testing.T) { + m, err := ParseManifest([]byte(`{"module":"downloader","version":"1", + "own-secrets":{"password":{"path":"/var/lib/mesh/downloader/password","taken":"at-start"}}, + "provides":[{"name":"downloader-api","credential":{"own":"password"}}], + "serves":{"downloader-api":{"port":8080,"username":"admin"}}}`)) + if err != nil { + t.Fatal(err) + } + if own, shared := m.SharedCredentialOf("downloader-api"); !shared || own != "password" { + t.Fatalf("the offer's credential was not read: %v %v", own, shared) + } + if got := m.ProvisionsSharing("password"); len(got) != 1 || got[0] != "downloader-api" { + t.Fatalf("the provisions sharing the secret: %v", got) + } + + for want, raw := range map[string]string{ + "declares no such secret": `{"module":"d","version":"1","provides":[{"name":"d-api","credential":{"own":"password"}}]}`, + "must say how the module takes it": `{"module":"d","version":"1","own-secrets":{"password":"/p"}, + "provides":[{"name":"d-api","credential":{"own":"password"}}]}`, + "names no own secret": `{"module":"d","version":"1","provides":[{"name":"d-api","credential":{"own":""}}]}`, + } { + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("expected a refusal saying %q, got %v", want, err) + } + } +} + +func sharingShelf() map[string]Manifest { + return shelf( + Manifest{Module: "downloader", Version: "1", + Provides: []Offer{{Name: "downloader-api", Scope: ScopeMesh, Credential: &OfferCredential{Own: "password"}}}, + OwnSecrets: OwnSecrets{"password": {Path: "/var/lib/mesh/downloader/password", Taken: TakenAtStart}}, + Serves: map[string]map[string]any{"downloader-api": {"port": 8080, "username": "admin"}}}, + Manifest{Module: "manager", Version: "1", Requires: []string{"downloader-api"}}, + ) +} + +func TestAConsumersNeedCarriesTheSharedSecretsName(t *testing.T) { + // On the same machine. + together, err := Resolve(sharingShelf(), []string{"downloader", "manager"}, workstation(), World{}) + if err != nil { + t.Fatal(err) + } + found := false + for _, n := range together.Needs { + if n.Name == "downloader-api" && n.For == "manager" { + found = true + if n.SharedOwn != "password" { + t.Fatalf("the need on one machine does not name the shared secret: %+v", n) + } + } + } + if !found { + t.Fatalf("the manager's need was not resolved: %+v", together.Needs) + } + // Across machines, the provider known by its module. + apart, err := Resolve(sharingShelf(), []string{"manager"}, onBoth("example.tld"), World{ + Offered: map[string][]Provider{"downloader-api": {{Node: "home-server", At: "home-server.internal", + Module: "downloader", Serves: map[string]any{"port": 8080}}}}, + }) + if err != nil { + t.Fatal(err) + } + for _, n := range apart.Needs { + if n.Name == "downloader-api" && n.SharedOwn != "password" { + t.Fatalf("the need across machines does not name the shared secret: %+v", n) + } + } +} diff --git a/internal/inventory/migrations/0049-a-shared-credential-is-one-value-sealed-many-times.sql b/internal/inventory/migrations/0049-a-shared-credential-is-one-value-sealed-many-times.sql new file mode 100644 index 0000000..c0a5cac --- /dev/null +++ b/internal/inventory/migrations/0049-a-shared-credential-is-one-value-sealed-many-times.sql @@ -0,0 +1,8 @@ +-- A provider with one credential shares it with every consumer (novox/hq ADR 0158). +-- +-- The provider's own secret and every consumer's pair row then carry one value, sealed once per +-- holder. The mesh keeps no plaintext, so it cannot tell by reading that they agree; it stamps the +-- act that made them instead. A pair row whose stamp is the own secret's was sealed from the same +-- value; one whose stamp differs, or is missing, is remade for every holder at once. +alter table module_secret add column generation text; +alter table secret add column generation text; diff --git a/internal/inventory/secrets.go b/internal/inventory/secrets.go index 56834df..e30f380 100644 --- a/internal/inventory/secrets.go +++ b/internal/inventory/secrets.go @@ -592,6 +592,10 @@ func (i *Inventory) RotateModuleSecret(ctx context.Context, node, module, name s "%s %s %s` with the new value", module, node, name, node, module, name)} } + if len(m.ProvisionsSharing(name)) > 0 { + // Shared with every consumer of those provisions (ADR 0158): one new value, sealed to all. + return i.remakeShared(ctx, record.ID, key, module, name, "", nil, "", "", "") + } operator, err := i.OperatorKey(ctx) if err != nil { return err @@ -608,3 +612,211 @@ func (i *Inventory) RotateModuleSecret(ctx context.Context, node, module, name s record.ID, module, name, made.ForConsumer, key, forOperator, operatorKey) return err } + +// SharedSecretFor is a consumer's copy of a provider's one credential (novox/hq ADR 0158): the +// provider's own secret, sealed to this consumer as a pair credential would be. +// +// **One value, many seals, made in one act.** The mesh keeps no plaintext, so a value cannot be +// sealed to a consumer that binds later; when a consumer's copy is missing or was made in a +// different act than the provider's own secret, a fresh value is made and sealed to the provider, +// to every consumer that holds the provision from this provider, to this consumer and to the +// operator — one generation, stamped on every row. Every holding machine must then be sent, which +// the plan's caller does by sending the node it was composing and `secret rotate` does for all. +// +// An accepted value is sealed to the consumers of the moment it was accepted and never remade: a +// consumer that binds later is refused with the way out, as ADR 0113 says. +func (i *Inventory) SharedSecretFor(ctx context.Context, provision, consumer, consumerModule, + provider, providerModule, local, own string) (Secret, error) { + consumerKey, err := i.SealingKeyOf(ctx, consumer) + if err != nil { + return Secret{}, err + } + consumerNode, err := i.NodeByName(ctx, consumer) + if err != nil { + return Secret{}, err + } + providerNode, err := i.NodeByName(ctx, provider) + if err != nil { + return Secret{}, err + } + providerKey, err := i.SealingKeyOf(ctx, provider) + if err != nil { + return Secret{}, err + } + if consumerKey == "" || providerKey == "" { + return Secret{}, fmt.Errorf("%s and %s both need a sealing key before %s can be shared", consumer, provider, provision) + } + + var ownGeneration, ownOrigin, ownKey *string + err = i.store.Pool().QueryRow(ctx, + `select generation, origin, node_key from module_secret where node = $1 and module = $2 and name = $3`, + providerNode.ID, providerModule, own).Scan(&ownGeneration, &ownOrigin, &ownKey) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return Secret{}, err + } + var held Secret + var pairGeneration *string + err = i.store.Pool().QueryRow(ctx, + `select for_consumer, for_provider, consumer_key, provider_key, origin, generation from secret + where name = $1 and consumer = $2 and consumer_module = $3 and provider = $4 and local = $5`, + provision, consumerNode.ID, consumerModule, providerNode.ID, local). + Scan(&held.ForConsumer, &held.ForProvider, &held.ConsumerKey, &held.ProviderKey, &held.Origin, &pairGeneration) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return Secret{}, err + } + current := ownGeneration != nil && pairGeneration != nil && *ownGeneration == *pairGeneration && + held.ConsumerKey == consumerKey && held.ProviderKey == providerKey && ownKey != nil && *ownKey == providerKey + if current { + held.Name, held.Consumer, held.Provider = provision, consumer, provider + held.ConsumerModule, held.Local = consumerModule, local + return held, nil + } + if ownOrigin != nil && *ownOrigin == OriginAccepted { + return Secret{}, fmt.Errorf( + "%s on %s needs %s from %s, whose credential is %s's own secret %q — a value given to the "+ + "mesh, which cannot seal it to a consumer that binds later (ADR 0158): `secret accept %s %s %s` "+ + "again, which seals it to every current consumer", + consumerModule, consumer, provision, provider, providerModule, own, provider, providerModule, own) + } + if err := i.remakeShared(ctx, providerNode.ID, providerKey, providerModule, own, provision, consumerNode.ID, consumerKey, consumerModule, local); err != nil { + return Secret{}, err + } + return i.SharedSecretFor(ctx, provision, consumer, consumerModule, provider, providerModule, local, own) +} + +// remakeShared makes one fresh value and seals it to the provider's own secret, to every pair row +// of the provisions sharing it, to the one consumer being added (when there is one), and to the +// operator, all under one generation. +func (i *Inventory) remakeShared(ctx context.Context, providerID any, providerKey, providerModule, own, + provision string, addConsumerID any, addConsumerKey, addConsumerModule, addLocal string) error { + m, err := i.declared(ctx, providerModule) + if err != nil { + return err + } + provisions := m.ProvisionsSharing(own) + if len(provisions) == 0 { + return fmt.Errorf("%s names no provision whose credential is its own secret %q", providerModule, own) + } + operator, err := i.OperatorKey(ctx) + if err != nil { + return err + } + value := secrets.Fresh() + generation := secrets.Stamp() + ownSealed, err := secrets.Seal(providerKey, []byte(value)) + if err != nil { + return err + } + forOperator, operatorKey := "", "" + if operator != "" { + if forOperator, err = secrets.Seal(operator, []byte(value)); err != nil { + return err + } + operatorKey = operator + } + tx, err := i.store.Pool().Begin(ctx) + if err != nil { + return err + } + defer func() { _ = tx.Rollback(ctx) }() + if _, err := tx.Exec(ctx, + `insert into module_secret (node, module, name, sealed, node_key, origin, operator_sealed, operator_key, generation) + values ($1, $2, $3, $4, $5, 'made', nullif($6,''), nullif($7,''), $8) + on conflict (node, module, name) do update set + sealed = excluded.sealed, node_key = excluded.node_key, origin = 'made', made_at = now(), + operator_sealed = excluded.operator_sealed, operator_key = excluded.operator_key, + generation = excluded.generation`, + providerID, providerModule, own, ownSealed, providerKey, forOperator, operatorKey, generation); err != nil { + return err + } + // Every consumer that already holds one of the sharing provisions from this provider. + rows, err := tx.Query(ctx, + `select s.consumer, s.consumer_module, s.local, s.name, n.sealing_key + from secret s join node n on n.id = s.consumer + where s.provider = $1 and s.name = any($2)`, providerID, provisions) + if err != nil { + return err + } + type holder struct { + consumer any + consumerModule, local, name, key string + } + var holders []holder + for rows.Next() { + var h holder + var key *string + if err := rows.Scan(&h.consumer, &h.consumerModule, &h.local, &h.name, &key); err != nil { + rows.Close() + return err + } + if key != nil { + h.key = *key + } + holders = append(holders, h) + } + rows.Close() + if addConsumerID != nil { + holders = append(holders, holder{consumer: addConsumerID, consumerModule: addConsumerModule, + local: addLocal, name: provision, key: addConsumerKey}) + } + for _, h := range holders { + if h.key == "" { + continue // a consumer whose key is gone cannot be sealed to; it is remade when it reports one + } + sealed, err := secrets.Accept(value, h.key, providerKey) + if err != nil { + return err + } + if _, err := tx.Exec(ctx, + `insert into secret (name, consumer, consumer_module, provider, for_consumer, for_provider, + consumer_key, provider_key, origin, local, generation) + values ($1, $2, $3, $4, $5, $6, $7, $8, 'made', $9, $10) + on conflict (name, local, 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, + origin = 'made', generation = excluded.generation`, + h.name, h.consumer, h.consumerModule, providerID, sealed.ForConsumer, sealed.ForProvider, + h.key, providerKey, h.local, generation); err != nil { + return err + } + } + return tx.Commit(ctx) +} + +// SharedHolders is every machine holding a copy of a provider's shared credential: the provider's +// and every consumer's, for the send that follows a rotation. +func (i *Inventory) SharedHolders(ctx context.Context, provider, providerModule, own string) ([]string, error) { + m, err := i.declared(ctx, providerModule) + if err != nil { + return nil, err + } + provisions := m.ProvisionsSharing(own) + if len(provisions) == 0 { + return nil, nil + } + providerNode, err := i.NodeByName(ctx, provider) + if err != nil { + return nil, err + } + rows, err := i.store.Pool().Query(ctx, + `select distinct n.name from secret s join node n on n.id = s.consumer + where s.provider = $1 and s.name = any($2)`, providerNode.ID, provisions) + if err != nil { + return nil, err + } + defer rows.Close() + seen := map[string]bool{provider: true} + out := []string{provider} + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + if !seen[name] { + seen[name] = true + out = append(out, name) + } + } + sort.Strings(out) + return out, nil +} diff --git a/internal/inventory/secrets_test.go b/internal/inventory/secrets_test.go index 0eb967f..d29b91c 100644 --- a/internal/inventory/secrets_test.go +++ b/internal/inventory/secrets_test.go @@ -8,6 +8,7 @@ import ( "errors" "github.com/novox/mesh-controller/internal/catalogue" "github.com/novox/mesh-controller/internal/secrets" + "reflect" "strings" "testing" @@ -843,3 +844,93 @@ func TestAnOwnSecretRotatesOnlyWhenTheModuleReadsItAtStart(t *testing.T) { t.Fatalf("an undeclared secret: %v", err) } } + +// A provider's one credential is one value sealed to every holder, remade for all at once when a +// consumer binds or a rotation is asked (novox/hq ADR 0158). +func TestASharedCredentialIsOneValueSealedToEveryHolder(t *testing.T) { + inv, ctx := twoNodesWithKeys(t) + provider := catalogue.Manifest{Module: "downloader", Version: "1", + Provides: []catalogue.Offer{{Name: "downloader-api", Scope: catalogue.ScopeMesh, Credential: &catalogue.OfferCredential{Own: "password"}}}, + OwnSecrets: catalogue.OwnSecrets{"password": {Path: "/var/lib/mesh/downloader/password", Taken: catalogue.TakenAtStart}}} + if err := inv.RegisterModule(ctx, provider, Source{}); err != nil { + t.Fatal(err) + } + for _, m := range []string{"manager", "indexer"} { + if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: m, Version: "1", Requires: []string{"downloader-api"}}, Source{}); err != nil { + t.Fatal(err) + } + } + generationOf := func() string { + var g *string + node, _ := inv.NodeByName(ctx, "provider") + if err := inv.store.Pool().QueryRow(ctx, `select generation from module_secret where node = $1 and module = 'downloader' and name = 'password'`, node.ID).Scan(&g); err != nil { + t.Fatal(err) + } + if g == nil { + t.Fatal("the provider's own secret carries no generation") + } + return *g + } + pairGeneration := func(module string) string { + var g *string + cn, _ := inv.NodeByName(ctx, "consumer") + pn, _ := inv.NodeByName(ctx, "provider") + if err := inv.store.Pool().QueryRow(ctx, `select generation from secret where name = 'downloader-api' and consumer = $1 and consumer_module = $2 and provider = $3`, cn.ID, module, pn.ID).Scan(&g); err != nil { + t.Fatal(err) + } + if g == nil { + return "" + } + return *g + } + + first, err := inv.SharedSecretFor(ctx, "downloader-api", "consumer", "manager", "provider", "downloader", "", "password") + if err != nil { + t.Fatal(err) + } + g1 := generationOf() + if pairGeneration("manager") != g1 { + t.Fatal("the consumer's copy was not sealed in the same act as the provider's own secret") + } + again, err := inv.SharedSecretFor(ctx, "downloader-api", "consumer", "manager", "provider", "downloader", "", "password") + if err != nil || again.ForConsumer != first.ForConsumer { + t.Fatalf("asking twice remade the value: %v", err) + } + + // A second consumer binding remakes the value for everyone, in one generation. + if _, err := inv.SharedSecretFor(ctx, "downloader-api", "consumer", "indexer", "provider", "downloader", "", "password"); err != nil { + t.Fatal(err) + } + g2 := generationOf() + if g2 == g1 { + t.Fatal("a new consumer did not remake the shared value") + } + if pairGeneration("manager") != g2 || pairGeneration("indexer") != g2 { + t.Fatalf("not every holder was sealed in the new act: %s %s %s", g2, pairGeneration("manager"), pairGeneration("indexer")) + } + holders, err := inv.SharedHolders(ctx, "provider", "downloader", "password") + if err != nil || !reflect.DeepEqual(holders, []string{"consumer", "provider"}) { + t.Fatalf("the holders: %v %v", holders, err) + } + + // Rotation remakes every copy. + if err := inv.RotateModuleSecret(ctx, "provider", "downloader", "password"); err != nil { + t.Fatal(err) + } + g3 := generationOf() + if g3 == g2 || pairGeneration("manager") != g3 || pairGeneration("indexer") != g3 { + t.Fatal("rotation did not remake every holder's copy") + } + + // An accepted value: sealed to the consumers of the moment, and a later consumer is refused. + if err := inv.AcceptSecretForModule(ctx, "provider", "downloader", "password", "the-real-one"); err != nil { + t.Fatal(err) + } + if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "late", Version: "1", Requires: []string{"downloader-api"}}, Source{}); err != nil { + t.Fatal(err) + } + _, err = inv.SharedSecretFor(ctx, "downloader-api", "consumer", "late", "provider", "downloader", "", "password") + if err == nil || !strings.Contains(err.Error(), "given to the mesh") { + t.Fatalf("a consumer binding after an acceptance must be refused with the way out: %v", err) + } +} diff --git a/internal/secrets/seal.go b/internal/secrets/seal.go index a26f3f5..ecdf5e7 100644 --- a/internal/secrets/seal.go +++ b/internal/secrets/seal.go @@ -45,6 +45,26 @@ type Sealed struct { ProviderKey string } +// Fresh is a new secret value, the shape Make seals: for the one caller that must seal one value +// to many holders at once (novox/hq ADR 0158) and discards it the same way. +func Fresh() string { + value := make([]byte, 30) + if _, err := rand.Read(value); err != nil { + panic("the system's random source failed: " + err.Error()) + } + return base64.RawURLEncoding.EncodeToString(value) +} + +// Stamp is a random mark for one act of sealing a value to several holders: rows carrying the same +// stamp were sealed from the same value, which the mesh cannot otherwise tell, holding no plaintext. +func Stamp() string { + mark := make([]byte, 16) + if _, err := rand.Read(mark); err != nil { + panic("the system's random source failed: " + err.Error()) + } + return hex.EncodeToString(mark) +} + // Make generates a secret and seals it to both ends, keeping no readable copy. // // The plaintext exists for the length of this call. Rotation is therefore generating a new one