Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

21 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

bw-agent

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.


Contents

  1. Why this exists
  2. Architecture
  3. Installation
  4. Command reference
  5. Cryptographic design
  6. Wire protocol
  7. Budget enforcement
  8. Security model
  9. Operations
  10. Testing
  11. Troubleshooting
  12. Status and limitations
  13. Design notes

1. Why this exists

The problem

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.

The design

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.

Universal fallback

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.

What the master password is not used for

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.


2. Architecture

┌─────────────── 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.

Crates

.            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.

State on disk

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.

3. Installation

Build

make            # builds everything; initialises the adw-askpass submodule first
make test       # cargo test --workspace

make 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 test must pass — specifically the RFC 9497 vectors in bw-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-keyctl

This uses cargo zigbuild if available (brew install cargo-zigbuild); plain cargo build needs a musl C toolchain because rusqlite bundles SQLite.

Server

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.target
sudo systemctl enable --now bw-keyd
journalctl -u bw-keyd -f

You 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.

Client

make install       # bw-agent, bw-proxy, adw-askpass -> ~/.local/bin
make install-nm    # native-messaging manifests for every browser found

make 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.

Enroll

On the server:

sudo -u bwkey bw-keyctl enroll --label laptop

Run bw-keyctl as bwkey, not as root. Root would create root-owned files in /var/lib/bw-key that 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.

Run it

For the first unlock, run the agent in a terminal so you can watch:

bw-agent

In 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

4. Command reference

bw-agent

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-pin costs 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.

bw-keyd

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.

bw-keyctl

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>.

Environment

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.

Make targets

all · submodules · static · install · install-server · install-nm · uninstall · uninstall-nm · test · launchd · launchd-unload · systemd · systemd-unload · clean


5. Cryptographic design

5.1 Key derivation

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 compute oprf_output themselves; 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 ikm is a different length. Moving between the two modes requires re-enrollment. This is the single most common way to confuse yourself with this tool.

5.2 Oblivious PRF

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 voprf crate, and deliberately nothing more. curve25519-dalek's RistrettoPoint::hash_from_bytes is not RFC 9380 hash_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.

5.3 Blob format

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 → fsyncrenamefsync the directory.

5.4 Randomness and zeroization

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.


6. Wire protocol

6.1 Transport

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.

6.2 Messages

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" }

denied is 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.

6.3 Unlock flow

 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.

6.4 Enrollment flow

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.

6.5 Extension-facing protocol

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).


7. Budget enforcement

This is the load-bearing component. Everything else is plumbing around it.

7.1 The design constraint

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:

  1. 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.
  2. 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.

7.2 Parameters

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.

7.3 The algorithm

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

7.4 The three invariants

These are the entire security argument for this component. A review of this code asks only these questions.

  1. 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 dozen fsync calls per day is nothing, and every optimisation here silently removes the only thing protecting the PIN. This is why the database runs synchronous = FULL.

  2. No client-controlled budget. The bucket moves only with elapsed time; lifetime moves only forward, or backward via bw-keyctl reset from an operator shell. Nothing arriving on the wire can increase either budget.

  3. 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.

7.5 Revocation

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.

7.6 Schema

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.


8. Security model

8.1 In scope

# 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

8.2 Out of scope

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.

8.3 Trust boundary

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.

8.4 Local hardening

  • The unix socket is created with umask(0o177) applied before bind, not chmod'ed afterwards — that ordering leaves a window at the ambient umask.
  • Every connection's peer uid is checked with SO_PEERCRED (Linux) or getpeereid (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.
  • unlockWithBiometricsForUser carries a userId; if it does not match the enrolled account the request is refused rather than answered with the wrong key.

8.5 Error handling policy

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"

9. Operations

Monitoring

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.

Alerting

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
  • lifetime crossing 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.

Backups

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.

Key rotation

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.

Co-hosting with Vaultwarden

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.


10. Testing

make test          # 48 tests

The gate

bw-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.

What else is covered

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 PermissionDenied on TcpListener::bind — they need loopback sockets. That is the sandbox, not the code.


11. Troubleshooting

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.


12. Status and limitations

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 → listrevoke.

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.


13. Design notes

Rejected alternatives

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.

Deferred

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.


License

See the repository. Bitwarden is a trademark of Bitwarden, Inc.; this is an unofficial, independent tool with no affiliation.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages