AutoPass security design
This document describes how AutoPass protects a vault, what each component can and cannot see, and where the design currently falls short. Parameters are taken from the code in this repository; file paths are given so you can check them.
AutoPass 1.0.0 has not been independently audited. Read "Known limitations" before trusting it with data you cannot afford to lose or leak.
Reporting a vulnerability
Email sales@endopowersports.com (Endo Powersports LLC, which operates AutoPass) with a description, the affected version or commit, and steps to reproduce. Please do not open a public issue for anything that could put users' vaults at risk.
- We aim to acknowledge reports within 3 business days and agree on a fix timeline within 10.
- We credit reporters in the release notes unless asked not to.
- Good-faith research against your own AutoPass server and accounts is welcome. Do not test against servers or accounts you do not own, do not access other people's data, and do not run denial-of-service tests against shared infrastructure.
Threat model
AutoPass is built so that the sync server can be fully compromised (database copied, code replaced, traffic read) without exposing vault contents, and without being able to trick a client into encrypting new data to keys the server knows.
In scope
- A malicious or breached sync server, or anyone holding a copy of its database or backups.
- A network attacker between client and server (TLS is required in production; SRP adds mutual authentication on top).
- Online password guessing against the login endpoints, and account enumeration.
- Malicious websites trying to get credentials or passkey signatures out of the browser extension: look-alike domains, hostile iframes, scripted clicks, clickjacking overlays, scripts reading the page.
Out of scope
- Malware or a hostile extension on the user's device with access to browser memory or extension storage while the vault is unlocked.
- Users who choose a weak master password and leak their Secret Key.
- Anyone who obtains a user's recovery code (if one is set up) and knows their email: the code is a full key and can reset the master password (see "Account recovery").
- A malicious server serving modified web-vault JavaScript to a browser that uses the web vault (see "Known limitations"). The extension's code is fixed at install time.
What the server stores, and what it never sees
| The server stores | The server never receives |
|---|---|
| Email address (lowercased) and a random account ID | The master password |
| KDF parameters: algorithm, iteration count, 16-byte salt | The Secret Key |
| Secret Key version tag (not the key) | The Account Unlock Key (AUK) |
| The encrypted keyset (private key + symmetric key, under the AUK) | Keyset contents in plaintext |
| The keyset public key (SPKI); clients do not trust it, see below | Any vault key in plaintext |
SRP group name, salt and verifier v = g^x mod N |
The SRP exponent x |
| Per vault: owner, encrypted name, per-member role and wrapped vault key | Vault names, item titles, URLs, usernames, passwords, notes, TOTP secrets, passkey private keys, tags |
| Per item: vault, encrypted overview, encrypted details, version, sync sequence number, trashed-at and updated-at timestamps | |
| Per attachment: its random ID, vault, item, ciphertext, ciphertext size, SHA-256 of the ciphertext, upload time | File names, types, contents and file keys |
| Sessions: SHA-256 hash of each bearer token, creation and last-use time, the device name the client gave, and the trusted device it came from | Bearer tokens in plaintext |
| Vault invitations: vault, inviter, recipient account ID and email, role, the vault key and name sealed to the recipient, the inviter's proof | The vault key or name inside an invitation |
| Share links: the encrypted item snapshot, a SHA-256 hash of the access token, expiry, view limit and count, creator, vault and item IDs | Share-link decryption keys (they live only in the link's URL fragment) |
| Travel safety: an on/off flag per account and a safe-for-travel flag per vault | |
| Account recovery (if set up): a second SRP salt and verifier derived from the recovery code, the keyset and Secret Key encrypted under a key derived from the code, and when it was set up | The recovery code, or anything derived from it that could open the encrypted copy |
| Recovery sessions: SHA-256 hash of each short-lived recovery token, its expiry, and a server-keyed fingerprint of the recovery code's and password's SRP credentials it was opened with (it ends when either changes) | Recovery tokens in plaintext |
| Two-step verification (if on): the authenticator secret, AES-256-GCM encrypted under a key derived from the server secret, and the last time step used; the phone number (E.164) for text messages; an HMAC of each unused backup code; for each trusted device an HMAC of its token, its name, and when it was trusted, last used and expires | Backup codes and trusted-device tokens in plaintext, and anything that decrypts the vault |
A per-deployment secret for decoy responses (AUTOPASS_SECRET, or generated into the database) |
|
| Optional Redis (several API processes): rate-limit counters, failed-proof timestamps, and in-flight SRP handshakes, all expiring within 15 minutes. Key names are HMACs of the real keys; handshakes and other values are AES-256-GCM encrypted under a key derived from the server secret | Emails or IP addresses in key names; handshake secrets readable without the server secret |
What a server operator can learn is metadata: the email address, how many vaults and
items an account has, ciphertext sizes, when items change, how many files are attached to
each item and how big each one is (to within 29 bytes; files are not padded), and client IP addresses (used
for rate limiting, in memory or, with Redis, only as HMACs in key names, and recorded in the reverse proxy's access log; the API's own
request log records only time, method, path, status and duration). The schema is in
apps/api/src/store.ts.
Key hierarchy
master password ─ NFKC ─ PBKDF2-HMAC-SHA256 (650,000 iter, 16-byte salt) ─► masterHash (32 B)
│
Secret Key (128 bits, generated on the device) ──── HKDF-SHA256 salt ─────────┤
│
info "AutoPass/auk/<accountId>" ──► AUK (AES-256-GCM, non-extractable)
info "AutoPass/srp/<accountId>" ──► SRP x ──► verifier v = g^x mod N
AUK ─AES-256-GCM, AAD accountId─► keyset { ECDH P-256 private key, 256-bit symKey }
symKey ─AES-256-GCM, AAD vaultId+accountId─► vault key (32 random bytes)
vault key ─AES-256-GCM, AAD vaultId+itemId+field─► item overview, item details
vault key ─AES-256-GCM, AAD vaultId─► vault name
file key (random, per file, kept in item details) ─AES-256-GCM, AAD vaultId+itemId+attachmentId─► file
recovery code (160 bits, optional) ── HKDF-SHA256, salt = 16 random bytes per code ──┐
info "AutoPass/recovery-wrap/v1/<accountId>" ──► recovery wrapping key │
info "AutoPass/recovery-srp/v1/<accountId>" ──► recovery SRP x ──► verifier
recovery wrapping key ─AES-256-GCM, AAD "autopass/recovery/v1|<accountId>"─► { keyset private key, symKey, Secret Key }
Two-secret key derivation (packages/core/src/kdf.ts)
- The master password is normalised with Unicode NFKC, then stretched with PBKDF2-HMAC-SHA256, 650,000 iterations, a 16-byte random salt, 256-bit output.
- That output is the input key material to HKDF-SHA256, with the 16-byte Secret
Key as the HKDF salt. Two independent 32-byte keys come out, separated by the
infolabel and bound to the account ID: the AUK and the SRP exponent seed. - Because the Secret Key is mixed in, everything the server holds (salt, iteration count, encrypted keyset, SRP verifier) is useless for offline password guessing without also having the 128-bit Secret Key.
- Clients enforce a minimum of 650,000 iterations. A server that hands back weaker KDF parameters (to make a captured verifier cheaper to attack) is refused.
Secret Key (packages/core/src/secretkey.ts)
128 bits from crypto.getRandomValues, rendered as a two-character version prefix plus 26
Crockford base32 characters in groups of 6-5-5-5-5. Parsing is strict: characters outside
the alphabet or a wrong length are rejected rather than skipped. The key is shown once at
account creation (with a downloadable Recovery Kit text file) and can be shown again in
Settings after re-entering the master password. It is never sent to the server in a form
the server can read: only account recovery, if set up, uploads it, encrypted under a key
derived from the recovery code (see "Account recovery").
Keyset (packages/core/src/keyset.ts)
- Each account has one keyset: an ECDH P-256 private key and a random 256-bit symmetric key, stored together as a single AES-256-GCM blob under the AUK. The blob's associated data is the account ID, so a server cannot substitute another account's keyset.
- On unlock the client derives the public key from the private key and rejects a server-supplied public key that does not match. A server therefore cannot get a client to seal anything to a key the server controls.
Vault keys
- Every vault has its own random 256-bit vault key.
- The account's own vault keys are wrapped with the keyset's symmetric key (AES-256-GCM, associated data = vault ID + account ID, and in the current format the key version and where the key came from). Each device remembers the newest key version it has adopted for each vault and never goes back to an older key, or to a different key at the same version, so a server can't roll a vault back to a key a removed member still holds by replaying an old wrap (tested). Old-format wraps (no version) are accepted only while no version above 1 has been seen. Each device also remembers which key it holds at the newest version, so a different key at that same version is refused, and a key accepted on re-joining a vault is labelled above the old one (tested). A brand-new device has no record yet. Because that key exists only inside the encrypted keyset, the server cannot create a "vault" whose key it knows and have the client accept it, and cannot move a wrapped key from one vault to another.
- Public-key sealing is used only to deliver a vault key in an invitation (see
"Sharing" below), and a sealed key is never used as-is: the recipient re-wraps it under
its own symmetric key when it accepts. Sealing is ECIES-style: a fresh ephemeral P-256
key pair, ECDH with the recipient's public key, HKDF-SHA256 with info
AutoPass/seal/v2- ephemeral public key + recipient public key, then AES-256-GCM with both public keys
(and a context string,
autopass/vaultkey-share/v1|<vaultId>for vault keys) as associated data. Blob layout:[0x02][ephemeral key length, 2 bytes BE][ephemeral SPKI][AEAD blob].
- ephemeral public key + recipient public key, then AES-256-GCM with both public keys
(and a context string,
Item encryption (packages/core/src/aead.ts, vault.ts)
- Each item is two independent ciphertexts under its vault key: a small overview (category, title, URLs, tags, favorite flag) and the details (all fields, custom fields, password history, passkey key material). The server cannot tell a login from a credit card.
- AEAD is AES-256-GCM with a fresh random 96-bit IV per encryption and a 128-bit tag.
Blob layout:
[version 0x01][IV, 12 bytes][ciphertext + tag]. - Every ciphertext is bound to where it belongs. Item ciphertexts carry the vault ID, the item ID and the field name (overview or details) as associated data; item IDs are generated on the client. Vault names are bound to the vault ID. A server that swaps ciphertext between items, moves it to another vault, or swaps an overview with a details blob causes a decryption failure, not a silently wrong item.
- Moving an item to another vault re-encrypts it under the destination vault's key.
Attachments (packages/core/src/file.ts, apps/extension/src/engine/attachments.ts)
- Every attached file gets a random ID and its own random 256-bit file key. The file
is encrypted with AES-256-GCM (same blob layout as items) with associated data
autopass/file/v1|<vaultId>|<itemId>|<attachmentId>. - The file key, name, MIME type, size and the SHA-256 of the ciphertext are stored in the item's encrypted details, so they sync with the item and the server never sees them.
- The server stores the ciphertext (
PUT /v1/files/:id) and checks its SHA-256 on upload. The client checks the SHA-256 again on download and then decrypts with the AAD for the item it expects, so a server that serves the wrong bytes, another item's file or a file moved from another vault causes an error, not a wrong file. - Moving an item to another vault creates a new item ID, so each file is decrypted and re-encrypted with a new attachment ID and key under the new vault and item IDs.
- Ciphertext stays on the device until the server acknowledges the upload (an offline
queue), and downloaded ciphertext is cached on the device (
chrome.storage.localwithunlimitedStoragein the extension, IndexedDB in the web vault). Only ciphertext is cached; it is removed when the item is purged, its vault is deleted, or you sign out. - Encrypted backups include attachment contents inside the encrypted payload.
Encrypted export
Settings > Import & export > Export encrypted backup produces a JSON file whose payload
is AES-256-GCM encrypted under a key derived from a separate backup password (minimum 8
characters) with PBKDF2-HMAC-SHA256 at 650,000 iterations and a fresh 16-byte salt. The
Secret Key is not involved, so the backup password alone protects the file.
Sharing (packages/core/src/sharing.ts, apps/api/src/sharing.ts, apps/extension/src/engine/sharing.ts, apps/extension/src/engine/rotation.ts)
Sharing needs a sync server; an account without one shows "Set up sync to share".
Vault sharing and roles
- Finding the recipient. A manager enters an email; the client asks the server for
that account's public key (
POST /v1/keys/lookup, email in the body, never the URL). The server's answer is not trusted on its own: the client shows a fingerprint, the first 128 bits of SHA-256 over the public key (autopass/fingerprint/v1|+ SPKI), as 8 groups of 4 hex digits. Each user's own fingerprint is in Settings > Sharing, so the two people can compare them in person or on a call. A server that substitutes its own key cannot make the fingerprints match. The inviter's client seals the vault key only to a key the user already confirmed for that person, or after the user confirms its fingerprint ("They read me this same fingerprint"). If the server returns a different key than the confirmed one, the client refuses ("this person's key changed") until the fingerprints are compared again; a confirmed contact is never replaced silently (tested). - The invitation. The client seals the vault key to the recipient's public key
(bound to the vault ID), seals the vault name the same way, and adds a proof:
HMAC-SHA256 over (vault ID, inviter ID, recipient ID, role, sealed key, sealed name)
under a key derived (HKDF, info
AutoPass/invite-auth/v1+ both public keys) from a static-static ECDH between the inviter's and the recipient's keyset keys. Ephemeral sealing alone could be done by anyone who knows the recipient's public key, including the server; the proof can only be made by the holder of the inviter's private key. - Acceptance is required. The server stores the invitation and never turns it into a membership. The recipient's client lists it with the inviter's email and fingerprint and the decrypted vault name; an invitation whose proof doesn't verify (or whose sealed key doesn't open for that vault ID) is shown as unverifiable and can only be declined. On Accept the client opens the sealed key, re-wraps it under its own keyset symmetric key with AAD vault ID + its own account ID (exactly like its own vaults), and sends only that wrapped key. From then on the existing sync path is used unchanged: the client only ever opens vault keys wrapped under its own symmetric key, so a key the server seals to a user and lists as a membership is ignored (tested), and an invitation moved to another vault fails to verify (tested).
- What the proof does and doesn't show. The proof is checked against the inviter's public key, and that key comes from the server. A server can make an invitation with its own key pair and any inviter email, and the proof will check out. So an invitation counts as verified only when the recipient has already confirmed that person's fingerprint (on this device). Otherwise Accept stays disabled until the recipient ticks that the inviter read them the same fingerprint (tested).
- Roles are server-enforced authorization, not cryptography: viewer (read only), editor (create, edit and trash items, create share links), manager (invite, change roles, remove members, rename the vault, set its travel flag). Only the vault's creator can delete it, and never their last vault. Every member holds the vault key, so the server is what stops a viewer's writes; the clients also refuse edits to view-only vaults and the UI hides them. The vault's creator cannot be removed or demoted. The server re-checks the caller's role inside the transaction that writes an invitation, a rotation or an attachment, so a demotion that lands mid-request wins (tested).
- Who a vault key is shared with is decided by the client. The server lists a vault's members and their public keys, but clients don't trust that list. Each vault carries a member list (fingerprints of account ID + public key) encrypted under the vault key inside the vault-name blob. The creator writes it, and an inviter adds the exact key whose fingerprint the user saw. A rotation seals the new key only to keys on that list: an unlisted member stops the rotation with a visible error (a manager can re-confirm that member's fingerprint), and an invitation whose key changed is dropped. A vault is never rotated just because the server asks, and never if the list names nobody besides you, so the server can't use a rotation to get your Personal vault's key sealed to itself (tested). Every change to the list raises a revision counter inside the encrypted blob and is written as a compare-and-swap; each device remembers the newest (key version, revision) it has seen and never adopts an older list, and a different list at the same revision is treated as a fork: the device keeps its own, republishes it if it can, and warns (tested). A vault from before member lists gets no list from the server: a device with no copy of its own lists only itself and the people it already confirmed (with the key it confirmed); everyone else shows as "Not verified" until a manager confirms their fingerprint, and rotation stops at them (tested). Cancelling an invitation or removing a member unpins the entries this device recorded for that person, not whatever key the server reports.
- Removing a member rotates the vault key. The manager's client makes a new random vault key, decrypts every item and re-encrypts it (and the vault name) under the new key, wraps the key under its own keyset, and seals it to every remaining member as a key update. It sends all of that in one request; the server removes the member and swaps the ciphertext in a single transaction, and refuses the whole rotation (409, the client syncs and retries) unless it covers exactly the vault's current items and members. The member's share links for the vault and their pending invitations go too; other pending invitations are re-sealed with the new key and re-issued under new IDs, so an accept prepared with the old key can't land. Items keep their version (nothing changed for the user) and get new sequence numbers, so every member pulls the new ciphertext. The new key is never wrapped under the old one: the removed member has that.
- Key updates carry the new key sealed to the member's public key (context
autopass/vaultkey-rotate/v1|<vault>|<version>) and a proof: HMAC-SHA256 over (vault ID, key version, rotator ID, member ID, sealed key) under a key derived (HKDF, infoAutoPass/rekey-auth/v1+ both public keys) from the static-static ECDH between the rotator's and the member's keysets and the vault key the member already holds. So the server, which has no vault key, can't forge or replay one (tested), and an update meant for another member or another version doesn't verify (tested). Updates chain one version at a time, so a member who slept through several rotations verifies each with the key before it. The member's client then re-wraps the newest key under its own keyset (the only form it ever opens on later syncs) and re-encrypts anything it still has queued or cached under the old key; its other devices notice the re-wrapped key and do the same. - Writes under an old key are refused. Every item write carries the vault key version it was encrypted under; the server answers 409 for an older one, and the client accepts the key update, re-encrypts the queued change and sends it again (tested). A manager whose key is behind can't invite or rotate until it catches up.
- Leaving, or a member's account being deleted, flags the vault; the next sync of any manager whose key is current rotates it (no member is removed in that case), provided that manager's own member list says someone else had the key. Items a deleted account wrote into other people's vaults stay there and pass to each vault's owner.
- What rotation doesn't do. Anything the removed member already synced or opened may remain on their devices and in copies they made, including attachments (each has its own file key, kept in the item, which they could already read); rotation protects what is written after they're gone. Change passwords that matter after removing someone. A removed member knows the old key and could forge a member list or a key update for that key version, so a removed member working together with the server could have a key of their choosing accepted by remaining members (they would also appear as the update's sender), and the server can replay an older member list within one key version. Rotation defends against a removed member on their own, and against the server on its own, not against both together.
- Default vault. New items, captured logins, passkeys and imports default to the account's first vault, then to another vault the client's own authenticated records say this account created and nobody else is in. The server's "owned" and "shared" labels and its ordering are only a fallback for vaults made before those records existed, so a server can't steer captured logins into a vault whose key it knows (tested).
- Enumeration. For an unknown email, the key lookup returns a stable decoy: the same
account ID that
srp/starthands out for that email and a valid P-256 public key. The decoy key is a random key whose private half is discarded at once (only the public key is stored, per email), so nobody, including whoever holds the database and the deployment secret, can open an invitation sealed to it (tested). Both branches do the same work. An invitation to a decoy is accepted and stays pending, exactly like one to a real account that hasn't answered. Invitations are listed, accepted and declined only by the account whose email they were sent to, never by an account that merely has the ID, and registration refuses an account ID that pending invitations are addressed to, so nobody can claim an invitation to an email that hasn't registered (tested). So the lookup reveals no more than registration already does (see limitation 2). Lookups are limited to 30 per minute per IP and 200 per hour per account. Members of a vault can see each other's email addresses and fingerprints.
Item share links
- The client makes a snapshot of the item (title, website, the category's fields,
custom fields; never a passkey) and
encrypts it with AES-256-GCM under a fresh random 256-bit key, with associated data
autopass/share/v1|<shareId>. - One-time codes stay out unless the user opts in (
apps/extension/src/engine/onetime.ts). That covers the item's own one-time-code field and its stored secret, and also any other field or extra field holding anotpauth://link or one of those secrets (spaces, dashes and case ignored). It also covers every one-time code a 1Password import kept: a second code on the same item, or one inside a structured value. Detection works on content, so a secret pasted in some other form (for example as a QR code image in an attachment, which is never shared) isn't recognized. - The server stores the ciphertext, a SHA-256 hash of a random 256-bit access token, the
expiry (the app offers 1 hour to 30 days; the server accepts 1 minute to 30 days) and the view limit (1, 5 or unlimited). The link is
<server>/s/#<token>.<key>: the key is only in the URL fragment, which browsers do not send to servers. Tests check that the key never appears in any request URL or body, in the database, or in the request log. GET /v1/shares/<token>needs no account, is rate-limited (60 per minute per IP), and counts the view in the same transaction that returns the ciphertext. Unknown or revoked tokens get 404; expired or used-up links get 410 and their ciphertext is wiped. The API's request log replaces the token with:token; a reverse proxy's access log may still record it (never the key).- The share viewer is part of the web vault (served at
/s/). It decrypts in the page, removes the fragment from the address bar, and sendsReferrer-Policy: no-referrer. Like the web vault, it trusts the server for its code (limitation 7). - Owners can list and revoke their links (Settings > Sharing, or Share… on the item). Purging the item, deleting the vault or deleting the account deletes its links. Trashing an item does not. A link is a copy: anyone who opened it may have kept it.
Travel safety
- Managers mark vaults safe for travel; each account has a travel switch. While
it is on, the server leaves every other vault out of
GET /v1/vaults,GET /v1/itemsand every role check for that account, so each device forgets those vaults (keys, items, queued changes) on its next sync, and the account can't read or change them through the API. Other members of a shared vault are unaffected. - Turning it on needs only a session. Turning it off needs the master password (checked on the device) and a fresh SRP login: the server refuses sessions older than 10 minutes, like a password change. Someone holding an unlocked device can't bring the vaults back without the master password.
- When it is turned off, the server re-stamps the hidden vaults' items in the change feed so every device downloads them again.
- It protects devices, not the server: the hidden vaults stay on the server. A device that doesn't sync after the switch is turned on keeps its copy, so open each device online before you travel.
Authentication: SRP-6a (packages/protocol/src/srp.ts)
The master password is never sent anywhere, not even hashed. Login is a password- authenticated key exchange:
- Group: RFC 5054 2048-bit prime, generator
g = 2. Hash: SHA-256. k = H(pad(N) | pad(g)),u = H(pad(A) | pad(B)), with 256-bit random ephemerals.xis the 2SKD output above (reduced mod N), so it depends on both the master password and the Secret Key.- Proofs in the RFC 2945 / RFC 5054 form with SHA-256:
K = H(pad(S)), client proofM1 = H(H(N) xor H(g) | H(I) | s | pad(A) | pad(B) | K)whereIis the account ID andsthe SRP salt the server reported instart(decoys included), and server proofM2 = H(pad(A) | M1 | K). A proof made for one account or salt can't be replayed for another (tested). The server compares M1 in constant time. The client verifies M2 and stops if it does not match, so a server that does not hold the verifier cannot impersonate the real one. - The server rejects
A ≡ 0 (mod N); the client rejectsB ≡ 0 (mod N). - A challenge lives at most 60 seconds and is single-use.
- A challenge is bound to the complete credential it was started with: a server-keyed
fingerprint of the SRP group, salt and verifier (
store.credentialStamp).finishchecks that the account still exists with exactly that credential after verifying the proof, and again after its last asynchronous step, in the same SQLite transaction that issues the session (so another API process sharing the database can't change it in between). A password change, recovery reset or account deletion while a challenge is open therefore makes a proof made with the old password fail like a wrong one. The pending two-step sign-in carries the fingerprint captured atstart, never one read later. The recovery login works the same way and is bound to both the recovery code's complete credential and the password's (tested incredential-races.test.ts).
Flow: POST /v1/auth/srp/start {email, A} returns {challengeId, B, accountId, kdf}; the
client derives x, then POST /v1/auth/srp/finish {challengeId, M1} returns
{M2, accessToken}.
Enumeration resistance and brute-force limits
Indistinguishable decoys. For an email with no account,
srp/startanswers like a real account: a stable account ID (a well-formed UUID of the same variant as real ones), salt and verifier derived with HMAC-SHA256 from the per-deployment secret, default KDF parameters, and the same amount of server work. The proof never verifies.Registration validates before it checks existence, so a malformed request can't be used to probe for an email without paying the full validation path. A well-formed request for a taken email still returns 409 (see limitations).
Lockouts are keyed by email + network (an IPv4 address, or an IPv6 /64, with IPv4-mapped addresses treated as IPv4). Ten failures in 15 minutes lock that network out for that email only, with growing delays after five. A per-email backstop (100 failures in 15 minutes from all networks) bounds guessing spread across many networks, but it never locks a network that signed in successfully, or registered, for that email in the last 30 days, and it never stops anyone from starting a sign-in or a recovery: networks that have never signed in share one password check per minute for that email until the attack stops, so a sign-in from a new network may be asked to wait. Every attempt is counted before it is checked, so parallel requests can't get past these limits (tested); the same holds for two-step codes.
Per-network rate limits (fixed windows; IPv6 counted per /64):
bucket limit all /v1/requests600 per minute POST /v1/auth/srp/startand/finish,POST /v1/auth/recovery/startand/finish,POST /v1/auth/mfa/smsand/verify30 per minute (one shared bucket) POST /v1/account(registration)10 per hour POST /v1/keys/lookup(sharing recipient)30 per minute (and 200 per hour per account) GET /v1/shares/<token>(open a share link)60 per minute POST /v1/shares,POST /v1/vaults/<id>/invites30 per minute POST /v1/vaults/<id>/rotate(vault key rotation)20 per minute Behind a reverse proxy set
TRUST_PROXY=1so the client address fromX-Forwarded-Foris used (only a proxy that overwrites that header). On Fly.io setTRUST_PROXY=fly: the address comes from Fly'sFly-Client-IP, because Fly appends to anX-Forwarded-Forthe client sent, so its first entry can be forged.AUTOPASS_RATE_LIMIT=offexists for tests only.Where the counters live. Rate limits, lockouts and the SRP handshake between
startandfinishare kept in one place, the server's "guard": in the process's memory by default, or in Redis whenREDIS_URLis set, so several API processes share one budget and one lockout per (email, IP), a sign-in can finish on a different process than it started on, and none of it resets when the API restarts. In Redis, key names are HMACs (no emails or IPs) and stored values, which include the server's secret SRP ephemeral, are AES-256-GCM encrypted under an HKDF-derived key from the server secret with the key name as associated data. If Redis is unreachable, each call falls back to the process's own memory, so an outage weakens limits to per process rather than stopping sign-in; failures recorded during an outage still count, in that process, once Redis is back (see limitation 15).Local unlock does not touch the server. Unlocking a device that already has the account decrypts the cached keyset locally. Only if that fails does the client ask the server whether the KDF salt changed (the password was changed on another device); if the salt is the same, the password is simply wrong and no proof is sent. Typos never count toward the server's lockout.
Google sign-in (apps/api/src/google.ts, apps/extension/src/ui/google.tsx, googleDesktop.ts, desktop google.rs)
Google only confirms who you are. It never receives a password, Secret Key or vault key, and a Google confirmation can't open a vault: signing in on a device still needs the master password and Secret Key (and two-step verification, if it's on).
- What a confirmation can do. The server turns a verified Google identity into a short-lived (10 minutes), single-use proof. That proof can register a new account whose email matches the Google account, or link Google to a vault the user has just unlocked (a login from the last 10 minutes, with a matching email or the same existing link). It never creates a session or returns a token.
- The OAuth flow. Authorization code with PKCE (S256), a nonce,
prompt=select_accountand the scopesopenid emailonly. The ID token is verified against Google's fixed key endpoint: RS256, issuer, audience, authorized party, expiry, issue time, nonce andemail_verified. Thestateis single-use and bound to a__Host-cookie in the browser that started the flow; starts from another site are refused. - Web vault. After Google returns, the page collects the proof with a same-origin
request; it is kept in memory only and the
?googleparameter is removed from the URL. - Desktop app. Google's policies don't allow sign-in in an embedded web view, so the
app opens sign-in in the default browser:
- The app makes a random 256-bit secret, keeps it in memory, and sends the server only
its SHA-256. The server answers a handoff id and a link (
/desktop/begin?h=<id>). - The native side opens that link, and only that link: https on an AutoPass server, the
exact path, and nothing but the handoff id in the query (
google.rs; tested). - The link first shows a confirmation page in the browser, with a warning not to continue if someone else sent the link. Google starts only after the user presses Continue there (a form post that must come from the AutoPass origin), and only within two minutes of the app creating the handoff, so a sign-in link someone else started and sent you is refused, and a link can't start Google on its own (tested). The link runs the normal flow above once; opening it again is refused. When Google returns, the server stores the result under the handoff and the browser tab only says to go back to AutoPass: no proof or token is in any URL, cookie or page.
- The app polls with the handoff id and the secret; the result is handed over once (concurrent requests: exactly one wins; tested), as the same proof as on the web.
- Cancel in the app, cancelling at Google, a failure, or 10 minutes (from the start,
however often the link is opened) ends the handoff; a late browser return then gets
nothing for the app (tested).
The handoff id alone is useless without the secret. Unbegun handoffs live two minutes,
and new handoffs are capped server-wide, so this unauthenticated step can't fill the
server's shared state and crowd out sign-in for everyone. The app's polling routes take no
cookies, so they allow any origin (the desktop window's origin is
tauri://localhost); the cookie-backed web routes don't.
- The app makes a random 256-bit secret, keeps it in memory, and sends the server only
its SHA-256. The server answers a handoff id and a link (
Sessions and tokens
- A successful SRP login (or registration) returns a random 256-bit bearer token. The server stores only its SHA-256 hash.
- Sessions expire after 30 days without use (sliding) and 180 days after creation (absolute). Expired sessions are pruned hourly.
- Tokens are sent only in the
Authorizationheader, never in cookies, so the API's permissive CORS policy cannot be used for cross-site request forgery. - The device keeps its session token across lock and unlock, so locking does not create a new server session each time.
- Changing the master password requires a fresh login (the client re-runs SRP with the current password first). The client generates a new KDF salt, re-encrypts the keyset under the new AUK and registers a new SRP verifier. Every other session is revoked; other devices must unlock with the new password. Items and vault keys are not re-encrypted.
- Deleting the account requires the master password and a session created in the last 10 minutes (the client performs a fresh SRP login first). The server deletes the account, its vaults, items, memberships and sessions in one transaction.
- Sign out revokes the device's token, stops trusting the device for two-step verification, and removes AutoPass's data from that device.
- Settings > Devices lists the account's sessions (device name, when it signed in, last activity, which one is this device) and its trusted devices. Signing a device out deletes its session and forgets its trust; "Sign out everywhere else" does that for every session and trusted device but this one. These need only a session, not a fresh login: they only take access away. A device that is still unlocked keeps its login secret (see "Unlock with your device" and the session data above), so a signed-out device without two-step verification can sign straight back in; change the master password to cut it off for good. With two-step verification on, it needs a code.
- Recovery tokens (see "Account recovery") are separate from sessions: SHA-256 hashed, 10-minute lifetime, usable only to fetch the recovery blob and reset the password. A reset revokes every session and returns one new session for the device that did it.
- Server address. Clients refuse a server URL that is not
https://, exceptlocalhost/127.0.0.1for local development.
Local-only accounts and connecting a server later (apps/extension/src/engine/localfirst.ts)
The extension works without any server. Creating an account without one runs exactly the
same client-side account creation (account ID, Secret Key, keyset, first vault) and
registers nothing: the profile stores serverUrl: null, and the encrypted cache on the
device is the vault. No outbox is kept and the engine makes no network requests for a
local account (a unit test runs the whole lifecycle with a fetch that always throws).
Changing the master password re-wraps the keyset locally under a new salt and AUK.
Connecting a server later (Settings > Sync across devices) first re-proves the master password on the device, then registers the existing account: the same server record a synced signup would send (account ID, email, KDF parameters, Secret Key version, encrypted keyset, public key), an SRP verifier derived from the current password, and the first vault. Every other vault is created with its own ID and wrapped key, and every item (including trashed items and passkeys) is uploaded with its own ID. Nothing is re-encrypted: ciphertext stays bound to its account, vault and item IDs, so the server learns exactly what it would have learned had the account synced from the start. Before each step the client asks the server what it already has, so a connect interrupted half way is simply run again. If registration says the email or account ID already exists, the client starts an SRP login but sends a proof only if the server reports this account's own ID and KDF salt; an email registered by someone else gets a clear error and never receives a guess. Turning sync off keeps the device's copy and signs out the session; the server copy stays for other devices.
Unlock with your device (packages/core/src/deviceunlock.ts, apps/extension/src/engine/biometric.ts, apps/extension/src/ui/deviceUnlock.ts)
Opt-in, per device: open the vault with Touch ID, Windows Hello or the device's screen lock instead of typing the master password. It never replaces the master password; it is a device-bound shortcut to the result of a password unlock.
How it works. The UI page creates a WebAuthn platform credential (authenticatorAttachment: "platform", userVerification: "required", residentKey: "discouraged", attestation
none) with the PRF extension and a random 32-byte salt. PRF makes the authenticator
return a 32-byte secret for that credential and salt, only after it has verified the user;
the client also checks the UV flag in the authenticator data. That secret is never stored:
PRF output ─HKDF-SHA256, info "<ctx>|prf-key"─► AES-256-GCM key ─AAD "<ctx>|dk-device"─► device key DK (random 256-bit)
keyset symKey ─HKDF-SHA256, info "<ctx>|keyset-key"─► AES-256-GCM key ─AAD "<ctx>|dk-keyset"─► DK (second wrapping)
DK ─AES-256-GCM, AAD "<ctx>"─► bundle { privateKey, symKey, SRP x, KDF salt, sealedAt, expiresAt }
<ctx> = "autopass/device-unlock/v1|<accountId>|<credentialId>"
Unlocking runs navigator.credentials.get with allowCredentials = that one credential,
user verification required, and the stored salt; the PRF output unwraps DK, DK opens the
bundle, and the engine rebuilds the session exactly like a password unlock (the private key
must match the account's public key, and only vault keys wrapped under the bundle's symKey
are opened). The WebAuthn ceremony runs in the page because MV3 service workers have no
navigator.credentials; the page passes only the credential ID and the PRF output to the
worker (extension pages only, as for every RPC), which does all the cryptography.
Where it lives. One record, deviceUnlock, in device-local storage only:
chrome.storage.local in the extension, localStorage in the web vault. It holds the
credential ID, the PRF salt, the two wrapped copies of DK, the encrypted bundle, and plain
copies of the expiry and KDF salt for the UI. It is never synced, exported or sent to the
server, and the server has no notion of it. No new extension permission is used.
The bundle is password-equivalent on this device. It contains the SRP exponent x
so that sync can log in again after a browser restart (when no session token is left).
Whoever can make this device's authenticator answer (pass Touch ID / Windows Hello / the
screen lock) and read this browser profile's storage can open the vault and log in to
the server as the account, until the bundle expires or the password changes. It does not
reveal the master password or the Secret Key, and the UI still requires the master
password to show the Secret Key, change the master password, delete the account, turn off
travel safety, connect a server, and turn device unlock on.
Decisions and limits.
- Turning it on requires an unlocked vault and re-entering the master password.
- 14 days. Device unlock works for 14 days after the master password was last entered on this device; device unlocks don't extend it. The expiry is inside the authenticated bundle (the plain copy is only for display), so editing the record doesn't help, and a clock set back to before the bundle was sealed is refused. Every password unlock re-seals the bundle with a fresh window, using the copy of DK wrapped under the keyset symKey, with no extra platform prompt. Holding the symKey already means holding the vault, so that wrapping gives nothing away.
- Password changes. Changing the master password on this device re-seals the bundle
with the new
xand KDF salt. A bundle whose KDF salt differs from the device's current one is refused until the master password is entered once. When the password is changed on another device, every other session is revoked; the next sync here fails to log in and device unlock is erased on this device. A device that is offline (or kept offline) can still be opened with the stale bundle until it expires, exactly as the old master password still opens that device's cached vault. - Erased on sign out, account deletion, "Turn off" in Settings, a record for another account, and any failure to decrypt (wrong authenticator secret, tampering): the user is told to unlock with the master password and turn it on again. A cancelled or refused prompt is not a failure and changes nothing.
- RP ID. The browser's default for the page: in the extension, Chromium scopes the
credential to the extension origin itself (the virtual-authenticator tests show RP ID
chrome-extension://<extension id>), so no website can request or use it; in the web vault it is the server's host name. IP-address origins (http://127.0.0.1) can't be RP IDs, so the web vault offers it only on a host name. - Research notes (Chromium, verified with the CDP virtual authenticator,
hasPrf: true). WebAuthn create/get work onchrome-extension://pages with no permission;getClientCapabilities()reportsextension:prf; PRF results are returned at creation and are stable per credential and salt (32 bytes) and differ per salt. The client falls back to a second (get) prompt when an authenticator only evaluates PRF on sign-in, and refuses authenticators without PRF or without user verification. - Extension popup. The popup closes when the system's verification dialog takes focus, so its "Unlock with your device" button opens AutoPass in a tab, which starts the prompt; enrollment is done from the tab.
- Passkey hook. AutoPass's own page hook passes platform-authenticator creation requests that ask for PRF straight to the browser (AutoPass passkeys can't evaluate PRF), so the web vault's device unlock works with the extension installed.
Threat model. Device theft: the thief needs the device's user verification (a fingerprint, face or the device PIN/password) as well as the browser profile; without it the record is ciphertext under a key only the authenticator can produce, and the vault still needs the master password. Biometric bypass or a known device PIN: gives vault access on this device for up to 14 days; change the master password from another device (cuts server access at once and erases device unlock here at the next sync) or turn it off. Malware on the device that can drive the authenticator, or that runs while the vault is unlocked, was already out of scope; malware that only reads storage gets nothing it couldn't get before. Synced passkey providers: some platforms (e.g. iCloud Keychain) sync the credential; that alone doesn't open anything, because the bundle never leaves this device.
In the desktop app (macOS): Touch ID (apps/desktop/src-tauri/src/devicekey.rs)
The desktop window can't use WebAuthn (its origin is tauri://localhost, not a website),
so the native side stands in for the passkey's PRF. What is enforced where:
- Turning it on needs an unlocked vault and the master password again (checked by the vault window, as above), then macOS confirming the user (LocalAuthentication: Touch ID, Apple Watch or the login password; native side). The native side makes a random 256-bit device secret and a random credential ID, keeps the secret in the user's login keychain as an item of this app (another app asking for it gets a macOS prompt), under a keychain service of its own for each AutoPass account, and hands both to the vault window once.
- One enrollment at a time (native side). A second "turn on" is refused while one is between enrolling and settling (for up to 5 minutes, the vault window's own limit). When the vault has saved an enrollment, that account's other items are removed, but never one still in flight; a cancelled or failed enrollment removes only its own item. Enrolling or turning off one account never affects another's (tested on a throwaway keychain).
- Unlocking asks macOS to confirm the user, then reads the secret back for that credential ID. The vault window uses it exactly like a PRF output: it unwraps the device key that seals the bundle, with the same 14-day limit, the same refusal after a password change, and the same "wrong secret: turn device unlock off" rule (vault window; tested).
- Seal time (native side). Each keychain item records when its bundle was last sealed with the master password (in the item's generic attribute): set at enrollment, renewed only after a master-password unlock re-seals the bundle (the vault window tells the native side; a Touch ID unlock never renews it). The native side refuses to hand out a secret whose seal time is more than 14 days plus one day's grace old, in the future, or missing (items made before this check: the next master-password unlock renews them). This is a second, independent copy of the bundle's own 14-day limit; it doesn't stop code that reads the keychain item directly (see below).
- The secret goes with the record (vault window, then native side). Whenever the vault window erases its device-unlock record (Turn off, sign out, account deletion, a decrypt failure, a password change on another device, a record for another account), it tells the native side to forget that account's keychain items; and if an account has no record at all, it does so once per session, so a secret left behind (AutoPass quit mid-way) is cleaned up. Forgetting removes every item of the account except an enrollment in flight; the file-based keychain deletes one matching item per call, so the native side deletes until none is left.
- What's different from the browser version. A passkey's PRF output comes from the platform authenticator only after its own user verification. Here the Touch ID check is made by AutoPass before it reads the keychain item; the item itself isn't bound to Touch ID by macOS (that needs a keychain access group entitlement, which a Developer ID app without a provisioning profile can't have). So code that can read AutoPass's keychain items without a prompt, or run inside AutoPass, could skip the Touch ID step and the seal-time check; it would still need the sealed bundle from this Mac's AutoPass data, and gets nothing after 14 days or a password change (the bundle's own limits). Windows: not available yet (no Windows Hello unlock).
Browser extension isolation
The extension (apps/extension/src/background.ts, content.ts) is a Chrome MV3
extension whose service worker owns the vault engine.
- Key storage. While unlocked, decrypted key material and the session token live in
chrome.storage.session: memory only, cleared when the browser closes, and restricted to trusted extension contexts so content scripts cannot read it. The vault survives the service worker being stopped and restarted. Locking removes it. - Persistent storage (
chrome.storage.local) holds the account profile (including the Secret Key, see limitations), the ciphertext cache, the pending-change outbox and settings. It never holds the master password, the AUK, the private key or vault keys in plaintext. - Two RPC surfaces. Extension pages (popup, full-page tab) get the full engine API,
checked by sender ID and
chrome-extension://URL. Content scripts get a narrow set of messages, accepted only from the top frame of anhttp(s)page; the content script is also injected withall_frames: false. - The worker decides what matches. The worker uses the page URL reported by the
browser (
sender.url), never anything the page claims, and requires the sender's security origin (sender.origin) to be the same as that URL's origin. A sandboxed document (Content-Security-Policy: sandbox, origin"null") on a saved site gets nothing, and the content script and passkey hook stay inert in it (tested). List results contain titles and usernames only. A password is released only for a specific item, after the worker re-checks that item's saved URL against the sender's URL. Popup "Fill" re-checks the active tab's URL, and the content script refuses if the page's origin changed meanwhile. - Closed shadow root. The inline fill menu, the save prompt and the passkey approval
prompt live in a
closedshadow root on an always-mounted custom element, so page scripts cannot read or restyle them. The host is reset withall: initialand the UI sets its own fonts and colors. Whatever is drawn can be found by hit-testing, so nothing is drawn on page load: the floating sign-in prompt and the fill menu appear only after the user presses or types in the sign-in form (tested). After that, a page can tell that AutoPass has a login for it. - Anti-clickjacking. Inline UI actions accept only trusted (real user) events, and only after the field was focused by a trusted event. Before acting, the UI hit-tests that the click really landed on AutoPass's element (not on something layered over it), checks that the element is fully opaque and visible, and ignores clicks in the first 500 ms after it appears. The passkey approval button is never focused automatically; keyboard approval needs focus moved there by a real Tab or click (tested).
- Sign in after fill. After the user chooses a login, AutoPass clicks one visible, enabled sign-in button (Sign in, Log in, Continue, Next or Verify). It never does this on sign-up or new-password forms, invalid forms, pages with more than one candidate button, or if the page changed meanwhile. It is on by default and can be turned off in Settings.
- One-time codes (
apps/extension/src/otp.ts,otpfill.ts). A field is treated as a one-time-code field when it hasautocomplete="one-time-code", a name / id / placeholder / label hint (otp, totp, 2fa, mfa, one-time, verification or security code, authenticator), orinputmode="numeric"with a maxlength of 6 to 8, or when it is one of 4 to 8 adjacent single-character boxes; card-security-code, postcode, phone and login fields are excluded. Focusing one (by a trusted gesture, as above) lists the logins for this exact origin that have a one-time-code secret: title and username only, never a code. Only after a genuine click on a row does the worker re-check that the item is offered forsender.urland return the current code, which is filled into the chosen field (one digit per box for split entry). A code with under 3 seconds left is never filled: the worker waits for the next 30-second window first, so the code is valid for its full period even on servers that accept only the current step. After AutoPass fills a login that has a one-time-code secret, the worker remembers that item for that tab and exact origin for 2 minutes and offers only it on the next one-time-code field; nothing is filled until the user clicks. - Save-login capture. Only trusted user actions count: a real click or Enter, or a submit within 5 seconds of a real press or keypress, so a page script can't raise the prompt by itself (tested). When a form with a filled password field is submitted, the content script sends the username and password to the worker, which compares them with the vault. A "new" or "update" result is held in worker memory for that tab for up to two minutes so the prompt can reappear after the post-login navigation. Nothing is saved unless the user clicks Save or Update.
- Clipboard. Copied secrets are cleared after 90 seconds by default (including the share viewer and the two-step setup key). In a web page this only works while the page has focus.
- Internal messages. The popup's state listener and the offscreen document accept
messages only from the extension itself (
sender.id, no tab). - Auto-lock. Default 10 minutes of inactivity (1, 5, 10, 30 or 60 minutes, or only when the browser closes). The extension can also lock when the system goes idle or the screen locks.
Autofill origin policy (apps/extension/src/engine/urlmatch.ts)
A saved login is offered or filled only when all of these hold:
- The page is
https:orhttp:. Nothing else (nofile:,data:, extension pages). - A login saved with an
https:URL is never filled on anhttp:page. - The host matches exactly, case-insensitively. A single leading
www.and a trailing dot are ignored on both sides. There is no parent-domain or sibling- subdomain matching:a.github.ionever matchesb.github.io, andgithub.com.evil.ionever matchesgithub.com. - The port must match exactly (an explicit default port is treated as the default).
A saved URL without a scheme is treated as https://.
Passkeys (apps/extension/src/engine/passkey.ts)
The extension includes a software WebAuthn authenticator:
- A hook injected into the page's own JavaScript world intercepts
navigator.credentials.create()/.get()and forwards the request to the extension. This needs thescriptingpermission and host access tohttp(s)pages. - Nothing is created or signed without the user approving it in AutoPass's prompt (same
closed shadow root and anti-clickjacking rules as above) while the vault is unlocked.
A create request that lists a passkey the vault already has is answered with
InvalidStateErroronly after the user clicks in the prompt, so a site can't silently check which passkeys you hold (tested). The page's request is validated before use, and passkey items are titled by the RP ID, never by the page-suppliedrp.name. - Passkeys are ES256 (P-256) key pairs generated in the extension, "none" attestation, discoverable, and flagged backup-eligible and backed-up because they sync.
- The RP ID must equal the page's host or a registrable parent of it. Passkeys
require
https:(orhttp://localhost), are refused on IP addresses and bare top-level domains, and a built-in list of common public suffixes (for examplegithub.io,co.uk) cannot be claimed as an RP ID. - Passkeys in the autofill menu (conditional mediation). A page's
navigator.credentials.get({ mediation: "conditional" })is held by the extension's isolated-world bridge while the worker is asked (bysender.url, as always) which AutoPass passkeys match the RP. If there are none, the original options, including the page'sAbortSignal, go to the browser's ownnavigator.credentials.get, so other authenticators still work. If the vault is locked the request stays with AutoPass and the menu offers to unlock; the next focus re-asks the worker. Otherwise the inline fill menu lists those passkeys (account names only) when the user focuses the page's username field (autocompletecontainingwebauthn, or a login username field) by a trusted gesture. A genuine click on one has the worker sign the pending request's own challenge for this origin; each request can be answered once, only with a passkey it was offered. The menu and the bridge talk through an object on the isolated world's global, which page scripts cannot see. If the page aborts or replaces the request, its rows are removed at once and the click delay restarts for the rest of the menu.PublicKeyCredential.isConditionalMediationAvailable()reportstruewhile the hook is installed. - Each passkey is stored as a "Passkey" vault item, encrypted end to end like any other item, so it syncs to the user's other devices. The web vault can list and manage passkeys but cannot use them on websites; only the extension answers WebAuthn requests.
Web vault
The API serves a standalone web vault at / so any device with a browser can use the
same account. It runs the same engine in the page:
- Unlocked key material is held only in JavaScript memory. Reloading or closing the tab locks the vault.
- The profile (including the Secret Key) and the ciphertext cache are kept in the
origin's
localStorage, so the device can unlock offline. - Responses carry a strict Content-Security-Policy (
default-src 'self'; script-src 'self'; connect-src 'self' https://api.pwnedpasswords.com https://haveibeenpwned.com; frame-ancestors 'none'; object-src 'none'; base-uri 'none'; form-action 'none'),X-Frame-Options: DENY,nosniff,no-referrer, a restrictivePermissions-PolicyandCross-Origin-Opener-Policy: same-origin. The two extraconnect-srcorigins are used only by the opt-in Vault health checks below.
Vault health checks (apps/extension/src/engine/watchtower.ts, breach.ts, twofa.ts)
Weak, reused and http:// checks, and "two-factor available" (a bundled list of sites
that support one-time codes, generated from the MIT-licensed 2fa.directory dataset by
apps/extension/scripts/update-twofa.mjs), run entirely on the device.
Two checks contact Have I Been Pwned and are off by default (Settings > Vault health checks). They run in the UI only when Vault health is opened or "Check now" is pressed:
- Exposed passwords uses the Pwned Passwords k-anonymity range API. The client
computes SHA-1 of each distinct password and sends only the first 5 hex characters
(
GET https://api.pwnedpasswords.com/range/ABCDE,Add-Padding: true, no cookies, no referrer, not stored in the HTTP cache). The suffix is compared locally. There are only 16^5 (about a million) prefixes, each shared by hundreds of known hashes and by an unbounded number of possible passwords, so a prefix does not identify a password; the service does learn the prefixes and the client's IP address. One request per distinct prefix; answers are cached in page memory only, for 24 hours. - Breached sites downloads the public catalogue
(
GET https://haveibeenpwned.com/api/v3/breaches) and compares its domains with each login's saved hosts on the device. Nothing about the vault is sent.
Server hardening (apps/api/src/server.ts)
- The server process runs with umask 077, so the data directory it creates is 0700 and the database with its WAL and SHM files is 0600 (tested); the backup script does the same for backups.
- Request bodies over 5 MiB are rejected (64 MiB for a vault key rotation, which carries the whole vault; that limit, and the rotation rate limit of 20 per minute, apply only once the request is known to come from a signed-in manager of that vault, before the body is read); malformed base64 in authentication fields returns 400; every field has a size cap (for example 64 KiB per item overview, 1 MiB per item details); malformed JSON returns 400.
- Attachments (
apps/api/src/files.ts) take a raw body with its own cap (25 MiB of plaintext per file). Uploads need editor access to the vault and must name a live item in that vault, re-checked after the body arrives (an uploader removed or an item purged mid-upload gets 404); an oversized upload is answered 413 and cut off rather than read to the end; a SHA-256 mismatch returns 400; each vault owner has a storage quota (AUTOPASS_FILE_QUOTA_MB, default 1024) and going over returns 507. Downloads need membership of the vault, and a file that doesn't exist and one in someone else's vault both return 404. Retried uploads are idempotent. Purging an item, deleting its vault or deleting the owning account deletes its files. - Authorization is checked per request against vault membership. Updating or trashing an item that does not exist and one in someone else's vault return the same 404.
- Item writes use optimistic concurrency (
expectedVersion); a stale write gets 409 with the server's current copy. Item creation is idempotent per client-chosen ID within a vault; the same ID in another vault is a 409. - At most 100 vaults per account.
- Static files are served only from inside the web root (path traversal returns 403).
- Timeouts: 30 s per request, 15 s for headers.
GET /healthzreports database health, andredis: "ok" | "down" | "off". It stays 200 while Redis is down, because the API falls back to per-process limits.- Storage is SQLite (
node:sqlite) with WAL, foreign keys and transactions; schema versions are tracked withPRAGMA user_version. - Production deployments terminate TLS in front of the API (see docs/DEPLOY.md); the API itself speaks plain HTTP on a private port.
Security review: fixed before 1.0
An internal review before release found the following; all are fixed in 1.0.0 and the design above describes the fixed behaviour.
- Ciphertext was not bound to its location. Item, vault-name and keyset ciphertexts now carry associated data (vault ID + item ID + field; vault ID; account ID), and item IDs are generated client-side. A server can no longer swap or move ciphertext between items, vaults or accounts undetected.
- A server could plant a vault whose key it knew. Own vault keys are now wrapped with a symmetric key that exists only inside the encrypted keyset, bound to vault ID + account ID. Public-key sealing (used only inside sharing invitations, which must be verified and accepted) binds both public keys into the KDF and the associated data.
- The client trusted the server's copy of its public key. The public key is now derived from the private key; a mismatching server copy is rejected.
- Decoy logins were distinguishable from real ones (ID format, timing, and a registration path that checked existence before validating). Decoys now look and cost the same, and registration validates first.
- Per-email lockouts let anyone lock a user out, and the client opened a new server session on every unlock. Lockouts are now keyed by email + IP with a looser per-email backstop, and the session token survives lock/unlock.
- Changing the master password did not require a fresh login. It now does.
- Inline UI could be clickjacked. The fill menu, save prompt and passkey prompt now hit-test, check opacity, ignore clicks for 500 ms after appearing, require trusted-event focus, and live on an always-mounted host.
- Secret Key parsing was lenient (invalid characters were skipped). It is now strict.
- Clients accepted any KDF iteration count from the server. They now require at least 650,000.
- Plain
http://server URLs were accepted. Onlyhttps://is allowed now, exceptlocalhostfor development. - Autofill ignored the port when the saved URL had none. Ports must now match exactly.
Security review, 2026-10-06
A second internal review (source review only; no testing against the live service) found the following. All are fixed and the sections above describe the fixed behaviour; each has a regression test that failed before its fix.
- A malicious server could get a vault key sealed to itself. Clients rotated any vault the server flagged, sealing the new key to whatever member keys the server listed, including for the Personal vault. Rotation now seals only to the client's own encrypted member list and never runs on the server's say-so alone.
- Invitations to unregistered emails were exposed. Decoy public keys were derived from the server secret (so a database copy opened them), and anyone could register under an unregistered email's decoy ID and receive its invitations. Decoy keys are now random with no private key kept, invitations go only to the account with that email, and those IDs can't be registered.
- A fabricated invitation showed as verified, and the server could steer captured logins into a vault it knew. Verification now needs a confirmed fingerprint, and the default vault comes from the client's own records.
- A replayed old wrapped key could undo a rotation. Wraps now carry an authenticated key version and devices never go back.
- Autofill worked in sandboxed (
origin "null") pages on a saved site. - The desktop agent bridge let an approved app grant fill any login, into any app claiming that identifier, with approval clickable by automation; the development extension ID was trusted in release builds; IPC read unbounded lines before authenticating.
- IPv6 address rotation bypassed per-IP limits; the per-email backstop let a stranger lock someone out; parallel requests got past attempt caps; the SMS endpoints could be used to send many texts.
- Smaller authorization gaps: deleting an account erased items it wrote in other people's vaults; a co-manager could delete the creator's vault; unauthenticated requests could make the server read 64 MiB bodies; uploads, invitations and rotations didn't re-check the caller's role after an await; credential changes didn't forget trusted devices; pages could raise the save prompt, detect AutoPass, or silently check for passkeys.
Account recovery (packages/core/src/recovery.ts, apps/api/src/recovery.ts, apps/extension/src/engine/recovery.ts)
AutoPass still cannot reset a master password on its own: nobody, including whoever runs the server, holds anything that can decrypt a vault. What it offers is an optional, user-held recovery code that does the reset, without the server learning anything new. It is not organizer- or family-based recovery: no other person or administrator can reset an account.
The code and what it protects
- Format. 160 bits from
crypto.getRandomValues, rendered asRC-plus 32 Crockford base32 characters in 8 groups of 4 (different prefix and grouping from the Secret Key so the two can't be confused). Parsing is strict like the Secret Key's: wrong prefix or length, or characters outside the alphabet, are rejected (case, spaces,O/I/Llook-alikes are forgiven). A Secret Key pasted into the field is rejected. - Derivation. With a fresh 16-byte random salt per code, HKDF-SHA256 over the code
yields two independent 32-byte values, separated by info labels that include the account
ID: a recovery wrapping key (
AutoPass/recovery-wrap/v1/<accountId>) and a recovery SRP exponent (AutoPass/recovery-srp/v1/<accountId>). - The encrypted copy. The wrapping key encrypts
{ keyset private key, keyset symKey, Secret Key }with AES-256-GCM and associated dataautopass/recovery/v1|<accountId>, so a server can't move one account's blob to another (tested), and any tampering fails. It wraps the keyset, not the Account Unlock Key, so changing the master password never invalidates the code (tested). It includes the Secret Key, so recovery works even if the Secret Key was lost too. - Why no slow KDF. PBKDF2 and its kin make guessing a low-entropy secret expensive. The code is 160 uniformly random bits, so the verifier and blob the server holds can only be attacked by searching 2^160 codes; stretching would add nothing meaningful and would make recovery slow on phones.
- The code is a full key. Anyone who has it and knows the email can reset the master password and read the vault. Store it offline (the Recovery Kit file, printed), not in AutoPass, email or a cloud folder. If you think it leaked, make a new one in Settings: that invalidates the old one at once.
Setting it up
Settings > Account recovery (also offered, skippable, right after the Secret Key during
signup). The master password is re-entered and checked on the device. For a synced account
the client then makes a fresh SRP login and the server accepts PUT /v1/account/recovery only on a session less than 10 minutes old, like a password change.
The server stores the recovery SRP salt and verifier and the encrypted blob, replacing any
previous code; replacing it also ends any recovery session opened with the old code. The
code is shown once, with copy and a Recovery Kit download that also holds the Secret Key.
A local-only account keeps the salt, verifier and blob in the device profile instead, so "Forgot master password?" works on that device with no network. Connecting a sync server uploads them (with the connect's fresh session) and the device then drops its copy. After "Stop syncing this device" the device no longer holds a usable copy, and Settings says recovery isn't set up there until a new code is made.
Recovering
"Forgot master password?" on the lock screen or the sign-in screen asks for the email, the recovery code and a new master password.
- Recovery login (
POST /v1/auth/recovery/start,/finish) is SRP-6a against the recovery verifier, built exactly like password login: decoys for unknown emails, the same per-IP rate-limit bucket, and failures count toward the same (email, IP) lockout as wrong passwords (both directions, tested). An account without recovery gets a decoy salt and verifier stable per email, and the account ID that password login already reports for that email, so the endpoint can't tell "no account", "no recovery set up" and "wrong code" apart (tested). The client checks the server's proofM2. - A successful proof returns a recovery token, not a session: it lives in its own
table, expires after 10 minutes, stops working if the code is replaced, and every
other route treats it as unauthenticated (tested route by route). It can only
GET /v1/recovery/account(the server record and the encrypted blob) andPOST /v1/recovery/reset. - The client opens the blob with the code, checks that the keyset's public key matches the account's, and re-keys exactly like a password change (new KDF salt, keyset re-wrapped under the new AUK, new SRP verifier). Nothing else is re-encrypted.
- The reset applies that change and, in the same transaction, consumes the code (recovery is off), and revokes every session, normal and recovery. Other devices must unlock with the new master password before they sync again.
- The device signs in (or, on a locked device, unlocks keeping its cache and queued changes) and immediately makes a new recovery code, shown once.
A local-only account does steps 3 to 5 on the device: the code opens the profile's blob, and the new password's keyset and a new code's blob replace the old ones in one write.
Without a recovery code
- Lost the Secret Key? Any signed-in device shows it again in Settings after you enter the master password. The Recovery Kit file also contains it, and so does a recovery.
- Forgot the master password and never set up a recovery code? There is no reset. If you have a backup made with "Export encrypted backup" and remember its password, you can import it into a new account.
- Lost the master password, the recovery code and every backup: the data is gone. This is the price of zero knowledge.
Two-step verification (apps/api/src/mfa.ts, apps/api/src/twilio.ts, apps/extension/src/engine/mfa.ts)
Optional, per account, for synced accounts (Settings > Two-step verification). After the master password, signing in asks for a code from an authenticator app (TOTP) or a text message, or one of 10 backup codes.
What it protects, and what it doesn't
- It protects access to the encrypted data on the server, not decryption. A session lets someone download ciphertext, see metadata, and delete or overwrite data. Decrypting still needs the master password and the Secret Key; two-step verification adds nothing there, and a device that already has the vault opens it offline without a code.
- So it matters most when the master password and Secret Key have both leaked (a stolen Recovery Kit plus a phished password, say): the attacker still can't sign in to the server from a new device without the second factor. It does not help against malware on a signed-in device, or a compromised server (which has the ciphertext anyway).
- Text messages are the weaker option. A code can be intercepted by someone who takes over the phone number (SIM swap, number porting, SS7). Use an authenticator app where you can; the settings say so when only SMS is on.
Sign-in
srp/finish(and the recovery login'srecovery/finish) verifies the proof exactly as before: lockouts, rate limits and decoys are unchanged, and the server still proves itself withM2, which the client checks before anything else.- If the account has two-step verification on and the request doesn't carry a valid
trusted-device token for that account, the answer is
{M2, mfaRequired, mfaToken, methods, phoneHint}and no session.mfaTokenis 256 random bits, kept (as a SHA-256 key) in the server's guard for 5 minutes, single use, and bound to the account and the client's IP address; a token from another address is treated as unknown. POST /v1/auth/mfa/verify {mfaToken, method, code}returns the session (or, for a recovery login, the recovery token). 5 attempts per pending sign-in, after which it is gone; wrong codes also count toward a per-account limit of 10 per 15 minutes across all sign-ins (then 429). Failures look alike ("that code didn't work"; an unknown, expired, used or foreign token is "sign-in expired, start again").- A session made this way counts as a fresh login (credential changes are allowed for 10 minutes, like after any login).
- The code check can take a while (a text-message provider round trip). After its last
asynchronous step,
verifychecks again that the account still has the credentials the proof was made with (password, and for a recovery login also the recovery code), and trusts the device and issues the session or recovery token in the same SQLite transaction. Every change made with a session (password, recovery code, account deletion, adding or removing a factor, backup codes) checks inside its transaction that the session still exists, and the session is looked up only once the request body has arrived, so a session revoked meanwhile (by a password change or "sign out everywhere else", from any API process) changes nothing.
All of this short-lived state (pending sign-ins, attempt counters, text-message counters, unconfirmed setups) is kept through the guard, so it is shared by every API process when Redis is configured.
Every client flow handles it the same way (engine/mfa.ts): the login throws a typed error; the UI asks for the code; the client sends it, keeps the resulting session for at most 2 minutes in memory-only storage, for the same server, email and login secret, and runs the original operation again, which uses that session once instead of logging in again. This covers signing in on a new device, unlocking after the password changed elsewhere, connecting a server, account recovery (a recovery reset now returns a session for the device doing it, so one code covers the whole recovery), and the fresh login before a credential change. Background re-authentication never waits for a person: if the server asks for a code during sync, the device stops syncing with "Verify it's you to keep syncing", remembers that (memory only), and makes no further login attempts until the user clicks it and enters a code.
Methods
- Authenticator app (TOTP, RFC 6238): HMAC-SHA1, 6 digits, 30-second steps, one step of clock drift either way. The 160-bit secret is generated on the server, shown once (QR code and key), and stored AES-256-GCM encrypted under a key derived from the server secret, bound to the account ID. The server records the last step accepted and refuses that step and any earlier one, so a code is never accepted twice (also atomically across processes). Setup is pending (in the guard, encrypted, 10 minutes) until a valid code confirms it.
- Text messages (Twilio Verify): offered only when the server has Twilio credentials.
Twilio generates, sends and checks the code; the server sends Twilio the phone number
and the code the user typed. Setup sends a code to the number and saves it only after
that code is confirmed. At most 3 texts per account and 10 per network every
10 minutes (sign-in and setup together), 10 per destination number per day (no
more than 5 of them for setup), and a server-wide daily cap (
AUTOPASS_SMS_DAILY_CAP, default 1000; when reached, texts are refused until the next day and authenticator apps and backup codes still work). Limit keys for numbers are HMACs, never the number. The number is shown back only as "•••• 12". The server never logs numbers, codes or the Twilio credentials, and errors to clients are generic. - Backup codes: 10 codes of 50 random bits (
XXXXX-XXXXX, Crockford base32), shown once when two-step verification is turned on or when new ones are made (which cancels the old ones). Stored as HMACs under the server secret; each is deleted when used.
Turning a method on or off and making new backup codes need the master password (checked on the device) and a fresh login, like a password change. Turning off the last method turns two-step verification off and deletes the backup codes and every trusted device.
Trusted devices
"Trust this device" (ticked by default) makes the server issue a 256-bit device token,
stored as an HMAC on the server and in the device's local storage (per server and
account; chrome.storage.local in the extension, localStorage in the web vault). Every
later login from that device sends it, and the server skips the code if it is valid for
that account. It expires 30 days after it was last used (sliding). It is forgotten when
the device signs out, when it's signed out or forgotten in Settings > Devices, by "Sign
out everywhere else", when two-step verification is turned off, when the master password
is changed (every trusted device except the one making the change), and after a recovery
reset (all of them). A password change or a recovery reset also ends any half-finished
two-step sign-in. Anyone who can read
that device's storage gets the token, so trust only devices you alone use. The device that
turns two-step verification on is trusted too, unless unticked.
Decoys and what's observable
A pending two-step sign-in is created only after a correct SRP proof, and a decoy's
proof (unknown email) never verifies. So up to the point where a real account would ask for
the code, an unknown email, a wrong password and a two-step account all look the same
(srp/start is unchanged; srp/finish fails with the same 401), and whether an account
has two-step verification, which methods, and the phone hint are visible only to someone
who has its master password and Secret Key (tested). The code-step endpoints say
nothing without a real pending sign-in. Timing differs between "correct password, no 2FA"
and "correct password, 2FA" only after a correct proof.
What Twilio sees
The account's phone number, when a code is sent and checked, and the server's Twilio account. Not the email address, the account ID, or anything from the vault.
Desktop agent bridge (apps/desktop/src-tauri/src/agent/, ipc.rs, browserpeer.rs, codesign.rs, apps/extension/src/desktop/)
The desktop app can let a local AI agent (over MCP, autopass --mcp) ask for a login to
be filled, without the agent ever receiving it (see docs/AGENTS.md).
- Agents never receive secrets. Tools return grant IDs and login titles only. AutoPass types or fills the login itself.
- Each grant is narrow. It is bound to one item the user picked, one website or app
identifier, one MCP connection, and a deadline; a website grant is also bound to the
browser link that was connected when the user approved, and an app grant to the
developer verified then. At every fill the vault page re-checks that the item is saved
for that website, or for that app as
app:<bundle id or exe>; for an app request, only logins saved for that app can be chosen. - The browser link must be the browser. The IPC token only proves a helper runs as the
user. A helper asking to be the browser link is also checked, on macOS, against the OS:
the process on the other end of the socket (identified by its audit token) must satisfy
this app's own designated requirement (it is the
autopassnative-messaging host), and its parent must be a validly signed browser on an allowlist of Team IDs and signing identifiers (Google Chrome, Microsoft Edge, Brave; see docs/AGENTS.md). Otherwise it is denied after the handshake. Agent browser fills go only to that verified link, and only to the one that was connected when the user approved: if it disconnects or another link replaces it, the grant ends. A browser request is refused up front when no verified link (or more than one) is connected. - App targets. A bundle ID (macOS) or exe (Windows) is required; name-only requests,
and browsers, terminals, code editors, script hosts and AI assistants, are refused. On
macOS the app's developer is pinned when the user approves (shown in the dialog): a Team
ID is believed only if the code satisfies
anchor apple generic and (certificate leaf[field.1.2.840.113635.100.6.1.9] exists or certificate leaf[subject.OU] = "<team>")(a certificate Apple issued to that team, or Apple's Mac App Store signing), and code with no Team ID is accepted only if it satisfiesanchor apple(Apple's own apps). Ad-hoc signed or unsigned apps are never pinned. If the app isn't running, or can't be verified, the dialog says so and Allow stays off until it can (it re-checks every few seconds); there is no trust on first fill. Every fill checks the app in front is valid and signed by the pinned developer. - Typed text is never a key press. Quick Access and agent fills refuse a username, password or one-time code containing a control character (a newline would be Return, a tab would move focus), as identity fields already did; the user can copy it instead. The typer checks this itself before typing anything.
- Windows (main branch). There is no peer-process check on the named pipe and no Authenticode signer check yet (the Windows work in PR #10 isn't merged), so agent sign-ins into websites and desktop apps are both refused on Windows, before any dialog, and the agent Settings say so. Quick Access is unaffected.
- Approval needs the user. Nothing is pre-selected. Allow works only after the dialog has been visible and focused for a second, only for a real click or key press, and on macOS only after Touch ID or the account password (LocalAuthentication).
- The local connection. The app and its helpers (MCP, browser host) authenticate with a mutual HMAC challenge on a random 256-bit token kept in a file only the user can read; the token itself never crosses the connection, so a process squatting the socket or pipe name learns nothing. Lines are capped at 1 MiB before authentication, and at most 16 connections may be in the handshake at once (more are closed). Agent names are cleaned of control and direction-changing characters.
- Inside the app. Webviews can't emit events, the vault page listens only for events
addressed to its own window, bridge request IDs are random, and the vault window's
connect-srclists only AutoPass's sync servers and the breach-check services (so a custom sync server needs a build with its origin added; see docs/DESKTOP.md). - Extension IDs. Release builds trust only the store extension IDs (the development ID only in debug builds), and an agent browser fill is refused when more than one browser (or profile) is linked.
- Clipboard. Quick Access copies are concealed, kept on this Mac (not Universal Clipboard), and cleared after 90 seconds, on lock and on quit.
- What remains (known gaps). A process that can run code as the user is not stopped by any of this: it can read the screen (including a field after it is filled), drive the browser or load an extension into it (an unpacked extension can claim the store extension's ID with its public key, and then receives the fills the verified host relays), send synthetic input, read the token file and talk to AutoPass like an agent, and change the device-local agent setting. The browser-link check raises the bar from "read a file" to "control a browser"; it is not a boundary against malware running as the user. Browsers not on the allowlist (Chromium builds, which are ad-hoc signed; Arc, Vivaldi and Opera until their Team IDs are confirmed) can't be used for agent fills. Windows has none of the OS checks above yet.
Known limitations
- No independent security audit yet. The cryptography uses standard Web Crypto primitives, but the protocol composition and the hand-written SRP implementation have only been reviewed internally (twice, see the review sections above) and tested by the suite in this repository.
- Registration reveals whether an email is taken.
POST /v1/accountreturns 409 for an existing email. Login resists enumeration; registration does not. SetAUTOPASS_REGISTRATION=closedonce your accounts exist to remove this oracle. - Email addresses are not verified. Nothing is ever emailed; the address is only a login name.
- No rollback protection per item. A malicious server cannot forge or move an item, but it can serve an older, genuine version of an item (or withhold recent changes).
- PBKDF2 is not memory-hard. GPUs and ASICs attack PBKDF2 more efficiently than Argon2id or scrypt. The Secret Key is what makes the server's data uncrackable; the KDF matters only if the Secret Key also leaks.
- The Secret Key is stored in plain form on trusted devices (
chrome.storage.localin the extension,localStoragein the web vault) so a device can unlock with just the master password. Malware or a hostile extension that can read that storage gets the Secret Key, leaving only the master password protecting the cached ciphertext. - The web vault trusts the server for its code. A compromised server could serve modified JavaScript that captures the master password the next time someone unlocks in the web vault. Use the extension where that matters.
- Clipboard clearing is blind. AutoPass clears the clipboard 90 seconds after a copy, even if you have since copied something else.
- Extension fingerprinting. The content-script chunk is listed in
web_accessible_resources, so a website can detect that AutoPass is installed. Once the user interacts with a sign-in form, a page can also tell whether AutoPass offers a login for it. A passkey request shows the same first prompt whether or not AutoPass holds a matching passkey, but once the user clicks "Use AutoPass" the next step differs. - Passkey RP-ID checks use a small built-in public-suffix list, not the full Public Suffix List. Uncommon shared-hosting suffixes are not covered.
- Attachment sizes and counts are visible to the server. File contents, names and types are not, but exact sizes (unpadded) and how many files each item has are.
- A conflict copy does not get its own copy of attachments. If an edit loses a sync conflict, the "(conflict copy)" item lists the same files, which stay bound to the original item; they can't be opened from the copy, and purging the original deletes them. Download them from the original item.
- Large uploads on slow connections can time out. The server allows 30 seconds per request; a 25 MB file needs roughly 7 Mbit/s upstream. A timed-out upload stays queued and retries on the next sync.
- Sharing limits. Rotation after a removal protects only what is written afterwards and doesn't hold up against a removed member colluding with the server, roles are enforced by the server rather than cryptographically, fingerprints must be compared by the people themselves (a confirmed fingerprint is remembered only on that device), in vaults from before member lists were added, members this device hasn't confirmed show as "Not verified" (and rotation stops at them) until someone confirms them, two managers inviting at the same moment can lose one pin (the member then shows as "Not verified"), older clients don't keep member lists, and the share-link viewer's code comes from the server. See "Sharing" above.
- Shared limits depend on Redis, and the database is still single-host. Without
REDIS_URL, rate limits, lockouts and pending sign-ins are per process: they reset when the server restarts, and N processes give an attacker N times the guesses. With Redis they are shared and survive API restarts, but the bundled Redis keeps nothing on disk, so restarting Redis resets them; and while Redis is unreachable or out of memory each process falls back to its own in-memory limits (logged, and shown as"redis":"down"on/healthz), so during an outage the limits are per process again. Someone with access to Redis can't read handshakes or see which emails or IPs are being limited, but can see how many keys there are and their timing, and can delete entries to reset limits; protect it with a password on a private network. All processes must share the same server secret. The SQLite database is still one file on one host, so every API process has to run where that file is. - The sign-in proof format changed once, without versioning. Since the move to the RFC 5054 proof form, clients built before it can't sign in to an updated server (and the other way round) until both are updated. Nothing older was ever published.
- A local-only vault exists on one device. Until sync is turned on, losing or resetting the browser profile (or clearing site data for the web vault) loses the vault. Export encrypted backups, or connect a sync server.
- Reconnecting after a local password change. If sync is turned off, the master password is changed locally, and the same server is connected again, the server still holds the old verifier and the connect is refused; connect to a fresh server instead.
- Breached-site flags depend on dates the vault can't fully know. A login is flagged when its site's breach was added to the catalogue after the password was last changed (from password history, else the item's last-modified time, which also moves on unrelated edits and is the import time for imported items), so some older breaches are not flagged. Breaches are matched to the breached domain and its subdomains only.
- A recovery code is a second way into the account. It is as powerful as the master password and Secret Key together, and it is only as safe as where it is kept. Lockouts and rate limits slow online guessing, but the code's 160 bits are what make guessing hopeless. Recovery revokes sessions but cannot wipe other devices: until a device syncs, its offline copy still opens with the old master password (as after any password change).
- Recovery status is weakly observable. The recovery start endpoint's salt for an account changes when a code is set up, replaced or used, while a decoy's salt never changes. Someone polling it over time can tell that something changed for that email, not whether recovery is set up.
- Device unlock is password-equivalent on its device for up to 14 days, and in the desktop app its Touch ID check is made by AutoPass rather than enforced by the keychain (see "Unlock with your device"): someone who passes the device's user verification and has the browser profile can open the vault and log in to the server. A password change on another device revokes this only once the device syncs.
- Device unlock depends on the browser's WebAuthn PRF support. Browsers or authenticators without PRF (or without user verification) can't use it; the option says so instead.
- Two-step verification guards the server, not the data. It doesn't stop a device that already has the vault from opening it, and a signed-in, unlocked device can sign in again on its own if it is trusted. Text-message codes can be intercepted (SIM swap); prefer an authenticator app. Recovery with a recovery code also needs the second factor (or a trusted device, or a backup code), so keep backup codes with the Recovery Kit.
- Two-step secrets depend on the server secret. Authenticator secrets are encrypted, and backup codes and trusted-device tokens are HMACs, under the per-deployment secret; a copy of the database together with that secret reveals the authenticator secrets, and changing the secret makes every account's two-step verification unusable.
- An account whose only method is text messages can't verify on a server without Twilio configured (for example after the operator removes it), except with a backup code.
- Rate limits are per network. Carrier NAT on IPv4 shares one budget among many people, and someone with a large IPv6 allocation (a /48 or more) still has many /64s. While an attacker keeps the per-email backstop tripped, a first sign-in from a new network can be slowed until the attack stops (devices that signed in from that network before are not).
- The desktop agent bridge on Windows has no code-signature check of the target app or of the browser link, and no Windows Hello confirmation yet, so agent website and app sign-ins are refused there (see "Desktop agent bridge").
- The 14-day device-unlock window is measured with the device's clock. A clock set back to before the bundle was sealed is refused, but someone who passes the device's user verification and can set the clock back by less than that can stretch the window.