Skip to content

Post-quantum hybrid encryption

To defend against “harvest now, decrypt later” adversaries, Zentalk’s X3DH handshake can run in hybrid mode: the classical X25519 agreement is combined with an ML-KEM-768 (Kyber-768) key encapsulation, so an attacker must break both primitives to recover the session secret.

Implementation: zentalk/src/services/e2ee/pqc/ (hybrid-x3dh.ts, kyber.ts, hybrid-keys.ts), using the mlkem library (NIST FIPS 203 ML-KEM).

Parameter ML-KEM-768 (Kyber-768)
Public key 1,184 bytes
Private key 2,400 bytes
Ciphertext 1,088 bytes
Shared secret 32 bytes
Security level ~192-bit post-quantum (X25519 provides ~128-bit classical)

All sizes are validated before every encapsulation/decapsulation; mismatches abort the handshake.

PQC_HYBRID_ENABLED (NEXT_PUBLIC_PQC_HYBRID_ENABLED) gates Kyber key generation, encapsulation, and decapsulation client-wide. It is on unless explicitly set to false; with the flag off, all PQC operations throw and sessions are established classically.

The flag is necessary but not sufficient. It only controls the local client; whether a given session runs hybrid is decided by the peer’s published bundle as well — see Negotiation. The flag has defaulted on for months without a single hybrid session being established.

The published key bundle is extended with two fields:

Field Content
pqcSupported Capability flag advertising hybrid support. Computed as PQC_HYBRID_ENABLED && identityKey.kyberPublic != null — both terms, not just the flag
kyberIdentityKey ML-KEM-768 public key (1,184 bytes)

Which accounts carry a Kyber identity key:

Account type Kyber identity key Since
Wallet Randomly generated with the identity 2026-01-11
Phone / seedless Derived from the same master seed as the classical keys (HKDF info zentalk-kyber768-seed-v1 → ML-KEM.KeyGen_internal(d ‖ z), FIPS 203 §7.1), so it is stable across devices and re-logins exactly like the classical identity aafdcf8 — before that, absent, and these bundles advertised pqcSupported: false

The initiator uses the hybrid handshake only when all of the following hold:

  1. PQC_HYBRID_ENABLED is on,
  2. the local identity has a Kyber key pair,
  3. the peer bundle advertises pqcSupported and includes a kyberIdentityKey.

Otherwise the handshake falls back to classical X25519-only X3DH. Condition 2 is the one that failed for phone accounts, and condition 3 the one they failed to satisfy for their peers.

When hybrid mode is used, the session records pqcEnabled: true, which surfaces as a “Quantum-protected” badge in the safety-number dialog (zentalk/src/components/security/pqc-badge.tsx). Because no hybrid session has ever been established, that badge has so far never been shown.

  1. Run standard X3DH, producing the 32-byte X25519 secret.

  2. Encapsulate against the peer’s kyberIdentityKey: (kyberCiphertext, kyberSecret) = ML-KEM-768.Encap(pk).

  3. Combine the two secrets:

    HKDF combiner
    okm = HKDF-SHA256(x25519_secret || kyber_secret,
    salt = HYBRID_X3DH_SALT,
    info = "zentalk.hybrid-x3dh.v1",
    length = 64)
    SK = okm[0:32] // the Double Ratchet root secret

    The combiner’s 64-byte output is not the root key. The Double Ratchet root secret is its first 32 bytes; by RFC 5869 §2.3 the OKM is T(1) || T(2) || ..., so those bytes are bit-for-bit what HKDF returns when asked for 32, and Signal’s PQXDH §3.3 likewise defines SK as 32 bytes of KDF output. This truncation lives in one place, deriveRatchetRootSecret (zentalk/src/services/e2ee/ratchet/state.ts), called by both the initiator and the responder ratchet init; a classical 32-byte secret passes through byte-identically, and any other length is rejected rather than silently keyed. Before c98cc41 this step did not exist and every hybrid handshake failed here.

    HYBRID_X3DH_SALT is a dedicated 32-byte protocol constant (derived from the string zentalk-hybrid-x3dh-salt-v1), distinct from the classical X3DH salt — this prevents cross-protocol key reuse between classical and hybrid derivations of the same DH outputs.

  4. Attach the encapsulation to the initial message. The hybrid wire format (v=2) extends the classical initial message with:

    Field Content
    kyberCiphertext Base64 ML-KEM-768 ciphertext (1,088 bytes)
    pqcEnabled true

The responder runs classical X3DH, then inspects the initial message:

  • If it asserts hybrid mode (pqcEnabled and kyberCiphertext present), the responder must decapsulate. If it cannot — missing Kyber private key or PQC disabled locally — the handshake fails rather than silently continuing classically.
  • If the message is classical, the responder completes classically. Compatibility with non-PQC peers is preserved: hybrid is only ever attempted when both sides advertised support.

After decapsulation the responder applies the identical HKDF combiner and obtains the same combined secret, which seeds the Double Ratchet root.

Kyber key material is cleared with multi-pass zeroization (random fill, 0xFF fill, 0x00 fill) when no longer needed, matching the hygiene applied to classical DH outputs.

Hybrid protection covers the session key agreement and nothing else. Symmetric layers (AES-256-GCM, HMAC-SHA256, HKDF-SHA256) already offer adequate margins against Grover-type quantum attacks. Three residuals are recorded in the threat model in the zentalk-web repository (THREAT_MODEL.md, adversary A1*):

  1. Not yet effective in production. See the status callout at the top of this page. This is the residual that dominates the other two today.
  2. The post-quantum half has no forward secrecy. Encapsulation runs against the long-lived kyberIdentityKey, not against a rotating Kyber prekey. An adversary who ever obtains that one private key retroactively strips the post-quantum layer from every past session — which is exactly the harvest-now-decrypt-later attack the hybrid handshake is meant to stop. Kyber prekeys are generated and rotated client-side but are dropped at the wire boundary, and the signed-prekey signature transcript covers only the X25519 key. The design that closes this is docs/architecture/M6-PQC-FORWARD-SECRECY.md in the zentalk-web repository; it is a draft, not shipped.
  3. Bundle signatures remain Ed25519 (no ML-DSA yet), so a quantum adversary that arrives before a bundle expires could forge one.