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:
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user