diff --git a/engine/src/main/kotlin/io/flamenet/e2e/E2ESession.kt b/engine/src/main/kotlin/io/flamenet/e2e/E2ESession.kt index 9907ab4..59fbd07 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/E2ESession.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/E2ESession.kt @@ -90,17 +90,30 @@ class E2ESession private constructor( .put("spk_id", bundle.signedPreKeyId) .put("opk_id", bundle.oneTimePreKeyId) + // 🔴 **Offering a PQ prekey is not a claim to §13.** Every v2 device + // publishes one; only a v3 device can read a PQ ratchet step. Sending + // one to a v2 peer breaks the conversation twice over — their root + // step derives without `ss` under the `_v1` info string while ours + // used `_v3`, and their canonical header omits `pqdh`/`pqct` so the + // AAD does not match either. The first message never opens. + // + // ⚠️ `bundle.proto` alone is the relay's word for it. `chosen` is + // non-null only when the capability signature over THIS proto + // verified (§12.7), so the pair together is a proven claim to v3. + val postQuantumRatchet = + if (chosen != null && bundle.proto >= 3) chosen.publicKey else null + return E2ESession( // 🔴 `chosen?.publicKey` is the PQ prekey X3DH just encapsulated // to, and the ratchet has to start from that exact key or the // responder cannot decapsulate the first step (§13.2). ratchet = DoubleRatchet.initiator( - initiated.sharedSecret, bundle.signedPreKey, chosen?.publicKey), + initiated.sharedSecret, bundle.signedPreKey, postQuantumRatchet), associatedData = X3DH.associatedData( identity.identitySigningPublic, bundle.identitySigning, initiated.pqCiphertext), pendingHandshake = handshake, - version = initiated.version, + version = if (postQuantumRatchet != null) 3 else initiated.version, // Rides the first envelope only. Every later `msg` is // byte-identical to v1, because the KEM secret is already // folded into the root key and the associated data binds the diff --git a/engine/src/test/kotlin/io/flamenet/e2e/HybridSessionTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/HybridSessionTest.kt index 7d6c938..a40b5c6 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/HybridSessionTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/HybridSessionTest.kt @@ -203,6 +203,34 @@ class HybridSessionTest { assertEquals("spk", moved.provenPostQuantumPreKey()?.kind) } + /** + * 🔴 **A v2 peer must not be sent a v3 ratchet step.** + * + * The released fleet advertises `proto: 2`: it does post-quantum key + * AGREEMENT and has no post-quantum ratchet. Attaching `pqdh`/`pqct` to a + * message for one of those devices breaks the conversation twice over — + * their root step derives without `ss` and under the `_v1` info string + * while ours used `_v3`, and their canonical header omits the two fields + * entirely so the AAD does not match either. The first message never opens + * and never will. + * + * ⚠️ Offering a PQ prekey is NOT the same claim as implementing §13. Every + * v2 device offers one. The version has to be what gates this, which is + * what §13.4 says and what the iOS engine already does. + */ + @Test + fun `a peer that only claims v2 is not sent a post-quantum ratchet step`() { + val alice = DeviceIdentity.create() + val bob = DeviceIdentity.create() + // A real v2 bundle: PQ prekeys published, capability signed for 2. + val session = E2ESession.initiate(alice, published(bob)) + val envelope = session.encrypt("hello") + assertNull(envelope.header.pqdh, + "a v2 peer was sent a v3 ratchet step it cannot read") + assertNull(envelope.header.pqct, + "a v2 peer was sent a PQ ciphertext it cannot decapsulate") + } + // MARK: - Persistence /** diff --git a/interop/src/main/kotlin/io/flamenet/interop/Main.kt b/interop/src/main/kotlin/io/flamenet/interop/Main.kt index a6e227b..e399061 100644 --- a/interop/src/main/kotlin/io/flamenet/interop/Main.kt +++ b/interop/src/main/kotlin/io/flamenet/interop/Main.kt @@ -6,6 +6,7 @@ import io.flamenet.e2e.DeviceIdentity import io.flamenet.e2e.DoubleRatchet import io.flamenet.e2e.E2ESession import io.flamenet.e2e.Envelope +import io.flamenet.e2e.PQKEM import io.flamenet.e2e.PeerBundle import io.flamenet.e2e.Ed25519 import io.flamenet.e2e.Hkdf @@ -51,10 +52,24 @@ private class Device( val signedPreKey: KeyPair, val oneTimePreKeyId: Int, val oneTimePreKey: KeyPair, + /** + * The post-quantum signed prekey (§12.5). Published in the bundle, and the + * key an initiator encapsulates to — which is also where the v3 ratchet + * starts, because `PQr` must be exactly the key X3DH targeted (§13.2). + */ + val pqSignedPreKeyId: Int = 1, + val pqSignedPreKey: PQKEM.KeyPair = PQKEM.generate(), ) private class Session(val ratchet: DoubleRatchet, val ad: ByteArray) +// ⚠️ Spelled out here rather than imported: `DeviceIdentity.PQDomain` is +// private, and a driver that reached into the engine's internals to build a +// bundle would be checking the engine against itself. These are the strings the +// SPEC names, so a divergence between engine and spec shows up as a failure. +private val PQ_SPK_DOMAIN = "FlamenetE2E_PQSPK_v2".toByteArray(Charsets.UTF_8) +private val CAPS_DOMAIN = "FlamenetE2E_Caps_v2".toByteArray(Charsets.UTF_8) + fun main() { val out = System.out.bufferedWriter() generateSequence(::readLine).forEach { line -> @@ -86,7 +101,7 @@ private fun unb64(text: String): ByteArray = java.util.Base64.getDecoder().decod private fun JSONObject.bytes(key: String): ByteArray = unb64(getString(key)) private fun handle(req: JSONObject): JSONObject = when (req.getString("cmd")) { - "device" -> newDevice() + "device" -> newDevice(req) "initiate" -> initiate(req) "respond" -> respond(req) "encrypt" -> encrypt(req) @@ -122,7 +137,13 @@ private fun handle(req: JSONObject): JSONObject = when (req.getString("cmd")) { * exactly that — so the day Kotlin gains PQ, this bundle changing is what makes * the assertion fail and get updated. */ -private fun newDevice(): JSONObject { +private fun newDevice(req: JSONObject): JSONObject { + // 🔴 A v1 bundle is still a thing this engine must be able to publish, and + // still a thing the harness must be able to test against. The fleet holds + // released builds that advertise 1 and 2; if this driver could only speak + // v3, the legacy paths would stop being covered on the day v3 landed — + // which is the day they matter most. + val postQuantum = req.optBoolean("pq", true) val id = next("kotlin-device-") val device = Device( deviceId = id, @@ -146,7 +167,20 @@ private fun newDevice(): JSONObject { .put("opk", JSONObject() .put("id", device.oneTimePreKeyId) .put("pub", b64(X25519.rawFromPublicKey(device.oneTimePreKey.public)))) - .put("proto", 1) + // §12.3/§12.5 — the PQ signed prekey, under its OWN domain string. + // Signing the bare key would verify against nothing the peer computes: + // the web engine checks `Ed25519(ik_sig, "FlamenetE2E_PQSPK_v2" ‖ pub)`. + .putOpt("pqspk_id", if (postQuantum) device.pqSignedPreKeyId else null) + .putOpt("pqspk", if (postQuantum) b64(device.pqSignedPreKey.publicKey) else null) + .putOpt("pqspk_sig", if (postQuantum) b64(Ed25519.sign( + device.ikSig, PQ_SPK_DOMAIN + device.pqSignedPreKey.publicKey)) else null) + // 🔴 §13.4 — advertising a version is a SIGNED claim, not a number. + // Without `caps_sig` the web engine's `bundleFromWire` discards the + // whole v2 block and `negotiate` returns v1, which is exactly what this + // harness was quietly doing before. + .putOpt("caps_sig", if (postQuantum) b64(Ed25519.sign( + device.ikSig, CAPS_DOMAIN + byteArrayOf(3.toByte()))) else null) + .put("proto", if (postQuantum) 3 else 1) return JSONObject().put("device", id).put("bundle", bundle) } @@ -166,6 +200,18 @@ private fun initiate(req: JSONObject): JSONObject { } val opk = bundle.optJSONObject("opk") + + // §12.5 — the peer's PQ prekey, verified against the same identity key that + // signed the classical one. An unverified PQ prekey is worth nothing: a + // relay that can substitute it chooses the secret. + val pqspk = bundle.optString("pqspk").ifEmpty { null }?.let(::unb64) + if (pqspk != null) { + val sig = bundle.optString("pqspk_sig").ifEmpty { null }?.let(::unb64) + if (sig == null || !Ed25519.verify(theirIkSig, sig, pqspk)) { + return JSONObject().put("error", "badPQSignature") + } + } + val initiated = X3DH.initiate( identityPrivateDH = me.ikDH.private, bundle = X3DH.Bundle( @@ -173,11 +219,16 @@ private fun initiate(req: JSONObject): JSONObject { signedPreKey = spk, oneTimePreKey = opk?.let { unb64(it.getString("pub")) }, ), + bobPQ = pqspk, ) - // v1 associated data: the two signing identities, initiator first. - val ad = me.ikSig.publicKey + theirIkSig - val ratchet = DoubleRatchet.initiator(initiated.sharedSecret, spk) + // The AD binds the PQ ciphertext on v2+, so a substituted one fails the + // AEAD open rather than silently deriving a different key. + val ad = X3DH.associatedData(me.ikSig.publicKey, theirIkSig, initiated.pqCiphertext) + // 🔴 `pqspk` — the SAME key X3DH just encapsulated to. The responder holds + // its seed, so this is the only value the first ratchet step can be + // decapsulated against (§13.2). + val ratchet = DoubleRatchet.initiator(initiated.sharedSecret, spk, pqspk) val id = next("kotlin-session-") sessions[id] = Session(ratchet, ad) @@ -188,6 +239,12 @@ private fun initiate(req: JSONObject): JSONObject { .put("ek", b64(initiated.ephemeralPublic)) .put("opk_id", opk?.getInt("id") ?: -1) .put("spk_id", bundle.optInt("spk_id", 0)) + .put("version", initiated.version) + .put("pq", initiated.pqCiphertext?.let { + JSONObject().put("kind", "spk") + .put("id", bundle.optInt("pqspk_id", 0)) + .put("ct", b64(it)) + }) .put("header", headerJson(message.header)) .put("ct", b64(message.ciphertext)) } @@ -199,16 +256,25 @@ private fun respond(req: JSONObject): JSONObject { val theirIkDH = req.bytes("ik_dh") val theirIkSig = req.bytes("ik_sig") + // The PQ half, when the initiator sent one. `pq.ct` was encapsulated to our + // published PQ prekey, so the seed is what opens it. + val pqBlock = req.optJSONObject("pq") + val pq = pqBlock?.let { me.pqSignedPreKey.seed to unb64(it.getString("ct")) } + val sharedSecret = X3DH.respond( identityPrivateDH = me.ikDH.private, signedPreKeyPrivate = me.signedPreKey.private, oneTimePrivate = if (usedOpk) me.oneTimePreKey.private else null, initiatorIdentityDH = theirIkDH, initiatorEphemeral = req.bytes("ek"), + pq = pq, ) - val ad = theirIkSig + me.ikSig.publicKey - val ratchet = DoubleRatchet.responder(sharedSecret, me.signedPreKey) + val ad = X3DH.associatedData(theirIkSig, me.ikSig.publicKey, pq?.second) + // 🔴 The seed, not the public key. The initiator encapsulated to our PQ + // prekey, so decapsulating the first ratchet step needs the private half — + // and `PQr` is adopted from that first header, not set here (§13.2). + val ratchet = DoubleRatchet.responder(sharedSecret, me.signedPreKey, pq?.first) val id = next("kotlin-session-") sessions[id] = Session(ratchet, ad) @@ -216,6 +282,7 @@ private fun respond(req: JSONObject): JSONObject { return JSONObject() .put("session", id) .put("ad", b64(ad)) + .put("version", if (pq == null) 1 else 2) .put("text", String(text, Charsets.UTF_8)) } @@ -234,10 +301,15 @@ private fun decrypt(req: JSONObject): JSONObject { return JSONObject().put("text", String(text, Charsets.UTF_8)) } +// §13.3 — `pqdh`/`pqct` present only on a PQ step, omitted entirely otherwise. +// `JSONObject.put(String, null)` drops the key, which is the shape the canonical +// header needs. private fun headerJson(header: DoubleRatchet.Header): JSONObject = JSONObject() .put("dh", b64(header.dh)) .put("pn", header.pn) .put("n", header.n) + .put("pqdh", header.pqdh?.let(::b64)) + .put("pqct", header.pqct?.let(::b64)) private fun messageFrom(req: JSONObject): DoubleRatchet.Message { val header = req.getJSONObject("header") @@ -246,6 +318,8 @@ private fun messageFrom(req: JSONObject): DoubleRatchet.Message { dh = unb64(header.getString("dh")), pn = header.getInt("pn"), n = header.getInt("n"), + pqdh = header.optString("pqdh").ifEmpty { null }?.let(::unb64), + pqct = header.optString("pqct").ifEmpty { null }?.let(::unb64), ), ciphertext = req.bytes("ct"), )