diff --git a/engine/src/main/kotlin/io/flamenet/e2e/DoubleRatchet.kt b/engine/src/main/kotlin/io/flamenet/e2e/DoubleRatchet.kt new file mode 100644 index 0000000..da336d9 --- /dev/null +++ b/engine/src/main/kotlin/io/flamenet/e2e/DoubleRatchet.kt @@ -0,0 +1,227 @@ +package io.flamenet.e2e + +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +/** + * The Double Ratchet (spec §6), with unencrypted headers. + * + * Two ratchets turning at different rates. The **symmetric** one advances once + * per message, so a compromised message key reveals nothing about earlier ones. + * The **Diffie-Hellman** one advances whenever the other side sends a new + * ratchet public key, which is what lets a conversation recover after a + * compromise rather than staying broken forever. + * + * ``` + * MK = HMAC-SHA256(key = CK, msg = 0x01) + * CK' = HMAC-SHA256(key = CK, msg = 0x02) + * (RK', CK') = HKDF-SHA256(IKM = dh_out, salt = RK, + * info = "FlamenetE2E_Ratchet_v1", L = 64) + * ``` + * + * ⚠️ **Everything here is state, and state is where this goes wrong.** X3DH is + * a pure function and either agrees or does not. A ratchet can agree for six + * messages and then diverge on the seventh because one side counted a skipped + * message differently — and the symptom, again, is only that something will not + * decrypt. The tests below therefore run conversations, out of order and with + * gaps, rather than checking any single step. + * + * ⚠️ **Never encrypt twice under one message key.** Two AES-GCM encryptions + * under the same key and nonce leak the XOR of both plaintexts and destroy the + * authentication guarantee — it is a catastrophic failure, not a degraded one. + * The sending chain advances before the key is used, so a key cannot be reused + * even if a caller retries. + * + * A port of `engine/src/e2e.js`, which is normative. + */ +class DoubleRatchet private constructor( + private var rootKey: ByteArray, + private var sendingChain: ByteArray?, + private var receivingChain: ByteArray?, + private var selfRatchet: java.security.KeyPair?, + private var remoteRatchet: ByteArray?, + /** Messages sent in the current sending chain. */ + private var sent: Int, + /** Messages received in the current receiving chain. */ + private var received: Int, + /** Length of the PREVIOUS sending chain, so the far side knows how many to skip. */ + private var previousChainLength: Int, +) { + + /** Keys for messages that arrived out of order, or have not arrived yet. */ + private val skipped = LinkedHashMap() + + data class SkippedKey(val ratchetPublic: String, val index: Int) + + data class Header(val dh: ByteArray, val pn: Int, val n: Int) { + /** + * The exact bytes that go into the AAD. + * + * Hand-built rather than serialised by a JSON library: the AAD has to be + * reproduced byte-for-byte by three implementations, and an encoder is + * free to reorder keys or add spaces between releases. Key order is + * `dh, pn, n` and there are no spaces — both are part of the protocol. + */ + fun canonicalJson(): ByteArray = + ("{\"dh\":\"" + Bytes.b64(dh) + "\",\"pn\":" + pn + ",\"n\":" + n + "}") + .toByteArray(Charsets.UTF_8) + } + + data class Message(val header: Header, val ciphertext: ByteArray) + + companion object { + private val RATCHET_INFO = "FlamenetE2E_Ratchet_v1".toByteArray(Charsets.UTF_8) + private val MSGKEY_INFO = "FlamenetE2E_MsgKey_v1".toByteArray(Charsets.UTF_8) + private val ZERO_SALT = ByteArray(32) + + /** + * A cap on how many keys a single header may make us derive. + * + * Without one, a header claiming `n = 2_000_000_000` is a denial of + * service that costs the sender nothing: the receiver dutifully derives + * two billion keys. The number is generous for real reordering and + * ruinous for that. + */ + const val MAX_SKIP = 1000 + + /** Matches `Aead`'s GCM nonce length; the ratchet strips it on the wire. */ + private const val NONCE_LEN = 12 + + /** Alice, who has just completed X3DH and knows Bob's signed prekey. */ + fun initiator(sharedSecret: ByteArray, remoteSignedPreKey: ByteArray): DoubleRatchet { + val self = X25519.generate() + val (rk, ck) = rootStep(sharedSecret, X25519.agree(self.private, remoteSignedPreKey)) + return DoubleRatchet( + rootKey = rk, sendingChain = ck, receivingChain = null, + selfRatchet = self, remoteRatchet = remoteSignedPreKey, + sent = 0, received = 0, previousChainLength = 0, + ) + } + + /** + * Bob, whose signed prekey IS his first ratchet key. + * + * He does not step the root yet: he has nothing to step it with until a + * message from Alice tells him her ratchet public key. + */ + fun responder(sharedSecret: ByteArray, signedPreKey: java.security.KeyPair): DoubleRatchet = + DoubleRatchet( + rootKey = sharedSecret, sendingChain = null, receivingChain = null, + selfRatchet = signedPreKey, remoteRatchet = null, + sent = 0, received = 0, previousChainLength = 0, + ) + + private fun rootStep(rootKey: ByteArray, dhOutput: ByteArray): Pair { + val out = Hkdf.derive(dhOutput, rootKey, RATCHET_INFO, 64) + return out.copyOfRange(0, 32) to out.copyOfRange(32, 64) + } + + private fun hmac(key: ByteArray, msg: ByteArray): ByteArray { + val mac = Mac.getInstance("HmacSHA256") + mac.init(SecretKeySpec(key, "HmacSHA256")) + return mac.doFinal(msg) + } + + /** One symmetric step: a message key, and the chain key that replaces it. */ + private fun chainStep(chainKey: ByteArray): Pair = + hmac(chainKey, byteArrayOf(0x01)) to hmac(chainKey, byteArrayOf(0x02)) + + /** A message key expands into an AES key and the nonce it is used with. */ + private fun aeadParams(messageKey: ByteArray): Pair { + val okm = Hkdf.derive(messageKey, ZERO_SALT, MSGKEY_INFO, 44) + return okm.copyOfRange(0, 32) to okm.copyOfRange(32, 44) + } + } + + fun encrypt(plaintext: ByteArray, associatedData: ByteArray): Message { + val chain = sendingChain ?: error("cannot send before the first ratchet step") + val (messageKey, nextChain) = chainStep(chain) + // Advanced BEFORE use, so a caller that retries cannot encrypt twice + // under one key. + sendingChain = nextChain + + val header = Header( + dh = X25519.rawFromPublicKey(selfRatchet!!.public), + pn = previousChainLength, + n = sent, + ) + sent += 1 + + val (aesKey, nonce) = aeadParams(messageKey) + // `Aead.seal` returns `nonce || ct || tag`, and the nonce is dropped + // here: the ratchet DERIVES it from the message key, so transmitting it + // would put twelve redundant bytes on every message and invite a + // receiver to trust a nonce off the wire instead of the one the key + // says it must be. + val sealed = Aead.seal(aesKey, nonce, plaintext, associatedData + header.canonicalJson()) + return Message(header, sealed.copyOfRange(NONCE_LEN, sealed.size)) + } + + fun decrypt(message: Message, associatedData: ByteArray): ByteArray { + val aad = associatedData + message.header.canonicalJson() + + // A key held for a message that never arrived, or arrived late. + val skippedKey = SkippedKey(Bytes.b64(message.header.dh), message.header.n) + skipped.remove(skippedKey)?.let { mk -> + val (aesKey, nonce) = aeadParams(mk) + return Aead.open(aesKey, nonce + message.ciphertext, aad) + } + + // A new ratchet key from the far side: catch up on their old chain, + // then turn the DH ratchet. + if (remoteRatchet == null || !message.header.dh.contentEquals(remoteRatchet)) { + skipTo(message.header.pn) + dhRatchet(message.header.dh) + } + skipTo(message.header.n) + + val chain = receivingChain ?: error("no receiving chain") + val (messageKey, nextChain) = chainStep(chain) + receivingChain = nextChain + received += 1 + + val (aesKey, nonce) = aeadParams(messageKey) + return Aead.open(aesKey, nonce + message.ciphertext, aad) + } + + /** Derive and store keys for messages we have not seen yet. */ + private fun skipTo(target: Int) { + val chain = receivingChain ?: return + if (target < received) return + require(target - received <= MAX_SKIP) { + "refusing to derive ${target - received} skipped keys" + } + var ck = chain + var n = received + val remote = Bytes.b64(remoteRatchet ?: return) + while (n < target) { + val (mk, next) = chainStep(ck) + skipped[SkippedKey(remote, n)] = mk + ck = next + n += 1 + } + receivingChain = ck + received = n + } + + /** + * Turn the DH ratchet: one root step to open a receiving chain against + * their new key, a fresh keypair of our own, and a second root step to open + * the sending chain they will decrypt with. + */ + private fun dhRatchet(theirRatchet: ByteArray) { + previousChainLength = sent + sent = 0 + received = 0 + remoteRatchet = theirRatchet + + var (rk, ck) = rootStep(rootKey, X25519.agree(selfRatchet!!.private, theirRatchet)) + rootKey = rk + receivingChain = ck + + selfRatchet = X25519.generate() + val stepped = rootStep(rootKey, X25519.agree(selfRatchet!!.private, theirRatchet)) + rootKey = stepped.first + sendingChain = stepped.second + } +} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/DoubleRatchetTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/DoubleRatchetTest.kt new file mode 100644 index 0000000..032dc52 --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/DoubleRatchetTest.kt @@ -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 { + 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 { 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 { 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 { 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) + } +}