mesh/merge-gate head 39c38f37459a merged into trunk 8aa37e8b835d — pass: the change touches no module of the mesh's graph
mesh/repo-check head 39c38f37459a merged into trunk 8aa37e8b835d — fail: its merge-check.sh failed: files asking for a setting without saying whether it is…
mesh/delivery superseded: a newer head of the same pull request
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
146 lines
8.9 KiB
Markdown
146 lines
8.9 KiB
Markdown
# mesh-vault
|
|
|
|
Holds the `mesh-vault` seat: the mesh's shared secrets, each kept sealed, handed out only on request and
|
|
reply, sealed to whoever asked (novox/hq ADR 0085, ADR 0113, ADR 0349 §7 and §11). Go, one binary
|
|
(`cmd/mesh-vault`), launched by its node's runtime and speaking MCP to it through the Go SDK.
|
|
|
|
## Where the secrets are kept, and why there
|
|
|
|
In the vault's own store: one file per secret in `${dir:store}` on the vault's own disk, mode 0600 in a
|
|
0700 directory. Not a database, as most Go modules keep their data, because:
|
|
|
|
- the vault is installed before any other module built on the runtime base (ADR 0113 §14), and a database
|
|
login is itself a shared secret the vault makes — the vault cannot need one to start;
|
|
- every value in the store is ciphertext anyway, so a database would add nothing a file does not;
|
|
- no secret is in a bucket, sealed or not (ADR 0201 §13), so the module declares no `state`.
|
|
|
|
Each record holds the value twice, never in the clear:
|
|
|
|
- **at rest**, sealed to the vault's own key (the sealed box of ADR 0206, `mesh-sdk/go/seal`). The key is
|
|
an X25519 pair the vault makes once in `${dir:state}/vault-key.json`; its private half never leaves it.
|
|
The key is made only for an empty store: with the key file missing and secrets in the store, the vault
|
|
does not start and says so, rather than make a key that opens none of them.
|
|
- **for the operator**, sealed to the operator key (ADR 0113 §22), so nothing exists only inside the
|
|
vault. A secret is refused, loudly, while the vault has no operator key.
|
|
|
|
Every file is written whole, synced, renamed into place and its directory synced.
|
|
|
|
### What the encryption at rest protects against, and what not
|
|
|
|
The vault's private key lives in plaintext in `${dir:state}`, on the same disk as the store, readable by the
|
|
vault's account alone. So:
|
|
|
|
- **It protects** against a copy of the store directory alone — a `store` backup, a file sent somewhere,
|
|
a listing of `${dir:store}` by a tool that reads files — holding anything usable, and against a value
|
|
being found by searching the disk for it.
|
|
- **It does not protect** against anyone who can read the vault's state directory too: root on the
|
|
vault's node, the vault's own account, or a backup that holds both `state` and `store` (both are
|
|
class `valuable`, so the node's backup holds both). Against those, at-rest encryption adds nothing; the
|
|
protection is the node's own security. Keeping the key elsewhere (another directory class, a key the
|
|
operator unlocks) is for V3's review, before real secrets move in.
|
|
|
|
## Where the operator key comes from
|
|
|
|
From the export the mesh writes to the vault's root directory (`${dir:root}/export.json`, the manifest's
|
|
`keeps`): its `operator-key` is the public half `operator key set` recorded in the controller. The vault
|
|
checks it every minute; a new key makes the vault seal every operator copy again to it — it can, because
|
|
it opens its own copy. Each record names the key its copy is sealed to, so a failure partway is finished on
|
|
the next minute's pass, and nothing is left sealed to an earlier key for good.
|
|
|
|
The vault takes an operator key only as SPKI PEM (`SetOperatorKey`). A PEM private key is refused, and so
|
|
is a raw 32-byte key, because a raw private key looks the same. The one raw key it reads is the export's,
|
|
which the controller writes from what it recorded; the vault wraps it as SPKI before taking it. It keeps
|
|
the key raw in base64, the form of the operator's NaCl box. When the secrets move to the vault (design 57's
|
|
V3), the operator key moves with them.
|
|
|
|
### How the operator opens a copy
|
|
|
|
`mesh-vault.secret_vault_export` answers the vault's copies in the shape `secret export` writes. Saved to a
|
|
file, one is opened with the operator's private key by
|
|
|
|
nox secret recover vault mesh-vault <name> --key <operator key file> --from-export <file> --out <file>
|
|
|
|
(`--out` is needed: a name holds `/`). `TestTheOperatorRecoversAVaultSecretFromItsExport` reads the file
|
|
as that command does and opens it as it does. `secret_export` still answers the controller's export, as
|
|
before; until V3 the controller's secrets are there.
|
|
|
|
## The boxes and the key formats — settled
|
|
|
|
Two boxes on the mesh today, both over X25519:
|
|
|
|
| | format | keys | who uses it |
|
|
|---|---|---|---|
|
|
| NaCl anonymous box | XSalsa20-Poly1305, `box.SealAnonymous` | raw 32 bytes, base64 | the controller sealing into declarations; the node-engine's `sealing.key` (mesh-host `internal/identity/sealing.go`); the operator key and `secret recover --from-export` |
|
|
| the sealed box (ADR 0206) | X25519, HKDF-SHA256, AES-256-GCM, `{v, eph, iv, tag, ct}` | PEM: SPKI public, PKCS #8 private | the licence manager and `claude-code`; `mesh-sdk/go/seal` |
|
|
|
|
What the vault uses:
|
|
|
|
- **Its answers to `get` are the ADR 0206 box, for every asker, a node included.** The asker sends its
|
|
public key as SPKI PEM. A node's provisioner client (V4) needs no second key: its node's sealing key is
|
|
X25519 already, so it sends that key's public half wrapped as SPKI and opens the answer with the private
|
|
half wrapped as PKCS #8 (`TestANodesSealingKeyOpensTheAnswer` does exactly this). A raw 32-byte key is
|
|
refused: a raw private key looks the same, and the vault takes no private key.
|
|
- **Its operator copies are the NaCl anonymous box**, to the operator key as it is made today, so the
|
|
operator's recovery opens them with the key and the command it already has.
|
|
- **The at-rest copy is the ADR 0206 box**, to the vault's own key.
|
|
|
|
The NaCl box stays on the node-engine only for what the controller seals into declarations, until the last
|
|
module has moved (ADR 0349 §17). For the core's own credentials that travel in a declaration sealed by the
|
|
vault (ADR 0349 §16), V4 adds opening the ADR 0206 box with the same sealing key to the node-engine.
|
|
|
|
## The seat's verb
|
|
|
|
`mesh-vault.get` — `name`, `public_key` (SPKI PEM). It answers `{name, generation, changed-at, sealed}`,
|
|
`sealed` being the secret sealed to that key, and only to an asker allowed for that secret, the asker being
|
|
the bus user the runtime proved — never an argument.
|
|
|
|
Every answer and every refusal is recorded in the vault's own store (`handed.jsonl`: who, which secret,
|
|
which generation, when, and why a refusal), synced before the answer is given — as ADR 0277 §25 records
|
|
asks. `secret_handed` reads it.
|
|
|
|
**Who may ask, today: nobody.** The rule (ADR 0349 §11) is a node's provisioner client under a bus account
|
|
of its own, for what its node's declaration names, and a module under its own account (issue 569). Neither
|
|
exists yet, so the running vault refuses every asker; the tests set a stand-in list. The real rule goes
|
|
behind the `Askers` interface in `askers.go` (design 57's V4 and P3).
|
|
|
|
## Events
|
|
|
|
- `secret-changed` — `name`, `by`, `at`, `generation`: which secret, who changed it, when. Never a value
|
|
or a sealed copy.
|
|
- `provisioned`, `rotated` — the custody ledger's, by fingerprint, as before.
|
|
- `provisioner.retirement` (ADR 0230 §35) — `waiting`, `settled`, `approved`, `rejected`, `retired`,
|
|
`reenabled`, `deleted`, naming holders, never a value.
|
|
|
|
## The tools
|
|
|
|
`secret_export`, `secret_holders`, `secret_verify` answer as the TypeScript vault's did, and its custody
|
|
ledger (`${dir:ledger}`, the same JSON files) is kept by a pass over the contributions file every minute.
|
|
`secret_handed` and `secret_vault_export` read the vault's own store. None returns a value.
|
|
|
|
**The ledger follows ADR 0230.** A contributions file that does not say who is asked for (unreadable, not
|
|
JSON, no `given` list or a null one, or for another requirement) is not "nobody": the pass records nothing,
|
|
retires nobody and starts its count again. A holder no longer asked for is retired — a mark with when and
|
|
why in its own ledger file, its history kept — only after the same set has gone unasked in five passes and
|
|
ten minutes; more than three at once, or more than half of those held, waits for a person
|
|
(`provisioner_retire_approve`, `provisioner_retire_reject`); asked for again it is re-enabled; it is deleted
|
|
only by `provisioner_delete`, a person's act with a why. `provisioner_retirement` shows it all. The vault is
|
|
mark-only: it holds no access to switch off.
|
|
|
|
What the TypeScript vault did and this one does not: it consumed its own events only to print an audit
|
|
line, which the pass now prints itself; and it withdrew a holder on the first pass that missed it, with its
|
|
history, and said `deprovisioned` — that is gone for ADR 0230's retirement.
|
|
|
|
## What comes later
|
|
|
|
Taking values in (`secret accept`, `secret-ask`, `issued`, `rotate`) is V2; the controller's secrets move
|
|
in with V3; nodes ask with V4; pair credentials are minted here with V5. Until V3 the vault's store is
|
|
empty and the controller still serves every secret.
|
|
|
|
## Tests
|
|
|
|
go test ./...
|
|
|
|
`vault_test.go` is ADR 0349's vault test list as far as this package goes: no bucket holds a secret; no
|
|
private key is accepted; every secret has an operator-sealed copy; `get` answers only an allowed asker,
|
|
sealed to its key; `secret-changed` carries no value.
|