Skip to content

Cryptography in detail

Ground rule: there is no home-made cipher code. All primitives come from established, vetted libraries.

Purpose Method Parameters
Asymmetric key / DH X25519 Curve25519
Key derivation HKDF-SHA256 32-byte outputs, dedicated domain labels
Symmetric AEAD AES-256-GCM 96-bit nonce, 128-bit tag
Chain keys in the ratchet HMAC-SHA256 Signal constants
Post-quantum KEM ML-KEM-1024 (FIPS 203) category 5, 32-byte secret
Password-authenticated pairing J-PAKE (RFC 8236) NIST 3072 group
Password KDF, pairing Argon2id 32 MB, 3 passes
Password KDF, backup Argon2id 64 MB, 4 passes, parameters in the file header
Second factor TOTP (RFC 6238) or biometrics 6 digits, 30 s, ±1 window
Database at rest SQLCipher (AES-256) passphrase only in the keystore
Key wrapping AES-256-GCM in the Android keystore StrongBox preferred, TEE as fallback

Every device owns an X25519 identity key pair. The private part is stored encrypted with AES-256-GCM; the wrapping key lives in the hardware keystore (StrongBox preferred).

Derived from it:

  • a static session key per contact (ECDH → HKDF) for bootstrap and control traffic as well as the post-quantum exchange,
  • the initial ratchet root key per contact, into which the ML-KEM secret is mixed,
  • an ephemeral message key per message (ratchet), or a one-time subkey on the static paths.

The database passphrase and the attachment key are independent random keys, likewise wrapped.

The static session key carries the most frequent messages there are – presence, confirmations, profile details, the post-quantum exchange and all call signalling. Its Diffie-Hellman part is therefore cached process-wide, capped at 256 entries and cleared on an identity change. That puts no additional secret into memory: the value depends solely on your own private identity key, which is in the process anyway, and on the other side’s public key. If they change theirs, a new entry is created – a stale one is never used.

Exactly one master secret stays hardware-wrapped; everything else is derived from it via HKDF and wrapped in software. On StrongBox devices that saves several seconds of cold start without giving up the hardware binding: without the secure element of that device the master secret cannot be unwrapped, and without it none of the others can either.

Signal-conformant: a DH ratchet on every change of direction, a fresh message key per message, retention of skipped keys with a hard cap (200). The result is forward secrecy and post-compromise security.

The ratchet state is saved only after successful authentication; access to it is serialised. Retained message keys expire after seven days – previously they sat there indefinitely; the sweep runs at most hourly so that the check costs nothing.

Sessions can fall out of step: one side reinstalls, a backup is restored, a key changes. Three mechanisms handle that:

  • On receipt, bootstrap candidates are checked for the first sending chain of a session even when a session already exists. That is safe because every candidate has to decrypt authenticated – strangers cannot slip a session in.
  • On the sending side, after many unconfirmed attempts a throttled rebuild happens.
  • If the sender is more than 200 steps ahead, the chain is fast-forwarded in a controlled way – capped at 25,000 steps so an absurd figure cannot create computational load.

A second case that occurred for real: every delivery attempt used to re-encrypt and consume a fresh message key. After hours without a connection the sender was hundreds of steps ahead of the recipient, whose skip limit then made every further message unreadable. Today retries send the same ciphertext again.

X3DH-lite: forward secrecy from the first message

Section titled “X3DH-lite: forward secrecy from the first message”

Without a prekey server the first root key of a session would be derived deterministically from the two identities – and therefore be constant for each pair of contacts until the ratchet takes a step. Anyone who later obtained a private identity key could retroactively decrypt the first messages.

TalX solves this without a server: the sender bootstraps the ratchet against a rotating prekey of the recipient, distributed through their profile. Rotation happens every 30 days; the previous prekey is kept for one more round so that messages in flight do not fall into a void – together, a grace window of roughly 60 days. After that the private part is deleted. This gives the very first message forward secrecy.

After connecting, both sides negotiate an ML-KEM-1024 secret. Who is initiator and who is responder follows deterministically from the fingerprints; the encapsulation is idempotent.

The secret is mixed into the root key in addition to the classical X25519 part (HKDF over both, with a label of its own). The classical method therefore stays in place – the post-quantum share comes on top and cannot make anything worse.

An important design point: the exchange itself runs over the static session key, not over the ratchet it is only just establishing. Otherwise a chicken-and-egg problem arises – which is exactly what happened and is documented as finding F-15.

Control traffic (presence, profile, synchronisation, confirmations, call signals, the post-quantum exchange, group fallbacks), attachments at rest and the WebRTC connection details each use a one-time subkey per message, file or blob: HKDF(static key, 16-byte salt, label), with the salt carried in the header.

Every combination of key and nonce is therefore independent, and a nonce repetition would be inconsequential.

Every member has a chain of their own, distributed over the one-to-one ratchet. Rotation happens after 100 of your own messages, additionally on a time basis – a quiet group must not leave its chain standing for months – and mandatorily on every membership change. After a rotation, 16 keys of the old chain remain valid so that reordered messages already in flight are not lost. Skipped group keys are likewise capped at 200.

A comparable sequence of digits is formed from both public identities. It is identical regardless of device – an earlier bug, where it came out differently depending on the language setting and thereby raised a false alarm, has been fixed.

Every chunk carries its sender’s protocol version – currently 13. Since protocol version 13 the chunk contains a marker that changes per transfer instead of the sender identifier: HMAC(shared key, transfer identifier). Only the intended recipient can attribute it; two messages from the same sender are not recognisable as related from the outside.

That protects against third parties – a stranger’s device within radio range, someone reading the payload at the relay. Against the relay operator it does not help; they tie every delivery to the registered connection.