When we set out to rebuild DChat's encryption, we gave ourselves one rule: invent nothing that the field already solved. Messaging cryptography has two mature answers. For two people, it is the Double Ratchet, which now comes with post-quantum key agreement (PQXDH) and a post-quantum ratchet (SPQR). For groups, it is MLS, the IETF standard, RFC 9420. E2EE v2 uses both, with post-quantum key exchange in each, and adds the parts a decentralized network needs: an identity that belongs to you, devices that prove themselves, and relays that do not learn who is talking.
In this article
- Identity: a hybrid ML-DSA-87 and Ed25519 account key, a DID hashed from it, and device certificates on a list anchored on chain.
- One-to-one: PQXDH, the Double Ratchet and SPQR, matched byte for byte against the reference implementation over a whole 367-message session.
- Groups: MLS on suite 0xF005, ML-KEM-1024 + P-384, matched byte for byte against an independent implementation of the suite.
- Calls: group calls encrypted frame by frame with keys from the group's MLS, up to 200 people.
- Sealed sender: a multi-recipient seal, so relays deliver without seeing the sender.
Start with the account
Everything else rests on who you are, so that is where E2EE v2 starts. Your recovery phrase derives an account identity key that is a hybrid of ML-DSA-87, the NIST post-quantum signature at its highest category, and Ed25519. A signature from it counts only when both halves verify. Its jobs are narrow on purpose: it defines your DID, signs your device list and signs your device certificates. It never encrypts a message.
Your DID is a hash of the two public keys, written as 52 lowercase base32 characters after did:dchat:. It is self-certifying, it never contains your username, and it is built from the identity key rather than the wallet key. Usernames become names that point at a DID.
Each device then makes its own keys, which never leave it. The account signs a certificate for each device, and the list of certificates is signed again and anchored on the DChat chain by hash and version. A client checks the list a relay serves against that anchor, so a relay cannot add a device to your account or hide one from your contacts. Because the certificates are ML-DSA-87 signatures, that holds against a quantum attacker too.
One-to-one: PQXDH, the Double Ratchet and SPQR
We wrote our own implementation of PQXDH, the Double Ratchet and SPQR from the published specifications rather than linking an existing library. It lives in DChat's Rust core, next to the rest of the client, and runs the same on Android and iPhone.
PQXDH
A session starts with PQXDH. The recipient's device publishes a bundle: its identity key, a signed X25519 prekey, one-time X25519 prekeys, and ML-KEM-1024 prekeys, each signed. The sender runs three or four X25519 agreements and one ML-KEM-1024 encapsulation, and feeds all of them into a single HKDF-SHA-384. To read the first message, an attacker has to break X25519 and ML-KEM-1024 both.
IKM = 0xFF x 32 || DH1 || DH2 || DH3 [|| DH4] || SS_mlkem1024,
info = "DChat_PQXDH_CURVE25519_SHA-384_ML-KEM-1024" )
Before any of that, the sender checks the bundle hard: the certificate stored with it must be signed by the account and current, it must name the device and the bundle's key, every signature must hold, the ML-KEM key must pass the FIPS 203 encapsulation key check, and the signed prekey must be no older than 30 days.
The Double Ratchet and SPQR
From there, the Double Ratchet gives every message its own key, deleted once used, and turns over a fresh X25519 agreement with each reply. That is forward secrecy, and it is also healing: a device whose state was stolen once locks the thief out again within a round trip. SPQR, the sparse post-quantum ratchet, keeps mixing fresh ML-KEM-1024 secrets into the session, so that healing holds against a quantum attacker as well. Messages are sealed with AES-256-GCM.
Every device is an endpoint
A message to a person is encrypted once for each of their devices, and once for each of your own other devices, every pair on its own session. Up to 32 devices of an account receive it. When a device cannot open a message, for example after restoring an old copy of its data, it asks the sender to resend, and the sender starts a fresh session and sends it again.
Checking it against the reference, byte for byte
A protocol that looks right can still be wrong in one byte, and one byte is enough to break interoperability with the specification or, worse, to weaken it quietly. So we did not stop at our own tests. We ran the reference implementation of the protocol, outside our code base, and recorded one complete session: identity keys, prekeys, signatures, and 367 messages flowing both ways, through ratchet steps and post-quantum steps.
Then we replayed it through DChat. Every key, every signature and every ciphertext came out identical, byte for byte, across the whole session. That recording ships with our test suite, so the comparison runs with every test run.
Groups: MLS with post-quantum keys
Groups need a different tool. Pairwise sessions to every member do not scale, and sender keys leave a removed member's old keys in play until everyone rotates. MLS, Messaging Layer Security (RFC 9420), solves both with a key tree: every device is a leaf, every change of members is a commit that moves the group to a new epoch, and the new epoch's keys reach only the leaves still in the group. New members cannot read the past, and removed members cannot read the future.
For the algorithms, we chose MLS_192_MLKEM1024P384_AES256GCM_SHA384_P384 on code point 0xF005:
| Piece | Suite 0xF005 |
|---|---|
| Key exchange | ML-KEM-1024 + P-384 hybrid HPKE, 1,665-byte public keys |
| Encryption | AES-256-GCM |
| Hash and key derivation | SHA-384, HKDF-SHA-384 |
| Leaf signatures | ECDSA P-384 |
| Leaf credential | The device certificate, signed with ML-DSA-87 + Ed25519 |
Choosing a suite another team already ships means choosing algorithms that experienced cryptographers already reviewed and put into production. The hybrid key exchange means a group's keys stay safe as long as either ML-KEM-1024 or P-384 holds. The big post-quantum keys travel only in commits, key packages and welcomes, so everyday group messages stay small and fast on a phone.
Every commit is checked against the group's own admin rules before any device applies it, and in a group on MLS every photo and file gets its own key, carried inside the encrypted MLS message.
Checking it against an independent implementation
The upstream MLS library we build on did not have an ML-KEM-1024 + P-384 suite, so we added 0xF005 and its KEM type to match an existing production implementation of the suite, and wrote DChat's own crypto provider around them. Then we ran that implementation at a pinned version and recorded its output. Our provider matches it byte for byte on the suite and component identifiers, the HPKE key pair from a fixed seed, an HPKE seal and open, the exporter, the P-384 key and its signature, and a key package it built and signed, which parses, validates and re-encodes identically in DChat. The other way round, it validated a key package built by DChat.
A note on the number: 0xF005 is a private-use code point, not an IETF assignment. It names a reviewed choice of algorithms; DChat groups talk only to DChat.
Calls on top of MLS
Group calls in E2EE v2 are end-to-end encrypted. Each call gets its own MLS group, exported from the chat's group so only members can find it. Every participant's frames are encrypted on the device with that participant's own key, derived from the call group's MLS exporter under the label dchat-calls-v1-media. A fresh key is made for every call and again whenever someone joins or leaves. The call server forwards frames it holds no key for, and a device drops frames rather than send them in the clear. One group call holds up to 200 people.
One-to-one calls are set up inside the E2EE v2 session itself: the offer, the answer and every call signal travel through the same encrypted channel as your messages. The media runs over DTLS-SRTP, the WebRTC standard, with an emoji check both people can compare.
Sealed sender
Encrypting content leaves one thing in the open: who is sending to whom. E2EE v2 closes that with sealed sender: a multi-recipient seal, checked byte for byte against the reference implementation. Each frame is sealed to the X25519 identity keys of the recipient's devices, with a fresh secret per frame, and the sender's account, device and identity key travel inside the seal. The recipient checks that the seal was made by the identity it names before opening the message.
The frame then goes to the relay on a separate channel that carries no login. To keep spam out without a name, it carries a proof made with the recipient's delivery key, which only the recipient's contacts and group co-members hold: the relay learns that the frame comes from one of the recipient's people, never which one. Inside the seal, content is padded to fixed steps, the message type and expiry are hidden, and the visible timestamp is rounded to the minute. The sealed sender article walks through it in plain language.
On real devices
Unit tests prove functions. Devices prove the product. We ran E2EE v2 on Android and iPhone against a full DChat network: signup with key-derived DIDs, one-to-one chats both ways with ticks, voice messages, edits, reactions, pins and unsend for everyone; a broken session healing itself after a device rolled back to an old copy of its data; voice and video calls both ways; groups with replies and a group video call; a linked iPhone that received the account's name, its history, pins and reactions and sent as the account; and contact requests between new accounts. Under all of it, more than 1,400 automated tests across the encryption core, the client, groups, devices and contacts pass on the code that ships.
Read the full design
The E2EE v2 security whitepaper covers every part in one place, and the protocol specification gives the wire formats and labels. For the plain-language version, read what E2EE v2 changes for you.