The Firefox extension is a standalone client. It runs vault-core and vault-client compiled to
WebAssembly, talks to vault serve directly, and needs no CLI. It follows the spec
Sync and Autofill matching, and the
Firefox rules in the requirements. It needs Firefox 153 or
later, for browser.publicSuffix.
Phase 3 builds the P0 and P1 requirements: FF-1 to FF-6, FF-8, and FF-9. PIN unlock (FF-7, P2) comes later. Bitwarden import and export stay in the CLI; no Firefox requirement needs them.
| ID | Feature |
|---|---|
| FF-1 | WASM core, direct server access |
| FF-2 | Popup: search, view, copy username and password |
| FF-3 | Fill from a user action: popup, keyboard shortcut, or context menu |
| FF-4 | Lock after an idle time and on browser close |
| FF-5 | Add and edit items; generate a password into a field |
| FF-6 | Offer to save or update after a login form submits |
| FF-8 | Setup from the setup text, the certificate exception, the secret key, and the master password |
| FF-9 | Certificate exception or publicly trusted certificate; http only to loopback |
| Component | Path | Role |
|---|---|---|
vault-wasm |
crates/vault-wasm/ |
wasm-bindgen bindings over vault-core and vault-client |
| Background script | extension/src/background/ |
The agent: holds the vault key, syncs in the background, runs the idle lock, and performs every fill |
| Popup | extension/src/popup/ |
Unlock, search, view, copy, fill, add, and quick edits, all through the background script |
| Vault page | extension/src/page/ |
A full-tab extension page for setup, credential change, recovery, restore, and turning on save offers |
| Fill script | extension/src/fill/ |
Injected on demand into one frame; writes one credential into a form |
| Save watcher | extension/src/save/ |
Optional content script that reports a submitted login form (FF-6) |
The split mirrors the CLI. The background script plays the agent, and the popup plays short
commands. Procedures that can wait minutes for prompts (setup, passwd, recovery, restore, and a
credential takeover) run in the vault page, because a popup closes when it loses focus and an idle
background script unloads.
The extension is TypeScript, bundled with esbuild, with no UI framework. Small scripts in the
repository package it, sign it through the addons.mozilla.org API, and drive Firefox through
geckodriver; web-ext is not used, so every npm package, build tools included, passes the licence
check without exceptions. Node dependencies are build and test tools only; none is bundled into the
extension except the generated wasm-bindgen glue. package-lock.json is committed, and a licence
check holds npm packages to the same licences as deny.toml. The shipped JavaScript is not
minified, so a reviewer can read it.
vault-wasm exposes coarse operations, so secrets cross the JavaScript boundary only where the UI
needs them:
- unlock, lock, and the state of the session;
- the
vault-clientmachines, stepped from JavaScript, with requests and prompts as plain objects; - item list entries without secrets, one item in full for viewing or editing, and edits;
- autofill matching, frame selection, and save offer decisions from
vault_client::autofill; - password generation.
The unlocked body stays inside WASM memory. The vault key crosses into JavaScript in two places:
storing it in storage.session, and reading it back when a new background instance starts. Code
that handles it holds it in a Uint8Array and zeroes the array when done. JavaScript receives a
password only for a view, an edit, a copy, or a fill, as a string that it does not store. Locking
zeroes the unlocked body and the vault key in WASM memory. wasm-bindgen's glue keeps the instance
for the life of the context, so the memory itself goes when the background script next unloads.
Randomness comes from crypto.getRandomValues, and the time from Date.now().
The background script and the vault page each run a WASM instance. The popup runs none: it sends
every operation to the background script, which holds the one unlocked session, so opening the popup
costs no second instance and no second unlock. The module is built with the wasm Cargo profile,
which inherits release and sets lto = "fat", codegen-units = 1, and opt-level = 3, with the
simd128 target feature and wasm-opt -O3 from binaryen 132 or later; older binaryen releases
cannot parse the module Rust 1.96 emits. It is loaded through wasm-bindgen's web output as an ES
module. The manifest's content security policy for extension pages is script-src 'self' 'wasm-unsafe-eval'; the default Manifest V3 policy refuses to compile WebAssembly.
| Measure (Ryzen 7 7840U) | Value |
|---|---|
| Unlock at the floor KDF parameters, spike module in Firefox 157 | 127 ms |
Unlock at the floor KDF parameters, vault-wasm in Node 22 |
108 ms |
| Unlock at the ceiling, spike module in Firefox 157 | 1.85 s |
vault-wasm module, raw and gzipped |
925 KB and 383 KB |
| WASM memory after a floor unlock | 65 MiB |
About a third of the gzipped module is data, mostly the Unicode normalisation and IDNA tables.
opt-level = "s" would save about 40 KB gzipped at the cost of about 25 ms per unlock.
| Data | Store |
|---|---|
Local state (local.json contents, including the vault file) |
IndexedDB |
| Server URL, token, fingerprint, and device ID | IndexedDB |
| Secret key, and a pending one | IndexedDB |
| Vault key while unlocked, last activity time | storage.session |
| Pending save offers, the hash of the last copied value | storage.session |
| Settings without secrets | storage.local |
The extension's content scripts can read storage.local, so it holds no secret and no credential.
IndexedDB in the extension's own origin is reachable only from the background script and extension
pages; a content script's indexedDB opens the page's origin. storage.session is not available to
content scripts at all, and Firefox clears it when the browser closes. Nothing is written to
storage.sync. The extension holds the unlimitedStorage permission, which grants persistent
storage at install, and calls navigator.storage.persist() from the background script, which then
resolves true with no prompt. Firefox therefore does not evict the local state under storage
pressure. Extension storage survives "Clear recent history" and "Clear Data" with every option
selected.
Every change to the local state runs inside navigator.locks.request("vault-state"), which plays
the CLI's file lock across the background script, the popup, and the vault page. Inside the lock, a
context reads the state, runs vault-client's commit rule, and writes the result, including any
secret key change in the order vault-client gives, in one IndexedDB transaction. Other contexts
learn of a change from a runtime message and reload.
Firefox runs a Manifest V3 background script as an event page (background.scripts), and unloads
it after about 30 seconds without events, discarding its memory. Each new instance reads the vault
key from storage.session and, when one is present, reopens the local vault file in WASM memory,
which takes about 1 ms. An unload therefore does not lock the vault, and WASM memory, which never
shrinks while an instance lives, is released.
The script registers every event listener synchronously at the top level, so an event that wakes
it is delivered. Work that must happen later runs from alarms, which wake a suspended background
script; setTimeout does not survive an unload.
- Idle lock (FF-4): only user actions count as activity: a popup or vault page request other than a
status query or a manual sync, and a fill or copy the user started. Messages from content scripts
never count. Every request, and every start of a new instance, first compares the last activity
time in
storage.sessionwith the idle time (default 15 minutes) and locks when it has passed, before doing anything else. An alarm also checks every minute, so a suspended machine locks on wake. Locking clears the vault key, the pending save offers, and the idle and sync alarms, and zeroes the session in WASM memory. The vault page watchesstorage.sessionand, when the vault key is removed, locks and frees every session it holds. Restore, which needs the page's own session, then refuses. A credential change, recovery, or setup that started unlocked still commits its result, because the server may already hold it, but does not put its vault key back intostorage.session, so the vault stays locked. The clipboard alarm survives a lock, so a password copied just before the lock is still cleared after 45 seconds. - Browser close: Firefox clears
storage.session, so the next session starts locked. - Sync: on unlock (pull first, as the spec requires), after every local change, and from an alarm
every 5 minutes while unlocked. One sync runs at a time; a request during a sync sets a flag that
runs one more afterwards. Before a sync,
permissions.containschecks the server's host permission, and a missing one is reported in the popup. A sync that needs a prompt recordsstopped, and the popup names the action that resolves it, which opens the vault page.
Requests to the server use fetch with redirect: "error", credentials: "omit", and
cache: "no-store".
The popup and the vault page send requests to the background script with runtime.sendMessage. The
background script accepts a request only when sender.id equals runtime.id, its URL is under the
extension's own origin, its envType is addon_child, and it either has no tab (the popup) or
has a tab whose URL is also under the extension's origin (the vault page), and it validates each
request's shape. The manifest has no externally_connectable key and an empty
web_accessible_resources, so no web page can message the extension or load its pages. A content
script, told apart by its envType of content_child, can send only the save watcher's one message
type, and never receives anything but the credential of an explicit fill.
A fill starts from a user action: the popup's fill button, the keyboard shortcut (commands,
default Ctrl+Shift+L), or the context menu. The shortcut and the context menu grant activeTab, so
filling needs no site permissions. The background script:
- Takes the active tab of the focused normal browser window, or else of the most recently used one,
and the URL of its top frame from
tabs.get. Firefox 157 ignores the window type filter ofwindows.getLastFocused, which can return the popup's window. It never takes a URL from page content or from a content script. - Finds the candidates with
vault_client::autofill, supplying known suffixes frombrowser.publicSuffix.getKnownSuffix(thepublicSuffixpermission). It returnsnullwhen only the list's default rule applies, and throws on a host it cannot parse, which counts as a failed answer. - From the shortcut or the context menu, fills only when exactly one candidate has the outcome
fill. Otherwise it sets a badge on the toolbar button showing the number of candidates, and the user picks one in the popup. Firefox 157 letsaction.openPopup()run after the listener's asynchronous work only becauseextensions.openPopupWithoutUserGesture.enableddefaults to true; the extension does not depend on that preference. While the vault is locked, the shortcut and the context menu set a!badge with the title "vault is locked" instead. A fill started from the popup for an item that is not a candidate asks for confirmation first, naming the page's host, and is refused on anhttppage that is not a local host. - Asks for confirmation for a candidate with the outcome
confirm: anhttpsitem on anhttppage of a local host. - After the choice, reads the frames with
webNavigation.getAllFrames, selects them withvault_client::autofill, and injects the fill script withscripting.executeScriptinto each selected frame, passing the credential and the top frame's origin. UnderactiveTabalone Firefox refuses every frame outside the top frame's origin, but with the all-sites permission that save offers request it injects into any frame, so the selection, not the browser, keeps the credential to the top frame's origin. A frame ID names the frame, not its document: a frame that navigates after the frames are read receives the fill script in its new document.
The fill script runs in the isolated world, as one synchronous function, so its origin check and its
writes apply to the same document. It writes nothing when self.origin differs from the origin it
was given. Frames are selected by their URL, so about:blank, srcdoc, and data: frames are
never selected, even when they inherit the page's origin; the origin check is a second line of
defence. It uses self.origin rather than location.origin, which is "null" in an inheriting
frame. It finds the visible username and password fields, where a field is not visible when it is
smaller than 4 by 4 pixels, outside the document, below an opacity of 0.1 on itself or an ancestor,
clipped by an ancestor, or covered at its centre by another element, sets their values, dispatches
input and change events, and keeps no reference to the credential afterwards.
Generating a password into a field (FF-5) fills only a visible password field in a selected frame, and records the generated password as a pending save offer for the page's origin, so a form that fails to submit does not lose it.
Save offers (FF-6) need the save watcher in every page, and so the all-sites host permission, which
the extension requests only when the user turns save offers on in the vault page. In the same click,
the vault page registers the watcher with scripting.registerContentScripts and
persistAcrossSessions; the manifest has no static content_scripts entry. When a form with a
password field submits in the top frame through a trusted event, after the user has interacted with
the page (navigator.userActivation.hasBeenActive, which stays set once the user has clicked or
typed, so a login that submits from script after an asynchronous check still counts), the watcher
sends the username and password to the background script. The background script takes the page URL
from the sender's tab and frame, as the browser reports them, and ignores a message from any frame
but the top frame. It answers every message the same way, so a page learns nothing about the vault,
and keeps at most one pending offer per origin and ten in all, the newest replacing the oldest. A
message that arrives while the vault is locked is dropped, so no submitted password waits in
storage.session without an unlocked vault.
vault_client::autofill decides the offer. A pending offer sets a badge on the toolbar button, and
the popup offers to save or update. A new item stores only the page's origin, never its path or
query. Nothing is saved without the user's confirmation.
Setup runs in the vault page.
- Paste the setup text from
vault serve token add. The extension checks it withvault-coreand, in the same click, requests the host permission<scheme>://<host>/*, without the port. - For an
httpsURL withfp, the extension first tests a request without the token; a401proves that the connection succeeded, and the token is sent only once the certificate is settled. When the request succeeds before any exception is accepted, the profile already trusts a certificate for the host, which may be an earlier exception that nobody compared; the extension warns that it cannot check the fingerprint and suggests removing the exception in Firefox's certificate manager. Otherwise it opens/v1/vaulton the server in a new tab, whichvault serveanswers with an empty body, and shows the fingerprint in both forms in the vault page; nothing the tab displays is trusted. After the user accepts the exception, compares the fingerprints, and confirms, the extension closes the tab and tests a request again. The extension never offers to accept a new certificate on its own; a new certificate means new setup text. A loopbackhttpURL skips this step. - Type or paste the secret key, and optionally the vault ID from the emergency kit.
- Enter the master password. The extension runs the
vault-clientsetup machine.
Copying a username or password writes it with navigator.clipboard.writeText under the
clipboardWrite permission, and stores a SHA-256 hash of the value in storage.session. An alarm
45 seconds later wakes the background script, which reads the clipboard under the clipboardRead
permission and clears it when its hash matches. Both work from the background script with no
extension page open and the browser window unfocused. Firefox refuses custom clipboard types, so the
extension cannot offer x-kde-passwordManagerHint, and a clipboard manager may keep a copied
password.
The extension does not run in private windows unless the user allows it in Firefox's add-on settings. Its state is the same in a private window; a fill or save offer there follows the same rules.
| Command | Purpose |
|---|---|
cargo build -p vault-wasm --target wasm32-unknown-unknown --profile wasm |
The WASM module |
npm run build in extension/ |
wasm-bindgen, wasm-opt, esbuild, and the manifest; WASM_BINDGEN and WASM_OPT name the tools |
npm test in extension/ |
Unit tests for message validation, storage, transport, the fill script, and the manifest; the licence check |
npm run e2e in extension/ |
Headless Firefox against a real vault serve; GECKODRIVER, FIREFOX, and optionally VAULT_BIN name the tools, and util-linux script gives the CLI a terminal |
cargo test -p vault-wasm --target wasm32-unknown-unknown |
The spec vectors through the WASM bindings, under Node |
npm run start in extension/ |
A visible Firefox with the extension installed temporarily in a fresh profile |
npm run pack in extension/ |
A reproducible .xpi in extension/artifacts/ |
npm run sign in extension/ |
Uploads the .xpi to addons.mozilla.org as unlisted and downloads the signed file; AMO_API_KEY and AMO_API_SECRET hold the key |
npm test runs the licence check, npm audit --omit=dev, and the unit tests, which include
checks of the manifest and of every shipped bundle (no eval, no Function constructor, no remote
script, no inline script in the pages) in place of an add-on linter.
The spec vectors, autofill.json included, run through the WASM bindings in CI, and the release
gate runs against a module built with the same wasm profile as the shipped one, so a test_kdf
header is refused in the build users install. vault-wasm joins the CI WASM job.
Unit tests run in Node. The end-to-end tests drive headless Firefox through geckodriver against a
real vault serve and the CLI, and cover setup, unlock, sync in both directions between the
extension and the CLI, a credential change, a credential change made on another device, recovery,
token repair, restore, fills into same-origin, same-site, and cross-origin frames, a frame that
navigates before the fill, the http refusal and the local-host confirmation, save offers, the
clipboard clear, the idle lock after an unload, a readable Date header from the server, and the
transport behaviours in the requirements. Headless Firefox cannot click
the toolbar button, so the tests open the popup page in a popup window and grant activeTab as the
click does.
Firefox release builds load only signed extensions. The extension is signed as an unlisted add-on
through the addons.mozilla.org API, which needs an AMO account and an API key and secret, read
from the environment. A signed .xpi installs from a file.
The manifest fixes browser_specific_settings.gecko.id as vault@codeheadsystems.com from the
first build, because Firefox keys the extension's storage by it and a change loses every device's
local state, and sets strict_min_version to "153.0". data_collection_permissions declares
{"required": ["none"]}, which addons.mozilla.org requires: nothing reaches the developer, and the
server is the user's own. The keyboard shortcut needs no permission; Firefox gates the commands
API on the manifest key. npm run sign refuses a stub build, a placeholder ID, and a working tree
with uncommitted changes, and attaches the source archive from npm run source (the repository at
HEAD with extension/BUILD.md, which lists the exact tool versions and commands), because
addons.mozilla.org may ask for the source of the compiled module. Updates install from a new signed
.xpi; an update_url that serves signed updates is a later option.