Kotlin: the Double Ratchet

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>
This commit is contained in:
Paul Hitt
2026-08-23 14:51:30 -04:00
co-authored by Claude Opus 5
parent c893d412d2
commit e0589eff14
2 changed files with 375 additions and 0 deletions
@@ -0,0 +1,148 @@
package io.flamenet.e2e
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
/**
* The ratchet, driven as a conversation.
*
* Single-step assertions would pass on a ratchet that is subtly wrong. What
* catches a counting error is a conversation that goes back and forth, drops
* messages, and delivers some out of order — because the divergence only shows
* up once the two sides have taken a different number of steps.
*/
class DoubleRatchetTest {
private val AD = "flamenet-test-ad".toByteArray()
/** Alice and Bob, having already agreed a secret via X3DH. */
private fun pair(): Pair<DoubleRatchet, DoubleRatchet> {
val bobSigned = X25519.generate()
val aliceIdentity = X25519.generate()
val bobIdentity = X25519.generate()
val initiated = X3DH.initiate(
aliceIdentity.private,
X3DH.Bundle(
identityKeyDH = X25519.rawFromPublicKey(bobIdentity.public),
signedPreKey = X25519.rawFromPublicKey(bobSigned.public),
oneTimePreKey = null,
),
)
val bobSecret = X3DH.respond(
identityPrivateDH = bobIdentity.private,
signedPreKeyPrivate = bobSigned.private,
oneTimePrivate = null,
initiatorIdentityDH = X25519.rawFromPublicKey(aliceIdentity.public),
initiatorEphemeral = initiated.ephemeralPublic,
)
assertContentEquals(initiated.sharedSecret, bobSecret, "X3DH disagreed before the ratchet started")
val alice = DoubleRatchet.initiator(
initiated.sharedSecret, X25519.rawFromPublicKey(bobSigned.public))
val bob = DoubleRatchet.responder(bobSecret, bobSigned)
return alice to bob
}
@Test
fun `a message survives the trip`() {
val (alice, bob) = pair()
val msg = alice.encrypt("hello".toByteArray(), AD)
assertContentEquals("hello".toByteArray(), bob.decrypt(msg, AD))
}
@Test
fun `a conversation going both ways stays in step`() {
// Each direction change turns the DH ratchet. Four turns is enough for a
// root-key or chain-key mistake to compound into a failure.
val (alice, bob) = pair()
assertContentEquals("a1".toByteArray(), bob.decrypt(alice.encrypt("a1".toByteArray(), AD), AD))
assertContentEquals("b1".toByteArray(), alice.decrypt(bob.encrypt("b1".toByteArray(), AD), AD))
assertContentEquals("a2".toByteArray(), bob.decrypt(alice.encrypt("a2".toByteArray(), AD), AD))
assertContentEquals("b2".toByteArray(), alice.decrypt(bob.encrypt("b2".toByteArray(), AD), AD))
assertContentEquals("a3".toByteArray(), bob.decrypt(alice.encrypt("a3".toByteArray(), AD), AD))
}
@Test
fun `messages arriving out of order still open`() {
val (alice, bob) = pair()
val one = alice.encrypt("one".toByteArray(), AD)
val two = alice.encrypt("two".toByteArray(), AD)
val three = alice.encrypt("three".toByteArray(), AD)
// Delivered 3, 1, 2 — the receiver must hold keys for the gap rather
// than advancing past them.
assertContentEquals("three".toByteArray(), bob.decrypt(three, AD))
assertContentEquals("one".toByteArray(), bob.decrypt(one, AD))
assertContentEquals("two".toByteArray(), bob.decrypt(two, AD))
}
@Test
fun `a message lost forever does not break the ones after it`() {
val (alice, bob) = pair()
alice.encrypt("lost".toByteArray(), AD) // never delivered
val after = alice.encrypt("after".toByteArray(), AD)
assertContentEquals("after".toByteArray(), bob.decrypt(after, AD))
}
@Test
fun `a gap across a direction change is handled`() {
// `pn` exists for exactly this: Bob has to know how many messages were
// in Alice's previous chain to skip them when she starts a new one.
val (alice, bob) = pair()
assertContentEquals("a1".toByteArray(), bob.decrypt(alice.encrypt("a1".toByteArray(), AD), AD))
assertContentEquals("b1".toByteArray(), alice.decrypt(bob.encrypt("b1".toByteArray(), AD), AD))
alice.encrypt("dropped".toByteArray(), AD) // never delivered
val next = alice.encrypt("next".toByteArray(), AD)
assertContentEquals("next".toByteArray(), bob.decrypt(next, AD))
}
@Test
fun `the header is bound to the ciphertext`() {
// The AAD covers the canonical header, so editing the counter must break
// the tag rather than silently addressing a different message key.
val (alice, bob) = pair()
val msg = alice.encrypt("bound".toByteArray(), AD)
val tampered = DoubleRatchet.Message(
DoubleRatchet.Header(msg.header.dh, msg.header.pn, msg.header.n + 5),
msg.ciphertext,
)
assertFailsWith<Throwable> { bob.decrypt(tampered, AD) }
}
@Test
fun `a different associated data does not open the message`() {
val (alice, bob) = pair()
val msg = alice.encrypt("bound".toByteArray(), AD)
assertFailsWith<Throwable> { bob.decrypt(msg, "other-ad".toByteArray()) }
}
@Test
fun `an absurd skip is refused rather than computed`() {
// A header claiming a huge counter costs the sender nothing and would
// cost the receiver everything.
val (alice, bob) = pair()
val msg = alice.encrypt("x".toByteArray(), AD)
val absurd = DoubleRatchet.Message(
DoubleRatchet.Header(msg.header.dh, msg.header.pn, 500_000),
msg.ciphertext,
)
assertFailsWith<IllegalArgumentException> { bob.decrypt(absurd, AD) }
}
@Test
fun `every message uses a different key`() {
// If two messages ever produced identical ciphertext for identical
// plaintext, the chain would not be advancing.
val (alice, _) = pair()
val first = alice.encrypt("same".toByteArray(), AD)
val second = alice.encrypt("same".toByteArray(), AD)
assertTrue(!first.ciphertext.contentEquals(second.ciphertext))
assertEquals(0, first.header.n)
assertEquals(1, second.header.n)
}
}