Encryption and keys
In progress
Primitives
Section titled “Primitives”| Purpose | Algorithm |
|---|---|
| Password key derivation | Argon2id, memory 64 MiB, iterations 3, parallelism 1. Parameters are stored per account and can be upgraded. |
| Key derivation | HKDF-SHA256, with a distinct info label for each purpose |
| Symmetric encryption | XChaCha20-Poly1305 with random 192-bit nonces and associated data |
| Public-key encryption (sharing) | Hybrid X25519 + ML-KEM-768 sealed boxes (HKDF + XChaCha20-Poly1305), with X25519-only boxes for accounts that have no ML-KEM key yet. Not yet reviewed by an outside cryptographer. |
| Signatures | Ed25519 |
| Login | OPAQUE (RFC 9807) over ristretto255 with Argon2id. See authentication. |
| Passkeys | ES256 (P-256) and EdDSA |
| Randomness | The OS CSPRNG (getrandom; crypto.getRandomValues in WebAssembly) |
All of this is implemented once, in the Rust core, using RustCrypto crates. All key material is zeroed from memory when no longer needed.
Parallelism is 1 because WebAssembly runs Argon2 lanes one after another, so a higher value would only make browser unlock slower.
Key hierarchy
Section titled “Key hierarchy”master password ─NFKD─► Argon2id(salt = HKDF(account_salt, email)) ─► pw_key ─┐ ├─► HKDF ─► AUK (Account Unlock Key)Secret Key (128-bit, made on device) ─► HKDF(salt = account_salt) ─► sk_key ───┘ └──► auth_secret ─► OPAQUE
AUK unwraps ASK (Account Symmetric Key, random 256-bit)ASK unwraps your private keys (X25519 encryption, Ed25519 signing)ASK unwraps your personal Vault Keysyour X25519 key opens shared Vault Keys (sealed to each member, signed by the sharer)Vault Key unwraps Item Keys (random, one per item)Item Key decrypts item overview (title, URLs, icon hint) and item detailsItem Key unwraps File Keys (random, one per attachment)File Key decrypts the attachment's file, in 64 KiB chunks
Recovery Key (256-bit, shown once) wraps ASK (stored on the server)Device Key (hardware-backed, biometric-gated) wraps ASK (stored only on that device)Derivation in detail
Section titled “Derivation in detail”account_salt = 16 random bytes, made at sign-up and returned by preloginargon2_salt = HKDF-SHA256(ikm = account_salt, salt = "octapass/v1", info = email)[0..16]pw_key = Argon2id(NFKD(password), argon2_salt, 64 MiB, 3 iterations, 1 lane) → 32 bytessk_key = HKDF-SHA256(ikm = Secret Key, salt = account_salt, info = "octapass/v1/secret-key")AUK = HKDF-SHA256(ikm = pw_key ‖ sk_key, info = "octapass/v1/auk")auth_secret = HKDF-SHA256(ikm = pw_key ‖ sk_key, info = "octapass/v1/auth")The email is trimmed and lower-cased. The password is NFKD-normalised, so the same password typed on different keyboards gives the same key, but it isn’t trimmed. Because the KDF parameters come from the server, apps refuse anything weaker than the defaults: a malicious server can’t make your password cheaper to guess.
auth_secret is the password input to OPAQUE. It never leaves your device either; OPAQUE only proves you know it.
Why two secrets
Section titled “Why two secrets”The Secret Key is generated on your device, kept on your signed-in devices and in your Recovery Kit, and never sent to the server. It looks like:
OP1-XXXXXX-XXXXXX-XXXXX-XXXXX-XXXXThat’s a version prefix plus 128 random bits as 26 Crockford base32 characters. When you type it, case, spaces and dashes don’t matter, and look-alikes such as O and 0 are read the same. If the server database leaks, an attacker has the OPAQUE records and wrapped keys but not the Secret Key, and 128 random bits can’t be brute-forced. So even a weak master password is safe against a server breach. 1Password uses the same two-secret approach.
Why per-item keys
Section titled “Why per-item keys”Each item has its own random key, wrapped by its vault’s key. An item can be shared or moved by re-wrapping one small key instead of re-encrypting the whole vault. Removing someone from a shared vault re-wraps item keys under a new vault key.
Attachments hang off the item key in the same way: each file has its own random key, wrapped by the item key and kept in the item’s encrypted details. A key rotation re-wraps item keys only, so files stay readable without being downloaded or encrypted again.
Ciphertext format
Section titled “Ciphertext format”version (1 byte, 0x01) ‖ key_id (16 bytes) ‖ nonce (24 bytes) ‖ ciphertext ‖ tag (16 bytes)Encoded as base64url without padding in JSON. key_id is the first 16 bytes of SHA-256("octapass/v1/keyid" ‖ key), so an app can tell which key a ciphertext needs without trying to decrypt it.
Associated data binds every ciphertext to where it belongs: "octapass/v1|" followed by a context.
| Context | What’s inside | Encrypted with |
|---|---|---|
ask |
Account key | AUK |
recovery |
Account key | Recovery code |
privkeys |
X25519 and Ed25519 private keys | Account key |
vaultkey:<vault id> |
Vault key | Account key |
itemkey:<vault id>:<item id> |
Item key | Vault key |
item:<vault id>:<item id>:overview |
Title, type, URLs, tags | Item key |
item:<vault id>:<item id>:details |
Everything else | Item key |
attachmentkey:<vault id>:<item id>:<attachment id> |
File key | Item key |
attachment:<vault id>:<item id>:<attachment id> |
Every chunk of the file | File key |
service-account-name:<vault id>:<service account id> |
A service account’s name | Its creator’s account key |
audit-detail:<account id> |
A security log entry only you can read | Account key |
Decryption fails if the server moves an encrypted key, field or file into another item or vault.
Attachments are encrypted a chunk at a time, so neither the app nor the server needs a whole file in memory:
file = header ‖ chunk_0 ‖ … ‖ chunk_nheader = version (0x01) ‖ key_id (16 bytes) ‖ nonce prefix (19 random bytes)chunk_i = XChaCha20-Poly1305(file key, nonce = prefix ‖ i (4 bytes) ‖ last (1 byte), associated data = "octapass/v1|attachment:…")Each chunk holds 64 KiB of the file, except the last, which holds the rest and is the only one marked as last. A file that was cut short, had chunks dropped, reordered or added, or was moved to another item fails to decrypt, and the apps never show part of one. Encryption adds 52 bytes plus 16 per 64 KiB. Vault and item ids are UUIDs, so they can’t contain the : separator. Each item also stores a schemaVersion inside its encrypted overview and details.
Files you encrypt
Section titled “Files you encrypt”octapass encrypt writes the age v1 format, through the age crate. Recipients are X25519 keys (age1…); passphrases use age’s scrypt recipient. Folders are a tar archive inside age, and a file is only trusted to the end of the last chunk that checked out, so output takes its name only when it’s whole.
- Your file key is an age key derived from your account’s X25519 private key:
HKDF-SHA256(ikm = private key, salt = "", info = "octapass/v1/age-identity"). Every device derives the same one. HKDF can’t be run backwards, so exporting it doesn’t reveal the account key. - Another user’s key is their published account X25519 key, in age’s encoding. It needs nothing new from the server. As with sharing, it’s as trustworthy as the fingerprint you compared. Their private key opens shared vaults too, so it’s never exported.
- age’s X25519 stanza and our sealed box use different HKDF
infolabels (age-encryption.org/v1/X25519,octapass/v1/seal), so neither can open the other’s output.
Octa Pass Safe
Section titled “Octa Pass Safe”safe key (32 random bytes, in a vault item) ├─ HKDF(salt = safe id, info = "octapass/v1/safe/records") ─► record key └─ HKDF(salt = safe id, info = "octapass/v1/safe/state") ─► state key
record blob = envelope(record key, padded(JSON record), aad = "octapass/v1|saferecord:<safeId>:<blobId>")record = { object, rev, prev, parent, name, kind, size, modified, content, fileKey }padded = length (u32) ‖ JSON ‖ zeros to a multiple of 512 bytes
content blob = header ‖ chunk_0 ‖ … ‖ chunk_n, the attachment format above, with aad = "octapass/v1|safecontent:<safeId>:<objectId>:<blobId>"Blobs are named by random UUIDs and never rewritten: a change writes new blobs under a new file key and nonce prefix, so no nonce is reused. A record names its content blob, and that blob’s associated data names the object, so a record can’t be paired with another file’s content or an old one. Record blob ids start with 0x4d, content ids with 0x43, so a device can fetch records first.
Writes in progress are sealed chunk by chunk in an unnamed temporary file, under a key that exists only in memory, and re-sealed as a new content blob when flushed.
A synced Safe’s blobs are stored as attachments of its vault item, which that item’s details don’t list. Vectors: crates/octapass-core/tests/vectors/safe.json.
Service accounts
Section titled “Service accounts”A service account is a vault member that isn’t a person. Its token holds a random 256-bit root that’s never sent to the server. Two keys come from it:
encryption key = X25519(HKDF-SHA256(root, salt = service account id, info = "octapass/v1/service-account/encryption"))signing key = Ed25519(HKDF-SHA256(root, salt = service account id, info = "octapass/v1/service-account/signing"))token = "opsa1_" + base64url(0x01 ‖ id (16) ‖ vault id (16) ‖ root (32) ‖ creator's public signing key (32) ‖ URL length (2, big-endian) ‖ server URL ‖ checksum (4))The vault key reaches it as an ordinary signed grant, sealed to the X25519 key, exactly as for a member. The grant is checked against the creator’s key in the token, never one the server names. To sign in, it signs a single-use challenge:
"octapass/v1/service-account-auth" ‖ 0x00 ‖ for each of id, challenge: length (4, big-endian) ‖ UTF-8 bytesThe checksum is the first 4 bytes of SHA-256 over everything before it. It catches damaged copies and protects nothing.
Recovery code
Section titled “Recovery code”The recovery code is 256 random bits plus a 16-bit checksum, shown as 55 characters in 11 groups of 5. The checksum catches typos. It wraps the account key through HKDF-SHA256(ikm = code, info = "octapass/v1/recovery"). See account recovery.
Device unlock in the web vault
Section titled “Device unlock in the web vault”The web vault can unlock with your device’s own lock (Touch ID, Face ID, Windows Hello or the device passcode) through a passkey with the WebAuthn PRF extension. The passkey gives a 32-byte secret that only works on that device after it checks it’s you. The vault turns it into a device key with HKDF-SHA256(ikm = PRF output, salt = 16 random bytes, info = "octapass/v1/device-unlock") and wraps your account key with it (context device-unlock). The wrapped copy stays in that browser and is never sent to the server. You still need your master password at least every 14 days.
Stay unlocked
Section titled “Stay unlocked”With Stay unlocked on, the apps wrap your account key the same way (context device-unlock) under a random 32-byte secret kept where your device’s own lock guards it but nothing asks for it: on iPhone a Keychain item that exists only while the phone has a passcode, on Android a Keystore key usable only while the phone is unlocked, on a Mac a Keychain item for the app, on Windows the system’s data protection for your user, on Linux the Secret Service keyring. The browser extension keeps nothing on disk. Changing your master password, rotating your Secret Key or signing out deletes it, and the 14-day master password check still applies.
Test vectors
Section titled “Test vectors”Deterministic test vectors for the key derivation, Secret Key, recovery code, ciphertext envelope, attachment files, OPAQUE messages, device unlock, a service account and a complete account are in crates/octapass-core/tests/vectors. Other implementations can use them to check that they interoperate.
Locking
Section titled “Locking”Locking zeroes the account key and every key derived from it in memory, and closes the decrypted search index.