Architecture
ADR 0001: How the Vault seals, releases and shares credentials
Status: accepted, 26 Sep 2026 · Workstream: vault · Spec: sections 7.5 and 11 (floor rule 8)
The problem
A teammate leaves, and a .env file of shared keys leaves with them. Nobody knows what the file
held, so nobody rotates anything, and the keys keep working for as long as that person wants.
The Vault exists to make this impossible, and to replace a password manager for a person and for
their agents.
"Impossible" here comes down to four properties:
- No value in a file anyone can carry. Values are sealed at rest. A copy of
~/.vyre, a backup or a stolen disk yields ciphertext. - Sharing without handing over. By default a teammate gets a relayed pass: their calls go through the owner's box, which adds the credential on the way out. The value never reaches their machine, so they have nothing to walk off with.
- Offboarding is one action.
vault.offboard {person}ends every relayed pass at once and names every item that was ever handed over in sealed form, since only those need rotating. - No value on any screen, log or event (floor rule 8). Agents use items by name and never see them.
Threat model
Defended:
- Theft or copying of
~/.vyre,vyre.dbor backups: everything undervault/is AES-256-GCM ciphertext, and the key is somewhere else (the keychain, a file readable only by the vyred user, or a passphrase). - A departing holder of a relayed pass: they never had the value. Revoke works immediately because every use is a live request to the owner's box.
- A stolen or leaked pass ticket: a ticket carries no secret. Relay requests must be signed with the holder's device key, which never leaves the holder's vault.
- A holder pointing a relayed credential at a server they control, to catch it: each relayed item is bound to the hosts it may be sent to, redirects are not followed, and the value is scrubbed from responses.
- Claude reading values through its tools:
vault.list, events, logs, audit rows, errors, the MCP listing and the HTTP API carry names only.vault.putrefuses themcpcaller, so Claude is never the channel a value travels through. - Ciphertext swapping (moving item B's sealed file into item A's slot so a module granted A receives B): the item id and name are bound into each item's key and AAD.
- A process on the socket posing as a module to call
vault.release: vyred refusesmodule:callers arriving over HTTP. Only the loader can assert a module identity.
Not defended, stated plainly:
- Arbitrary code running as the vyred user. It can read the key the same way vyred does. On a box, run vyred as its own user so the key file is not the agent's to read. On a Mac, the Rules deny commands that reach for the vault folder or the Vault's keychain item, which is defence in depth, not a boundary.
- A module the user granted an item to. A grant is trust. That is why grants are explicit and
audited, and why an agent (the
mcpcaller) cannot grant or share on its own authority: its grants and passes wait as pending until a person approves them. - A process started by
vyre vault run. It receives values in its environment by design. Its stdout and stderr are scrubbed of the values it was given (asop rundoes), which stops accidents, not a program that transforms a value before printing it.
Decisions
1. Only node:crypto
No dependencies. AES-256-GCM, HKDF-SHA256, scrypt, Ed25519 and X25519 are all in Node 22.5+. A vault is the worst place to take a dependency whose update could read what it encrypts.
2. One master key, kept out of the data folder
A random 32-byte master key (MK) per Vyre home. Where it lives is the keystore, chosen by
vault.keystore in config.json:
| Keystore | Default on | Where the MK lives |
|---|---|---|
keychain | macOS | A generic password in the macOS keychain, service vyre-vault, account the first 16 hex of sha256(vault folder), so two homes never collide. Written through security -i on stdin, never on argv. vault.keychain names a specific keychain file (tests use a temporary one). |
file | Linux | vault/key, mode 0600, in a 0700 folder, in a home owned by the vyred user. |
passphrase | on request | Nowhere at rest. vault/key.wrapped holds the MK wrapped by a key from scrypt (N=2^17, r=8, p=1) of the passphrase. The vault is locked until vyre vault unlock. |
The keychain and key file keep the vault usable by agents while nobody is at the machine, which is the point of a box. The passphrase keystore is for people who want the disk alone to be useless.
scrypt rather than PBKDF2: it is memory-hard, so a stolen key.wrapped costs an attacker memory
as well as time, and Node has it built in. The prototype used PBKDF2 at 600k rounds for a blob a
phone had to open; the Vault key is only ever unwrapped by vyred.
3. A key per item, bound to the item
Each item is sealed with its own key: HKDF-SHA256(MK, salt = item id, info = "vyre vault item v1"),
a random 12-byte IV, and AAD vyre:item:v1:<id>:<name>. The sealed file is
vault/items/<id>.json:
{ "v": 1, "alg": "A256GCM", "iv": "<b64>", "tag": "<b64>", "ct": "<b64>" }
The plaintext is the item's fields object as JSON. Writes go to a temporary file and are
renamed into place, so a crash never leaves half an item.
Per-item keys mean a nonce is never reused across items even if the RNG repeats one, and binding id and name means a file moved to another item's slot fails authentication instead of decrypting into the wrong grant.
4. What is listable and what is sealed
An item is { name, kind, description, fields }. Kinds: secret, api-key, login, card,
note, env-set.
- Listable, in
vault_itemsin vyre.db: name, kind, description, the names of its fields,urlfor a login (autofill has to match pages without unsealing),hosts(where a relayed pass may send it), created, updated, whether it must be rotated, and where it came from. - Sealed, in
vault/items/: every field value, including a login's username and a TOTP seed.
The sealed files live in the vault folder rather than in vyre.db because the Rules already deny
any tool that reaches into vault/, and vyre.db is readable by every module.
5. Release: a grant, per module, checked by the vault
vault.release {name, field?, watcher?} is registered internal: true, so only modules can call
it and it is never listed. It returns { value } only when a grant exists for exactly the calling
module (and, for the watcher runtime, exactly that watcher). Names are matched exactly; the
prototype learned that fuzzy lookup before a grant check lets a fragment reach another agent's
credential.
The manifest's needs.vault is checked by the loader in ctx.vault.fetch, but a module could
call vault.release through ctx.call directly, so the grant is the boundary, not the
declaration. Every release, refusal and miss is an audit row: when, which item, which module,
the outcome. Never the value.
6. Who may call what
Callers are cli, local (another client on the socket, such as the Deck), mcp (Claude) and
module:<name>.
| Tool | cli / local | mcp | module |
|---|---|---|---|
vault.put | yes | refused | new items, or items it put itself; may grant only those |
vault.unlock, vault.approve, vault.inject | yes | refused | refused |
vault.grant, vault.pass.create | yes | pending until approved | refused |
vault.list, vault.audit, vault.pass.list, vault.revoke, vault.pass.revoke, vault.offboard, vault.import, vault.identity, vault.pass.accept, vault.relay | yes | yes | yes |
vault.totp | yes | refused | with a grant |
vault.generate | yes | only with name (stored, never returned) | refused |
vault.release | no (internal) | no | with a grant |
Taking access away is always allowed; giving it needs a person. The one exception is a module
granting an item it put itself (onboarding storing the Claude credential for the switchboard):
that reveals nothing the module did not already hold, and it cannot replace or grant anyone
else's item. A tool a caller may not use is
also left out of that caller's listing, so Claude is not offered vault.put.
7. Passes
Each Vyre has a device identity: an Ed25519 key for signing and an X25519 key for receiving
sealed items, both private halves sealed in the vault. Its public card is
vyre-card:v1:<base64url JSON {name, sign, box, relay}> and carries no secret.
vault.pass.create {holder, card?, items, mode?, hosts?, expires?, note?} returns a ticket,
vyre-pass:v1:<base64url JSON>, for the owner to send the holder. It carries no secret either:
the pass id, the owner's relay address and signing key, the item names and, for a sealed pass,
each item encrypted to the holder's box key.
- Relayed (default). The holder calls
vault.relay {item, request}with a request whose headers or body contain{{vault}}(or{{vault.<field>}}). Their vyred signs{pass, item, request, ts, nonce}and posts it to the owner's relay listener. The owner checks the signature against the pass's holder key, that the pass is active and unexpired, that the item is in it, that the request's origin is in the item'shosts(narrowed by the pass's ownhosts), the timestamp is within 60 seconds and the nonce is new. Then it substitutes the value, sends the request with redirects off, a 30-second timeout and a 5 MB cap, scrubs the value from the response and returns it. A relayed item with nohostscannot be put in a pass: a credential that may be sent anywhere may be sent to the holder. - Sealed (on request). Each item's fields are encrypted to the holder with ECIES: an
ephemeral X25519 key,
HKDF-SHA256(shared, salt = ephemeral public key, info = "vyre pass seal v1"), AES-256-GCM with AADvyre:pass:v1:<pass id>:<item>. The holder'svault.pass.acceptstores them as ordinary sealed items. Revoking a sealed pass cannot un-send it, so it marks each item rotate, andvault.listshows the mark until the item isputagain.
Behind tailscale serve, a pass can also be bound to the holder's Tailscale login: their card
carries it (vault.login), and with vault.relay.identity: "tailscale" the listener refuses a
request without serve's Tailscale-User-Login header or with another login. The header is
trusted only because the listener is then reachable through serve alone.
The relay listener is a separate HTTP server owned by the vault module, bound to
vault.relay.host/port in config (the tailnet address in production, set up by the box
workstream). It serves one route, POST /v1/relay, and nothing else. When the Gate lands (M9),
it takes over adding credentials at the boundary and this listener becomes its client.
8. Offboarding
vault.offboard {person} revokes every pass that person holds, forgets their card so they cannot
be given new passes by mistake, and returns { revoked: [pass ids], rotate: [item names] }, where
rotate is exactly the items they received sealed. It emits person.offboarded with the counts.
9. vyre vault run and vault.inject
The CLI asks vyred for the values over the socket (vault.inject, cli and local only, never
listed for Claude or modules), puts them in the child's environment only, and pipes the child's
stdout and stderr through a scrubber that replaces each value with <concealed by vyre>. Nothing
is printed and nothing is written to disk. Each run is an audit row naming the items and the
command's first word.
10. Items for people: TOTP, generate, import, and the autofill API
vault.totp {name}: RFC 6238 (SHA-1/256/512, 6 or 8 digits, period from theotpauth://URI or 30 s) from the item's sealedtotpfield.vault.generate {length?, words?, symbols?, name?}: characters fromcrypto.randomIntwith no modulo bias, or words made of three consonant-vowel syllables (80^3 per word, 18.9 bits), which needs no word list and so no licence file. Returns the entropy in bits.vault.import {file, format?}:.env, 1Password CSV, Bitwarden CSV and JSON, Chrome CSV and Safari CSV, recognised by their headers. vyred reads the file itself, so values never pass through Claude. The file is never modified; the result tells the user to delete it. 1Password.1pux(a zip) is next.- Autofill is built as designed in the addendum,
docs/adr/0010-vault-autofill.md. The first sketch:vault.match {url}returns names and hosts of logins for a page (no values), andvault.fill {name}returns a login's username and password only to an unlocked surface session (Touch ID through the Capsule helper, or a passphrase on the Deck). Agents never getvault.fill.
Consequences
- A copied
~/.vyreis worthless without the keychain item, the key file, or the passphrase. - Losing the keychain item or passphrase loses the vault.
vyre vault export --sealed(next) will write a passphrase-sealed backup. - Relayed passes need the owner's box to be up and reachable. That is the price of revocation that works; sealed passes exist for the offline case, and they are the only thing offboarding asks anyone to rotate.