Files
mesh-controller/internal/inventory/secret_move.go
T

219 lines
8.2 KiB
Go

package inventory
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"time"
"github.com/jackc/pgx/v5"
)
// The move of the controller's secrets into the vault (novox/hq ADR 0349 clause 7; design 57's V3, issue 676; the
// operator's decision of 2026-10-14 on that issue).
//
// The controller opens none of the values it holds. Each row is moved by the node-engine of a machine it was sealed
// to: a module's own secret by its machine's, a pair credential by either end's. This is what the controller reads to
// ask each machine — every row sealed to it, as the store holds it — and where it keeps what moved. Nothing here
// opens, makes or changes a value: the rows are read as they are.
// SecretToMove is one row of the controller's store as the move asks a machine to hand it in.
type SecretToMove struct {
// Secret is the vault's name for the row (VaultNameOfOwn, VaultNameOfPair).
Secret string
// Node is the machine asked: the module's machine for an own secret, the end being asked for a pair.
Node string
// Module and Name are an own secret's; empty for a pair.
Module, Name string
// Provider, Provision, Consumer, ConsumerModule and Local are a pair credential's; empty for an own secret.
Provider, Provision, Consumer, ConsumerModule, Local string
Origin string
// Generation is the controller's stamp of the act that made the value (ADR 0158, ADR 0305 §5); empty for none.
Generation string
MadeAt time.Time
// Sealed is the value sealed to Node, and SealedTo the key the row says it was sealed to.
Sealed, SealedTo string
// Current says SealedTo is Node's sealing key now: only then can its engine open it.
Current bool
// Digest is the digest of the row's sealed text as the store holds it — both ends' for a pair, so either end
// reads the same — by which a row the controller changed after it moved is told from one that did not.
Digest string
// Moved is the record of the row having moved, by either end, or nil.
Moved *SecretMoved
}
// Own says the row is a module's own secret.
func (s SecretToMove) Own() bool { return s.Module != "" }
// SealedDigest is sha256 of a row's sealed texts, each on its own line, in hex: nothing of the value is kept.
func SealedDigest(sealed ...string) string {
h := sha256.New()
for _, s := range sealed {
h.Write([]byte(s + "\n"))
}
return "sha256:" + hex.EncodeToString(h.Sum(nil))
}
// SecretMoved is a row the vault took: which machine's engine handed it in, the vault's generation, the digest of the
// sealed text it was moved from, and when.
type SecretMoved struct {
Node string
Generation int
Digest string
At time.Time
}
// VaultNameOfOwn is the vault's name for a module's own secret (mesh-vault OwnSecret).
func VaultNameOfOwn(node, module, name string) string {
return "own/" + node + "/" + module + "/" + name
}
// VaultNameOfPair is the vault's name for one row of the pair credentials (mesh-vault PairSecret): both ends, the
// consuming module, and the local name last when it has one.
func VaultNameOfPair(provider, provision, consumer, consumerModule, local string) string {
name := "pair/" + provider + "/" + provision + "/" + consumer + "/" + consumerModule
if local != "" {
name += "/" + local
}
return name
}
// SecretsToMoveFrom is every row sealed to a machine, as the store holds it: its modules' own secrets, and each pair
// credential it is an end of, sealed to it; each with whether its key is the machine's now and whether it moved.
func (i *Inventory) SecretsToMoveFrom(ctx context.Context, node string) ([]SecretToMove, error) {
record, err := i.NodeByName(ctx, node)
if err != nil {
return nil, err
}
current, err := i.SealingKeyOf(ctx, node)
if err != nil {
return nil, err
}
moved, err := i.secretsMoved(ctx)
if err != nil {
return nil, err
}
var out []SecretToMove
rows, err := i.store.Pool().Query(ctx,
`select module, name, origin, coalesce(generation, ''), made_at, sealed, node_key from module_secret
where node = $1 order by module, name`, record.ID)
if err != nil {
return nil, err
}
for rows.Next() {
s := SecretToMove{Node: node}
if err := rows.Scan(&s.Module, &s.Name, &s.Origin, &s.Generation, &s.MadeAt, &s.Sealed, &s.SealedTo); err != nil {
rows.Close()
return nil, err
}
s.Secret, s.Digest = VaultNameOfOwn(node, s.Module, s.Name), SealedDigest(s.Sealed)
out = append(out, s)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
rows, err = i.store.Pool().Query(ctx,
`select p.name, s.name, c.name, s.consumer_module, s.local, s.origin, coalesce(s.generation, ''), s.created_at,
case when s.consumer = $1 then s.for_consumer else s.for_provider end,
case when s.consumer = $1 then s.consumer_key else s.provider_key end,
s.for_consumer, s.for_provider
from secret s join node p on p.id = s.provider join node c on c.id = s.consumer
where s.consumer = $1 or s.provider = $1
order by p.name, s.name, c.name, s.consumer_module, s.local`, record.ID)
if err != nil {
return nil, err
}
for rows.Next() {
s := SecretToMove{Node: node}
var forConsumer, forProvider string
if err := rows.Scan(&s.Provider, &s.Provision, &s.Consumer, &s.ConsumerModule, &s.Local, &s.Origin,
&s.Generation, &s.MadeAt, &s.Sealed, &s.SealedTo, &forConsumer, &forProvider); err != nil {
rows.Close()
return nil, err
}
s.Digest = SealedDigest(forConsumer, forProvider)
s.Secret = VaultNameOfPair(s.Provider, s.Provision, s.Consumer, s.ConsumerModule, s.Local)
out = append(out, s)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
for n := range out {
out[n].Current = current != "" && out[n].SealedTo == current
if m, ok := moved[out[n].Secret]; ok {
out[n].Moved = &m
}
}
return out, nil
}
func (i *Inventory) secretsMoved(ctx context.Context) (map[string]SecretMoved, error) {
rows, err := i.store.Pool().Query(ctx,
`select m.secret, n.name, m.generation, m.sealed_digest, m.moved_at from secret_moved m join node n on n.id = m.node`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]SecretMoved{}
for rows.Next() {
var name string
var m SecretMoved
if err := rows.Scan(&name, &m.Node, &m.Generation, &m.Digest, &m.At); err != nil {
return nil, err
}
out[name] = m
}
return out, rows.Err()
}
// RecordSecretMoved keeps that the vault took a row, read back the same, from a machine's engine. Kept once: the
// first record of a row stands, so the other end of a pair moving it again changes nothing.
func (i *Inventory) RecordSecretMoved(ctx context.Context, s SecretToMove, generation int) error {
record, err := i.NodeByName(ctx, s.Node)
if err != nil {
return err
}
_, err = i.store.Pool().Exec(ctx,
`insert into secret_moved (secret, node, generation, sealed_digest) values ($1, $2, $3, $4)
on conflict (secret) do nothing`, s.Secret, record.ID, generation, s.Digest)
return err
}
// SecretMove is the move as started: when, by whom, and when the record of asks was copied.
type SecretMove struct {
StartedAt time.Time
StartedBy string
AsksMovedAt *time.Time
}
// StartSecretMove records that the move started, the first time; later starts keep the first. It answers the move
// as recorded.
func (i *Inventory) StartSecretMove(ctx context.Context, by string) (SecretMove, error) {
if _, err := i.store.Pool().Exec(ctx,
`insert into secret_move (started_by) values ($1) on conflict (one) do nothing`, by); err != nil {
return SecretMove{}, err
}
m, _, err := i.SecretMoveStarted(ctx)
return m, err
}
// SecretMoveStarted is the move as recorded, and whether it was started: a machine heard from is moved only then.
func (i *Inventory) SecretMoveStarted(ctx context.Context) (SecretMove, bool, error) {
var m SecretMove
err := i.store.Pool().QueryRow(ctx, `select started_at, started_by, asks_moved_at from secret_move`).
Scan(&m.StartedAt, &m.StartedBy, &m.AsksMovedAt)
if errors.Is(err, pgx.ErrNoRows) {
return SecretMove{}, false, nil
}
return m, err == nil, err
}
// RecordAsksMoved keeps that the record of asks was copied into the vault.
func (i *Inventory) RecordAsksMoved(ctx context.Context) error {
_, err := i.store.Pool().Exec(ctx, `update secret_move set asks_moved_at = now() where asks_moved_at is null`)
return err
}