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 which code it was opened with | 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). 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 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 the server fabricates or moves to another vault fails to verify (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 or delete the vault, set its travel flag). 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.
- 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).
- 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. The rotating client seals the new key to the member list the server reports, and a removed member knows the old key, 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). 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 a vault the user created that has no other members, never to a vault shared with them. The "owned" and "shared" labels are metadata reported by the server; a lying server could mislabel a vault, but every member of any vault still had to be invited by a key holder and accept.
- 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 derived from the deployment secret, with the same work done in both branches. An invitation to a decoy is accepted and stays pending, exactly like one to a real account that hasn't answered. 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; the one-time-code secret only if the user opts in; never a passkey) and
encrypts it with AES-256-GCM under a fresh random 256-bit key, with associated data
autopass/share/v1|<shareId>. - The server stores the ciphertext, a SHA-256 hash of a random 256-bit access token, the
expiry (1 hour 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.
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 + client IP, with a looser per-email backstop. Repeated wrong proofs from one address lock that address out for that email; a much higher per-email ceiling catches distributed guessing. Keying by IP means a stranger can't lock a user out of their own account from elsewhere by spamming wrong guesses.
Per-IP rate limits (fixed windows):
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.
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.
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. 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. - 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.
- No auto-submit. AutoPass fills fields; the user submits.
- 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. 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.
- 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.
- 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, and is limited to managers and 20 per minute); 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; 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. - 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.
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).
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 client address every 10 minutes (sign-in and setup together). 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", and when two-step verification is turned off. 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.
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 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. - 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 (nothing pins a fingerprint once seen), 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 (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.