Credentials the mesh delivers and cannot read

HAL keeps env vars in the registry, encrypted at rest. Its own tooling
records what that bought and what it did not. `secret_locate` matches by
value rather than by name — because the same password sits in
mesh_provisions, in module_env, in each node's .env in plain text, and
inside every connection string composed from it, and its documentation
says those URL copies "are often the only copies actually in use". And a
query against the encrypted column returns zero rows and proves nothing,
so auditing moved to the decrypted copies on the nodes.

Two faults there, and encryption at rest addresses neither: the control
plane can read what it stores, so a copy of the database is a copy of
every credential; and one secret has many homes with nothing tracking
them.

So here the mesh generates a password, seals it to each end with keys
those nodes generated, stores both blobs, and discards the plaintext. It
cannot read what it holds. Neither can the broker relaying it. And
nothing is composed centrally — a connection string is assembled on the
machine that needs one — so no copy is ever minted in a shape nothing
tracks. `Compromise of a node is compromise of that node` (ADR 0004) is
now true of secrets, not only of identity.

Two files rather than one, because the mesh cannot compose a document
containing a value it discarded: `binds` carries the readable facts,
`secrets` carries the credential alone. The readable half stays readable
in the declaration; the secret half changes only when the secret does,
which makes restart-on precise. The provider gets a directory, one file
per consumer, for the same reason.

It is made once and kept — regenerating per declaration would restart
both ends on every push, and the password a provider was told to create
would never be the one its consumer was given. It is remade when either
end's sealing key changes, and both ends learn the new one in the same
push, so there is no window where half the mesh holds a dead credential.

Two tests found passing for the wrong reason, both caught because their
injection came back clean:

- the provider's copy was asserted non-empty, which reads the same
  whichever column is selected. It now opens the blob with the
  provider's own key.
- RotateSecret deleted and re-created; the re-create was dead, because
  the next read makes one anyway. Removed, and a second path to the same
  act is how two ends come to disagree.

And one real fault: three places built a declaration, and the one behind
`--json` predated credentials, so it silently produced a declaration
missing them — a difference between what `plan` showed and what anything
reading `--json` got. There is one path now.
This commit is contained in:
2026-08-30 00:21:18 +02:00
parent c4782ae2fd
commit 20f78cd5f1
13 changed files with 883 additions and 14 deletions
+64 -12
View File
@@ -1005,6 +1005,22 @@ func planFor(ctx context.Context, inv *inventory.Inventory, nodeName string) (ca
return catalogue.Resolution{}, nil, err
}
// The credential for each thing this node takes from elsewhere. Made once and kept, so the
// password a provider is told to create is the one its consumer was given — and sealed to
// this node before it was ever written down, so nothing between here and there can read it.
for i, n := range resolved.Needs {
secret, err := inv.SecretFor(ctx, n.Name, nodeName, n.From)
if err != nil {
// Said rather than skipped. A machine that resolves cleanly and receives no
// credential is one that will fail to authenticate at some later, less obvious
// moment.
return catalogue.Resolution{}, nil, fmt.Errorf(
"%s needs %s from %s and no credential could be made for it: %w",
nodeName, n.Name, n.From, err)
}
resolved.Needs[i].Sealed = secret.ForConsumer
}
// Settings for everything that resolved, including modules nobody assigned directly: a
// requirement pulled in by something else is still configurable, and finding out that it is
// not only when you try would be an arbitrary line nobody could predict.
@@ -1179,6 +1195,51 @@ func whereEveryoneIs(ctx context.Context, inv *inventory.Inventory,
return out, nil
}
// declarationFor is everything a node would be sent.
//
// One place, because there were three and one of them was written before credentials existed and
// silently produced a declaration missing them — a difference between what `plan` showed and what
// `plan --json` handed to anything reading it.
func declarationFor(ctx context.Context, inv *inventory.Inventory, node string,
plan catalogue.Resolution, settings catalogue.SettingsBy) ([]map[string]any, error) {
gens, err := generators(ctx, inv)
if err != nil {
return nil, err
}
return declarationWith(ctx, inv, node, plan, settings, gens)
}
// declarationWith is the same, for a caller that has already worked out the generators once and
// is about to use them for every node.
func declarationWith(ctx context.Context, inv *inventory.Inventory, node string,
plan catalogue.Resolution, settings catalogue.SettingsBy,
gens map[string]catalogue.Generator) ([]map[string]any, error) {
grants, err := grantsFor(ctx, inv, node)
if err != nil {
return nil, err
}
return plan.Declaration(
catalogue.Rendering{Settings: settings, Generators: gens, Grants: grants})
}
// grantsFor is every credential this node must create, because something elsewhere uses it.
//
// The mirror of what a consumer is given, and the half that makes the credential real: a password
// nothing was told to create is a password that authenticates nowhere. Sealed to this node, so
// the mesh hands over something it cannot itself use.
func grantsFor(ctx context.Context, inv *inventory.Inventory, node string) ([]catalogue.Grant, error) {
issued, err := inv.SecretsFrom(ctx, node)
if err != nil {
return nil, err
}
out := make([]catalogue.Grant, 0, len(issued))
for _, s := range issued {
out = append(out, catalogue.Grant{
Provision: s.Name, Consumer: s.Consumer, Sealed: s.ForProvider})
}
return out, nil
}
func planCommand(ctx context.Context, args []string) error {
set := flag.NewFlagSet("plan", flag.ContinueOnError)
// Because "one resource" does not tell you whether the settings landed. Being able to read
@@ -1211,12 +1272,7 @@ func planCommand(ctx context.Context, args []string) error {
return nil
}
if *asJSON {
gens, err := generators(ctx, inv)
if err != nil {
return err
}
resources, err := plan.Declaration(
catalogue.Rendering{Settings: settings, Generators: gens})
resources, err := declarationFor(ctx, inv, args[0], plan, settings)
if err != nil {
return err
}
@@ -1242,11 +1298,7 @@ func planCommand(ctx context.Context, args []string) error {
for _, n := range plan.Needs {
fmt.Printf(" needs %s from %s, for %s\n", n.Name, n.From, n.For)
}
gens, err := generators(ctx, inv)
if err != nil {
return err
}
resources, err := plan.Declaration(catalogue.Rendering{Settings: settings, Generators: gens})
resources, err := declarationFor(ctx, inv, args[0], plan, settings)
if err != nil {
return err
}
@@ -1331,7 +1383,7 @@ func pushCommand(ctx context.Context, args []string) error {
// The private network is in here with everything else. It used to be composed separately
// and prepended, which meant every machine with an address was on it and no machine could
// be kept off. It is a module now, so it arrives the way a module does.
resources, err := plan.Declaration(catalogue.Rendering{Settings: settings, Generators: gens})
resources, err := declarationWith(ctx, inv, n.Name, plan, settings, gens)
if err != nil {
refusals = append(refusals, fmt.Sprintf("%s:\n%v", n.Name, err))
continue