A pair credential is sealed to the operator key too
The secret the vault provides a module is the credential of the consumer↔vault pair, and so is every credential a provider grants; sealing only own secrets to the operator left exactly those unrecoverable. Same column, same call; the export and `secret recover` address a pair by consumer node, module and the provision's name, and say which kind each entry is.
This commit is contained in:
@@ -739,10 +739,19 @@ func receivedFile(requirement, path string, given []Contribution) (map[string]an
|
|||||||
|
|
||||||
// Kept is one secret as the operator can recover it: where it belongs, and the value sealed to the
|
// Kept is one secret as the operator can recover it: where it belongs, and the value sealed to the
|
||||||
// operator's key. Never a node's blob, and never a value.
|
// operator's key. Never a node's blob, and never a value.
|
||||||
|
//
|
||||||
|
// Two kinds, addressed the same way — by the node and module that hold it and the name they know
|
||||||
|
// it by. An `own` secret is one a module has for itself; a `pair` secret is a credential the
|
||||||
|
// module was granted for a provision it requires (`name` is the provision), and Provider says which
|
||||||
|
// node grants it.
|
||||||
type Kept struct {
|
type Kept struct {
|
||||||
Node string `json:"node"`
|
Node string `json:"node"`
|
||||||
Module string `json:"module"`
|
Module string `json:"module"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
// Kind is `own` or `pair`.
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
// Provider is the node granting a pair credential; empty for an own secret.
|
||||||
|
Provider string `json:"provider,omitempty"`
|
||||||
// Origin is `made` or `accepted` — whether the mesh minted it or a person supplied it.
|
// Origin is `made` or `accepted` — whether the mesh minted it or a person supplied it.
|
||||||
Origin string `json:"origin"`
|
Origin string `json:"origin"`
|
||||||
// Sealed is the value, sealed to the operator key named in Key.
|
// Sealed is the value, sealed to the operator key named in Key.
|
||||||
|
|||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
-- The same second seal for a pair credential (novox/hq ADR 0085, amended).
|
||||||
|
--
|
||||||
|
-- 0023 gave a module's own secrets an operator-sealed copy. A secret the vault provides to a module
|
||||||
|
-- is not an own secret -- it is the credential of the consumer-vault pair -- and so were the
|
||||||
|
-- credentials every provider grants. Without this, a vault-provided password could be rotated and
|
||||||
|
-- audited but not recovered, which is a vault that keeps everything except what it was for.
|
||||||
|
|
||||||
|
alter table secret
|
||||||
|
add column operator_sealed text,
|
||||||
|
add column operator_key text;
|
||||||
@@ -69,17 +69,21 @@ type Kept = catalogue.Kept
|
|||||||
// an export say what it does not cover, rather than being taken for complete.
|
// an export say what it does not cover, rather than being taken for complete.
|
||||||
func (i *Inventory) KeptForOperator(ctx context.Context) (kept []Kept, unrecoverable []Kept, err error) {
|
func (i *Inventory) KeptForOperator(ctx context.Context) (kept []Kept, unrecoverable []Kept, err error) {
|
||||||
rows, err := i.store.Pool().Query(ctx,
|
rows, err := i.store.Pool().Query(ctx,
|
||||||
`select n.name, s.module, s.name, s.origin, coalesce(s.operator_sealed, ''),
|
`select 'own', n.name, s.module, s.name, '', s.origin, coalesce(s.operator_sealed, ''),
|
||||||
coalesce(s.operator_key, ''), s.made_at
|
coalesce(s.operator_key, ''), s.made_at
|
||||||
from module_secret s join node n on n.id = s.node
|
from module_secret s join node n on n.id = s.node
|
||||||
order by n.name, s.module, s.name`)
|
union all
|
||||||
|
select 'pair', c.name, s.consumer_module, s.name, p.name, 'made', coalesce(s.operator_sealed, ''),
|
||||||
|
coalesce(s.operator_key, ''), s.created_at
|
||||||
|
from secret s join node c on c.id = s.consumer join node p on p.id = s.provider
|
||||||
|
order by 1, 2, 3, 4`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var k Kept
|
var k Kept
|
||||||
if err := rows.Scan(&k.Node, &k.Module, &k.Name, &k.Origin, &k.Sealed, &k.Key, &k.MadeAt); err != nil {
|
if err := rows.Scan(&k.Kind, &k.Node, &k.Module, &k.Name, &k.Provider, &k.Origin, &k.Sealed, &k.Key, &k.MadeAt); err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
if k.Sealed == "" {
|
if k.Sealed == "" {
|
||||||
@@ -93,15 +97,25 @@ func (i *Inventory) KeptForOperator(ctx context.Context) (kept []Kept, unrecover
|
|||||||
|
|
||||||
// KeptSecret is one secret's operator-sealed copy, for recovery.
|
// KeptSecret is one secret's operator-sealed copy, for recovery.
|
||||||
func (i *Inventory) KeptSecret(ctx context.Context, node, module, name string) (Kept, error) {
|
func (i *Inventory) KeptSecret(ctx context.Context, node, module, name string) (Kept, error) {
|
||||||
|
// An own secret first, then a pair credential by the provision's name. A module whose own
|
||||||
|
// secret and requirement share a name is refused at resolution, so the two cannot both answer.
|
||||||
var k Kept
|
var k Kept
|
||||||
err := i.store.Pool().QueryRow(ctx,
|
err := i.store.Pool().QueryRow(ctx,
|
||||||
`select n.name, s.module, s.name, s.origin, coalesce(s.operator_sealed, ''),
|
`select 'own', n.name, s.module, s.name, '', s.origin, coalesce(s.operator_sealed, ''),
|
||||||
coalesce(s.operator_key, ''), s.made_at
|
coalesce(s.operator_key, ''), s.made_at
|
||||||
from module_secret s join node n on n.id = s.node
|
from module_secret s join node n on n.id = s.node
|
||||||
where n.name = $1 and s.module = $2 and s.name = $3`, node, module, name).
|
where n.name = $1 and s.module = $2 and s.name = $3`, node, module, name).
|
||||||
Scan(&k.Node, &k.Module, &k.Name, &k.Origin, &k.Sealed, &k.Key, &k.MadeAt)
|
Scan(&k.Kind, &k.Node, &k.Module, &k.Name, &k.Provider, &k.Origin, &k.Sealed, &k.Key, &k.MadeAt)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return Kept{}, fmt.Errorf("%s on %s holds nothing called %q", module, node, name)
|
err = i.store.Pool().QueryRow(ctx,
|
||||||
|
`select 'pair', c.name, s.consumer_module, s.name, p.name, 'made', coalesce(s.operator_sealed, ''),
|
||||||
|
coalesce(s.operator_key, ''), s.created_at
|
||||||
|
from secret s join node c on c.id = s.consumer join node p on p.id = s.provider
|
||||||
|
where c.name = $1 and s.consumer_module = $2 and s.name = $3`, node, module, name).
|
||||||
|
Scan(&k.Kind, &k.Node, &k.Module, &k.Name, &k.Provider, &k.Origin, &k.Sealed, &k.Key, &k.MadeAt)
|
||||||
|
}
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return Kept{}, fmt.Errorf("%s on %s holds nothing called %q — neither a secret of its own nor a credential for a provision", module, node, name)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Kept{}, err
|
return Kept{}, err
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package inventory
|
package inventory
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
@@ -95,3 +96,74 @@ func TestAnOwnSecretIsSealedToTheOperatorToo(t *testing.T) {
|
|||||||
t.Fatal("the new key is not the mesh's key")
|
t.Fatal("the new key is not the mesh's key")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A pair credential — what the vault provides a module — is sealed to the operator too, and the
|
||||||
|
// operator's copy is the very value the consumer's node unseals.
|
||||||
|
func TestAPairCredentialIsSealedToTheOperatorToo(t *testing.T) {
|
||||||
|
inv := fresh(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
// Two nodes with keys, keeping the consumer's opener: the test made the key, so it can play the
|
||||||
|
// consumer's host for one assertion.
|
||||||
|
var openAsConsumer func(string) ([]byte, bool)
|
||||||
|
for _, n := range []string{"consumer", "provider"} {
|
||||||
|
node, err := inv.AddNode(ctx, n)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
key, open := aSealingKey(t)
|
||||||
|
if n == "consumer" {
|
||||||
|
openAsConsumer = open
|
||||||
|
}
|
||||||
|
if err := inv.RecordSealingKey(ctx, node.ID, key); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, m := range []string{"gitea", "mesh-vault"} {
|
||||||
|
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: m, Version: "1"}, Source{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub, priv, err := secrets.Keypair()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := inv.SetOperatorKey(ctx, pub); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
made, err := inv.SecretFor(ctx, "secret", "consumer", "gitea", "provider")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
kept, err := inv.KeptSecret(ctx, "consumer", "gitea", "secret")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if kept.Kind != "pair" || kept.Provider != "provider" {
|
||||||
|
t.Fatalf("kept as %+v", kept)
|
||||||
|
}
|
||||||
|
fromOperator, err := secrets.Open(priv, kept.Sealed)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// The consumer's blob is sealed to the consumer node. Same value, two recipients.
|
||||||
|
fromNode, ok := openAsConsumer(made.ForConsumer)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("the consumer cannot open its own blob")
|
||||||
|
}
|
||||||
|
if string(fromOperator) != string(fromNode) {
|
||||||
|
t.Fatal("the operator's copy of the pair credential differs from the consumer's")
|
||||||
|
}
|
||||||
|
all, _, err := inv.KeptForOperator(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var pairs int
|
||||||
|
for _, k := range all {
|
||||||
|
if k.Kind == "pair" {
|
||||||
|
pairs++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pairs != 1 {
|
||||||
|
t.Fatalf("%d pair credential(s) in the export, expected 1", pairs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -74,20 +74,35 @@ func (i *Inventory) SecretFor(ctx context.Context, name, consumer, consumerModul
|
|||||||
return held, nil
|
return held, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
made, err := secrets.Make(consumerKey, providerKey)
|
// And to the operator, when the mesh has one (novox/hq ADR 0085, amended): the third copy that
|
||||||
|
// makes a vault-provided secret recoverable, and nothing the mesh can open.
|
||||||
|
operator, err := i.OperatorKey(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Secret{}, err
|
return Secret{}, err
|
||||||
}
|
}
|
||||||
|
var also []string
|
||||||
|
if operator != "" {
|
||||||
|
also = append(also, operator)
|
||||||
|
}
|
||||||
|
made, more, err := secrets.MakeAlso(consumerKey, providerKey, also...)
|
||||||
|
if err != nil {
|
||||||
|
return Secret{}, err
|
||||||
|
}
|
||||||
|
var forOperator, operatorKey *string
|
||||||
|
if operator != "" {
|
||||||
|
forOperator, operatorKey = &more[0], &operator
|
||||||
|
}
|
||||||
_, err = i.store.Pool().Exec(ctx,
|
_, err = i.store.Pool().Exec(ctx,
|
||||||
`insert into secret (name, consumer, consumer_module, provider, for_consumer, for_provider,
|
`insert into secret (name, consumer, consumer_module, provider, for_consumer, for_provider,
|
||||||
consumer_key, provider_key)
|
consumer_key, provider_key, operator_sealed, operator_key)
|
||||||
values ($1, $2, $3, $4, $5, $6, $7, $8)
|
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
on conflict (name, consumer, consumer_module, provider) do update set
|
on conflict (name, consumer, consumer_module, provider) do update set
|
||||||
for_consumer = excluded.for_consumer, for_provider = excluded.for_provider,
|
for_consumer = excluded.for_consumer, for_provider = excluded.for_provider,
|
||||||
consumer_key = excluded.consumer_key, provider_key = excluded.provider_key,
|
consumer_key = excluded.consumer_key, provider_key = excluded.provider_key,
|
||||||
created_at = now()`,
|
created_at = now(),
|
||||||
|
operator_sealed = excluded.operator_sealed, operator_key = excluded.operator_key`,
|
||||||
name, consumerNode.ID, consumerModule, providerNode.ID,
|
name, consumerNode.ID, consumerModule, providerNode.ID,
|
||||||
made.ForConsumer, made.ForProvider, made.ConsumerKey, made.ProviderKey)
|
made.ForConsumer, made.ForProvider, made.ConsumerKey, made.ProviderKey, forOperator, operatorKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Secret{}, err
|
return Secret{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user