DEV Community

Cover image for Don't trust your messenger: post-quantum sealing you can verify with 7 tests
Umbera
Umbera

Posted on AI-assisted

Don't trust your messenger: post-quantum sealing you can verify with 7 tests

Almost every messenger says "we can't read your messages." Almost none of them let you check.

While building Umbera, a messenger for Android and iOS, I decided to do the opposite: publish the cryptographic core — the code that decides what our server can learn — and write tests that rebuild the scheme and verify its properties with one command.

This post walks through:

  • how each message is sealed with a hybrid post-quantum construction (X25519 + ML-KEM-1024),
  • why breaking one of the two algorithms gets an attacker nothing,
  • what metadata the server does see (this matters),
  • how calls are protected from DTLS fingerprint substitution,
  • and how to verify every claim yourself.

TL;DR

  • The sealing key is derived from two shared secrets: a classical one (X25519) and a post-quantum one (ML-KEM-1024). An attacker has to break both.
  • The relay stores only a mailbox ID and an opaque blob. There is no sender or recipient field.
  • Call media uses DTLS-SRTP end to end; a TURN relay only sees encrypted datagrams.
  • Seven Rust tests check the properties; calls can be checked with a packet capture.

How a message is sealed

Message sealing pipeline

  1. Double Ratchet. The text is encrypted with the conversation's ratchet session, so every message gets a fresh key (forward secrecy and post-compromise recovery).
  2. Inner package. The ratchet ciphertext is wrapped together with the sender's identity, so the sender travels inside the ciphertext (sealed sender), not in a header.
  3. Seal to the recipient. The sender generates an ephemeral X25519 key pair for this message only, computes DH with the recipient's long-term X25519 key, and in parallel runs ML-KEM-1024 Encaps against the recipient's Kyber key. ss1 || ss2 goes into HKDF-SHA256 with a domain-separation label; the first 32 bytes are the AES-256-GCM key.
  4. Padding. The result is padded to a fixed size bucket so length leaks as little as possible.
  5. Delivery. The envelope is dropped into a mailbox whose address only the two peers can compute from their shared secret. Addresses rotate weekly.

Here is the sealing from the proof tests — the same construction the apps use:

fn seal(to: &Recipient, plaintext: &[u8]) -> Sealed {
    let ephemeral_private = random::<32>();
    let ephemeral_public = x25519_public_key(ephemeral_private.clone()).unwrap();
    let ss1 = x25519_shared_secret(ephemeral_private, to.x25519_public.clone()).unwrap();
    let enc = mlkem1024_encapsulate(to.kyber_public.clone()).unwrap();
    let iv = random::<12>();
    let box_ = aes256_gcm_encrypt(key(ss1, enc.shared_secret), iv.clone(), plaintext.to_vec(), vec![]).unwrap();
    Sealed { ephemeral_public, kyber_ciphertext: enc.ciphertext, iv, box_ }
}
Enter fullscreen mode Exit fullscreen mode

Why hybrid and not Kyber alone? ML-KEM was standardised recently (FIPS 203). Classical X25519 hedges against a flaw in the new scheme; Kyber hedges X25519 against a quantum computer. Because the key comes from both secrets, the seal holds as long as either algorithm does.

What the server sees

A proof that hides the inconvenient parts is just marketing, so here is the full picture:

Data Visible to the server?
Content (text, media, voice) No — AEAD ciphertext
Sender No — identity is inside the seal
Recipient No — only a rendezvous ID derived from the pair's shared secret, rotated weekly
Length Partly — only the size bucket
Timing Yes — when an envelope is written and fetched
Client network address Yes on a direct connection; with Tor enabled, only the exit node

A stored message is a mailbox ID, an opaque ciphertext, a type and timestamps. There is no sender, recipient or account field.

Seven proof tests

core/tests/proof.rs rebuilds the seal and checks one property per test. The key one is the post-quantum guarantee:

#[test]
fn breaking_only_x25519_is_not_enough() {
    let bob = new_recipient();
    let eve = new_recipient();
    let sealed = seal(&bob, MESSAGE);
    assert_eq!(open(&bob.x25519_private, &eve.kyber_private, &sealed), None);
}
Enter fullscreen mode Exit fullscreen mode

