217 lines
15 KiB
Markdown
217 lines
15 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; a secret whose hand-out cannot be recorded is not handed out. A refusal is recorded only for a caller
|
|
of a bus user's shape and a well-formed secret name, so an arbitrary argument fills nothing. The record is
|
|
bounded: rolled at 1 MiB into `handed.jsonl.1` … `.3`, the oldest dropped, each roll said loudly in the
|
|
journal and counted in `vault_status`. `secret_handed` reads it.
|
|
|
|
`vault_status` says what needs a person: operator copies still sealed to an earlier key, an operator key in
|
|
the export the vault refused, and how often the record was rolled. **It is not yet a condition**: a health
|
|
check is declared on a long-running resource (ADR 0240), and the vault is a bundle the tool runner loads, so
|
|
nothing asks it on its own; until it runs as a resource of its own (V3's review) these reach a person
|
|
through the journal and this tool only.
|
|
|
|
**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).
|
|
|
|
## Taking values in: `accept`, `secret-ask`, `issued`, `rotate`
|
|
|
|
Design 57's V2 (issue 675; ADR 0349 clauses 8 to 10, ADR 0277 §22–§27 and ADR 0305 with the vault in the
|
|
controller's place). `intake.go`.
|
|
|
|
**Two steps of one verb, from the controller alone.** The first step names the secret and answers a `call` and
|
|
`seal_to`, the public half of an X25519 key the vault made for that one call (raw, base64 — the NaCl anonymous box
|
|
the desk's prompt and a provider's tool already seal to). The second step sends `call` and `sealed`, and the vault
|
|
opens it with the private half, which lives in this process only and is forgotten once used or after two minutes.
|
|
A value sealed to any other key is refused, and a call is answered once.
|
|
|
|
**Why the controller carries it.** A desk's prompt (`node-launcher.secret`) and a provider's tool answer the
|
|
controller alone: the bus lets only the controller ask them, on the subject that names its caller (issue 365). The
|
|
vault cannot call in a name of its own while it runs in its machine's tool runner, whose one bus account carries
|
|
that machine's agents' calls too (issue 569, ADR 0305 §3). So the controller asks them with the vault's key and
|
|
passes the sealed answer on; it cannot open it. The value opens in the vault and nowhere else. The vault refuses
|
|
these steps from any caller but `controller`, as the runtime proved it.
|
|
|
|
- `accept` — node, module, name (a module's own secret) or provider, provision (a provision's one credential),
|
|
and `by`. The controller's terminal seals the value it read to the call's key before it leaves the terminal.
|
|
Kept with origin `accepted`; an accepted provision credential is never remade (`rotate` refuses it); a value a
|
|
provider's software issued is not taken by `accept`.
|
|
- `secret-ask` — node, module, name, desk and `asked_by` (who asked the controller, as the bus named its caller).
|
|
The ask is recorded in `asks.jsonl` before anything else, and refused while another ask for the same secret is
|
|
open (two minutes at most) or when three were made in the last hour. The answer names who asked as the record
|
|
has it, which the prompt shows. The second step carries what the prompt sealed, or `outcome` (dismissed,
|
|
timed-out, empty, refused, failed); how each ask ended is recorded. `secret_asks` reads the record. The hand-act
|
|
log stays the controller's: it records the act once the vault has taken the value.
|
|
- `issued` — provision, provider (machine), module. The controller asks the provider's tool with the call's key,
|
|
as it asks it today; the value is kept as the provision's one credential with origin `issued`. The same value
|
|
again writes nothing, says nothing and keeps the generation (ADR 0305 §5).
|
|
- `rotate` — a module's own secret or a provision's credential, made anew. Refused for an `issued` value (the
|
|
software rotates it, ADR 0305 §9) and an accepted provision credential (clause 8). Who asked is the proven caller,
|
|
or the `by` the controller names when it carries the act; both are kept (`changed-by`, `changed-via`).
|
|
|
|
Names in the store: `own/<node>/<module>/<name>` and `provision/<provider node>/<provision>`. V3 copies the
|
|
controller's rows in under these names.
|
|
|
|
**Which modules reach the vault this way.** The controller sends a module's verbs here only when its secrets are
|
|
filled the new way (its machine asks the vault for them, V4); every other module keeps the controller's path, so
|
|
no value lands where its machine never looks (ADR 0349 clause 17; the release lead's decision on issue 675). Until
|
|
V4 that is no module: these verbs are served and tested, and nothing calls them yet.
|
|
|
|
**What the issuing provider's test is here.** `TestAnIssuingProviderAnswersOnlyTheControllersCallSealedToTheVault`
|
|
uses a stand-in provider that refuses every caller but the controller and answers only sealed to the call's key.
|
|
plex's own tool already answers the controller alone; whether it moves with the media catalogue is for that move,
|
|
after release 57.
|
|
|
|
## 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.
|
|
|
|
The words are the controller's: the `provisioner.retirement` event is mesh-controller's `link.Retirement`
|
|
(`provider-node`, `consumers` as objects) and the `provisioner_retirement` answer its `link.RetirementState`
|
|
(`held`, `bound`, `retires_by: mark-only`), so `retire approve`, `retire reject` and `cleanup list` read the
|
|
vault and a set waiting for a person raises the controller's urgent condition. The acts take what the SDK's
|
|
harness takes: approve and reject repeat the set in `consumers` and refuse any other set; delete repeats the
|
|
name in `confirm`; each records the `by` and `via` the controller sends. `testdata/retirement-*.json` are
|
|
those types marshalled by the controller (feat/57 at dc36536), and the tests hold the vault's words to them.
|
|
|
|
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. Its retirement ran on the SDK's harness, which this one follows: mark-only,
|
|
and a holder's ledger file removed only through a person's delete, as here. The difference is where the mark
|
|
lives: the harness kept it in its process, so a restart forgot it (ADR 0230 §13); here it is in the ledger
|
|
file.
|
|
|
|
## What comes later
|
|
|
|
Taking values in is built (above); the controller's secrets move in with V3; nodes ask with V4, which also turns
|
|
the controller's switch on for each module it moves; 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. `intake_test.go` is ADR 0349's `secret-ask` test as far as
|
|
the vault answers it (recorded before the prompt, one open ask per secret and three an hour, the asker named as
|
|
the bus named it, the typed value opened only here), the issuing provider's test with a stand-in provider (ADR 0305
|
|
§13's checks that belong to the vault: nothing rewritten for a value held, `rotate` refused, no other way in), and
|
|
accept and rotate. `followups_test.go` holds V1's review follow-ups.
|