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 }