Files
mesh-catalog/modules/mesh-vault
mesh-adminandClaude Opus 5.5 39c38f3745
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
mesh-vault: tests for the review's fixes, and the README saying what at-rest encryption protects (review 250)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 01:21:30 +00:00
..

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.