A secrets object with one local name delivered no file. Two requirements could share a local name. secret recover and the export could not tell two locals apart. The recipe check missed continued lines and read heredoc bodies as bases. repo:tag@digest kept the tag in the repository. ask now publishes mandatory, so a tool nothing serves is said at once rather than after the wait.
221 lines
8.9 KiB
Go
221 lines
8.9 KiB
Go
package inventory
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"github.com/novox/mesh-controller/internal/catalogue"
|
|
"github.com/novox/mesh-controller/internal/secrets"
|
|
)
|
|
|
|
// The operator's sealing key: the one holder of secrets that is not a node.
|
|
//
|
|
// Every secret a module holds for itself is sealed to the node that uses it, and a node whose key
|
|
// is gone takes its secrets with it — the mesh's root secrets included. novox/hq ADR 0085 (amended)
|
|
// gives them a second recipient: a person, holding a key whose private half never enters the mesh.
|
|
// What is recorded here is the public half, which is all the mesh needs to seal to it; what it
|
|
// yields is one more blob per secret that the mesh cannot open.
|
|
|
|
// operatorColumns is the pair of nullable columns a secret row carries for its operator copy:
|
|
// both null when the mesh has no operator key, so a row says plainly that no such copy exists.
|
|
func operatorColumns(operator, blob string) (sealed, key *string) {
|
|
if operator == "" || blob == "" {
|
|
return nil, nil
|
|
}
|
|
return &blob, &operator
|
|
}
|
|
|
|
// operatorSeal seals a value somebody supplied to the operator key, when the mesh has one.
|
|
func (i *Inventory) operatorSeal(ctx context.Context, value string) (sealed, key *string, err error) {
|
|
operator, err := i.OperatorKey(ctx)
|
|
if err != nil || operator == "" {
|
|
return nil, nil, err
|
|
}
|
|
blob, err := secrets.Seal(operator, []byte(value))
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
sealed, key = operatorColumns(operator, blob)
|
|
return sealed, key, nil
|
|
}
|
|
|
|
// OperatorKey is the public key secrets are also sealed to, or empty when the mesh has none.
|
|
func (i *Inventory) OperatorKey(ctx context.Context) (string, error) {
|
|
var key string
|
|
err := i.store.Pool().QueryRow(ctx,
|
|
`select public from operator_key order by made_at desc limit 1`).Scan(&key)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return "", nil
|
|
}
|
|
return key, err
|
|
}
|
|
|
|
// SetOperatorKey records the operator's public key, replacing any earlier one.
|
|
//
|
|
// **Replacing is said, not silent.** Secrets sealed to the earlier key stay sealed to it: the
|
|
// plaintext is gone, so they cannot be sealed again to the new one until each is issued again. The
|
|
// number of them is returned so the caller can say so — a key swapped with nothing said would look
|
|
// like a mesh with a recovery path and be a mesh without one.
|
|
func (i *Inventory) SetOperatorKey(ctx context.Context, public string) (orphaned int, err error) {
|
|
if public == "" {
|
|
return 0, fmt.Errorf("an operator key is a public key, and this is nothing")
|
|
}
|
|
tx, err := i.store.Pool().Begin(ctx)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
// Both tables: a module's own secrets and the pair credentials. A count over one of them said
|
|
// "nothing orphaned" about a mesh whose every vault-provided secret had just been.
|
|
if err := tx.QueryRow(ctx,
|
|
`select (select count(*) from module_secret where operator_key is not null and operator_key <> $1)
|
|
+ (select count(*) from secret where operator_key is not null and operator_key <> $1)`,
|
|
public).Scan(&orphaned); err != nil {
|
|
return 0, err
|
|
}
|
|
if _, err := tx.Exec(ctx, `delete from operator_key where public <> $1`, public); err != nil {
|
|
return 0, err
|
|
}
|
|
if _, err := tx.Exec(ctx,
|
|
`insert into operator_key (public) values ($1) on conflict (public) do nothing`, public); err != nil {
|
|
return 0, err
|
|
}
|
|
return orphaned, tx.Commit(ctx)
|
|
}
|
|
|
|
// Kept is the catalogue's: one secret as the operator can recover it.
|
|
type Kept = catalogue.Kept
|
|
|
|
// OperatorExport is the export as the operator and the vault both keep it: every secret sealed to
|
|
// the mesh's current operator key, every one sealed to an earlier key (recoverable with that key,
|
|
// if the person still has it), and every one with no operator copy at all. Nil when the mesh has
|
|
// no operator key. One constructor, so the file `secret export` writes and the file the mesh puts
|
|
// on the vault's disk cannot drift apart.
|
|
func (i *Inventory) OperatorExport(ctx context.Context) (*catalogue.KeptExport, error) {
|
|
operator, err := i.OperatorKey(ctx)
|
|
if err != nil || operator == "" {
|
|
return nil, err
|
|
}
|
|
kept, earlier, unrecoverable, err := i.KeptForOperator(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &catalogue.KeptExport{
|
|
Export: 1, OperatorKey: operator, Fingerprint: secrets.Fingerprint(operator),
|
|
Kept: kept, EarlierKey: earlier, Unrecoverable: unrecoverable,
|
|
}, nil
|
|
}
|
|
|
|
// KeptForOperator is every secret by what can open it: the mesh's current operator key, an
|
|
// earlier operator key, or nothing.
|
|
//
|
|
// The last two are the honest half. A secret minted before the mesh had an operator key has no
|
|
// operator-sealed copy and cannot get one — the plaintext was discarded; one sealed to a key the
|
|
// mesh has since replaced is not opened by the current key, however the export is labelled. Naming
|
|
// both is what lets an export say what it does not cover, rather than being taken for complete.
|
|
func (i *Inventory) KeptForOperator(ctx context.Context) (kept, earlier, unrecoverable []Kept, err error) {
|
|
current, err := i.OperatorKey(ctx)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
rows, err := i.store.Pool().Query(ctx,
|
|
`select 'own', n.name, s.module, s.name, '', s.origin, coalesce(s.operator_sealed, ''),
|
|
coalesce(s.operator_key, ''), s.made_at, ''
|
|
from module_secret s join node n on n.id = s.node
|
|
union all
|
|
select 'pair', c.name, s.consumer_module, s.name, p.name, s.origin, coalesce(s.operator_sealed, ''),
|
|
coalesce(s.operator_key, ''), s.created_at, s.local
|
|
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, 10`)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var k Kept
|
|
if err := rows.Scan(&k.Kind, &k.Node, &k.Module, &k.Name, &k.Provider, &k.Origin, &k.Sealed, &k.Key, &k.MadeAt, &k.Local); err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
switch {
|
|
case k.Sealed == "":
|
|
unrecoverable = append(unrecoverable, k)
|
|
case k.Key != current:
|
|
earlier = append(earlier, k)
|
|
default:
|
|
kept = append(kept, k)
|
|
}
|
|
}
|
|
return kept, earlier, unrecoverable, rows.Err()
|
|
}
|
|
|
|
// KeptSecret is one secret's operator-sealed copy, for recovery.
|
|
//
|
|
// 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. A pair
|
|
// credential is keyed by provider as well, and a consumer whose provision moved leaves the old
|
|
// provider's row behind: two rows is refused with both providers named, never answered with
|
|
// whichever came first, unless `provider` says which.
|
|
func (i *Inventory) KeptSecret(ctx context.Context, node, module, name, provider, local string) (Kept, error) {
|
|
var k Kept
|
|
err := i.store.Pool().QueryRow(ctx,
|
|
`select 'own', n.name, s.module, s.name, '', s.origin, coalesce(s.operator_sealed, ''),
|
|
coalesce(s.operator_key, ''), s.made_at
|
|
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).
|
|
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) {
|
|
rows, qerr := i.store.Pool().Query(ctx,
|
|
`select 'pair', c.name, s.consumer_module, s.name, p.name, s.origin, coalesce(s.operator_sealed, ''),
|
|
coalesce(s.operator_key, ''), s.created_at, s.local
|
|
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 and ($4 = '' or p.name = $4)
|
|
and s.local = $5
|
|
order by p.name`, node, module, name, provider, local)
|
|
if qerr != nil {
|
|
return Kept{}, qerr
|
|
}
|
|
defer rows.Close()
|
|
var found []Kept
|
|
for rows.Next() {
|
|
var row Kept
|
|
if err := rows.Scan(&row.Kind, &row.Node, &row.Module, &row.Name, &row.Provider, &row.Origin, &row.Sealed, &row.Key, &row.MadeAt, &row.Local); err != nil {
|
|
return Kept{}, err
|
|
}
|
|
found = append(found, row)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return Kept{}, err
|
|
}
|
|
switch len(found) {
|
|
case 0:
|
|
err = pgx.ErrNoRows
|
|
case 1:
|
|
k, err = found[0], nil
|
|
default:
|
|
providers := make([]string, 0, len(found))
|
|
for _, f := range found {
|
|
providers = append(providers, f.Provider)
|
|
}
|
|
return Kept{}, fmt.Errorf("%s on %s holds a %q credential from more than one provider (%s); say which with --provider",
|
|
module, node, name, strings.Join(providers, ", "))
|
|
}
|
|
}
|
|
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 {
|
|
return Kept{}, err
|
|
}
|
|
if k.Sealed == "" {
|
|
return Kept{}, fmt.Errorf(
|
|
"%s on %s holds %q, but it was made before the mesh had an operator key and so has no "+
|
|
"copy a person can open. Issue it again (secret accept, or let the mesh remake it) "+
|
|
"and it will", module, node, name)
|
|
}
|
|
return k, nil
|
|
}
|