Security · Architecture
How authentication
and encryption work,
in detail.
This page is the technical deep-dive — the protocol, the parameters, and the honest caveats. If you're a security researcher, an interested customer, or just want to verify the claims made on the main security page, you're in the right place.
The short version: your password never leaves your browser, in any form, at any point in any flow. The long version is below.
What we store
Per-account state on the server.
Here's exactly what our database holds for each user. None of it reveals your password or your data.
| Field | Purpose |
|---|---|
email | Login identifier. Used to look up the account at sign-in. |
salt | 32 random bytes per account. Input to Argon2id during key derivation. |
auth_token_hash | bcrypt(sha256(auth_token), cost=12) — proof that the user knows the password. Verifying this on sign-in costs ~250 ms per attempt. |
wrapped_dek | The data encryption key, encrypted ("wrapped") with a key derived from your password. Without that wrapping key, the DEK is unrecoverable. |
kdf_params | Argon2id parameters used (currently 32 MiB memory, 2 iterations, 1 thread, version 0x13). Stored so the browser can reproduce the derivation on next sign-in. |
If our database were exfiltrated — every column above would be in the dump, and the attacker still could not read any user's data. To recover one user's password, they would need to run Argon2id (32 MiB memory per attempt) plus bcrypt (cost-12) on every guess. Every parameter is tuned to be expensive for an attacker.
Inside your browser
Client-side key derivation.
Everything below happens in your browser. None of these intermediate values ever reach our servers.
master_seed = Argon2id(password, salt, kdf_params) // 32 bytes auth_token = HKDF-SHA256(master_seed, "comma:v2:auth-token") KEK = HKDF-SHA256(master_seed, "comma:v2:kek") // AES-KW key DEK = AES-256-GCM key, generated once at signup wrapped_dek = AES-KW(KEK, DEK)
Why two HKDF info strings?
"comma:v2:auth-token" and "comma:v2:kek" are domain separators. They guarantee the auth token and the wrapping key cannot be substituted for one another, even though they share the same master seed.
Why a separate DEK?
The DEK never changes during an account's lifetime. Password changes re-wrap the DEK with a new wrapping key derived from the new password — encrypted data is not touched. That's why password rotation is instant.
The three protocols
Sign-up, sign-in, and password change.
Three flows handle every authentication interaction. None of them transmits your password.
Sign-up
- Browser generates 32 random bytes of salt and a fresh AES-256-GCM data encryption key (DEK).
- Browser derives the auth token and wrapping key from
password + saltvia Argon2id + HKDF, then wraps the DEK with the wrapping key. - Browser POSTs
{email, salt, auth_token, wrapped_dek, kdf_params}to the auth-signup endpoint. - Server bcrypt-hashes the auth token, stores the row, returns a session.
Sign-in
- Browser fetches the salt and KDF parameters from the auth-get-salt endpoint. Unknown emails get a deterministic HMAC-derived fake salt of identical shape — the server cannot tell us whether an email exists, so neither can a casual attacker probing the endpoint.
- Browser derives the auth token from
password + salt + kdf_params. - Browser POSTs
{email, auth_token}to the auth-login endpoint. Server bcrypt-compares against the stored hash, rotates a server-side random secondary password (defence against captured-session replay), returns the wrapped DEK plus a fresh session. - Browser derives the wrapping key, unwraps the DEK in memory, and is ready to decrypt your cloud-synced data.
Change password
- You type your current password. The browser re-derives the wrapping key from
current_password + cached_salt + cached_kdf_paramsand attempts to unwrap the cached wrapped DEK. AES-KW has built-in integrity, so a wrong key throws — the current-password challenge is fully client-side, with no network round-trip. - You type your new password. The browser derives a fresh auth token, a new wrapping key, and a new salt, and re-wraps the existing in-memory DEK with the new wrapping key.
- Browser POSTs the new authenticator material to auth-change-password.
- Done. Encrypted data is untouched; the same DEK is now protected by a different wrapping key.
For existing users
Migrating legacy accounts.
Accounts created before this architecture shipped used a legacy authentication path that stored your password in the standard auth provider. On your first sign-in under the new system, the migration runs entirely in your browser:
- Tries the new path; falls back to the legacy path if there is no new-architecture row yet.
- Decrypts every stored data blob with the legacy key (in your browser).
- Generates a fresh new-architecture key set and re-encrypts every blob with the new DEK before writing back.
- Creates the new-architecture account row.
The legacy authentication record is kept alive for at least 30 days and at least two successful new-architecture sign-ins before any cleanup is eligible — a deliberate safety margin so that a partial migration leaves the old auth path usable.
A short interstitial appears once during this transition: "Upgrading your account security. Your password never leaves your browser. This takes about 30 seconds and only happens once."
Where we are, and aren't
Honest caveats.
The architecture above is what's shipped today. These are the real limitations — the things we don't claim, but acknowledge plainly.
The big tradeoff
Forgot password = data lost.
Because the wrapping key is derived from your password and we never see it, we cannot recover an account when you forget. There is no email reset link that recovers your encrypted data. If a device is still signed in, you can export your transactions to CSV from there before logging out. A printable recovery phrase (BIP39-style) is on our roadmap but not currently shipped.
Not yet
No SSO yet.
Sign-in with Google, Apple, and similar providers don't expose passwords, so adding them requires a separate design that derives the wrapping key from a credential bound at SSO time. We're working on it; it isn't shipped today.
Future improvement
No PAKE yet.
Today, the derived auth token reaches our server (we then bcrypt-hash it). A future Password-Authenticated Key Exchange would let the server verify you know the password without ever seeing the token. That's a strict improvement on what we have today — but what we have is already strong enough that an attacker would need to grind Argon2id + bcrypt to recover anything useful from a captured token.
Verification
CI proves the claim.
An end-to-end test grep's every authentication-related network request body and asserts the password is never present in any form. The assertion runs on every CI cycle — so if a refactor ever broke the invariant, the build would fail before deploy.
Found a flaw?
We welcome scrutiny. If you find an issue with the protocol described on this page, the implementation, or any of our claims — please reach out. Responsible disclosure welcome.