Merge pull request 'A provider with one credential shares it with every consumer, remade for all at once (ADR 0158)' (#184) from feat/0158-a-provider-with-one-credential-shares-it into main

This commit was merged in pull request #184.
This commit is contained in:
2026-10-01 10:27:08 +00:00
9 changed files with 541 additions and 15 deletions
+28 -1
View File
@@ -163,7 +163,14 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso
} }
continue 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 { if err != nil {
// Said rather than skipped. A machine that resolves cleanly and receives no // 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 // 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 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 ""
}
+15 -2
View File
@@ -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", 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()) name, module, node, time.Now().UTC().Format(time.RFC3339), whoAsked())
// 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) fmt.Printf("sending %s, so %s starts again on the new value:\n", node, module)
if err := sendTo(ctx, open, []string{node}); err != nil { } 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 "+ 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 return nil
} }
+59 -6
View File
@@ -127,6 +127,38 @@ type Offer struct {
Name string `json:"name"` Name string `json:"name"`
// Scope defaults to the node, which is where most things must be to be usable. // Scope defaults to the node, which is where most things must be to be usable.
Scope string `json:"scope,omitempty"` 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. // At is this offer's scope, with the default applied.
@@ -147,24 +179,28 @@ func (o *Offer) UnmarshalJSON(raw []byte) error {
var full struct { var full struct {
Name string `json:"name"` Name string `json:"name"`
Scope string `json:"scope,omitempty"` Scope string `json:"scope,omitempty"`
Credential *OfferCredential `json:"credential,omitempty"`
} }
if err := json.Unmarshal(raw, &full); err != nil { dec := json.NewDecoder(bytes.NewReader(raw))
return fmt.Errorf("a provided name is either a string or {name, scope}: %w", err) 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 return nil
} }
// MarshalJSON writes back the short form when there is nothing else to say, so a manifest that // 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. // went through the mesh comes out looking like the one that went in.
func (o Offer) MarshalJSON() ([]byte, error) { func (o Offer) MarshalJSON() ([]byte, error) {
if o.Scope == "" { if o.Scope == "" && o.Credential == nil {
return json.Marshal(o.Name) return json.Marshal(o.Name)
} }
return json.Marshal(struct { return json.Marshal(struct {
Name string `json:"name"` Name string `json:"name"`
Scope string `json:"scope"` Scope string `json:"scope,omitempty"`
}{o.Name, o.Scope}) Credential *OfferCredential `json:"credential,omitempty"`
}{o.Name, o.Scope, o.Credential})
} }
// Manifest is everything a module says about itself. // Manifest is everything a module says about itself.
@@ -1142,6 +1178,23 @@ func ParseManifest(raw []byte) (Manifest, error) {
if !name.MatchString(p) { if !name.MatchString(p) {
problems = append(problems, fmt.Sprintf("%q is not a usable name to provide", 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 { if instead, generic := engineGeneric[p]; generic {
// A consumer is written against an engine, not a role (novox/hq ADR 0027). Providing // 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 // the role means a requirement for it matches any engine, resolves as satisfied, and
+25 -2
View File
@@ -179,6 +179,10 @@ type Needed struct {
// for one requirement (ADR 0094); empty for the ordinary one. Part of what identifies the pair // 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. // credential, so two secrets from one provider to one module are two secrets.
Local string 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 // 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 // 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 // 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{ needs = append(needs, Needed{
Name: want, From: node.Name, At: at, 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 { } else if served := servedHere(catalogue, chosen, want); len(served) > 0 {
// Answered here with no credential to mint, but the provider serves facts the // 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. // 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)) node.Name, want, p.Node, meshNetwork))
return 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, 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 { switch {
case world.Unchecked: 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 // 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 // manifest alone, which is why walking the catalogue mid-resolution is enough here and is not
// enough for the values. // 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 { func servedHere(catalogue map[string]Manifest, chosen map[string]bool, want string) map[string]any {
for name, m := range catalogue { for name, m := range catalogue {
if !chosen[name] { if !chosen[name] {
@@ -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)
}
}
}
@@ -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;
+212
View File
@@ -592,6 +592,10 @@ func (i *Inventory) RotateModuleSecret(ctx context.Context, node, module, name s
"%s %s %s` with the new value", "%s %s %s` with the new value",
module, node, name, node, module, name)} 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) operator, err := i.OperatorKey(ctx)
if err != nil { if err != nil {
return err 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) record.ID, module, name, made.ForConsumer, key, forOperator, operatorKey)
return err 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
}
+91
View File
@@ -8,6 +8,7 @@ import (
"errors" "errors"
"github.com/novox/mesh-controller/internal/catalogue" "github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/secrets" "github.com/novox/mesh-controller/internal/secrets"
"reflect"
"strings" "strings"
"testing" "testing"
@@ -843,3 +844,93 @@ func TestAnOwnSecretRotatesOnlyWhenTheModuleReadsItAtStart(t *testing.T) {
t.Fatalf("an undeclared secret: %v", err) 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)
}
}
+20
View File
@@ -45,6 +45,26 @@ type Sealed struct {
ProviderKey string 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. // 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 // The plaintext exists for the length of this call. Rotation is therefore generating a new one