Server-assisted PIN unlock for the Bitwarden browser extension.
bw-agent stands in for the Bitwarden desktop application as the native-messaging
host behind the extension's "unlock with biometrics" feature, substituting a short
PIN for the biometric. A self-hosted server takes part in every unlock, so that PIN
guesses can be counted — and capped.
The server never learns your PIN, never sees your vault key, and cannot decrypt anything on its own.
- Why this exists
- Architecture
- Installation
- Command reference
- Cryptographic design
- Wire protocol
- Budget enforcement
- Security model
- Operations
- Testing
- Troubleshooting
- Status and limitations
- Design notes
A 6–8 character PIN carries roughly 20–50 bits of entropy. In the obvious design, the encrypted file on disk contains everything needed to check a guess except the PIN itself. Anyone who obtains that file — through a backup, a sync client, a cloud snapshot, a discarded disk — can enumerate candidates locally, at whatever rate their hardware allows, with no network and no rate limit.
Hardening the KDF only changes the cost per guess. That is a losing race against hardware. Sealing the file in a TPM relocates the secret without adding any entropy to it, and fails when the hardware does.
The only defence that scales is limiting the number of guesses. That requires two things: a counting party the attacker does not control, and a guarantee that a guess cannot be checked without contacting it.
Three mechanisms, each doing one job:
| Mechanism | Purpose |
|---|---|
| Oblivious PRF | A guess is uncheckable without a live server round trip. Eliminates offline attack. |
| Budget enforcement | Guesses are metered per device. Bounds online attack. |
| Revocation | The server destroys a device's key; that device's file becomes permanently undecryptable. |
An oblivious PRF (RFC 9497) lets the server apply a secret key to an input it cannot see. The client blinds the PIN, the server multiplies by a per-device key, the client unblinds. The server learns nothing about the input — not "computationally nothing", information-theoretically nothing — and the client cannot compute the result without the round trip.
Because each round trip yields exactly one candidate, with no batching and no amortisation across guesses, counting requests is counting guesses. That is the whole point of the construction.
Every failure path — server unreachable, device revoked, budget exhausted, database lost, laptop stolen and re-imaged — degrades to logging in with your Bitwarden master password. No vault data is ever at risk from a failure of this system.
That is what makes it safe to set aggressive limits and to make revocation irreversible. The worst outcome of any bug here is that you type your master password.
The master password appears exactly once, during enrollment, to obtain the user key through the normal Bitwarden login flow. It is then discarded. No master key material is stored, transmitted, or derivable anywhere else in this system.
┌─────────────── client host ────────────────┐
│ │
│ browser extension │
│ │ native messaging (stdio) │
│ ▼ │
│ bw-proxy │
│ │ unix socket, 0600, uid-checked │
│ ▼ │
│ bw-agent ──── askpass (PIN prompt) │
│ │ │
└────────┼───────────────────────────────────┘
│ Noise_IK over the tailnet
▼
bw-keyd ──── SQLite (per-device keys, budgets)
▲
bw-keyctl (admin, local only)
| Binary | Host | Role |
|---|---|---|
bw-proxy |
client | Native-messaging host registered as com.8bit.bitwarden. Relays framed stdio to the agent socket. Deliberately trivial. |
bw-agent |
client | Long-running user daemon. Speaks the extension's unlock protocol, prompts for the PIN, performs the OPRF exchange, decrypts the blob. Also the enrollment CLI. |
bw-keyd |
server | Holds per-device OPRF keys, enforces budgets, answers blinded queries. |
bw-keyctl |
server | Local admin CLI. Enrollment tokens, revocation, counter reset. |
. bw-agent client binaries: bw-agent, bw-proxy
bw-proto/ bw-proto lib: OPRF, Noise transport, wire messages, paths, RNG
bw-key/ bw-key server binaries: bw-keyd, bw-keyctl
adw-askpass/ adw-askpass optional GTK4 PIN prompter (submodule)
bw-proto exists so both ends share exactly one copy of the ciphersuite wiring,
the framing, and the message types. A silent divergence between client and server
is precisely the failure class this design cannot tolerate, so there is only one
implementation of each.
Client — $XDG_STATE_HOME/bw-agent, default ~/.local/state/bw-agent, mode 0700:
| File | Size | Mode | Contents |
|---|---|---|---|
blob |
124 B | 0600 |
The encrypted Bitwarden user key. See §5.3. |
identity.key |
32 B | 0600 |
X25519 static private key. This device's identity to the server. |
device.toml |
— | 0644 |
device_id, account id, label, server address, pinned server public key. No secrets. |
s.bw |
— | 0600 |
Unix socket to bw-proxy. |
Deliberately not ~/.cache. Cache directories are precisely what naive backup
and sync tools replicate, which is the exfiltration threat this design exists to
close. On macOS the directory is tagged with
com.apple.metadata:com_apple_backup_excludeItem at creation.
Both bw-agent and bw-proxy resolve these paths through one shared function.
An earlier version resolved the socket path twice, in two different ways, so
setting XDG_CACHE_HOME silently disconnected the pair with no error at all.
Server — /var/lib/bw-key (or $STATE_DIRECTORY), mode 0700:
| File | Mode | Contents |
|---|---|---|
devices.db |
0600 |
SQLite, WAL, synchronous=FULL. One row per device. |
server.key |
0600 |
X25519 static private key. Generated on first run. |
make # builds everything; initialises the adw-askpass submodule first
make test # cargo test --workspacemake runs git submodule sync --recursive && git submodule update --init --recursive before building. adw-askpass is a workspace member, so without it
Cargo fails with an opaque manifest error rather than anything actionable.
Before you enroll a real vault,
make testmust pass — specifically the RFC 9497 vectors inbw-proto/tests/rfc9497.rs. A blinding or domain-separation error is silent: the system works end to end and the offline protection is simply not there. Those vectors are the only thing that distinguishes the two cases. See §10.
For a server that is not the machine you build on:
make static # static x86_64-unknown-linux-musl build of bw-keyd + bw-keyctlThis uses cargo zigbuild if available (brew install cargo-zigbuild); plain
cargo build needs a musl C toolchain because rusqlite bundles SQLite.
sudo useradd --system --home /var/lib/bw-key bwkey
sudo install -m755 target/release/bw-keyd target/release/bw-keyctl /usr/local/bin/Create /etc/systemd/system/bw-keyd.service:
[Unit]
Description=bw-agent key server
After=network-online.target tailscaled.service
Wants=network-online.target
[Service]
Type=simple
User=bwkey
ExecStart=/usr/local/bin/bw-keyd
Restart=on-failure
RestartSec=5
StateDirectory=bw-key
StateDirectoryMode=0700
# Optional: run on every alert, with the message as argv[1].
# Environment=BW_KEYD_ALERT_COMMAND=/usr/local/bin/notify-me
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
NoNewPrivileges=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
SystemCallFilter=@system-service
MemoryDenyWriteExecute=true
[Install]
WantedBy=multi-user.targetsudo systemctl enable --now bw-keyd
journalctl -u bw-keyd -fYou should see listening on 100.x.y.z:8787 (static <base64>).
bw-keyd defaults to --bind tailscale0: it looks up that interface and picks its
tailnet address. It refuses to bind a wildcard address and exits. If the
interface has no tailnet address it fails with a message rather than silently
falling back to something reachable. --bind also accepts a bare IP (port defaults
to 8787) or a full addr:port.
make install # bw-agent, bw-proxy, adw-askpass -> ~/.local/bin
make install-nm # native-messaging manifests for every browser foundmake install-nm writes com.8bit.bitwarden.json for Chrome, Chromium, Brave,
Edge, Vivaldi and Firefox, on both macOS and Linux, with the correct
allowed_origins (Chromium family) or allowed_extensions (Firefox) key. Restart
the browser afterwards.
On the server:
sudo -u bwkey bw-keyctl enroll --label laptopRun
bw-keyctlasbwkey, not as root. Root would create root-owned files in/var/lib/bw-keythat the daemon then cannot write.
It prints a single-use token, valid 15 minutes, along with a ready-to-paste
command — the daemon publishes its listen address into the database, so the token
output already knows the right --server value:
token (valid 15 minutes, single use):
MEIDNBQGQ4DIMFZGKZ...
on the client:
bw-agent enroll --email <email> --server 100.64.0.1:8787 --token MEIDNBQGQ4DIMFZGKZ...
The token carries the server's static public key, so you never copy that separately, and the client pins it from the moment of enrollment.
On the client:
bw-agent enroll --email you@example.com \
--vault https://vault.example.com \
--server 100.64.0.1:8787 \
--token <token>Prompts, in order: master password (once), TOTP if you have 2FA, then the PIN twice. Registration with the server happens before the master-password prompt, so a stale or mistyped token costs you nothing but a retyped command.
Choose 8 characters. See §7.2 for what that buys you.
For the first unlock, run the agent in a terminal so you can watch:
bw-agentIn the extension: Settings → Unlock with biometrics → enable. Lock the vault,
click unlock, and you should get a PIN dialog. The agent logs -> unlock granted;
the server logs REQ device=… outcome=ok tokens=5.00 lifetime=1.
Then install it as a service:
make systemd # Linux, user unit
make launchd # macOS| Invocation | Effect |
|---|---|
bw-agent |
Run the daemon. Requires prior enrollment. |
bw-agent enroll --email E [--vault URL] [--password P] [--label L] [--server H:P --token T] |
Log in once, seal the user key under a PIN. --server/--token must be given together. |
bw-agent change-pin |
Re-seal the existing user key under a new PIN. |
bw-agent remove |
Delete all local state. |
bw-agent status |
Show local enrollment state. |
Global: --askpass <cli|osascript|adw-askpass|zenity|kdialog|ssh-askpass>. Without
it, the best available prompter is chosen automatically.
--vault defaults to https://vault.bitwarden.com; point it at your Vaultwarden.
change-pincosts two attempts from a server-backed device's budget: one round trip to open the blob under the current PIN, one to seal it under the new one. The salt is stable for the life of the blob, so only the PIN changes.
| Flag | Default | Meaning |
|---|---|---|
--bind |
tailscale0 |
Interface name, bare IP, or addr:port. Wildcards are refused. |
--state |
$STATE_DIRECTORY or /var/lib/bw-key |
Directory holding devices.db and server.key. |
--print-pubkey |
— | Print the static public key and exit. |
| Command | Effect |
|---|---|
list |
device_id, label, state, last_seen, tokens, lifetime |
enroll --label <name> |
Issue a single-use token, 15-minute expiry |
revoke <device_id> |
Destroy that device's key. Irreversible. Accepts a unique prefix. |
reset <device_id> |
Clear the lifetime counter |
panic --yes |
Revoke every device |
pubkey |
Print the server's static public key |
Global: --state <dir>.
| Variable | Used by | Meaning |
|---|---|---|
BW_KEYD_ALERT_COMMAND |
bw-keyd |
Program run on each alert, message as argv[1]. Spawned detached. |
XDG_STATE_HOME |
bw-agent, bw-proxy |
Client state directory root. |
STATE_DIRECTORY |
bw-keyd, bw-keyctl |
Server state directory (systemd sets this). |
SSH_ASKPASS |
bw-agent |
Used by the ssh-askpass prompter. |
all · submodules · static · install · install-server · install-nm ·
uninstall · uninstall-nm · test · launchd · launchd-unload · systemd ·
systemd-unload · clean
K = HKDF-SHA256(
ikm = scrypt(PIN, salt, N=2^17, r=8, p=1, dkLen=32) ‖ oprf_output,
salt = salt,
info = "bw-agent/v2/blob",
L = 32
)
Two independent inputs, each covering the other's failure mode:
oprf_output(64 B, requires the server) makes offline guessing impossible. This is the primary defence.scrypt(32 B, purely local, 128 MiB, ~0.5 s) is the backstop for server compromise. If a device key ever leaks, an attacker holding the blob can computeoprf_outputthemselves; without scrypt the remaining grind is a millisecond-per-guess HKDF. With it they pay 128 MiB and about half a second per candidate.
Neither input alone is sufficient and neither is redundant. Because scrypt takes ~0.5 s, it runs on a worker thread concurrently with the server round trip, so the two costs overlap. It is never cached across attempts.
A blob written without a server does not decrypt with one, and vice versa — the
ikmis a different length. Moving between the two modes requires re-enrollment. This is the single most common way to confuse yourself with this tool.
Ciphersuite: RFC 9497 OPRF, ristretto255-SHA512, mode 0x00 (base OPRF).
Verifiable mode (VOPRF) is not used: an AEAD failure already detects a server returning incorrect evaluations, and the client authenticates by device identity, so it has no anonymity to protect.
Client — Blind
inputElement = HashToGroup(PIN) // hash_to_ristretto255, RFC 9380
blind ←$ nonzero scalar mod ℓ
blindedElement = blind · inputElement → 32 bytes, sent
Server — Evaluate
evaluatedElement = k_i · blindedElement → 32 bytes, returned
Client — Finalize
N = blind⁻¹ · evaluatedElement
oprf_output = SHA-512( I2OSP(len(PIN),2) ‖ PIN
‖ I2OSP(32,2) ‖ SerializeElement(N)
‖ "Finalize" ) → 64 bytes
Why the server learns nothing. blind is uniform over the nonzero scalars, so
blindedElement is uniform over the group independently of the PIN. The
transmitted value carries zero bits of information about the input. A server that
logs every request forever and is later fully compromised gains nothing from those
logs.
Why the client cannot proceed alone. Computing oprf_output requires k_i;
recovering k_i from an evaluation is the discrete logarithm problem. Each round
trip yields exactly one candidate evaluation, with no batching, compression, or
amortisation across guesses.
Implementation note. This is a thin wrapper over the
voprfcrate, and deliberately nothing more.curve25519-dalek'sRistrettoPoint::hash_from_bytesis not RFC 9380hash_to_ristretto255— it omits the domain separation tag. Using it produces a system that works perfectly, silently disagrees with the RFC vectors, and provides none of the protection it appears to.
124 bytes, fixed size, no header, no magic value.
offset size field
------ ---- -----------------------------------------------
0 32 salt random at enrollment, stable for life
32 12 nonce fresh random on every write
44 80 ct ‖ tag AES-256-GCM, 80 B plaintext + 16 B tag
Plaintext (80 bytes):
offset size field
------ ---- -----------------------------------------------
0 64 user_key Bitwarden user key (32 enc ‖ 32 MAC)
64 16 padding zero on write, ignored on read
AEAD: AES-256-GCM under K, the stored nonce, and AAD = salt. Binding the
salt as associated data means one blob's ciphertext cannot be married to another's
salt.
There is no version byte. A format revision would be signalled by the file's length, which is validated before anything is parsed.
Writes are atomic: temporary file in the same directory → fsync → rename →
fsync the directory.
All key material comes from the OS CSPRNG via getrandom, through a single
bw_proto::rng module. No userspace PRNG is used for any key, salt, nonce, blind,
or identity.
Zeroizing<T> wraps the PIN, the blind scalar, the OPRF output, the scrypt output,
K, the user key, and — during enrollment only — the master password and master
key. voprf's client and server states are ZeroizeOnDrop.
Known holes, worth naming rather than papering over: AES-GCM internal buffers,
serde_json output buffers, base64 String allocations, and kernel socket
buffers. The user key is kept out of serde_json::Value entirely by serialising a
placeholder and substituting the real key into the JSON string afterwards; that
helps, but does not close the rest.
Noise_IK_25519_ChaChaPoly_BLAKE2s for evaluation,
Noise_NK_25519_ChaChaPoly_BLAKE2s for enrollment, via the snow crate.
IK is right for the steady state: the client knows the server's static public key
from enrollment and transmits its own inside the first message, so the server can
identify the device and apply per-device policy immediately. Enrollment uses NK
because the client's static key is not yet trusted at that point.
| Property | Rule |
|---|---|
| Client identity | identity.key, X25519 static keypair generated at enrollment |
| Server identity | Static public key pinned in device.toml. A mismatch is a hard failure — never a prompt to accept |
| Binding | Tailnet interface only. Wildcard binds are refused at startup |
| Sessions | Fresh handshake per request. No resumption, no long-lived sessions |
| Framing | 4-byte big-endian length prefix, then the Noise message. Max frame 65535 |
| Timeouts | 15 s server-side, 10 s client-side |
| Request/response cap | 8 KiB each |
Since one listener serves both patterns, the client sends a single pattern
selector byte before the first frame: 0x01 for evaluation (IK), 0x02 for
enrollment (NK). Anything else closes the connection.
The browser native-messaging path uses a native-endian length prefix, as the Chrome and Firefox specifications require. The network path uses big-endian. These are not unified, on purpose.
On replay. Noise IK permits a payload in its first message, but that message
is replayable by design — 0-RTT data has no replay protection, and a replayed
eval would consume a device's budget. So the request is not carried in the
handshake: both handshake messages have empty payloads, and the request and
response travel in transport mode afterwards. A replayer cannot complete the
handshake without the initiator's ephemeral private key, so a captured exchange
cannot be made to cost anything. The price is one extra round trip on a path that
runs a few dozen times a day.
JSON bodies inside the Noise session. The transport already provides authentication, integrity and replay protection, so nothing is signed at the application layer.
Request
{ "v": 2, "type": "eval", "blinded": "<base64, 32 bytes>" }{ "v": 2, "type": "enroll", "token": "<base32>", "static_pub": "<base64, 32 bytes>",
"device_id": "<hex, 16 bytes>", "label": "laptop" }There is deliberately no device_id on eval. Identity comes from the
authenticated Noise static key; a client-supplied identifier could only ever create
the possibility of a mismatch between the claimed and the authenticated identity.
Responses
{ "v": 2, "type": "ok", "evaluated": "<base64, 32 bytes>", "tokens_left": 4 }
{ "v": 2, "type": "throttled", "retry_after_s": 1200 }
{ "v": 2, "type": "denied" }
{ "v": 2, "type": "enrolled" }
deniedis returned for revoked devices, unknown static keys, and pending enrollments alike, with identical bytes and identical timing. Every denial is padded to a fixed 120 ms floor from handshake completion. Distinguishing these cases would create a device-enumeration oracle.
There is no success acknowledgement, and there cannot be one. See §7.1.
1. extension → proxy → agent unlockWithBiometricsForUser {userId}
2. agent check userId matches the enrolled account
3. agent prompt PIN via askpass
4. agent blindedElement = Blind(PIN)
agent [concurrently] scrypt(PIN, salt)
5. agent → server eval{blinded}
6. server consume budget, COMMIT + fsync
7. server evaluatedElement = k_i · blindedElement
8. server → agent ok{evaluated, tokens_left}
9. agent oprf_output = Finalize(PIN, evaluated)
10. agent K = HKDF(scrypt(PIN) ‖ oprf_output, salt)
11. agent decrypt blob → user_key
12. agent → extension userKeyB64 over the existing EncString channel
13. agent zeroize all intermediates
Step 6 happens before step 7, durably. See §7.3.
1. operator bw-keyctl enroll --label laptop
→ row inserted with state='pending', SHA-256(secret), 15 min expiry
→ token = base32( 0x02 ‖ server_static_pub[32] ‖ secret[32] )
2. client bw-agent enroll --server H:P --token T
→ device_id ←$ 16 bytes; identity.key ←$ X25519 keypair
3. client → server enroll{token, static_pub, device_id, label} over Noise_NK
(server static key comes from the token and is pinned)
4. server validate hash + expiry, check device_id is unused across *all* rows
including revoked tombstones, generate k_i, set state='active',
clear the token hash
5. client master-password login → user_key ← the only time it exists
6. client prompt PIN twice; salt ←$ 32 bytes
OPRF round trip → oprf_output
K = HKDF(scrypt(PIN, salt) ‖ oprf_output, salt)
write blob atomically; write device.toml
7. client zeroize user_key, master password, master key, PIN
The device key k_i is generated as a canonical ristretto255 scalar, not 32
arbitrary random bytes. Non-canonical values are rejected by the OPRF server
constructor, so a device would enroll cleanly and then fail every single unlock,
permanently. There is a regression test for this.
Unchanged from what the Bitwarden extension dictates, and not ours to modify: an RSA-OAEP-SHA1 handshake establishing a shared key, then Bitwarden "EncString" type 2 (AES-256-CBC + HMAC-SHA256) for each message.
Note that this protocol provides no cryptographic binding between the extension and the agent — a limitation inherited from Bitwarden's own design. It is mitigated here by the peer-credential check on the unix socket (§8.4).
This is the load-bearing component. Everything else is plumbing around it.
The server cannot distinguish a successful unlock from a failed one. It never sees the user key or the blob — only a uniformly random group element. Two consequences follow, and both have produced incorrect designs elsewhere:
- The budget cannot reset on success. It refills only with elapsed time, or by operator action. A client-sent "that worked!" message would be forged by an attacker holding the device after every wrong guess.
- The budget must exceed legitimate usage, since the server counts your unlocks alongside an attacker's. Your usage therefore sets the attacker's floor rate. No parameter tuning escapes this. Only PIN length does.
| Layer | Setting | Bound |
|---|---|---|
| Token bucket | capacity 6, +1 per 1800 s | 48 guesses/day |
| Lifetime counter | monotonic, ceiling 15 000 → auto-revoke | total exposure |
| Alerting | >30 in 24 h, or >200 in 7 d, or 80 % of ceiling | ends a grind within days |
Annual success probability against the bucket:
| PIN | Probability/year |
|---|---|
| 6 digits | 1.75 % |
| 7 digits | 0.18 % |
| 8 digits | 0.018 % |
Use 8 characters. Each additional character multiplies every figure above by 10 (digits) or ~70 (alphanumeric). Nothing else in this document comes close to that leverage. The minimum accepted is 4; anything under 8 logs a warning.
If the budget feels tight in practice, lengthen the PIN or raise the extension's vault-timeout setting — never raise the ceiling. Moving the timeout from 15 to 60 minutes cuts unlock frequency roughly fourfold at no security cost, because an already-unlocked extension is outside what this system protects.
elapsed = max(0, now - tokens_ts) ← clamp; clocks move backwards
tokens = min(6.0, tokens + elapsed / 1800.0)
tokens_ts = now
if state != 'active': → denied
if lifetime >= 15000: revoke(device); → denied
if tokens < 1.0:
retry_after = ceil((1.0 - tokens) * 1800)
UPDATE tokens, tokens_ts; COMMIT ← lifetime NOT incremented
→ throttled
tokens -= 1.0
lifetime += 1
UPDATE tokens, tokens_ts, lifetime, last_seen
COMMIT + fsync ← BEFORE evaluating
evaluatedElement = k_i · blindedElement → ok
These are the entire security argument for this component. A review of this code asks only these questions.
-
Fail-closed. Both counters persist, with
fsync, before the evaluation is performed. A crash, power loss, or kill signal must cost an attempt, never grant one. Do not batch, defer, or cache these writes — a few dozenfsynccalls per day is nothing, and every optimisation here silently removes the only thing protecting the PIN. This is why the database runssynchronous = FULL. -
No client-controlled budget. The bucket moves only with elapsed time;
lifetimemoves only forward, or backward viabw-keyctl resetfrom an operator shell. Nothing arriving on the wire can increase either budget. -
Throttled requests do not count. A request rejected by the bucket must not increment
lifetime. Otherwise an attacker who cannot pass the bucket can still exhaust the ceiling and destroy the device — turning the backstop into a denial-of-service lever.
Each has a dedicated test. See §10.
UPDATE devices SET oprf_key = NULL, state = 'revoked', revoked_at = ? WHERE device_id = ?;
VACUUM;With k_i destroyed, oprf_output is uncomputable by anyone — including the
operator, and including an attacker who compromises the server afterwards. That
device's blob is ciphertext under a key that no longer exists. Other devices are
entirely unaffected.
The row is retained as a tombstone so the device_id can never be reused;
enrollment against a revoked id is refused. Rotation is revoke followed by
re-enrollment.
On deletion durability: without a TPM, VACUUM does not guarantee erasure from
SSD spare blocks. This is accepted. A forensically recovered k_i is worthless
without the victim's blob and their PIN, and an attacker holding the blob has the
device and better options available.
CREATE TABLE devices (
device_id BLOB PRIMARY KEY, -- 16 bytes
static_pub BLOB UNIQUE, -- 32 bytes; NULL until enrolled
oprf_key BLOB, -- 32 bytes; NULL once revoked
enroll_hash BLOB, -- SHA-256(token secret); NULL after use
enroll_exp INTEGER,
label TEXT,
state TEXT NOT NULL, -- 'pending' | 'active' | 'revoked'
tokens REAL NOT NULL DEFAULT 6.0,
tokens_ts INTEGER NOT NULL,
lifetime INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
last_seen INTEGER,
revoked_at INTEGER
);Enrollment tokens and revocation tombstones are states within this one table rather
than separate tables: a device has exactly one row, from the moment a token is
issued until long after it is revoked. A small meta table holds the daemon's
published listen address so bw-keyctl enroll can print a complete command.
| # | Adversary | Capability | Outcome |
|---|---|---|---|
| T1 | Blob exfiltration | Obtains blob via backup, sync client, cloud snapshot, discarded disk |
Gains nothing. Cannot check even one guess. |
| T2 | Online guessing | Has the blob and the device, can reach the server | ≤48 guesses/day, hard lifetime ceiling, alerting |
| T3 | Server compromise | Root on the server, all device keys, all logs | No vault access. PIN still scrypt-hardened. |
| T4 | Network adversary | Passive or active on the tailnet | Learns nothing. Cannot replay. |
| T5 | Local unprivileged process | A different unix user on the client | Cannot reach the agent socket |
| T6 | Device theft, powered off | Has the disk | FDE covers it; the blob is inert regardless |
Malware running as you. It keylogs the PIN, reads identity.key, and opens a
perfectly legitimate session. Budget enforcement is the only residual defence and
it is not sufficient. No design at this layer fixes this, and this document does
not pretend otherwise.
Compelled disclosure. Key-disclosure regimes attach penalties to failure-to-decrypt. Cryptography does not answer this.
A malicious server operator. You run the server. If you do not, do not use this.
| Property | Guaranteed by |
|---|---|
| Server never learns the PIN | Mathematics — blinding by a uniform random scalar is information-theoretic |
| Server never sees the user key | Never transmitted |
| Server alone cannot decrypt | Holds k_i only; lacks the blob |
| Compromised server gains nothing retroactively | Logged queries are uniformly random points |
| Server counts guesses honestly | Trust. Policy, not mathematics. |
Revocation destroys k_i |
Trust. |
Rate limiting is irreducibly a trusted operation — nothing compels a party to count. The mitigations are to keep that trusted surface tiny (§7.3 is about thirty lines) and to note that a server which lies about counting still holds nothing that decrypts anything, and the attacker still needs the blob.
- The unix socket is created with
umask(0o177)applied beforebind, not chmod'ed afterwards — that ordering leaves a window at the ambient umask. - Every connection's peer uid is checked with
SO_PEERCRED(Linux) orgetpeereid(macOS); anything other than the daemon's own uid is refused. - Session state lives per-connection rather than in a map keyed on a
client-supplied
appId, which is both unbounded and unauthenticated. - Concurrent connections are capped at 16; the PIN prompt is serialised by a mutex while the socket keeps accepting.
- Malformed extension input returns
invalidateEncryption. It never terminates the daemon — a single bad message used to be enough to kill it. unlockWithBiometricsForUsercarries auserId; if it does not match the enrolled account the request is refused rather than answered with the wrong key.
Never distinguished, in message or in timing:
- wrong PIN · corrupted blob · AEAD failure → one generic failure
- revoked · unknown · pending device →
denied
Deliberately distinguished, because these are operational rather than security-relevant, and a user who cannot tell them apart will do something worse:
- server unreachable → "server unreachable — unlock with your master password"
- throttled → "too many attempts, retry in N minutes"
bw-keyd writes one line per request to stderr (journald):
1785547469 REQ device=ea8df932… outcome=ok tokens=5.00 lifetime=1
Outcomes: ok, throttled, denied, enrolled, bad-element, error.
The blinded element is never logged. It is harmless in principle — a uniformly random group element — but there is no reason to retain it.
Set BW_KEYD_ALERT_COMMAND to a program that reaches you. It is spawned detached
with the message as argv[1] and never waited on, so a hanging hook cannot block a
transaction or a client. Alerts fire on:
- any
denied— a correctly configured device never produces one - any enrollment token use
lifetimecrossing 80 % of the ceiling- more than 30 attempts in 24 h, or 200 in 7 d, for any device
Alerting is the layer that actually terminates an attack; the counters merely bound the damage while it fires. Route it somewhere you will read while travelling, and test that path.
| Item | Back up? |
|---|---|
device.toml |
Optional; convenience only |
blob, identity.key |
No. Losing them costs a 30-second re-enrollment; backing them up recreates T1 exactly. |
devices.db |
Only if you accept that a restore resurrects revoked devices. Safer not to, and to re-enroll after a rebuild. |
restic backup ~ --exclude "$HOME/.local/state/bw-agent"
borg create ::archive ~ --exclude "$HOME/.local/state/bw-agent"
rsync -a --exclude '.local/state/bw-agent' ~/ dest:/backup/macOS marks the directory Time Machine-excluded automatically; verify with
xattr -l ~/.local/state/bw-agent.
Changing your Bitwarden master password does not invalidate a compromised user key — it re-wraps the same key under a new master key. If the user key is believed compromised, use Rotate account encryption key in the web vault, which re-encrypts the entire vault, then re-enroll every device. Know this before you need it.
Acceptable, but it places a small hard target behind a large soft one. Required
mitigations: separate unix user, separate database, Vaultwarden containerised with
no filesystem path to devices.db, and bw-keyd bound to the tailnet interface
only.
Keeping Vaultwarden itself tailnet-only does more for your overall posture than most of the cryptography in this document.
make test # 48 testsbw-proto/tests/rfc9497.rs checks BlindedElement, EvaluationElement, Output,
and the full chain against the RFC 9497 §A.1.1 vectors for
ristretto255-SHA512, mode 0x00. These must pass before enrolling a real
vault. A blinding or DST error is silent: everything works, and the protection is
simply absent.
| Area | Tests |
|---|---|
| Blob format | round-trip, wrong key fails generically, length validated before parse, salt-as-AAD tamper detection |
| Key derivation | OPRF input changes the key, salt changes the key, determinism |
| OPRF | full exchange agrees, different key differs, wrong input differs, malformed element rejected |
| Noise transport | IK authenticates the client, NK carries no client identity, wrong server key fails |
| Enrollment | valid token activates, single use, garbage denied, expired denied, revoked id never reused, pattern/message mismatch denied |
| Budget | 7th rapid request throttled, refill only with time, capacity capped, backwards clock does not over-refill, retry_after correct |
| Invariant 1 | attempt is committed and durable before the key is returned |
| Invariant 2 | no wire message increases either counter; unknown key creates nothing |
| Invariant 3 | throttled requests leave lifetime untouched |
| Backstop | ceiling auto-revokes and nulls the key rather than throttling |
| No oracle | revoked and unknown are byte-identical; denials share a timing floor |
| Interface | tailnet ranges recognised, neighbours rejected, missing interface errors rather than falling back |
Running the suite inside a restrictive sandbox will fail four tests with
PermissionDeniedonTcpListener::bind— they need loopback sockets. That is the sandbox, not the code.
| Symptom | Cause |
|---|---|
bw-proxy: connect …: No such file or directory |
The agent is not running, or XDG_STATE_HOME differs between the two processes. Check bw-agent status. |
server unreachable — unlock with your master password |
Expected and safe. Tailnet down, or bw-keyd not running. |
too many attempts, retry in N minutes |
Bucket empty. Wait, or bw-keyctl reset if this was your own doing. |
this device is not authorised |
Revoked, unknown, or never finished enrolling. The server does not distinguish these on purpose. |
unlock failed |
Wrong PIN, or a corrupted blob. Also deliberately indistinguishable. |
| Unlock always fails immediately after enrolling | The blob and the derivation disagree — almost always enrolled without a server and now running with one, or the reverse. Re-enroll. |
interface tailscale0 has no address |
tailscaled is not up or not logged in. Or pass --bind ADDR:PORT explicitly. |
refusing to bind …: give the tailnet address explicitly |
You passed a wildcard. That is intentional; name the interface or address. |
| Extension never offers biometric unlock | Manifest missing or browser not restarted. Re-run make install-nm. |
Server logs denied for a device you just enrolled |
The device_id collided with a tombstone, or the token expired. Issue a fresh token. |
For a first unlock, run bw-agent in the foreground — the log narrates every step.
Tested: 48 tests pass, including the RFC 9497 vectors, the three budget
invariants, enrollment over the real Noise transport against a real SQLite file,
and a live smoke run of the actual binaries through token issue → device
registration → list → revoke.
Not yet verified: this has never completed an unlock against a real vault. The master-password login and the extension handshake are carried over from the previous version's code paths, but the full path has not been exercised end to end in this design. Expect to debug the first unlock.
That is a low-stakes debugging session by construction: every failure falls back to unlocking with your master password, and nothing in the vault is at risk.
Recommended before relying on it: have the OPRF wrapper and the fail-closed path reviewed by someone else. A subtle error in blinding or in the commit ordering is silent and total — everything continues to work, and the protection simply is not there.
| Considered | Rejected because |
|---|---|
| Server stores per-device wrapped blobs | The server becomes a vault; one exfiltration re-enables offline grinding for every device |
| TPM2 sealing | Relocates the secret without adding entropy; fails when the hardware does; no TPM in the target deployment |
| YubiKey HMAC challenge-response | Portable and effective, but loss kills every blob and it provides no revocation. A reasonable addition later, not a replacement. |
A dev_secret file as a second KDF input |
Sits in the same directory with the same permissions as the blob; no realistic leak separates them. scrypt covers the case that actually matters. |
| Stronger KDF alone (Argon2id, higher scrypt) | Buys hours against a GPU. Not security for 20 bits. |
| Verifiable OPRF (VOPRF) | An AEAD failure already detects a lying server; the client has no anonymity to protect |
| Threshold OPRF across two servers | Genuine trust reduction, double the infrastructure. Revisit only if the single server becomes untrusted. |
| Client-sent success acknowledgement to reset the budget | Broken. An attacker holding the device forges it after every wrong guess. |
| Offline fallback cache | Moot with Vaultwarden tailnet-only — no tailnet means no vault to unlock. Would also reinstate offline grinding and delay revocation. |
Purely client-side blob-format changes; no protocol modification required.
Multi-slot table. Replace the single blob with a fixed 8-slot table, all slots always present and indistinguishable from random, trial-decrypted in constant time. Makes the number of enrolled PINs unanswerable from the file. Of limited value while the binary, the unit file and the native-messaging manifest all announce the system's presence.
Duress PINs. With a slot table, an action byte per slot could trigger self-revocation instead of unlocking, presenting to an observer as a failed unlock. Requires constant-time scanning, or unlock latency reveals which slot matched.
Push approval. With several devices enrolled, an unlock on one could require approval on another. Probably a larger practical gain than most of the cryptography above, and inexpensive now that the server exists.
Decoy vault. Not achievable with the extension protocol:
unlockWithBiometricsForUser carries a userId and the extension is logged into
exactly one account, so returning another account's key produces a decryption
failure rather than a plausible decoy — worse than a clean failure, because it
looks like corruption. A real decoy needs a second browser profile.
See the repository. Bitwarden is a trademark of Bitwarden, Inc.; this is an unofficial, independent tool with no affiliation.