A provider with one credential shares it with every consumer, remade for all at once (hq ADR 0158)
An offer may say `"credential": {"own": "<secret>"}`: the provider's own secret is the credential
every consumer of that provision receives, in the shape of a pair credential. The vault keeps one
value, sealed to the provider, to every consumer that holds the provision and to the operator, all
under one generation stamp; a consumer binding later, or `secret rotate` on the provider's secret,
makes a fresh value and seals it to every holder in one act, and the rotate command sends every
holding machine together. An accepted value is sealed to the consumers of the moment and never
remade: a consumer binding after it is refused with the way out (ADR 0113). The named own secret
must say how it is taken (issue 180), so the provider's start applies the file.
A need carries the shared secret's name from either side of the machine boundary; the plan mints a
consumer's copy from the provider's value. Registered manifests keep their bytes.
This commit is contained in:
+8
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user