This document describes how 2Secret protects your data: what is encrypted,
with which keys, what the sync server can and cannot see, and the known
limitations. The implementation lives in
crates/core/src/crypto.rs and
crates/core/src/session.rs.
Goals and threat model
2Secret is designed so that:
- The server operator learns nothing useful. Someone with full access to the sync server (including its database) cannot read items, vault names, URLs or titles, and cannot mount an offline attack on master passwords.
- A stolen device file is protected by the master password. The profile on disk is fully encrypted; without the master password it is useless.
- Network attackers learn nothing. TLS protects transport, but all
sensitive payloads are additionally end-to-end encrypted. The apps talk
to a server only over
https://(plainhttp://only to a server on the same device). - Members of a shared vault only get access to that vault.
2Secret does not protect against malware running as your user on an unlocked device (it can read the screen, memory and keystrokes), against a compromised operating system or browser, or against someone who knows both your master password and your Secret Key.
Primitives
| Purpose | Algorithm |
|---|---|
| Password stretching | Argon2id v1.3 (default: 64 MiB, 3 iterations, 4 lanes) |
| Key derivation / separation | HKDF-SHA256 |
| Symmetric encryption | XChaCha20-Poly1305 (256-bit key, random 192-bit nonce, 128-bit tag) |
| Public-key encryption | X25519 + ML-KEM-768 (FIPS 203) hybrid + HKDF-SHA256 + XChaCha20-Poly1305 |
| Randomness | OS CSPRNG (getrandom; crypto.getRandomValues in browsers) |
| Unicode normalisation | NFKD for master passwords |
All implementations are from the RustCrypto and dalek-cryptography projects;
the most important ones (chacha20poly1305, curve25519-dalek) have had
independent audits, the ML-KEM implementation not yet. Ciphertexts are encoded as 2s1. + base64url(nonce ‖ ciphertext ‖ tag).
Two-Secret Key Derivation (2SKD)
When an account is created, the device generates:
- a random 16-byte salt, and
- a Secret Key: 26 random characters from a 32-symbol alphabet (130 bits)
plus one check character, shown as
2S-XXXXXX-XXXXX-XXXXX-XXXXX-XXXXXX. Secret Keys created before version 1.1 have 25 random characters (125 bits) and keep working.
The Secret Key is stored only on the user's devices and in their printed Emergency Kit. Every unlock derives two independent keys:
salt' = HKDF(ikm = salt, salt = email, info = "2secret/v1/argon2-salt")
k_pw = Argon2id(NFKD(master password), salt')
k_sk = HKDF(ikm = Secret Key, salt = email, info = "2secret/v1/secret-key")
k = k_pw XOR k_sk
MUK = HKDF(k, info = "2secret/v1/master-unlock-key") # never leaves the device
AUTH = HKDF(k, info = "2secret/v1/auth-key") # proves knowledge to the server
Because k_sk contributes 130 bits of entropy, the authentication key the
server sees cannot be used to brute-force the master password, even with
unlimited computing power — the attacker would also have to guess the Secret
Key. A weak master password is still protected by the Secret Key against
server-side attacks, and a stolen device (which holds the Secret Key) is
protected by Argon2id.
Key hierarchy
Master Unlock Key (MUK) ─┐
▼
Account key (AK, random 256-bit) ──► X25519 private key
│ ML-KEM-768 private key (derived)
│ (public keys on server)
▼
Vault key (VK, random 256-bit, one per vault)
│
▼
Items and vault metadata
- The account keyset stores AK encrypted with the MUK and the X25519
private key encrypted with AK. The ML-KEM-768 private key is not stored: it
is derived from AK (
HKDF(AK, info = "2secret/v1/ml-kem-768/d")and".../z"as the 64-byte FIPS 203 seed), so every device of the account has it. Changing the master password only re-encrypts AK; vault keys and items stay untouched. - Every vault has its own random key. For the vault owner it is wrapped with AK ("symmetric" wrapping). For people the vault is shared with, it is wrapped to their public keys ("hybrid sealed" wrapping, see Sharing).
- Every item is serialised to JSON and encrypted with its vault key.
Associated data
Each ciphertext is bound to its purpose and location via AEAD associated data, so the server cannot swap ciphertexts around undetected:
| Ciphertext | Associated data |
|---|---|
| Account key | 2secret/v1/keyset/account-key |
| Private key | 2secret/v1/keyset/private-key |
| Vault key | 2secret/v1/vault-key/<vault id> |
| Vault metadata (name, description) | 2secret/v1/vault-meta/<vault id> |
| Item | 2secret/v1/item/<vault id>/<item id> |
| Sync token (on device) | 2secret/v1/server-token/<account id> |
An item moved by the server to another vault, or replaced by a different item's ciphertext, fails to decrypt. The decrypted item id is also checked.
What the server stores
| Data | Visible to the server? |
|---|---|
| E-mail address, display name | yes (needed for sign-in and sharing) |
| Salt and Argon2id parameters | yes (needed to derive keys on new devices) |
| Authentication key | only a server-side Argon2id hash of it |
| Account keyset | encrypted (account key under MUK) |
| Public keys (X25519, ML-KEM-768) | yes |
| Vault names, descriptions and icons | no (encrypted) |
| Profile photo | no (encrypted with the account key) |
| Item titles, usernames, passwords, URLs, notes, files | no (encrypted) |
| Number of vaults/items, size class of each item (padded), modification times | yes (metadata) |
| Who shares which vault with whom, and with which role | yes |
| Content of share links | no (encrypted with a key that only the link holds) |
| Share links' expiry, views left, whether they have a password | yes |
| Emergency contacts, their waiting periods and requests | yes |
| Vault keys for emergency contacts | no (sealed to the contact's public keys) |
Unknown accounts: the pre-login endpoint answers unknown e-mail addresses with stable fake salts (derived from a per-installation server secret), and failed logins for unknown addresses take as long as real ones, so the server does not reveal whether an address has an account.
Authentication and sessions
Signing in sends the 32-byte authentication key over TLS. The server stores
Argon2id(auth key, random salt) and compares in constant time. A successful
login returns a random 256-bit bearer token; the server stores only its
SHA-256. Tokens expire after 30 days of inactivity (configurable) and can be
revoked per device. Changing the master password signs out all other devices.
Failed logins are rate limited per account (10 per 15 minutes).
On the device the token is stored encrypted with the account key, so a copied profile file cannot be used to talk to the server either.
Sharing
To share a vault, the sharer fetches the recipient's public keys from the
server, wraps the vault key to them and uploads the wrapped key together with
a role (viewer, editor, manager). The server enforces roles for every
write.
Quantum-resistant sharing
Vault keys are wrapped with a hybrid of X25519 and ML-KEM-768 (the NIST post-quantum standard, FIPS 203), the same approach as Signal's PQXDH and Apple's iMessage PQ3:
x_ss = X25519(ephemeral private key, recipient X25519 public key)
pq_ss, kem_ct = ML-KEM-768.Encaps(recipient ML-KEM public key)
key = HKDF(ikm = pq_ss ‖ x_ss,
salt = epk ‖ recipient X25519 pk ‖ SHA-256(recipient ML-KEM pk) ‖ SHA-256(kem_ct),
info = "2secret/v1/hybrid-sealed-box")
wrapped vault key = XChaCha20-Poly1305(key, vault key, AD = "2secret/v1/vault-key/<vault id>")
An attacker has to break both X25519 and ML-KEM-768 to recover a vault key, so recording shared vaults today and decrypting them with a future quantum computer ("harvest now, decrypt later") does not work, and a flaw in the young ML-KEM would still leave X25519's protection in place.
The hybrid ciphertext only lives on the server until the recipient's next sync: their device then re-wraps the vault key with its own account key (256-bit symmetric) and replaces its copy on the server. Personal and shared vaults therefore rest on 256-bit symmetric keys only.
Accounts created with 2Secret 1.0 publish their ML-KEM public key on their first sync with 1.1. Until a recipient has done so, vaults shared with them fall back to X25519 alone, and their device re-wraps the key symmetrically on its next sync as described above.
Fingerprints
The apps show the recipient's public key fingerprint (e.g.
3F9A-77C1-0B2E-91D4), the first 8 bytes of SHA-256 over both public keys.
Comparing it out of band protects against a malicious server substituting
its own keys. A fingerprint changes once, when an account created with 1.0
publishes its ML-KEM key.
Revocation and key rotation
Removing someone stops the server from sending them further updates, and the vault immediately gets a new key: the remover's device generates a random vault key, re-encrypts the vault name and every item with it, wraps it for each remaining member (hybrid sealed, as above) and uploads everything in one request. The server applies it all or nothing, and only if the vault's member list and items still match what the client saw, so nobody is left behind and no item stays under the old key. Whatever is added or changed afterwards is unreadable with the key the removed person had, even with a copy of the server database.
Each vault key has a generation number. Writes encrypted under an older generation (from a device that was offline during the rotation) are refused; that device receives the new key on its next sync, re-encrypts its pending changes and sends them again, so no edit is lost. Managers can also replace a vault's key on request, for example after someone left on their own.
A device never takes back a key a vault had before: it remembers the keys it replaced, so a server cannot bring back the key a removed person still knows. (After restoring a server backup from before a key was replaced, such a vault does not sync on the devices that have the newer key; export it there and import it into a new vault.)
What rotation cannot undo: the removed person may have kept copies of what they could already see. Change the passwords that matter after removing someone.
Share links
An item (or the fields of it the user picks; one-time passwords only when
picked, attachments and passkeys never) can be shared with someone without
2Secret at a link https://<server>/s/<id>#<key>:
- The device encrypts the shared fields with AES-256-GCM (the item id of
the link as associated data) under
SHA-256("2secret/share/v1/key" ‖ link key [‖ PBKDF2-HMAC-SHA256(password, "2secret share " ‖ id, 600 000)]). The 256-bit random link key is only in the link's fragment, which browsers never send to a server. The optional password is for links sent through a channel others can read; it is sent another way. - The server stores the ciphertext, the expiry (at most 30 days), the number
of views left (or none) and
SHA-256("2secret/share/v1/proof" ‖ content key). It gives out the ciphertext only to a request with that proof (compared in constant time), so a link without its fragment, a guessed id or a wrong password neither reads it nor uses up its views. Ten wrong proofs delete the link; opening is rate limited per address. It deletes the link after its last view, when it expires and when its owner revokes it. - The page at
/s/<id>is served by the sync server itself (no third-party code) and decrypts in the browser with WebCrypto, which has AES-GCM, SHA-256 and PBKDF2 but neither XChaCha20-Poly1305 nor Argon2id; hence these choices here. It shows the content withtextContentonly, and onlyhttp(s)addresses become links. - The owner's list of links shows the item's title, encrypted with the
account key (associated data
2secret/v1/share-label).
What a link cannot undo: whoever opened it may keep a copy. A server that is compromised while a link is valid can serve a page that leaks the key, as with any web vault; links that expire soon and can be opened once limit that.
Emergency access
An account can trust up to ten other accounts on the same server as emergency contacts, each with a waiting period of 1 to 90 days:
- The owner's device seals the key of every vault the account owns to the contact's public keys (hybrid, exactly as when sharing; compare the fingerprint) and uploads it. Its devices keep this up to date on every sync: new vaults and replaced keys are sealed again. The server cannot open these keys and holds them back.
- The contact asks for access. The owner's apps show the request; the owner can approve it at once or deny it. Without either, the server grants it when the waiting period has passed. Then the contact can open the vaults: the server makes them a viewer of each vault whose current key it holds, and they read it like a shared vault (they cannot change anything).
- Denying a request after access was given, ending the access or removing the contact takes the contact out of those vaults, and the owner's device gives them new keys (see Revocation and key rotation). Vaults that were shared with the account by others are never part of it.
The waiting period is enforced by the server: a server that colludes with the contact can give access early. It cannot read the vaults on its own. Nobody gets the account itself, its master password or its Secret Key.
Passkeys
Passkeys (WebAuthn credentials) are stored inside items, so their private keys are end-to-end encrypted and synced like everything else. 2Secret acts as a software authenticator for synced passkeys: ES256 (ECDSA P-256) keys, "none" attestation, the backup-eligible and backed-up flags set, user verification by unlocking 2Secret, and a signature counter that stays 0.
In the browser extension, a small script in top-level https pages wraps
navigator.credentials.create() and .get(). It reads nothing from the
page; it forwards only the WebAuthn request. 2Secret then opens its own
window, where you decide: save or use a passkey, use another device or
security key (the browser takes over), or cancel. The page's origin is taken
from the browser, never from the page, and a website can only create or use
passkeys for its own domain or a parent domain that is not a public suffix
(alice.github.io can never use a passkey for github.io). The answer goes
back only to the page that asked, and only while it is still open. Passkey
suggestions in the username field (conditional mediation) and iframes stay
with the browser. Settings → Passkeys in this browser turns this off.
Passkeys can be imported from Bitwarden JSON exports. 2secret get --json
leaves passkey private keys out unless --reveal is given.
Sync and conflicts
Each vault has a server-side revision counter. Clients upload changes with the revision they were based on; stale writes are rejected as conflicts and resolved on the client: the most recently edited version wins and the other version's password is preserved in the item's password history, so a conflict never silently loses a password. Deletions are tombstones.
On the device
- Profile (
profile.json): account record, encrypted keyset, encrypted vaults and items, encrypted sync token. Written atomically with owner-only permissions (0600), with one backup copy. - Device secrets (
device.json): the Secret Key. Kept separate so the profile can be backed up or synced by other means without the Secret Key. On iPhone and iPad it is excluded from iCloud and computer backups (on Android everything is in the app's no-backup storage), so a backup never holds the Secret Key next to the encrypted vault. - Web vault and extension: both live in the browser's IndexedDB for the vault (or extension) origin.
- Decrypted keys and items exist only in memory while unlocked and are wiped (zeroized) when the app locks. Rust's memory safety rules out whole classes of bugs (buffer overflows, use-after-free) in the crypto and parsing code.
- Auto-lock after inactivity (default 10 minutes).
- Biometric unlock (optional; macOS, iOS, Windows): the account key is stored encrypted with a random 256-bit device key that the operating system releases only after Touch ID, Face ID or Windows Hello. On iOS the device key is a keychain item that the Secure Enclave opens only for the currently enrolled face or fingers, on this device, while a passcode is set. On macOS Touch ID is checked through LocalAuthentication and the key sits in the login keychain, readable only by 2Secret. On Windows the device key is derived (HKDF) from a Windows Hello signature over a fixed challenge: the private key stays in the TPM and the device key is never stored. The master password is required again every 14 days, and turning the feature off or removing the account deletes the device key.
- AutoFill on iOS and macOS (only while biometric unlock is on): the app shares the logins, passkeys and one-time password secrets AutoFill may fill with its extension through a file in the app group, encrypted with ChaCha20-Poly1305 under a new random key on every write. The key is a keychain item that opens only after Face ID or Touch ID with the current enrolment, on this device; on macOS it is in the data protection keychain, shared only with the extension (same Team ID and access group), and the extension runs in the App Sandbox. The extension cannot change the vault and stops filling after the same 14 days. The system receives websites, user names and passkey IDs for its suggestions, never passwords, keys or one-time password secrets. Details: APP-STORE.md.
- Setup links (
otpauth://): a link that opens the app is only read; its secret is saved, encrypted like any field, once you choose a login for it. On Windows, Linux and the BSDs a link starts a second 2Secret process, which hands it to the running app through a file in the data directory (readable only by you) and wakes it through a local socket or named pipe that carries nothing else. - Quick Access (desktop): the small search window gets titles, user names and websites of the items, never passwords, codes or keys; the main window copies the secret you pick, with the usual clipboard clearing. The window is closed and its list dropped when the vault locks.
- SSH agent (desktop, off by default): programs reach it through a Unix
socket in the data directory (
0600, in a folder only you can open) or, on Windows, through the pipe of the OpenSSH agent (openssh-ssh-agent; remote clients are refused, and other users cannot write to it). It lists the public keys of SSH Key items while the vault is unlocked and signs only after you allow it in 2Secret, which shows the program that asks and the key; until 2Secret locks skips the question for that key. Private keys are decrypted in memory for one signature and never leave 2Secret; adding keys, removing keys and agent extensions are refused. RSA keys sign with SHA-256 or SHA-512 only. - QR codes on the screen (desktop): Scan QR code takes a screenshot of the screens in memory, reads the QR code and drops the image; under Wayland the screenshot portal saves a file, which 2Secret deletes right after reading it.
- Clipboard: copied secrets are cleared after a configurable time (default 90 s) if the clipboard still contains them. The desktop apps and the CLI mark them as secret, so Windows' clipboard history and cloud clipboard and clipboard managers on Linux and macOS leave them out. Browsers only allow reading the clipboard after a permission prompt, so the web vault and the extension clear it without checking.
- CLI sessions (
eval "$(2secret unlock)") store the account key encrypted with a random key that lives only in the shell's environment variable, and expire after 30 minutes of inactivity. - Browser extension: to avoid re-entering the master password every time
the popup opens, the account key is kept in
chrome.storage.session— browser memory that is only accessible to the extension and cleared when the browser closes — until the auto-lock time passes. Autofill injects a small self-contained function into the active tab only when you click Fill. Logins and one-time codes never pass through a content script. Right before filling, the tab's address and the address of every frame are checked again against the item's websites, so credentials never reach a frame from another site (an ad, a hostile embed) or a page the tab navigated to after the popup opened. Websites are matched by registrable domain using the Public Suffix List (alice.github.ionever matchesmallory.github.io; an item forgithub.ioitself only matchesgithub.io), anhttpsitem is never filled into anhttppage, and an item URL with a port only matches that port. AutoFill on iPhone, iPad and Mac suggests logins by the same registrable domains.
Updates
The desktop and Android apps can check for updates; this is off by default and only contacts GitHub. An update is installed only if its manifest carries an Ed25519 signature from a key built into the app, is for a newer version (no downgrades), and the installer comes from that version's GitHub release with exactly the signed size and SHA-256. The downloaded update is verified again right before it is installed. Details: UPDATES.md.
Profiles
The apps can keep two accounts (profiles) on one device. They share nothing: each has its own encrypted storage, Secret Key, server session and biometric key. Switching keeps the profile you leave unlocked in memory until the app locks (auto-lock or Lock), which locks both. A sync that is running when you switch is bound to its account and stops instead of touching the other profile.
Website icons
When a login is saved, the apps fetch the icon of its website straight from
that website over HTTPS: the home page for the icons it links to, else
/favicon.ico, and for a login on a subdomain without an icon of its own
(login.example.com) the same from the main domain (example.com). The
request looks like one from a browser, as many websites turn others away.
No icon service is involved and no cookies are sent. Only
public host names are contacted, never IP addresses (in any notation, such
as 127.1) or local names, also not after a redirect, so an item someone shares with you cannot make your device
contact your own network. The website learns your IP address, as when you
visit it. The bytes must be an image by their content; an icon larger than
24 KB is scaled down to a small PNG (at most 512 KB is downloaded, and images
over 4096 pixels are not decoded). The icon is stored encrypted with the
item, so other devices (the web
vault and the browser extension too, which cannot fetch icons themselves)
show it without asking the website again. Turn it off in Settings →
Appearance → Website icons.
Master password and key derivation policy
- Master passwords need at least 12 characters and a "good" zxcvbn rating (name and e-mail address count against it).
- Argon2id parameters are stored per account so they can be raised later. Clients refuse to derive keys, and servers refuse to store accounts, with parameters below 19 MiB of memory and 2 passes, so a hostile or outdated server cannot talk a client into weak key derivation.
- Every encrypted JSON blob (items, vault names, keys, tokens) is padded with JSON whitespace to a size class before encryption: 256-byte steps up to 4 KiB, 4 KiB steps up to 64 KiB, then 64 KiB steps.
Lessons from the LastPass breaches
| What went wrong at LastPass | How 2Secret handles it |
|---|---|
| Stolen vault backups could be attacked offline; weak master passwords fell first | The Secret Key adds 130 random bits to every account: the server database alone is useless for guessing passwords |
| Website URLs were stored unencrypted, revealing which sites every user has | Titles, URLs, usernames, notes and vault names are all encrypted; item sizes are padded |
| Old accounts kept 1–5,000 PBKDF2 iterations for years | Argon2id (64 MiB, 3 passes) from day one, with an enforced minimum on client and server |
| Short master passwords (8 characters) were allowed | At least 12 characters and a "good" strength rating |
| Older vaults used AES-ECB/CBC without integrity protection | Authenticated encryption (XChaCha20-Poly1305) with every ciphertext bound to its place (associated data) |
| Browser extension bugs let web pages and iframes trigger fills or read data | Fill only on an explicit click, page and every frame re-checked against the item; pages can only ask for a passkey, which opens 2Secret for the user to decide |
| Access to the vault storage by a compromised engineer exposed everything | The server never holds a key that decrypts anything; self-hosters keep their own infrastructure |
Web vault hardening
2secret-server serves the web vault with a strict Content-Security-Policy
(script-src 'self' 'wasm-unsafe-eval', no inline scripts, no eval,
frame-ancestors 'none'), X-Frame-Options: DENY, nosniff,
Referrer-Policy: no-referrer and Cache-Control: no-store. The only
third-party connection allowed is the optional Have I Been Pwned range API.
Exports
Encrypted backups use a password-only envelope: Argon2id (default parameters)
- XChaCha20-Poly1305. Plain JSON/CSV exports contain secrets in clear text and the apps warn about this.
Known limitations
- Rotating a vault key uses the members' public keys as the server reports them; compare fingerprints in the member list (see Fingerprints).
- Metadata listed above (counts, size classes, timestamps, sharing graph, e-mail addresses) is visible to the server operator.
- Login uses TLS from the operating system or browser; whether that connection is quantum-resistant depends on the TLS stack and the reverse proxy in front of the server. The data inside it is end-to-end encrypted with 256-bit symmetric keys and the hybrid scheme above either way.
- Whoever runs a server, including the 2Secret server
sync.2secret.app(and Cloudflare, which it is reached through), sees the metadata above and IP addresses, never vault contents. Encrypted backups (Settings → Backup & export) keep your data independent of any server. - The web vault is only as trustworthy as the server that serves its code. If you do not trust the server operator, use the desktop/mobile apps or the extension, whose code does not come from the server.
- 2Secret has not yet had an independent security audit. Reviews are very welcome.