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:
co-authored by
Claude Opus 5
parent
c893d412d2
commit
e0589eff14
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user