Thunderbolt E2E Encryption Key Hierarchy: How Thunderbird Secures Your Sync Data
Thunderbolt uses a two-tier key hierarchy where a single AES-256-GCM content key encrypts all user data, and each device receives a unique hybrid envelope combining ECDH P-256 and ML-KEM-768 to protect that content key.
The Thunderbolt E2E encryption key hierarchy powers Thunderbird's zero-knowledge sync architecture, ensuring that user data remains encrypted client-side before reaching Mozilla's servers. This article examines the thunderbird/thunderbolt repository to break down how cryptographic keys are generated, wrapped, and stored across devices.
Understanding the Thunderbolt Key Hierarchy Structure
The hierarchy centers on a single content key protected by device-specific envelopes. This design ensures that compromising one device does not expose the raw content key in plaintext to other devices, while maintaining synchronization capability across a user's ecosystem.
The Content Key (CK)
The content key (CK) is a single AES-256-GCM symmetric key that encrypts all user data columns flagged for encryption. According to the source code in src/crypto/primitives.ts, the CK is generated once during initial setup via the generateCK function and remains identical across all devices belonging to the user. This key never leaves a device in unencrypted form.
Hybrid Device Envelopes
Each device receives a unique device envelope that wraps the CK. The envelope uses a hybrid post-quantum scheme combining two independent key exchange mechanisms:
- ECDH P-256: A classical elliptic-curve Diffie-Hellman key pair
- ML-KEM-768: A Module Lattice-based Key Encapsulation Mechanism (post-quantum resistant)
Both mechanisms operate independently; an attacker must break both the classical and post-quantum algorithms to extract the content key.
Cryptographic Primitives in Thunderbolt
The src/crypto/primitives.ts file implements the core cryptographic operations that underpin the key hierarchy.
Device Key Generation
When a user enables sync, the device generates its identity keys. The generateKeyPair function creates an ECDH P-256 key pair, while generateMlKemKeyPair generates the ML-KEM-768 key pair. Private keys are marked as non-extractable and never leave the device's secure storage.
import { generateKeyPair, generateMlKemKeyPair } from './crypto/primitives'
import { storeKeyPair } from './crypto/key-storage'
// ECDH P‑256 key pair
const ecdhPair = await generateKeyPair()
// ML‑KEM‑768 key pair (post‑quantum)
const { publicKey: mlkemPub, secretKey: mlkemSec } = generateMlKemKeyPair()
await storeKeyPair(
ecdhPair.privateKey,
ecdhPair.publicKey,
mlkemPub,
mlkemSec,
)
(source: primitives.ts for generation, key-storage.ts for storage)
Content Key Generation
The generateCK function creates the AES-256-GCM content key. During initial setup, the key is generated as extractable to allow encoding into a recovery phrase, then re-imported as non-extractable for operational use.
import { generateCK, reimportAsNonExtractable } from './crypto/primitives'
import { encodeRecoveryKey } from './crypto/recovery-key'
import { storeCK } from './crypto/key-storage'
// First‑device setup – make CK extractable so we can encode it
const ckExtractable = await generateCK(true)
// Encode as 24‑word mnemonic (shown once to the user)
const recoveryPhrase = await encodeRecoveryKey(ckExtractable)
// Re‑import as non‑extractable before persisting
const ck = await reimportAsNonExtractable(ckExtractable)
await storeCK(ck)
(source: primitives.ts for generateCK / reimportAsNonExtractable, recovery-key.ts for encoding)
How Device Envelopes Work in Thunderbolt E2E Encryption
The hybrid envelope mechanism ensures that the content key remains protected even if one cryptographic primitive is compromised.
The Hybrid Wrapping Process
When wrapping the CK for a target device, the wrapCK function performs the following steps:
- Ephemeral ECDH exchange: Generates an ephemeral P-256 key pair and computes a shared secret with the target device's public key
- ML-KEM-768 encapsulation: Encapsulates a shared secret against the target device's ML-KEM public key, producing a ciphertext
- Key derivation: Feeds both shared secrets into HKDF with the info parameter
"thunderbolt‑hybrid‑ck‑wrap‑v1" - AES Key Wrap: Uses the derived key to wrap the CK with AES-KW-256
The resulting envelope format is [version][ephemeral‑ECDH‑pub][ml‑kem‑ciphertext][wrapped‑CK].
import { wrapCK } from './crypto/primitives'
import { getKeyPair } from './crypto/key-storage'
// Assume we already have the local CK and the target device’s public keys
const localCK = await getCK()
const targetKeys = await getKeyPair() // e.g., fetched from server for a new device
if (!localCK || !targetKeys) throw new Error('Missing keys')
// Wrap CK for the target device
const envelopeBase64 = await wrapCK(
localCK,
targetKeys.ecdhPublicKey,
targetKeys.mlkemPublicKey,
)
// Send `envelopeBase64` to the backend; the target device will unwrap it.
(source: primitives.ts – wrapCK)
Envelope Unwrapping
The target device uses unwrapCK with its private keys to reverse the process:
import { unwrapCK } from './crypto/primitives'
import { getKeyPair } from './crypto/key-storage'
// Retrieve own private keys
const myKeys = await getKeyPair()
if (!myKeys) throw new Error('Key pair missing')
// `envelopeBase64` comes from the server
const ck = await unwrapCK(envelopeBase64, myKeys.ecdhPrivateKey, myKeys.mlkemSecretKey)
// Persist the CK for future encrypt/decrypt operations
await storeCK(ck)
(source: primitives.ts – unwrapCK)
Recovery and Key Management
Beyond the active sync keys, Thunderbolt provides mechanisms for disaster recovery and secure storage.
Recovery Key Export
The content key can be exported exactly once as a 24-word BIP-39 mnemonic. This recovery key allows users to restore their encrypted data if all devices are lost. The encoding and decoding logic resides in src/crypto/recovery-key.ts.
import { decodeRecoveryKey } from './crypto/recovery-key'
import { storeCK } from './crypto/key-storage'
const userPhrase = 'abandon ability ...' // 24 words supplied by the user
const extractedCK = await decodeRecoveryKey(userPhrase)
// Store the recovered CK (non‑extractable) for normal use
await storeCK(extractedCK)
(source: recovery-key.ts)
Canary Verification
A canary value—a fixed plaintext encrypted with the CK—is stored on the server. This serves two purposes: it verifies that a supplied recovery phrase correctly decodes to the right content key, and it signals whether end-to-end encryption has been fully configured for the account. The canary mechanism is documented in docs/e2e-encryption.md.
Secure Key Storage
All keys persist locally in IndexedDB via src/crypto/key-storage.ts. The storage layer maintains distinct IDs for the ECDH key pair, ML-KEM keys, and the content key, enabling atomic transactions for full wipes or selective clearing.
Summary
- Thunderbolt's E2E encryption key hierarchy uses a single AES-256-GCM content key (CK) shared across all user devices, wrapped individually for each device.
- Hybrid post-quantum protection combines ECDH P-256 and ML-KEM-768 in every device envelope, ensuring resistance against both classical and quantum attacks.
- Key operations are implemented in
src/crypto/primitives.ts, includinggenerateCK,wrapCK, andunwrapCKfor the hybrid envelope scheme. - Recovery mechanism exports the CK as a 24-word BIP-39 mnemonic via
src/crypto/recovery-key.ts, allowing account restoration without devices. - Local persistence uses IndexedDB through
src/crypto/key-storage.ts, storing device keys and the CK with non-extractable flags for security.
Frequently Asked Questions
What happens if I lose all my devices but have my recovery phrase?
You can restore access to your encrypted data by using the 24-word recovery phrase. The decodeRecoveryKey function in src/crypto/recovery-key.ts converts the mnemonic back into the original AES-256-GCM content key, which you can then store locally using storeCK to resume sync operations.
Why does Thunderbolt use both ECDH and ML-KEM-768?
Thunderbolt implements a hybrid post-quantum approach to protect against "harvest now, decrypt later" attacks. While ECDH P-256 provides proven classical security, ML-KEM-768 (Module Lattice-based Key Encapsulation Mechanism) offers protection against future quantum computing attacks. An attacker must break both algorithms simultaneously to compromise the content key.
Where are my private keys stored?
Private keys never leave your device. They are stored in the browser's IndexedDB via src/crypto/key-storage.ts, marked as non-extractable to prevent JavaScript exfiltration. The storage layer maintains separate entries for your ECDH private key, ML-KEM secret key, and the content key, allowing atomic operations for secure deletion or rotation.
How does Thunderbolt verify my recovery phrase is correct without storing it?
Thunderbolt uses a canary value—a fixed plaintext encrypted with the content key and stored on the server. When you enter a recovery phrase, the system derives the candidate content key, attempts to decrypt the canary, and verifies the resulting plaintext matches the expected value. This confirms the phrase correctly recovers the original content key without ever transmitting the phrase itself to the server.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →