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. - 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.
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. The operator key's private half never enters the mesh, and the vault refuses one
(ADR 0113 §2). When the secrets move to the vault (design 57's V3), the operator key moves with them.
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
getare 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 (TestANodesSealingKeyOpensTheAnswerdoes 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.
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,deprovisioned— the custody ledger's, by fingerprint, as before.
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.
None returns a value.
What the TypeScript vault did and this one does not: it consumed its own three events only to print an audit line; the pass now prints that line itself.
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.