The attacker here holds the recipient's X25519 private key (say, after a quantum break) but not the Kyber key, and the message does not open. The other tests check that the recipient can read it, that the ciphertext doesn't contain the plaintext, that foreign keys fail, that breaking only Kyber fails too, that flipping a single bit is rejected, and that the same text sent twice yields unrelated ciphertexts.

All you need is Rust:

git clone https://github.com/Vahe327/UMBERA
cd UMBERA/core
cargo test --test proof
Enter fullscreen mode Exit fullscreen mode
running 7 tests
test the_recipient_can_read_the_message ... ok
test the_ciphertext_does_not_contain_the_message ... ok
test anyone_with_other_keys_cannot_read_it ... ok
test breaking_only_x25519_is_not_enough ... ok
test breaking_only_kyber_is_not_enough ... ok
test a_tampered_message_is_rejected ... ok
test every_message_is_sealed_with_fresh_keys ... ok

test result: ok. 7 passed; 0 failed
Enter fullscreen mode Exit fullscreen mode

On top of these, the core has 23 tests with known-answer vectors for every primitive and end-to-end checks of ML-KEM-1024 and ML-DSA-65. We don't roll our own ciphers: primitives come from RustCrypto, dalek-cryptography and OpenMLS.

Calls: protecting DTLS fingerprints

Call encryption

Media goes over WebRTC: SRTP keys come from a DTLS handshake between the two endpoints. Behind symmetric NAT, a TURN relay forwards the traffic, but it works at the transport layer and never takes part in DTLS, so it never has the SRTP master secret.

The one weak spot of this setup is fingerprint substitution during call setup — a classic man-in-the-middle. A signalling server could swap in its own DTLS fingerprints and sit between the callers. So the call description (SDP with the fingerprints) is encrypted with the same scheme as messages and signed by the caller:

val result = messageEncryption.encryptMessage(
    content = data,
    recipientId = peerUserId,
    recipientPublicKey = peerKeys.first,
    recipientX25519PublicKey = peerKeys.second,
    type = "signaling",
    sequenceNumber = 0
)
Enter fullscreen mode Exit fullscreen mode

On the receiving side, a foreign or altered signature aborts call setup:

if (!Secp256k1.verify(ciphertext, sig, senderPublicKey)) {
    throw SecurityException("Invalid message signature")
}
Enter fullscreen mode Exit fullscreen mode

Group calls are a full mesh: each participant has a separate DTLS-SRTP session with every other one. There is no SFU or MCU that could decode a stream.

Check it with Wireshark

  1. Share your laptop's connection and capture the phone's traffic on that interface.
  2. Make a call.
  3. Filter the capture with dtls || rtp || srtp.
  4. Expect a DTLS ClientHello/ServerHello followed only by SRTP. Wireshark can't decode the payload, and there is no plain RTP.

Assumptions

  • Trusted endpoints. A compromised OS (malware, screen access) is outside what any E2E scheme can solve.
  • An honest peer. Cryptography can't stop a recipient from forwarding the decrypted text.
  • Sound primitives. We rely on NIST and IETF standards: FIPS 203, RFC 7748, RFC 5869, AES-GCM, RFC 5764.
  • Verified keys for important contacts. Key transparency (Merkle inclusion proofs) and key pinning warn you if a contact's keys change.

What is published

The repository holds the Rust crate with the primitives and the MLS engine (exported to the mobile apps via UniFFI), the Kotlin Multiplatform protocol code shared by Android and iOS, and the wire-format specification. The server and the UI are not included: the server only ever receives what this code has already encrypted, so it isn't needed to check the claims.

The license is PolyForm Strict 1.0.0 — you can read, build, run the tests and audit, but it's source-available, not open source.

Try to break it

If you can disprove either claim, I want to be the first to know — see SECURITY.md in the repo. I'd especially love feedback on how the two secrets are combined in HKDF and on the call-signalling protection.

Top comments (0)