Midori Sync uses end-to-end encryption (E2EE). The server never has access to plaintext data. All encryption and decryption happens in the browser extension.
| Purpose | Algorithm | Library |
|---|---|---|
| Symmetric Encryption | XChaCha20-Poly1305 (AEAD) | libsodium |
| Key Derivation (password) | Argon2id | libsodium |
| Key Derivation (sub-keys) | BLAKE2B (crypto_kdf) | libsodium |
| URL hashing (record IDs) | BLAKE2b-128 (crypto_generichash) |
libsodium |
User Passphrase (BIP39 mnemonic, 12 words)
│
▼ Argon2id (ops=3, mem=64 MB, ALG_ARGON2ID13)
Master Key (32 bytes)
│
▼ crypto_kdf_derive_from_key, context = "MSPv1key"
│
├── Index 0 → Wrapping Key (server-side key bundle)
├── Index 1 → Bookmarks
├── Index 2 → History
├── Index 3 → Tabs (alias: open-tabs, legacy)
├── Index 4 → Browser Settings
├── Index 5 → Midori Tab
├── Index 6 → Midori Privacy
├── Index 7 → Devices
└── Index 8 → Passwords
The 8-byte KDF context MSPv1key separates collection sub-keys from
unrelated key derivations (see "Local lock", below).
KDF indices are part of the on-the-wire contract. They MUST never be
reordered or repurposed: doing so silently re-derives a sub-key for the
wrong collection, producing data that decrypts cleanly but is typed
incorrectly. New collections claim a fresh index. Legacy aliases
(open-tabs → 3) reuse an existing index on purpose so that
already-uploaded ciphertexts remain decryptable.
A guardrail test at tests/collection-scope.test.js enforces that the
backend CollectionSeeder, the extension COLLECTION_INDEX, and the
adapter files under extension/background/collection-adapters/ stay in
sync.
The wire format for every encrypted record is base64 of a single contiguous byte string:
+-----------------------+----------------------------------+--------------+
| nonce (24 bytes) | ciphertext (= |plaintext| bytes) | tag (16 B) |
+-----------------------+----------------------------------+--------------+
The base64 alphabet is libsodium's ORIGINAL variant (RFC 4648 with
+// and = padding).
len(nonce) == crypto_aead_xchacha20poly1305_ietf_NPUBBYTES == 24.len(tag) == crypto_aead_xchacha20poly1305_ietf_ABYTES == 16.len(combined) == 24 + len(plaintext) + 16.- The nonce is generated with
randombytes_bufper encryption call. XChaCha20's 192-bit nonce makes random selection collision-safe at any realistic scale. - Encryption is randomized in the (key, plaintext) pair, so two consecutive encryptions of the same plaintext yield distinct ciphertexts.
- Authentication is computed over
(ciphertext, ad=nil). Flipping any byte of the combined payload (nonce, ciphertext, or tag) MUST cause decryption to fail with an error rather than return manipulated plaintext.
These invariants are validated by tests/crypto.test.js under the
"roundtrip properties" describe block.
- Serialize data to a JSON string.
- Get the collection sub-key from the master key (see hierarchy).
- Generate a random 24-byte nonce.
- Encrypt with
crypto_aead_xchacha20poly1305_ietf_encrypt. - Concatenate:
nonce (24) || ciphertext || tag (16). - Base64-encode the concatenated bytes.
- Send the base64 string as the
payloadfield to the API.
- Base64-decode the
payloadfield. - Split: first 24 bytes = nonce, the remaining bytes = ciphertext+tag.
- Get the collection sub-key.
- Decrypt with
crypto_aead_xchacha20poly1305_ietf_decrypt. - Parse the JSON string.
The master key itself is encrypted and stored on the server so that new devices can retrieve it after authentication:
- Derive a wrapping key (sub-key index 0) from the same passphrase.
- Encrypt the master key's base64 representation with the wrapping key.
- Store as JSON:
{ v: 1, salt: "<base64>", bundle: "<encrypted>" }.
To set up a new device the user enters their passphrase:
- Download the encrypted key bundle from the server.
- Parse JSON, extract
salt. - Re-derive the wrapping key from passphrase + salt.
- Decrypt to recover the master key.
Argon2id is memory-hard (64 MB) and CPU-bound (opslimit=3). To keep
the background page / service worker responsive, it is executed inside
a dedicated Web Worker at extension/lib/argon2-worker.js. The worker
loads its own copy of libsodium so the rest of the extension does not
need to import the sumo build until it actually performs other crypto
(lazy-loading objective).
MidoriSyncCrypto.deriveKeys() automatically delegates to the worker
when running inside the extension. In environments without Worker
support (Node tests, sandboxes) it falls back to a synchronous
crypto_pwhash call. If the worker errors out at runtime, the next
deriveKeys() call also degrades to the synchronous path so a broken
worker never blocks login.
Without configuration the seed phrase and the encryption key live in
plaintext inside browser.storage.local. Users who want at-rest
protection can opt in to a local passphrase:
- The seed phrase + encryption key are wrapped under a key derived from
the local passphrase (Argon2id, KDF context
MSPv1lckto keep this derivation distinct from collection sub-keys). - After the passphrase is set, plaintext copies are removed from
browser.storage.local. Only the encrypted bundle remains at rest. - An inactivity alarm (default 15 minutes, configurable per user)
wipes the in-memory key. The next user action requires
unlockEncryption. - The plaintext seed phrase, while unlocked, is held only in memory. Locking wipes it; viewing it again requires re-unlocking.
Lock-related IPC messages exposed by the background page:
| Message | Purpose |
|---|---|
getLockStatus |
{ hasPassphrase, locked, timeoutMinutes } |
enableLocalPassphrase |
Set or rotate the local passphrase |
disableLocalPassphrase |
Remove the passphrase, restore plaintext storage |
lockEncryption |
Manual lock now |
unlockEncryption |
Decrypt the bundle and rehydrate the key |
When no local passphrase is set, the existing behavior is preserved
verbatim: plaintext seed in storage.local, no inactivity timeout.
Rotation is needed when the user wants to invalidate a leaked passphrase, change KDF parameters, or recover from suspected device compromise. The procedure is incremental so a rotation can survive a power loss between collections.
Initiated from the extension Settings UI ("Rotate master key"). The caller supplies the current passphrase and a new passphrase.
- Verify the current passphrase by decrypting the server-side key bundle. Refuse to continue on failure.
- Generate a fresh 16-byte salt and derive a new master key
M_new = Argon2id(new_passphrase, salt_new). - Encrypt
M_newunder a wrapping key derived from `new_passphrase- salt_new
and upload the new bundle to the server with versionv+1`. The server keeps the previous bundle until the migration completes (so other devices can still read existing data).
- salt_new
- For each collection
C, in order:- Page records server-side using the existing
deltacursor andversionfilter. - For every page, decrypt with
M_old's sub-key forC, re-encrypt withM_new's sub-key forC, and upload via the existingbatchUpsertUPSERT. The server-sideversioncolumn advances naturally and conditional GETs on other devices invalidate. - Persist a per-collection rotation cursor in
browser.storage.local({ rotationCursor: { [collection]: lastRotatedDelta } }). Resuming after interruption picks up at the cursor.
- Page records server-side using the existing
- Once all collections report a cursor at or beyond the latest
deltaobserved in step 1, mark rotation complete: delete the previous bundle on the server, drop the rotation cursor, and overwriteM_oldin memory with zeros. - Other devices observe the bundle version bump and prompt for the new passphrase on their next sync.
M_oldandM_newcoexist in memory only (never both on disk) until step 5.- A record is never deleted as part of rotation; it is only re-encrypted.
A failed rotation leaves the system in a consistent but mixed state
(some collections under
M_new, others underM_old), which the next resume cycle finishes. - Reads during rotation are best-effort: if decrypt with
M_newfails, fall back toM_oldfor one cycle, then give up. This bounds the blast radius of a corrupt bundle.
- Forward secrecy across rotations: rotation does not re-key the
server-side
versionchain, so an attacker who recorded ciphertexts before rotation can still decrypt them withM_old. - Atomic global rotation: by construction the procedure is per-collection and idempotent so it can resume after browser restarts.
- Zero-knowledge server: server stores only encrypted blobs.
- Forward secrecy across records: each record uses a random nonce.
- Authenticated encryption: XChaCha20-Poly1305 is an AEAD cipher; any tampering causes decryption to fail.
- Memory-hard KDF: Argon2id with 64 MB raises the cost of brute-force on weak passphrases.
- Per-collection isolation: compromising one sub-key does not expose other collections.
- Off-main-thread KDF: Argon2id runs in a dedicated worker so the background page stays responsive during login and rotation.
The encryption library is at extension/lib/midori-sync-crypto.js and
depends on libsodium-wrappers-sumo (~375 KB; the sumo variant is
required for Argon2id). The Argon2id worker at
extension/lib/argon2-worker.js loads its own copy of libsodium so the
extension can avoid pulling sumo into pages that only need messaging.
XChaCha20 uses a 192-bit (24-byte) nonce. This is large enough that random nonce generation is safe without risk of collision, even over billions of encryptions.