The second half of a protocol Android did not have. With X3DH it can now agree
a secret, and with this it can hold a conversation.
Two ratchets: the symmetric one advances per message so a compromised key
reveals nothing earlier, the DH one advances on each direction change so a
conversation recovers from compromise rather than staying broken.
The tests run conversations rather than checking steps, because that is what
catches the bugs this code actually has. X3DH is a pure function — it agrees or
it does not. A ratchet can agree for six messages and diverge on the seventh
because one side counted a skipped message differently, and the symptom is only
that something will not decrypt. So: out-of-order delivery, a message lost
forever, a gap ACROSS a direction change (which is the whole reason "pn" is on
the wire), and four DH turns in sequence.
Three things that would each produce plausible-but-wrong bytes, so each is
tested rather than reasoned about:
- the sending chain advances BEFORE the key is used, so a caller that retries
cannot encrypt twice under one key — two GCM encryptions under one key and
nonce leak the XOR of both plaintexts
- the header's canonical JSON is hand-built, key order "dh, pn, n", no spaces,
because it goes into the AAD and three implementations must reproduce it
byte-for-byte
- the derived nonce is stripped from the wire rather than sent, so a receiver
cannot be handed a nonce that disagrees with the one its key implies
MAX_SKIP caps derivation at 1000: a header claiming a huge counter costs the
sender nothing and the receiver everything.
13 tests. Still missing before an app: a session layer, a wire client, and the
app itself.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>