Files
mesh-controller/internal/inventory/secrets.go
T
jschoubben 1469f5ff82 A module's own secret rotates when its definition says the module reads it at start (hq 180)
`secret rotate <node> <module> <name>` makes the secret anew the way the first mint did, seals it
to the machine and the operator, and sends the machine, so the module starts again on the new value
— said in the log with who asked and when, never the value. Only for a secret whose definition says
`"taken": "at-start"`: an own secret is now a path, or {path, taken}, and a definition that says
nothing of how a secret is taken is refused with the word to write, because a credential rotated
under software that never reads it again is worse than one left alone (issue 179). `applied` is
refused by name until the staged form ADR 0114 decided is built; a value given to the mesh is
refused as ADR 0113 says. `rotate` is a verb on the controller's seat with two shapes — a pair
credential by provision, an own secret by machine, module and name — so the console can ask.
Registered manifests keep their bytes: a path alone is written back as a path.
2026-10-01 11:41:49 +02:00

611 lines
25 KiB
Go

package inventory
import (
"context"
"errors"
"fmt"
"slices"
"sort"
"strings"
"github.com/jackc/pgx/v5"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/secrets"
)
// Where sealed secrets live.
//
// The table holds nothing usable — see the migration and internal/secrets for why that is the
// design rather than an inconvenience.
// Secret is one provision's credential, sealed to each end.
type Secret struct {
Name string
Consumer string
// ConsumerModule is which module on that machine it is for.
//
// **Part of the key, not a label** (novox/hq 04-ISSUES/022). Two modules on one node wanting
// the same provision are two consumers, and were one credential until this.
ConsumerModule string
// Local is the name the credential goes by inside the consumer where it keeps several for one
// provision (novox/hq ADR 0094); empty for the ordinary one. Part of the key.
Local string
Provider string
ForConsumer string
ForProvider string
ConsumerKey string
ProviderKey string
// Origin is `made` — the mesh generated it — or `accepted` — a person supplied it, for
// something outside the mesh, and the mesh cannot make another (novox/hq 04-ISSUES/070).
Origin string
}
// Where a pair credential came from.
const (
OriginMade = "made"
OriginAccepted = "accepted"
)
// SecretFor is the credential one module uses for one provision, making it the first time.
//
// **Made once and kept**, rather than regenerated whenever it is asked for. A secret that changed
// on every declaration would restart both ends on every push and would mean the password a
// provider was told to create never matches the one a consumer was given — which is a mesh that
// reports success and cannot connect.
//
// **Remade when either end's sealing key changes.** A node that rejoined generated a new key and
// can no longer open what was sealed to the old one, so keeping the blob would deliver something
// unreadable for ever. The new secret reaches both ends in the same push, which is the only
// moment they can be changed together.
func (i *Inventory) SecretFor(ctx context.Context, name, consumer, consumerModule, provider, local string) (
Secret, error) {
consumerKey, err := i.SealingKeyOf(ctx, consumer)
if err != nil {
return Secret{}, err
}
providerKey, err := i.SealingKeyOf(ctx, provider)
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
}
var held Secret
err = i.store.Pool().QueryRow(ctx,
`select for_consumer, for_provider, consumer_key, provider_key, origin from secret
where name = $1 and consumer = $2 and consumer_module = $3 and provider = $4 and local = $5`,
name, consumerNode.ID, consumerModule, providerNode.ID, local).
Scan(&held.ForConsumer, &held.ForProvider, &held.ConsumerKey, &held.ProviderKey, &held.Origin)
if err == nil && held.ConsumerKey == consumerKey && held.ProviderKey == providerKey {
held.Name, held.Consumer, held.Provider = name, consumer, provider
held.ConsumerModule, held.Local = consumerModule, local
return held, nil
}
if err == nil && held.Origin == OriginAccepted {
// A person supplied this, and the mesh does not hold the value: it cannot seal it to the
// new key. Refused aloud rather than replaced by something the mesh made up, which would
// be delivered, reported as applied, and fail to authenticate somewhere else entirely
// (novox/hq 04-ISSUES/070).
return Secret{}, fmt.Errorf(
"%s's %q credential from %s was accepted from a person, and a sealing key at one end "+
"has changed since. The mesh cannot re-seal a value it does not hold: accept it "+
"again with `secret accept %s %s %s --provider %s%s`",
consumerModule, name, provider, consumer, consumerModule, name, provider, localFlag(local))
}
// 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 {
return Secret{}, err
}
made, blob, err := secrets.MakeWithOperator(consumerKey, providerKey, operator)
if err != nil {
return Secret{}, err
}
forOperator, operatorKey := operatorColumns(operator, blob)
_, err = i.store.Pool().Exec(ctx,
`insert into secret (name, consumer, consumer_module, provider, for_consumer, for_provider,
consumer_key, provider_key, operator_sealed, operator_key, local)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
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,
created_at = now(),
operator_sealed = excluded.operator_sealed, operator_key = excluded.operator_key`,
name, consumerNode.ID, consumerModule, providerNode.ID,
made.ForConsumer, made.ForProvider, made.ConsumerKey, made.ProviderKey, forOperator, operatorKey, local)
if err != nil {
return Secret{}, err
}
return Secret{Name: name, Consumer: consumer, ConsumerModule: consumerModule, Local: local,
Provider: provider,
ForConsumer: made.ForConsumer, ForProvider: made.ForProvider,
ConsumerKey: made.ConsumerKey, ProviderKey: made.ProviderKey, Origin: OriginMade}, nil
}
// AcceptSecretForPair takes a value a person supplied into a pair credential — sealed to the
// consumer's node and to the provider's, and to the operator when the mesh has one — where the
// mesh would otherwise have made one (novox/hq 04-ISSUES/070, ADR 0092).
//
// This is the vault's third species: a credential for something outside the mesh, which only a
// person can supply. It is the counterpart to AcceptSecretForModule for a module's own secret;
// what differs is that both ends of the pair are sealed to, and that the record says `accepted`
// so a later read never replaces it with a minted one. The plaintext is discarded here.
func (i *Inventory) AcceptSecretForPair(ctx context.Context, name, consumer, consumerModule, provider, local, value string) error {
// Refused for a requirement the module does not have, or a local it does not keep under it
// (novox/hq 04-ISSUES/078): the credential would sit in the pair unread.
m, err := i.declared(ctx, consumerModule)
if err != nil {
return err
}
if !slices.Contains(m.Requires, name) {
return fmt.Errorf("%s does not require %q; it requires: %s", consumerModule, name, orNone(m.Requires))
}
if locals := m.SecretsMany[name]; len(locals) > 0 {
if local == "" {
return fmt.Errorf("%s keeps several secrets for %q; name one with --local: %s",
consumerModule, name, orNone(sortedNames(locals)))
}
if _, kept := locals[local]; !kept {
return fmt.Errorf("%s does not keep %q for %q; it keeps: %s",
consumerModule, local, name, orNone(sortedNames(locals)))
}
} else if m.Secrets[name] == "" {
return fmt.Errorf("%s requires %q but keeps no secret for it, so a delivered value would sit unread; "+
"it keeps secrets for: %s", consumerModule, name, orNone(sortedNames(m.Secrets)))
} else if local != "" {
return fmt.Errorf("%s keeps one secret for %q, not several; drop --local", consumerModule, name)
}
consumerKey, err := i.SealingKeyOf(ctx, consumer)
if err != nil {
return err
}
providerKey, err := i.SealingKeyOf(ctx, provider)
if err != nil {
return err
}
if consumerKey == "" || providerKey == "" {
return fmt.Errorf(
"both %s and %s need a sealing key before a credential can be sealed to them — a "+
"node joins to get one", consumer, provider)
}
consumerNode, err := i.NodeByName(ctx, consumer)
if err != nil {
return err
}
providerNode, err := i.NodeByName(ctx, provider)
if err != nil {
return err
}
sealed, err := secrets.Accept(value, consumerKey, providerKey)
if err != nil {
return err
}
forOperator, operatorKey, err := i.operatorSeal(ctx, value)
if err != nil {
return err
}
_, err = i.store.Pool().Exec(ctx,
`insert into secret (name, consumer, consumer_module, provider, for_consumer, for_provider,
consumer_key, provider_key, operator_sealed, operator_key, origin, local)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
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,
created_at = now(), origin = excluded.origin,
operator_sealed = excluded.operator_sealed, operator_key = excluded.operator_key`,
name, consumerNode.ID, consumerModule, providerNode.ID,
sealed.ForConsumer, sealed.ForProvider, sealed.ConsumerKey, sealed.ProviderKey,
forOperator, operatorKey, OriginAccepted, local)
return err
}
// RotateSecret discards what was there, so the next declaration carries a new one.
//
// Only a delete. Nothing reads the old value first, because nothing can — and making the
// replacement here rather than on the next read would be a second path to the same act, which is
// how two ends come to hold different passwords.
//
// The new secret then reaches both ends on the same push, together, which is what makes rotation
// a single event rather than a fanout with a window where half the mesh holds a dead credential.
//
// **An accepted credential is not rotated.** The mesh did not make it and cannot make its
// replacement; deleting it would have the next read mint one, which is exactly the wrong value
// delivered with the mesh insisting it was (novox/hq 04-ISSUES/070). Refused, and the remedy named.
func (i *Inventory) RotateSecret(ctx context.Context, name, consumer, consumerModule, provider, local string) error {
consumerNode, err := i.NodeByName(ctx, consumer)
if err != nil {
return err
}
providerNode, err := i.NodeByName(ctx, provider)
if err != nil {
return err
}
var origin string
err = i.store.Pool().QueryRow(ctx,
`select origin from secret where name = $1 and consumer = $2 and consumer_module = $3
and provider = $4 and local = $5`,
name, consumerNode.ID, consumerModule, providerNode.ID, local).Scan(&origin)
if err == nil && origin == OriginAccepted {
return fmt.Errorf(
"%s's %q credential from %s was accepted from a person, and the mesh cannot make "+
"its replacement. Accept the new value instead: `secret accept %s %s %s "+
"--provider %s%s --from <file>`",
consumerModule, name, provider, consumer, consumerModule, name, provider, localFlag(local))
}
_, err = i.store.Pool().Exec(ctx,
`delete from secret where name = $1 and consumer = $2 and consumer_module = $3
and provider = $4 and local = $5`,
name, consumerNode.ID, consumerModule, providerNode.ID, local)
return err
}
// SecretsFrom is every credential a provider node was issued, so it can be told what to create.
func (i *Inventory) SecretsFrom(ctx context.Context, provider string) ([]Secret, error) {
providerNode, err := i.NodeByName(ctx, provider)
if err != nil {
return nil, err
}
rows, err := i.store.Pool().Query(ctx,
`select s.name, c.name, s.consumer_module, s.local, s.for_provider from secret s
join node c on c.id = s.consumer
where s.provider = $1 order by s.name, c.name, s.consumer_module, s.local`, providerNode.ID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Secret
for rows.Next() {
s := Secret{Provider: provider}
if err := rows.Scan(&s.Name, &s.Consumer, &s.ConsumerModule, &s.Local, &s.ForProvider); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
// SecretForModule is a secret a module needs in order to be itself, on one machine.
//
// Not the credential a consumer is given: a superuser password is not *for* anybody. Made once
// and kept, because regenerating it on every declaration would change the password a running
// database has already been started with — and remade when the node's sealing key changes, for
// the same reason as everything else sealed here.
// ModuleSecretIfIssued is what a module already holds on a node, and nothing if it holds nothing.
//
// **The read half of SecretForModule**, which mints one when there is none — an insert, and a row
// lock, on a path that also serves questions. Composing a declaration to answer *is this machine
// running what I would send it* went through the minting version for every module on every node,
// so asking wrote to the database and blocked against the machine it was asking about.
//
// A module with no secret yet has never been sent one, which is the same answer the caller wanted
// anyway: this machine is not running what the mesh would send it.
func (i *Inventory) ModuleSecretIfIssued(
ctx context.Context, node, module, name string,
) (string, bool, error) {
key, err := i.SealingKeyOf(ctx, node)
if err != nil || key == "" {
return "", false, err
}
record, err := i.NodeByName(ctx, node)
if err != nil {
return "", false, err
}
var sealed, against, origin string
err = i.store.Pool().QueryRow(ctx,
`select sealed, node_key, origin from module_secret
where node = $1 and module = $2 and name = $3`,
record.ID, module, name).Scan(&sealed, &against, &origin)
if errors.Is(err, pgx.ErrNoRows) {
return "", false, nil
}
if err != nil {
return "", false, err
}
// Sealed to a key the node no longer has is not something it holds. Reported as absent rather
// than as an error: this is the read, and refusing here would make a question fail for a
// condition its writing counterpart is the right place to explain.
if against != key {
return "", false, nil
}
return sealed, true, nil
}
func (i *Inventory) SecretForModule(ctx context.Context, node, module, name string) (string, error) {
key, err := i.SealingKeyOf(ctx, node)
if err != nil {
return "", err
}
if key == "" {
return "", fmt.Errorf(
"%s needs a secret and %s has no sealing key, so nothing can be sealed to it",
module, node)
}
record, err := i.NodeByName(ctx, node)
if err != nil {
return "", err
}
var sealed, against, origin string
err = i.store.Pool().QueryRow(ctx,
`select sealed, node_key, origin from module_secret
where node = $1 and module = $2 and name = $3`,
record.ID, module, name).Scan(&sealed, &against, &origin)
if err == nil && against == key {
return sealed, nil
}
if err == nil && origin == "accepted" {
// Sealed to a key this node no longer has, and not the mesh's to invent again. Making one
// would put 32 random bytes where a working credential was: the machine would apply it,
// report success, and whatever reads it would fail to authenticate somewhere else
// entirely — with the mesh insisting the secret was delivered, which it was.
return "", fmt.Errorf(
"%s on %s holds %q, which was given to the mesh rather than made by it, and %s has "+
"since generated a new sealing key. The mesh cannot make another; issue it again",
module, node, name, node)
}
operator, err := i.OperatorKey(ctx)
if err != nil {
return "", err
}
// Sealed once to the machine — Make seals to two ends because a provision has two; here both
// are the same machine, and only one copy is kept — and once more to the operator when the
// mesh has one (novox/hq ADR 0085, amended), which is the copy a person can recover from.
made, blob, err := secrets.MakeWithOperator(key, key, operator)
if err != nil {
return "", err
}
forOperator, operatorKey := operatorColumns(operator, blob)
if _, err := i.store.Pool().Exec(ctx,
`insert into module_secret (node, module, name, sealed, node_key, origin, operator_sealed, operator_key)
values ($1, $2, $3, $4, $5, 'made', $6, $7)
on conflict (node, module, name) do update set
sealed = excluded.sealed, node_key = excluded.node_key,
origin = excluded.origin, made_at = now(),
operator_sealed = excluded.operator_sealed, operator_key = excluded.operator_key`,
record.ID, module, name, made.ForConsumer, key, forOperator, operatorKey); err != nil {
return "", err
}
return made.ForConsumer, nil
}
// AcceptSecretForModule keeps a value somebody supplied as a module's own secret.
//
// The counterpart to SecretForModule, which generates one. Some of what a module needs the mesh
// cannot invent: a broker account exists because the broker was told about it, and the password is
// whatever was agreed with the broker at that moment. The mesh's job is to carry it to the machine
// that will use it without being able to read it afterwards.
//
// Sealed on the way in and the plaintext discarded, exactly as a generated one is — so the only
// difference between the two is where the value came from.
func (i *Inventory) AcceptSecretForModule(ctx context.Context, node, module, name, value string) error {
// Refused for a name the module does not declare. A value stored under a name nothing reads
// is a delivery that changed nothing and reported success — the shape of failure the mesh
// is built to refuse (novox/hq 04-ISSUES/078).
m, err := i.declared(ctx, module)
if err != nil {
return err
}
if _, own := m.OwnSecrets[name]; !own {
return fmt.Errorf("%s does not declare %q as an own secret; %s", module, name, declaresOwn(m))
}
key, err := i.SealingKeyOf(ctx, node)
if err != nil {
return err
}
if key == "" {
return fmt.Errorf(
"%s has no sealing key, so nothing can be sealed to it — it joins again to get one",
node)
}
record, err := i.NodeByName(ctx, node)
if err != nil {
return err
}
sealed, err := secrets.Accept(value, key, key)
if err != nil {
return err
}
// And to the operator, when the mesh has one: a value a person supplied is the one a person
// most needs to get back, since the mesh cannot make another (novox/hq ADR 0085, amended).
forOperator, operatorKey, err := i.operatorSeal(ctx, value)
if err != nil {
return err
}
_, err = i.store.Pool().Exec(ctx,
`insert into module_secret (node, module, name, sealed, node_key, origin, operator_sealed, operator_key)
values ($1, $2, $3, $4, $5, 'accepted', $6, $7)
on conflict (node, module, name) do update set
sealed = excluded.sealed, node_key = excluded.node_key,
origin = excluded.origin, made_at = now(),
operator_sealed = excluded.operator_sealed, operator_key = excluded.operator_key`,
record.ID, module, name, sealed.ForConsumer, key, forOperator, operatorKey)
return err
}
// Holder is one end-to-end credential: who gets it and who must create it.
type Holder struct {
Provision string
Consumer string
// ConsumerModule is which module on that machine holds it. Part of what identifies a
// credential (novox/hq 04-ISSUES/022), so rotating one consumer's does not touch another's.
ConsumerModule string
// Local is the credential's name inside the consumer where it holds several (ADR 0094).
Local string
Provider string
}
// HoldersOf is every pair sharing a credential for one provision.
//
// **The question rotation has to ask, and the one HAL could not.** There, a provision had a single
// shared credential and rotating it updated the provider's row; nothing enumerated who else held
// the old one, so three nodes carried dead credentials for two days and the mesh reported success
// (novox/hq ADR 0001). Here each pair has its own credential, and this is the list that makes
// "every consumer" a set the mesh can name rather than a hope.
//
// Empty consumer means all of them.
func (i *Inventory) HoldersOf(ctx context.Context, provision, consumer string) ([]Holder, error) {
rows, err := i.store.Pool().Query(ctx,
`select s.name, c.name, s.consumer_module, s.local, p.name from secret s
join node c on c.id = s.consumer
join node p on p.id = s.provider
where s.name = $1 and ($2 = '' or c.name = $2)
order by c.name, s.consumer_module, s.local, p.name`, provision, consumer)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Holder
for rows.Next() {
var h Holder
if err := rows.Scan(&h.Provision, &h.Consumer, &h.ConsumerModule, &h.Local, &h.Provider); err != nil {
return nil, err
}
out = append(out, h)
}
return out, rows.Err()
}
// localFlag is the `--local` a remedy has to name where a credential has a local name.
func localFlag(local string) string {
if local == "" {
return ""
}
return " --local " + local
}
// declared is the manifest the mesh holds for a module — what a delivered value is checked
// against, so a delivery for a name the module does not have is refused rather than stored.
func (i *Inventory) declared(ctx context.Context, module string) (catalogue.Manifest, error) {
known, err := i.Catalogue(ctx)
if err != nil {
return catalogue.Manifest{}, err
}
m, ok := known[module]
if !ok {
return catalogue.Manifest{}, fmt.Errorf("%s is not a module the mesh knows; `module add` it first", module)
}
return m, nil
}
func declaresOwn(m catalogue.Manifest) string {
if len(m.OwnSecrets) == 0 {
return "it declares no own secrets"
}
return "it declares: " + strings.Join(sortedNames(m.OwnSecrets.Paths()), ", ")
}
func sortedNames(of map[string]string) []string {
names := make([]string, 0, len(of))
for name := range of {
names = append(names, name)
}
sort.Strings(names)
return names
}
func orNone(names []string) string {
if len(names) == 0 {
return "none"
}
return strings.Join(names, ", ")
}
// ErrNotRotatable says why the mesh will not rotate a module's own secret; the words are the caller's
// to print, and the remedy is in them.
type ErrNotRotatable struct{ Why string }
func (e ErrNotRotatable) Error() string { return e.Why }
// RotateModuleSecret makes a module's own secret anew, the way the first mint did (novox/hq
// ADR 0114, issue 180). The caller sends the node, so the module is started again on the new value.
//
// **Only a secret the module reads when it starts.** A secret the module's code applies to a
// backend that takes it once would, rotated this way, leave the backend on the old value and the
// module reading the new one — the fault issue 179 was. That form is staged, which the mesh does
// not build yet, and is refused by name. A secret whose manifest says neither is refused with the
// word to write; a secret given to the mesh rather than made by it is refused as 0113 says: the
// mesh will not replace what it cannot read.
func (i *Inventory) RotateModuleSecret(ctx context.Context, node, module, name string) error {
m, err := i.declared(ctx, module)
if err != nil {
return err
}
own, declared := m.OwnSecrets[name]
if !declared {
return fmt.Errorf("%s does not declare %q as an own secret; %s", module, name, declaresOwn(m))
}
switch own.Taken {
case catalogue.TakenAtStart:
case catalogue.TakenApplied:
return ErrNotRotatable{Why: fmt.Sprintf(
"%s applies %q to a backend that takes it once, so a rotation must be staged beside the "+
"current value until the module confirms it — the mesh does not do that yet (ADR 0114). "+
"Changing it is a person's work: change it in %s, then `secret accept %s %s %s`",
module, name, module, node, module, name)}
default:
return ErrNotRotatable{Why: fmt.Sprintf(
"%s does not say how it takes %q, so the mesh will not rotate it: a secret rotated under "+
"software that never reads it again is worse than one left alone. Its definition says "+
"\"own-secrets\": {%q: {\"path\": …, \"taken\": \"at-start\"}} when the module reads it as it "+
"starts, or \"applied\" when its own code applies it",
module, name, name)}
}
record, err := i.NodeByName(ctx, node)
if err != nil {
return err
}
key, err := i.SealingKeyOf(ctx, node)
if err != nil {
return err
}
if key == "" {
return fmt.Errorf("%s has no sealing key, so nothing can be sealed to it", node)
}
var origin string
err = i.store.Pool().QueryRow(ctx,
`select origin from module_secret where node = $1 and module = $2 and name = $3`,
record.ID, module, name).Scan(&origin)
if errors.Is(err, pgx.ErrNoRows) {
return fmt.Errorf("%s on %s holds no %q yet; the first push makes it", module, node, name)
}
if err != nil {
return err
}
if origin == OriginAccepted {
return ErrNotRotatable{Why: fmt.Sprintf(
"%s on %s holds %q as a value given to the mesh, not made by it, and the mesh will not "+
"replace what it cannot read (ADR 0113). Change it where it lives, then `secret accept "+
"%s %s %s` with the new value",
module, node, name, node, module, name)}
}
operator, err := i.OperatorKey(ctx)
if err != nil {
return err
}
made, blob, err := secrets.MakeWithOperator(key, key, operator)
if err != nil {
return err
}
forOperator, operatorKey := operatorColumns(operator, blob)
_, err = i.store.Pool().Exec(ctx,
`update module_secret set sealed = $4, node_key = $5, origin = 'made', made_at = now(),
operator_sealed = $6, operator_key = $7
where node = $1 and module = $2 and name = $3`,
record.ID, module, name, made.ForConsumer, key, forOperator, operatorKey)
return err
}