Oct 12, 2026 · @Ned Wolpert · Draft 7
A personal Bitwarden: one encrypted vault, mine, usable from the command line, from Android, and
from Firefox, synced through vault serve on a machine I run. Name: vault.
This is a restart of ../vlt. That project's goals were right; its storage design was not. One
encrypted object per entry, S3 with IAM keys per device, hash-chained history, per-device key
wrapping, and backend capability probing produced a 2,200-line spec and 36,000 lines of Rust before
the first non-CLI client existed. This version trades those features for a design small enough to
finish.
- Replace Bitwarden for daily use on Linux, Android, and Firefox.
- Zero-knowledge sync: the server stores one opaque blob and never sees plaintext, the master password, or the secret key. A bug in the server, or a stolen copy of its disk, exposes nothing that can be brute forced, as long as the current secret key has not left an enrolled device (see credential changes).
- Easy to use: adding a device is "scan or paste the server token, scan or type the secret key, type the master password". No IAM and no enrollment protocol.
- Easy sync: concurrent edits on two devices merge automatically, without conflict files.
- Lossless import from Bitwarden, and an exit path to Bitwarden and plain JSON.
- A design simple enough that the format spec fits in a few pages.
- More than one user; sharing; organisations.
- Windows, macOS, iOS, and Chrome clients.
- Attachments.
- A hosted service for anyone but me.
| Release | Scope |
|---|---|
| v1.0 | Logins and secure notes on CLI, Firefox, and Android, with sync, import, and export |
| v1.1 | TOTP: generate codes on every client; scan or paste otpauth:// URIs |
| v1.2 | WebAuthn: passkey storage, Firefox authenticator, Android Credential Manager provider |
TOTP seeds and passkeys present in a Bitwarden import are stored and preserved from v1.0, so nothing is lost before the features that use them ship.
flowchart LR
CLI[CLI<br/>Linux]
FF[Firefox extension<br/>WASM core]
AND[Android app<br/>UniFFI core]
CORE[vault-core<br/>crypto, item model, merge]
SRV[vault serve<br/>stores one encrypted blob<br/>plus recent revisions]
CLI --> CORE
FF --> CORE
AND --> CORE
CORE -- "HTTPS GET and conditional PUT" --> SRV
The whole vault is one file: a small authenticated header followed by one AEAD ciphertext of every item.
| Part | Contents |
|---|---|
| Header (authenticated, not secret) | Format version, generation number, KDF parameters and salt, vault key wrapped by the key derived from the master password and secret key |
| Body (encrypted with the vault key) | Every item, tombstones for deleted items, and vault settings |
The body's AEAD takes the complete header bytes as associated data, so a header cannot be moved onto a different body, and the generation number cannot be changed, without detection.
At 1,000 items the file is a few hundred kilobytes, so every save rewrites the whole thing. That is fine at this scale, and it removes indexes, per-entry objects, and cross-object consistency rules.
The same file is the backup format: copying it anywhere is a complete encrypted backup that any client can open with the master password and the secret key.
- The secret key is 128 random bits, generated by
vault init, and held by every client. It is never sent to the server and never stored in the vault file. - The master password and salt go through Argon2id; the result and the secret key go through HKDF to produce a key-encryption key, which unwraps a random 256-bit vault key. Unlocking needs both.
- Quick unlock (fingerprint, CLI agent, extension session) caches or wraps the vault key locally on that device only. Nothing device-specific is stored in the vault file.
vault initprints an emergency kit: the secret key, as text and a QR code, and the server URL. The kit plus the master password opens the vault on a fresh device.vault key showdisplays the secret key on an existing client. It asks for the master password again, even while the vault is unlocked, and prints the text form;--qradds a QR code of the same text. The key is shown on screen and never written to disk. The re-prompt protects against someone at an unlocked screen; it does not protect against malware running as the same user, which can read the secret key from the client's storage directly.
At rest on each client, the secret key lives in the OS keyring (CLI, falling back to a file readable only by the user), the Android Keystore-protected app storage (Android), or extension local storage (Firefox, which has no better place). Device theft therefore yields the secret key; the master password still protects the vault, and the response is a credential change.
vault passwd is the one credential-change operation. It always generates a new vault key and a
new salt and re-encrypts the body; the body is a few hundred kilobytes, so this costs nothing
noticeable. Options select what else changes:
| Command | New vault key | New master password | New secret key |
|---|---|---|---|
vault passwd |
Yes | Yes | No |
vault passwd --rekey |
Yes | No | No |
vault passwd --new-secret-key |
Yes | Optional | Yes |
- A new vault key on every change means an old header, together with the old credentials, opens only revisions written before the change. It never opens the current body.
- After any change, every other device's cached quick-unlock key is stale. Each device asks for the master password once (and the new secret key, if it changed) on its next unlock.
--new-secret-keyprints a new emergency kit. Every other device must have the new secret key entered, by the same means as adding a device. This is the response to a lost or stolen device, together withvault serve token rmfor that device's token.- Revisions written before a change stay readable with the old credentials for as long as the
server retains them.
vault serve revisions prune --before <revision>removes them on the server when that matters.
The secret key has one canonical text form, carried over unchanged from ../vlt: the prefix
VLT1-, then eight groups of four Crockford base32 characters encoding the 16-byte key followed by
a 4-byte check value (a truncated SHA-256 of the key under a fixed domain label).
VLT1-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX
- Decoding ignores case, dashes, and spaces, and reads
Oas0andIorLas1. - A check value mismatch is reported as a typo before any key derivation runs, so a mistyped key never looks like a wrong master password.
- Every QR code that carries the secret key (the emergency kit,
vault key show --qr) encodes this same text. There is no second format.
Every client keeps a full local copy of the vault file and works offline.
- Pull:
GETthe file with itsETag. - Merge: decrypt and merge the remote items with local changes, item by item (see below).
- Push:
PUTwithIf-Matchon theETagfrom step 1. A412means another device wrote first; repeat from step 1.
Merge rule: each item has a UUID and a modification stamp (timestamp plus device ID as the tie break). For each item ID the newer stamp wins. When the losing version had a different password, that password goes into the winner's password history, so a concurrent edit never silently discards a secret.
- A stamp more than 24 hours ahead of the merging device's clock is clamped to that clock and the item is flagged for review. One device with a wrong clock, or a thief with the vault key, cannot make an item win every future merge.
- Deletes are tombstones, kept for 180 days and then dropped. The body records a purge horizon: the stamp before which tombstones have been dropped. A local-only item stamped before the remote purge horizon is treated as deleted and shown for review, never pushed back. A device offline for longer than 180 days, or an old backup, does not resurrect deleted items.
- A credential change (
vault passwd) on one device and a concurrent write on another resolve through the conditionalPUT: the loser re-pulls, finds a header it cannot open with its cached key, and asks for the master password.
Clients sync on unlock, after every local change, and on a timer while unlocked.
Every push to the server increments the generation number in the authenticated header; local saves between syncs do not. Each client remembers the highest generation it has accepted from a file that opened. A remote file with a lower generation is an integrity error, shown to the user and never merged. This stops a stolen token, or a server bug, from re-serving an old revision to undo a password change or bring back old items.
- A deliberate restore (CORE-9) writes the restored content as a new revision with a higher generation, and re-stamps the restored items with the current time so the next merge keeps them.
- A remote file that does not decrypt (garbage pushed with a stolen token, or corruption) is also
an integrity error. The client keeps its local copy, warns, and stops syncing until
vault sync --recovermerges into the newest honest server revision (or, failing that, pushes the local copy). It never treats an unreadable remote as an empty vault. - A brand-new device has no remembered generation, so it cannot detect a rollback on its first sync. This is a residual risk; the device does detect any later rollback.
vault serve is a subcommand of the one vault binary. It runs on a machine that stays on (a home
desktop, a NAS, or a VPS) and is the sync base for every other client.
The server is dumb. It stores bytes and tokens, and nothing else:
- It never unlocks the vault, never merges, and never holds the vault key, master password, or secret key.
- Its data directory is separate from the local client's state on the same machine. The
vaultCLI on that machine is an ordinary client talking to the server overlocalhost. - A build feature produces a server-only binary, with no vault code, for hosts that never run the CLI.
| Endpoint | Behaviour |
|---|---|
GET /v1/vault |
Returns the current blob and its ETag, or 404 before the first push |
PUT /v1/vault |
Stores a new blob if If-Match (or If-None-Match: *) holds; otherwise 412 |
GET /v1/revisions |
Lists retained revisions |
GET /v1/revisions/<id> |
Returns one retained revision |
- Authentication is a bearer token per device, stored hashed.
vault serve token add <name>prints a token;vault serve token lsandvault serve token rm <name>list and revoke. These subcommands edit the server's data directory directly; there is no admin API over the network. - The server keeps the newest 100 revisions, one revision per device per day for 90 days, and each device's newest revision. Restoring one is a client operation: download, inspect, push as a new revision.
- A blob larger than 16 MB, or one not shaped like a vault file, is refused. Total storage has a quota (2 GB by default) that charges every revision at least 8 KB, and at most 10,000 revisions are kept; at either limit the server refuses pushes rather than delete retained revisions. A stolen token can therefore stop sync, but cannot fill the disk or erase history. Garbage pushed with a stolen token cannot push out each other device's newest revision.
- Each revision records the token that pushed it, so
vault serve token lsshows each device's last push and a misbehaving token is identifiable. - Storage is a directory on disk.
- The server sends no CORS headers. The Firefox extension reaches it through host permissions.
- Clients refuse plain
httpto anything except loopback. A token sent in clear on the home network would let anyone on that network push revisions. vault serveserves TLS with its own certificate or sits behind a reverse proxy. For a self-signed certificate,vault serve token addincludes its SHA-256 fingerprint in the URL and QR code, and the CLI and Android pin it. For a publicly trusted certificate (a reverse proxy with Let's Encrypt, which renews every 60 to 90 days), the fingerprint is left out and clients use ordinary certificate validation.- Firefox extensions cannot pin a certificate themselves, and
webRequest.getSecurityInfo()returns no certificates for a host trusted through an exception. Firefox trustsvault servethrough a certificate exception instead: the user opens the server URL in a tab once and accepts the certificate, after checking that the fingerprint Firefox shows matches the one in thetoken addoutput. Firefox stores the exception with the certificate's fingerprint and refuses the connection if the server later presents a different certificate, so the exception acts as a pin enforced by Firefox. - An exception belongs to one host name and port.
localhostand127.0.0.1are separate, so the extension uses the exact host name the exception was accepted for. - The extension's host permission names the server's host without a port: Firefox grants a pattern with a port but never matches it. A permission without a port covers every port of that host.
- When
vault serveruns on the same machine as Firefox, the extension useshttp://localhostand no certificate is involved. Firefox exempts loopback from the Manifest V3 defaultupgrade-insecure-requestsand from HTTPS-Only Mode. - A publicly trusted certificate (for example, Let's Encrypt through a DNS-01 challenge, which works for a host not reachable from the internet) also works, with no exception needed. It is an option for a user who already has a domain, not a requirement.
These Firefox behaviours were tested on 2026-10-12 against Firefox 157 (headless, temporary Manifest V3 extension, fetches from an extension page; exceptions added through the certificate override service that the "Accept the Risk and Continue" button calls):
| Request from the extension | Result |
|---|---|
https://localhost, self-signed certificate, no exception |
Fails |
Same, after an exception for localhost on that port |
Succeeds |
https://127.0.0.1, same port, with only the localhost exception |
Fails |
| Exception stored for certificate A, server presenting certificate B | Fails |
http://localhost and http://127.0.0.1, default Manifest V3 policy |
Succeeds, not upgraded |
| Same, with HTTPS-Only Mode on | Succeeds |
Because every client holds a full copy, losing the server loses nothing but revision history.
A new device needs two separate credentials from two separate places, plus the master password.
| Credential | Comes from | Grants |
|---|---|---|
| Server URL and token | vault serve token add <name> on the server |
The encrypted vault file |
| Secret key | vault key show on any existing client, or the emergency kit |
Nothing on its own |
| Master password | Memory | Nothing without the secret key |
The server and the secret key never meet: vault serve does not need a client on the same machine,
and no client sends the secret key anywhere.
- On the server, run
vault serve token add <name>. It prints the server URL and token as text and as a QR code. - On the new device, scan the QR code (Android) or paste the text (Firefox, CLI). Firefox, when
the server is not on
localhost, then opens the server URL in a tab, and the user accepts the certificate after comparing its fingerprint with the one in thetoken addoutput. - On an existing client, run
vault key show(orvault key show --qr). On the new device, type or paste the text, or scan the QR code (Android). - On the new device, type the master password. The device downloads the vault file and unlocks.
Recovery from the emergency kit is the same flow, with the secret key read from the kit in step 3. Onboarding and recovery share one code path.
Recommendation: stay with Rust, with a hard ceiling on core complexity.
| Option | For | Against |
|---|---|---|
| Rust (recommended) | One core runs natively in the CLI, via UniFFI on Android, and via WASM in Firefox; the server is a subcommand of the same binary; RustCrypto is mature; parts of ../vlt carry over |
Slower to write than the alternatives; Android build glue |
| Go | Fast to write; excellent CLI and server | gomobile is awkward; WASM output is several MB; weaker crypto library story for Argon2id in WASM |
| TypeScript | Native in the extension | CLI needs Node or Bun; no good path to a native Android core |
| Kotlin Multiplatform | Native on Android | Immature for CLI and WASM; thin crypto libraries |
Candidates to salvage from ../vlt: the secret key text form (secret_key_text.rs and the
Crockford codec in textcodec.rs), the password and diceware generator with its word list, the
Argon2id and XChaCha20-Poly1305 wrappers and their secret types, clipboard handling, the CLI
agent, the Bitwarden import mapping, and the CI and cargo-deny configuration. Everything about
storage, indexes, history chains, devices, and enrollment stays behind.
| Adversary | Can do | Must not be able to |
|---|---|---|
| Anyone with the server's disk or traffic | Read every revision of the blob, its size, and write times | Read any item, name, URL, or username without the master password |
| Thief with a device's sync token | Read the blob and every revision; push garbage revisions; re-push an old revision; stop sync by filling the quota | Decrypt anything; destroy the retained revisions (the server never deletes a revision on request); roll back any device that has synced before (rollback protection) |
| Server bug or misconfiguration (blob served without auth, data directory leaked) | Read the blob and every revision | Decrypt anything, even by offline guessing of the master password, because the current secret key is not on the server |
| Attacker on the home network | Observe and modify traffic | Read a token, or substitute a server (TLS required; certificate pinned on CLI and Android) |
| Thief with a locked device | Copy the local encrypted cache and the secret key | Unlock without the master password or that device's biometric or OS login |
| Malicious web page | Trigger fill prompts; embed frames; send messages to content scripts | Receive credentials for a different site; fill without a user action; learn anything about other items |
| Malicious Android app | Claim another app's package name; host a WebView and report a web domain | Receive credentials saved for a different app or site without a confirmation showing its real identity |
| Me, later | Forget the master password, or lose every device | Lose the vault while any device still unlocks with a biometric or agent session (that device can set a new password), or while I have the emergency kit and the master password |
A quick-unlock session can set a new master password without the old one. That is the recovery path in the last row, and it cuts both ways: someone who gets past a device's biometric or reaches a live CLI agent can change the password and lock the other devices out until the token is revoked and the password changed back from the emergency kit. This is accepted.
The secret key is there because the server is the component most likely to have a bug: it is new code, exposed to the network. With the secret key, a leaked blob is 128 bits plus a password away from being read, so the server's security is not load-bearing for confidentiality.
The residual risk is a stolen device: it holds the blob and the secret key, so the master password
is the remaining barrier against offline guessing. Defences are Argon2id parameters sized for the
slowest client, a diceware passphrase offered at vault init, and a warning for weak passwords.
After a device loss, vault passwd --new-secret-key restores the server-side guarantee for every
revision written from then on.
Security principles:
- Standard primitives only: Argon2id, XChaCha20-Poly1305, HKDF-SHA-256. No custom constructions and no post-quantum hybrid (nothing in this design uses public-key crypto).
- The body's AEAD authenticates the whole header, including the generation number.
- KDF parameters are read before anything can be verified, so authentication alone does not protect them. Every client rejects Argon2id parameters below the floor or above a ceiling before running Argon2id, whatever the vault file's source: the server, a backup, or an export. An import from another format (a Bitwarden export) has that format's lower bounds, but never exceeds the ceiling. The floor stops a client from writing weak parameters; the ceiling stops a file from exhausting memory, which matters most in the Firefox WASM build.
- Plaintext lives only in memory, with two stated exceptions:
vault edit(CLI-4) and plaintext export (CLI-8). Clipboard copies clear after 45 seconds. - Every client refuses to write a format version newer than it understands, and every client preserves fields it does not understand.
| Type | Fields |
|---|---|
| Login | Name, folder, username, password, URIs (each with a match mode), notes, custom fields, favourite flag |
| Secure note | Name, folder, notes, custom fields, favourite flag |
Every item also carries: UUID, created and modified stamps, password history (last 10), a totp
field (stored in v1.0, used in v1.1), and a passkeys list (stored in v1.0, used in v1.2).
- Custom fields are text or hidden.
- Folders are a path string (
Work/AWS), one level or many. - Bitwarden cards and identities import as secure notes with named custom fields, so no data is lost. Dedicated card and identity types are a later option, not a v1 requirement.
- URI match modes follow Bitwarden: base domain (default), host, exact, starts with, never.
- Login items record, per Android app, the signing certificate digest seen at first fill or save.
These rules hold on every client that fills, and the threat model's web page and Android app rows
depend on them. The matching rules themselves are normative in the spec
(Autofill matching) with test vectors, and are implemented
once in vault-client, so Firefox and Android cannot drift apart.
Firefox:
- Base domain means the registrable domain (eTLD+1) from the Public Suffix List. An item for one
github.iosite does not match another, and a host under a suffix the list does not know, such asnas.lan, matches only itself. - Matching uses the URL of the tab's top frame. An
httpsURI never fills anhttppage, except on a local host (loopback, a private address, or a name such asnas.lan), and there only after a confirmation. A user on a public network clicks through a confirmation too easily for it to protect a public site. A fill the user starts from the popup for an item that does not match the page asks first, naming the page's host, and is refused outright on anhttppage that is not a local host. - Fill reaches only the top frame and frames with the same origin as the top frame. A cross-origin iframe is never filled. The fill script checks the origin it runs in before writing anything, so a frame that navigates while the user picks an item receives nothing.
- The origin comes from the browser (the message sender and the tab URL), never from the page or a message a content script relays from it.
- The decrypted vault stays in the background script. A content script receives only the one credential being filled, and only after the user action.
- Nothing is ever written to
storage.sync.
Android:
- An app match is bound to the app's signing certificate digest, recorded at first fill or save. If the digest differs, or none is recorded, the user confirms, with the app's label and package name shown, before anything fills.
- A web domain reported by the autofill request is used only when the requesting app is on an allowlist of browser packages and certificates: Chrome and Firefox. From any other app, matching is by package name and certificate, so a WebView inside an ordinary app cannot ask for a website's credentials.
- An app is offered only the items already associated with it, through a recorded binding or an
imported
androidapp://URI. Any other item takes an explicit choice and a confirmation naming the item and the app. - Android reports a web domain without a port or a path, and each field's own frame host, but not which frame is the top page. Modes that need a port or a path ask first, and a fill reaches only the visible fields whose host and scheme equal the focused field's. A frame from the same host on another port cannot be told apart, and a framed page cannot be told from a top-level one. Chrome (133, tested 2026-10-13) labels a form in a cross-origin frame with the top page's host, so on a matched site such a frame receives a Chrome fill when I pick an item in it. Both are accepted (decided 2026-10-13); a current Chrome is re-tested on the phone.
- The fill picker unlocks only by biometric prompt; the master password is typed only in the app itself, never on a screen another app opened.
P0 blocks v1.0; P1 should land in v1.0 but does not block it; P2 is after v1.0.
| ID | Requirement | Priority |
|---|---|---|
| CORE-1 | Create, read, update, and delete logins and secure notes | P0 |
| CORE-2 | Unlock with master password and secret key; passwd changes credentials with a new vault key every time |
P0 |
| CORE-13 | Generation number in the authenticated header; rollback detected and reported, never merged | P0 |
| CORE-14 | Merge clamps future stamps and honours the purge horizon | P0 |
| CORE-15 | Argon2id parameter floor and ceiling checked before derivation, for every source | P0 |
| CORE-16 | An undecryptable or rolled-back remote keeps the local copy, warns, and stops syncing; vault sync --recover repairs it |
P0 |
| CORE-11 | Emergency kit at vault init: secret key as text and QR code, plus the server URL |
P0 |
| CORE-12 | Secret key text form: VLT1- Crockford base32 with check value; forgiving decoder; typos reported before key derivation |
P0 |
| CORE-3 | Password generator: length, character classes, ambiguous-character exclusion, diceware passphrases | P0 |
| CORE-4 | Search by name, folder, username, and URI | P0 |
| CORE-5 | Offline use from the local copy; sync and merge as described above | P0 |
| CORE-6 | Preserve unknown fields across read, edit, and write | P0 |
| CORE-7 | Password history per item, including passwords lost in a merge | P0 |
| CORE-8 | passwd --new-secret-key: secret key rotation with a new emergency kit |
P1 |
| CORE-9 | Restore the whole vault, or a single item, from a server revision, as a new generation with re-stamped items | P1 |
| CORE-10 | Health report: reused, weak, and old passwords | P2 |
| ID | Requirement | Priority |
|---|---|---|
| SRV-1 | GET and conditional PUT of one blob, with ETag |
P0 |
| SRV-2 | Per-device bearer tokens: vault serve token add, ls, rm, editing the data directory locally; no network admin API; add prints the URL and token as text and QR code |
P0 |
| SRV-7 | The server never receives, stores, or logs the secret key, master password, or vault key | P0 |
| SRV-3 | Revision retention: newest 100, one per device per day for 90 days, and each device's newest | P0 |
| SRV-9 | 16 MB blob limit; storage quota that refuses pushes instead of deleting revisions | P0 |
| SRV-10 | Each revision records its pushing token; token ls shows each token's last push |
P0 |
| SRV-11 | vault serve revisions prune --before <revision>, run locally on the server |
P1 |
| SRV-4 | vault serve in the vault binary for Linux x86_64 and arm64; data in its own directory, separate from client state |
P0 |
| SRV-5 | TLS with a supplied certificate, or plain HTTP bound to localhost behind a TLS reverse proxy; token add output carries the certificate fingerprint for a self-signed certificate; no CORS headers |
P0 |
| SRV-6 | Server-only build with no vault code | P1 |
| SRV-8 | Container image | P2 |
| ID | Requirement | Priority |
|---|---|---|
| CLI-1 | Verbs: init, setup (prompts for server URL and token, secret key, and master password), key show [--qr], serve, unlock, lock, ls, show, add, edit, rm, mv, gen, find, sync, passwd |
P0 |
| CLI-11 | Refuse plain http except to loopback; pin the server certificate fingerprint from setup |
P0 |
| CLI-2 | show -c copies the password (or --field <name>) and clears it after 45 s; X11 and Wayland |
P0 |
| CLI-3 | Agent keeps the vault unlocked for a configurable idle time | P0 |
| CLI-4 | edit opens the item in $EDITOR as a readable text form, written only to a private (mode 0700) directory on a tmpfs and removed afterwards; known editor side files (Vim swap, undo, and viminfo; Emacs auto-save and backup) are disabled for that file. Other editors may still write elsewhere, and the documentation says so |
P0 |
| CLI-5 | --json output and stable exit codes |
P0 |
| CLI-6 | bash, zsh, and fish completion | P0 |
| CLI-7 | import bitwarden <file> for plain and password-protected JSON exports |
P0 |
| CLI-8 | export to encrypted vault file, Bitwarden JSON, and plain JSON; plaintext formats need an explicit flag and go to stdout or a new mode 0600 file, with a warning |
P0 |
| CLI-9 | history and restore over server revisions |
P1 |
| CLI-10 | rofi, fuzzel, or dmenu picker | P1 |
| ID | Requirement | Priority |
|---|---|---|
| FF-1 | Standalone: WASM core, talks to the server directly, no CLI required | P0 |
| FF-8 | Setup by pasting the server URL and token; for a remote server, accepting the certificate exception in a tab after a fingerprint check the extension prompts for; then typing or pasting the secret key, then entering the master password | P0 |
| FF-2 | Popup: search, view, copy username and password | P0 |
| FF-3 | Fill username and password on the current page from a user action (popup, keyboard shortcut, or context menu), following the autofill matching rules | P0 |
| FF-9 | Server certificate trusted through a Firefox certificate exception (or a publicly trusted certificate); the extension uses the exact host name the exception covers; plain http only to loopback |
P0 |
| FF-4 | Lock after a configurable idle time and on browser close | P0 |
| FF-5 | Add and edit items; generate a password into a field | P1 |
| FF-6 | Offer to save or update after a login form submits | P1 |
| FF-7 | PIN unlock for the current browser session; the PIN-wrapped key is held only in storage.session (memory), never on disk |
P2 |
| ID | Requirement | Priority |
|---|---|---|
| AND-1 | Setup by scanning the server token QR code, then scanning or typing the secret key (from vault key show or the emergency kit), then entering the master password |
P0 |
| AND-8 | Show the secret key as text and QR code after re-entering the master password | P1 |
| AND-2 | Browse, search, view, and copy; copied secrets flagged sensitive | P0 |
| AND-3 | Autofill service for apps and browsers, following the autofill matching rules (signing certificate binding; web domains only from allowlisted browsers) | P0 |
| AND-4 | Biometric unlock: the cached vault key is wrapped by a Keystore key that requires strong biometric authentication for every use (CryptoObject) and is invalidated when a new biometric is enrolled |
P0 |
| AND-9 | Refuse plain http, loopback included, in release builds (every app on a phone shares loopback); pin the server certificate fingerprint from the setup QR code |
P0 |
| AND-5 | Add, edit, and generate | P0 |
| AND-6 | Offline use from the local copy | P0 |
| AND-7 | Minimum Android 10 | P0 |
| ID | Requirement |
|---|---|
| TOTP-1 | Core computes RFC 6238 codes (SHA-1, SHA-256, SHA-512; 6 or 8 digits; any period) from the totp field |
| TOTP-2 | CLI: vault otp <item> [-c] |
| TOTP-3 | Firefox: show code with countdown; copy code to the clipboard after a fill |
| TOTP-4 | Android: show code with countdown; scan an otpauth:// QR code to set the field |
| TOTP-5 | Accept a pasted otpauth:// URI or a bare base32 secret everywhere |
| ID | Requirement |
|---|---|
| WA-1 | Item model stores discoverable passkeys (credential ID, private key, RP ID, user handle, counter) |
| WA-2 | Firefox: act as a WebAuthn authenticator for registration and assertion, with an explicit user confirmation |
| WA-3 | Android: Credential Manager provider for passkeys (Android 14 and later) |
| WA-4 | CLI: list and delete passkeys; no assertion support |
| WA-5 | Passkeys imported from Bitwarden in v1.0 become usable without re-import |
| Area | Requirement |
|---|---|
| Setup | New vault to first synced item in under 5 minutes; adding a device in under 2 minutes, including typing the secret key |
| Unlock | Argon2id at 64 MiB and 3 iterations by default, about 1 s or less on the slowest client (Firefox WASM or the phone); 128 or 256 MiB only by explicit choice |
| Latency | show from a warm agent under 100 ms with no network; sync runs in the background |
| Sync | A change on one online device is visible on another within 10 s of that device's next sync |
| Scale | 2,000 items without noticeable slowdown; vault file under 2 MB |
| Durability | Every client holds a full copy; the server retains revisions per SRV-3 |
| Recovery | Emergency kit plus master password plus a new token opens the vault on a fresh device |
| Integrity | No client ever merges a lower generation or treats an unreadable remote as empty |
| Cost | One small process on hardware I already run |
| Licence | MIT; dependencies MIT, Apache-2.0, BSD, ISC, Zlib, Unicode, or MPL-2.0, enforced by cargo-deny; one exception, webpki-roots (CDLA-Permissive-2.0), because it is the Mozilla root set the CLI trusts for a publicly trusted certificate. MPL-2.0 covers UniFFI, its generated Kotlin, and the Public Suffix List bundled in the Android app, which no tool checks because it is not a package |
| Simplicity | Format spec (header, body, item schema, merge rule, server API) kept as short as the rules allow; no client-specific data in the vault file |
The previous attempt grew by accepting each feature that was individually reasonable. These are off the table unless a concrete problem forces them back, and adding one needs a written reason:
- Per-item files or objects, and any index over them.
- S3, IAM, or any cloud-provider-specific storage integration.
- Per-device keys or wrapped copies of the vault key in the shared file.
- A server that unlocks, merges, or otherwise understands the vault.
- Any per-device variant of the secret key. Rotation of the one shared secret key is allowed (written reason: without it, one lost device permanently removes the server-side guarantee).
- Hash-chained or client-maintained history (the server's revisions are the history).
- Post-quantum or public-key cryptography.
- Pluggable storage backends. One server API; other transports come later, if ever, as adapters.
| Direction | Format | Notes |
|---|---|---|
| Import | Bitwarden JSON (plain and password-protected) | Folders become folder paths; cards and identities become notes with custom fields; TOTP and passkeys preserved; KDF parameters in a password-protected export are bounds-checked; after import, a reminder to delete the source file |
| Export | Encrypted vault file | The native backup; opens on any client with the master password and secret key |
| Export | Bitwarden JSON | The exit path; re-importable into Bitwarden or Vaultwarden; same handling as plain JSON |
| Export | Plain JSON | Behind an explicit flag; stdout or a mode 0600 file |
Acceptance test: import my real Bitwarden export, export to Bitwarden JSON, and diff field by field with zero unexplained losses.
- Format spec and core: vault file, keys, item model, merge rule, rollback protection, test vectors.
- Server and CLI, including import and export. Exit: daily CLI use for 2 weeks alongside Bitwarden.
- Firefox extension. First, confirm the transport test results from a background script as well
as an extension page (confirmed 2026-10-12 from a Manifest V3 event page, with
'wasm-unsafe-eval'in the content security policy). Exit: Bitwarden extension removed. - Android app. Exit: Bitwarden uninstalled from the phone; v1.0 tagged; Bitwarden account closed.
- v1.1 TOTP, then v1.2 WebAuthn.
| Topic | Proposed decision |
|---|---|
| Storage model | One encrypted vault file, item-level merge |
| Sync transport | vault serve, a dumb blob server in the vault binary, with conditional PUT (decided 2026-10-12) |
| Secret key | Required in v1.0, never held by the server; rotatable with passwd --new-secret-key (decided 2026-10-12) |
| Credential changes | Every passwd generates a new vault key and re-encrypts the body (decided 2026-10-12) |
| Device setup | Server token from vault serve token add; secret key from vault key show or the kit, typed, pasted, or scanned (decided 2026-10-12) |
| Network reach | v1.x assumes clients reach vault serve on the home network; away from home, the phone works from its local copy and syncs on return (decided 2026-10-12) |
| Secret key format | VLT1- Crockford base32 with check value, reused unchanged from ../vlt (decided 2026-10-12) |
| Client order | CLI, then Firefox, then Android (decided 2026-10-12) |
| Android distribution | A sideloaded APK signed with my own key; application ID com.codeheadsystems.vault; no Google Play services (decided 2026-10-13) |
| Firefox architecture | Standalone WASM, not native messaging to the CLI (decided 2026-10-12) |
| Firefox server trust | Certificate exception accepted once in Firefox, which pins the certificate; http://localhost when the server is local (decided and tested 2026-10-12) |
| FreeBSD | Not in v1.x; a future direction (decided 2026-10-12) |
| Data model | Bitwarden-shaped items with pass-style CLI verbs, not pass's text format |
| Stack | Rust |
| Name | vault for the binary and the crates (vault-core); the secret key prefix stays VLT1- (decided 2026-10-12) |
Still open: nothing at the requirements level. The format spec settles the rest.
Raised in the security review of Draft 3. spec/format.md settles each of these:
- HKDF inputs and domain labels, and a fresh salt on every credential change.
- The wrap AEAD's associated data covers format version, flags, vault ID, KDF parameters, and salt, but not the generation. The body AEAD authenticates the whole header, generation included, so routine writes need only the vault key, which is all a quick-unlocked client holds.
- Random 24-byte XChaCha20 nonces on every seal, never derived from a counter or the
ETag. - Concurrent credential changes: no tie-break rule is needed, because a credential change runs only
online and the conditional
PUTlets exactly one win; the other aborts. - XChaCha20-Poly1305 is not key-committing. Partitioning-oracle attacks are infeasible because the 128-bit secret key is an input to the key-encryption key; the spec records this.
Rules for individual clients that are not part of the shared format. Each client's design document settles the details:
- CLI agent socket in
$XDG_RUNTIME_DIR, mode 0600, with anSO_PEERCREDuser check. - Clipboard: password-manager MIME hints (
x-kde-passwordManagerHint) so clipboard managers skip copied secrets; clearing after 45 seconds does not purge a manager's history. Firefox refuses custom clipboard types, so the extension copies without the hint, and a clipboard manager may keep what it copies. - WebAuthn (v1.2): RP ID checked against the origin the browser reports; signature counter fixed at 0, because synced passkeys and last-writer-wins merge make counters go backwards; UV flag set only after a real unlock; cross-origin iframe calls handled. Passkeys registered on two devices for the same item at once may need a union merge rule; v1.2 decides.
Not scheduled for any release. Each needs its own requirements pass before it is picked up.
| Direction | Notes |
|---|---|
| FreeBSD | CLI and vault serve built and tested on FreeBSD amd64 |
| Remote reach | Sync with vault serve from away from home: public host with TLS, a VPS, or Tailscale |
| Pairing code | A short code relayed through the server (Magic Wormhole style) instead of typing the secret key; rejected for v1.x because it adds a key-exchange protocol and makes the server part of key transfer |
| S3 or RustFS transport | The same get and conditional put over an S3-compatible bucket, for running without vault serve |