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.
45 lines
2.3 KiB
SQL
45 lines
2.3 KiB
SQL
-- The key a node's secrets are sealed to, and the sealed secrets themselves.
|
|
--
|
|
-- The arrangement is the opposite of encrypting a credential column. There, the control plane can
|
|
-- read every secret it stores, so a copy of its database is a copy of every credential in the
|
|
-- mesh, and encryption at rest only means somebody needs the process rather than the file. Here
|
|
-- the value is sealed to the node that will use it before it is written, so **this table holds
|
|
-- nothing usable** -- which is what makes novox/hq ADR 0004's "compromise of a node is compromise
|
|
-- of that node" true of secrets and not only of identity.
|
|
--
|
|
-- It also costs the ability to audit by value, and that is the right trade rather than an
|
|
-- oversight: a `where value like ...` over an encrypted column returns zero rows and proves
|
|
-- nothing, so the audit was never real. What is answerable here is which node holds what, which
|
|
-- is the question rotation actually asks.
|
|
|
|
alter table node add column sealing_key text;
|
|
|
|
create table secret (
|
|
-- What it is for. The provision as required -- `database` -- not the module answering it.
|
|
name text not null,
|
|
consumer uuid not null references node(id) on delete cascade,
|
|
provider uuid not null references node(id) on delete cascade,
|
|
|
|
-- The same value, sealed twice: once to each end. Two blobs rather than one shared key,
|
|
-- because a key both ends hold is a key the mesh must also hold to distribute.
|
|
--
|
|
-- The plaintext is never written. It exists for the length of one function call, is sealed to
|
|
-- both recipients, and is discarded -- so rotation means generating a new one rather than
|
|
-- reading the old one back, which is the only version of rotation that is honest about what
|
|
-- the mesh knows.
|
|
for_consumer text not null,
|
|
for_provider text not null,
|
|
|
|
-- Which key each was sealed to. A node that regenerates its sealing key can no longer open
|
|
-- what was sealed to the old one, and this is what lets that be reported rather than
|
|
-- discovered as a service that will not start.
|
|
consumer_key text not null,
|
|
provider_key text not null,
|
|
|
|
created_at timestamptz not null default now(),
|
|
|
|
primary key (name, consumer, provider)
|
|
);
|
|
|
|
create index secret_by_provider on secret (provider);
|