Architecture
ADR 0010: Vault autofill
Status: accepted, 26 Sep 2026 · Workstream: vault · Extends: ADR 0001, decisions 6 and 10 · Code:
core/vault/fill.js, modules/vault-extension/
The problem
To replace a password manager, the Vault has to put a login into a browser page. That is the one path where a value is meant to reach a screen, so it needs its own door. The door must stay shut to three callers: agents, web pages, and anyone who holds only a stolen browser profile.
Decision 10 of ADR 0001 sketched vault.match and vault.fill. This addendum settles how they
work.
Threat model
Defended:
- An agent asking for a login. No registry tool fills.
vault.fillis a route on a separate listener, not a tool, so Claude and modules are never offered it and cannot call it. - A web page calling the listener from script. Browsers attach an
Originheader to cross-origin requests. The listener refuses any request whoseOriginis present and is notchrome-extension://<id>ormoz-extension://<id>, and it does so before it reads the body. CORS is answered only for the extension origin that asked. A page also has no device token, and it would need one even without the Origin rule. - Phishing. A login fills a page only when the page's origin is one of the login's hosts,
compared exactly on scheme, host and port. A login for
https://mail.example.comis never filled intohttps://mail.example.com.evil.test,http://mail.example.comorhttps://mail.example.com:8443.matchapplies the same rule, so the popup offers only what will fill. The content script checkslocation.originagain before it sets a field, in case the tab moved between the click and the fill. - A stolen browser profile. The device token in
chrome.storage.locallists login names for a page and nothing more. A value needs a session. The session token lives in memory andchrome.storage.session, both cleared when the browser closes, and it ends 30 minutes after the proof that opened it (ADR 0028, decision 5; this ADR first said 10 minutes idle or 12 hours at most). - Guessing the unlock passphrase with a stolen device token. 5 wrong passphrases from a device within 15 minutes lock that device out for the rest of the window, even from the right passphrase. The verifier is scrypt (N=2^17, r=8, p=1), as for the vault key.
- Guessing a pairing code. A code is 8 characters from a 31-letter alphabet with no 0, O, 1, I or L. It works once, for 5 minutes. 10 wrong codes in 15 minutes stop all pairing for the rest of the window.
- Reading tokens out of vyre.db. Every module can read vyre.db. Device and session tokens are 32 random bytes stored as sha256, which a reader cannot reverse. Pairing codes are too short for that, so they are stored as HMAC-SHA256 under a key held only in vyred's memory. A restart voids codes nobody has used yet.
- A leaked audit trail. Pairing, each unlock (ok or refused), each fill (ok or refused),
lock and revoke are audit rows carrying names only, with
whoset todevice:<id>:<name>. No value, token, code or passphrase is ever written to an audit row, an event, an error or a log.
Not defended, stated plainly:
- A module that writes to vyre.db. It could add a device row or replace the unlock verifier.
vault_grantscarries the same exposure today, and the answer is the same: run vyred as its own user and install only modules you trust. A later change can MAC these rows under a key derived from the master key. - Malware in the browser or the extension's own process. It sees what the page sees once a field is filled, as it does with any password manager.
- The person filling a login into a page on the right origin that is itself compromised. The origin rule stops a lookalike host. It cannot stop an XSS on the real one.
Decisions
A1. A separate listener, owned by the vault module
Config: vault.fill: { host = "127.0.0.1", port }. It follows the relay listener's shape: its own
HTTP server, JSON in and out, {data} or {error: {code, message}}, and a 64 KB body cap. On a
box it sits behind tailscale serve, which gives the extension an https://*.ts.net address. It
serves these routes and nothing else:
| Route | Credentials | Body | Answer |
|---|---|---|---|
POST /v1/fill/pair | none | {code, name} | {device, name, token} |
POST /v1/fill/unlock | device | {passphrase} | {session, expires} |
POST /v1/fill/lock | device | {} | {locked, sessionsEnded} |
POST /v1/fill/match | device | {url} | {origin, logins: [{name, description, url}]} |
POST /v1/fill/fill | device + session | {name, url} | {username, password, totp?} |
GET /v1/fill/status | device, session optional | {device, canUnlock, unlocked, expires?, session?} |
The device token goes in Authorization: Bearer <token>, the session in X-Vyre-Session.
Requests without an Origin (curl, tests) are allowed and still need both tokens.
Error codes: origin_refused (403), unauthorized and revoked (401), bad_code (403),
bad_passphrase (401), locked_out (429), not_set (409, no unlock passphrase yet),
session_required and session_expired (401), wrong_origin (403), not_found (404),
vault_locked (423), too_large (413).
A2. Pairing
vyre vault pair calls vault.device.code {name?} and prints the code. The extension posts it
to pair and receives a device id and a device token: 32 random bytes in base64url, stored as
sha256 in vault_devices. vault.devices lists devices by name, when each was last seen and how
many sessions it holds. vault.device.revoke {id} ends a device at once, sessions included.
A3. Unlock and sessions
A device token alone never reveals a value. unlock {passphrase} opens a session: a second
32-byte token, stored as sha256 in vault_sessions, bound to that device. Since ADR 0028 (decision 5) it ends 30
minutes after it opened and use does not extend it; vault.fill.window (minutes, 1 to 30) can
only shorten that. This section first said 10 minutes without a fill or 12 hours at most.
The passphrase is the unlock passphrase, set with vault.unlock-passphrase {passphrase} and
kept as a scrypt verifier in vault_meta under the key unlock. It is separate from the vault
key so that a box on the keychain or file keystore, which unlocks itself for agents, still asks a
person before a browser gets a value. Changing it ends every session. When the keystore is
passphrase, the vault passphrase is also accepted, checked through the keystore by a callback
the module passes in.
Touch ID. The Capsule helper runs Touch ID and then calls vault.device.unlock {device},
which opens a session and returns only {ok, expires}. The session token waits in vyred's memory
and is handed to that device once, on its next status call. No token crosses the socket, and a
token nobody collects dies with the session.
A4. Who may call what
The table of decision 6 gains these rows:
| Tool | cli / local | mcp | module |
|---|---|---|---|
vault.device.code, vault.device.unlock, vault.unlock-passphrase | yes | refused | refused |
vault.devices, vault.device.revoke | yes | yes | yes |
| fill | not a tool | not a tool | not a tool |
Giving access needs a person; taking it away never does, as before.
A5. The extension
modules/vault-extension/ is a Chrome MV3 extension in plain JavaScript with no build step and
no remote code. Its permissions are storage, activeTab and scripting. Its only host
permission is the vyred address (default http://127.0.0.1/*; localhost and *.ts.net are
optional and requested when the person saves one). A strict CSP limits connect-src to those
addresses, so the extension cannot reach anything else.
- The background worker is the only part that talks to vyred. It sends the page's origin, never its path, query or content.
- The popup never holds a password. It asks the worker to fill a login by name.
- On Fill, the worker injects
fill.jsinto the active tab's top frame withchrome.scripting.executeScript. The script sets the username, password and one-time-code inputs through the native value setter, dispatchesinputandchange, and returns only the kinds of field it filled. - No content script runs on any page until the person clicks.
Consequences
- A paired browser is useless on its own: a thief also needs the unlock passphrase within five tries, or the person's finger on the Mac.
- A person proves presence at most every 30 minutes of use (ADR 0028, decision 5; first every 10 minutes of use and twice a day at most).
- Lookalike-domain phishing fails closed. A site that moved to a new origin needs its login put again with the new host, which is the price of exact matching.
- The existing
vault.matchtool keeps its looser hostname rule for local clients. The fill listener does not use it.