ArchitectureDraft
ADR 0054: Master password and the space vault
Status: accepted, 15 Oct 2026 · Workstream: identity · Supersedes the affected parts of ADR 0001 (the personal keystore) and ADR 0051 (what the recovery code opens).
Context
A person has one username, one master password and one recovery key. The personal vault already lends a credential into a request at the moment of use, and the caller receives the result, never the value. That lending has to keep working for the box's own agents and for an external agent on MCP, including when no person is unlocked. A space also needs a vault its members share, which must stop working for someone who was removed.
Decision
- The person address is
https://vyre.run/u/<name>. It is private by default.<name>.vyre.runanswers 301 to that URL, except the infrastructure hosts (names,app,api,www,relay,acme,mail,docs,status). Each space keeps its own server URL. - One master password. Argon2id in
kernel/identity/stretch.js(one lane; production memory 65536 KiB, 3 passes, 32-byte tag) unwraps a random root key. That root key wraps the identity signing key, the agreement key and a copy of the personal vault key. The password is entered on the person's device. A model caller that suppliespassword,recoveryorrecoveryKeyis refused (secret_refused). - One recovery key, shown once.
kernel/identity/code.jsis the only generator (128 bits, 26 base32 characters). The same key is the chain code entry fromspaces.identity.create(called with an empty chain password, so the existing code-key mechanism is unchanged) and the HKDF wrap of the root key. This supersedes the comfort in ADR 0051 that the recovery code is only a directory key: on a device that holds the master record, the recovery key opens the identity and the wrapped personal vault key. The chain's newcomer rule for later devices is unchanged. A migration cannot read the old code, because it was never stored, soidentity.migrateshows a new recovery key once and leaves the old chain entry in place. - GitHub links the username and does not unlock the vault.
identity.github.linkstores the login and subject. Signing in on another computer is the space server URL plus the username and the master password, or that GitHub link plus the master password (devices.signin). The tool records the server and returns wrapped material for the pairing workstream. It does not dial. - The master password is the root of the personal vault. When this process has unwrapped the master record,
currentPersonalKey()is the device keyVaultadopts (core/vault/master-root.js).vault/keyis the server-held copy of that same key. When the held key opens the agent vault and the file differs, the file is rewritten from the held key. With nobody unlocked, that copy still opens the agent vault, so lending does not wait. A home with no master record keeps the file or keychain keystore. A master record with neither a held key nor a server copy does not invent a new key. A held key that does not open the existing agent vault fails closed and leaves the server copy as it was. - A space vault is a second key.
kernel/identity/space-vault.jsdraws a random 32-byte key per generation, seals it to each member and to a server key that stays in the space vault directory, and seals each item under HKDF of that key (item id and name in the AAD). Agents and Flows open items with the server wrap while no member is unlocked. Removing a member callsrotateOnRemove, which never throws, so removal still finishes. Rotation journalscopyingthencommitting. A crash before the generation pointer moves leaves the old generation readable;resume()discards the half-written next generation. A crash after the pointer moves drops the old generation. If rotation throws, the failure is written beside the space vault, retried automatically (pumpRetries, including the next time identity starts), and shown in Needs you throughvault.space.attention. - Lending is unchanged in shape.
vault.space.useandexternalMcp(vault_space_request) put the value into the outbound request (Authorization bearer, or{{secret}}in a header or body that already names it) and return the response with the value replaced by[redacted]. The external caller gets that response and never the value. No member has to be online, because the server wrap opens the item. - Ownership transfer is the existing kernel grant.
spaces.owner.transferis the same registration asspaces.members.transferand callsgrants.transferOwner. The audit line isowner.changed. There is no second transfer implementation. - An agent proposes. The person's tap applies. Tools that change people, devices, ownership or vault access are
outwardandASK_FIRST. A hosted key may name exactly the tools in thegateobject ofcore/modules/hosted-allow.json. That file is the only declaration.hostedKeyMayreads it. Each of those tools is held for one tap.identity.unlockis not on it: unlocking is the person entering the master password.vault.space.useis not on it: it lends, and holding it would stop lending.identity.lockstays the person's own act.
Consequences
- A stolen recovery key is a bearer for the device that holds the master record, and a founder code key on the chain. Say that on the one screen that shows it.
- After migration the server copy of the master key still opens the personal vault while nobody is unlocked, so lending does not wait.
devices.signindoes not open a network connection. Pairing remains the other workstream.- The GitHub link is a login and a subject. There is no OAuth token in the master record.
- Secret-shaped fields (
password,passphrase,secret,secretKey,recovery,recoveryKey) are removed before an approval card is built, stored or hashed. The person's own surface supplies the master password.
Proof
kernel/identity/master.test.js pins the Argon2id vector, a recovery key that holds nothing until a device confirmation and then holds the identity only, and a migration that drops the plaintext vault key. core/vault/master-root.test.js shows the unlocked master key is the device key the vault adopts, a mismatched master key fails closed, a planted plaintext key is removed, and an item still opens from the sealed wrap with nobody unlocked. kernel/identity/space-vault.test.js crashes a rotation before and after commit, proves an external MCP tools/call of vault_space_request receives the HTTP result while the JSON result does not contain the secret, refuses a host the item was not saved for (including a redirect), shows the server private key sealed and absent from a member bundle, and shows a failed removal rotation throw while the removed member's wrap is already gone. test/approval-secret.test.js shows a password is absent from the card. test/hosted-gate.test.js shows the hosted gate is the declared allowlist. test/mcp-secret-egress.test.js shows each tool that can return a recovery key, a wrap, a space-vault value, a GitHub link token or a sealed record field is empty of that value for an MCP caller and still present for the person, and walks every tool the registry registered. vault.master.install puts the unlocked master key into the running vault and returns the space's published public key, with no private key in the result.