diff --git a/engine/src/main/kotlin/io/flamenet/e2e/DeviceIdentity.kt b/engine/src/main/kotlin/io/flamenet/e2e/DeviceIdentity.kt index 162c128..ef3b5d8 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/DeviceIdentity.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/DeviceIdentity.kt @@ -83,11 +83,26 @@ class DeviceIdentity private constructor( /** * The highest handshake version this engine implements. * - * 2 โ€” hybrid X3DH. Not 3: that puts ML-KEM into every ratchet step and - * is a separate piece of work. Claiming 3 here would have peers send - * `pqdh`/`pqct` on every message and this engine reject the lot. + * 3 โ€” ML-KEM in every ratchet step (ยง13), not only the handshake. This + * read 2 until the ratchet existed, with the note that claiming 3 early + * would have peers send `pqdh`/`pqct` on every message and this engine + * reject the lot. That was right, and it is why the number moves in the + * same change as the evidence rather than ahead of it. + * + * ๐Ÿ”ด **A version is a claim other devices act on.** Peers pin the + * highest proven version and the pin is monotonic (ยง13.4), so a build + * that advertises 3 and cannot honour it does not degrade โ€” it strands + * every contact that believed it. What backs this number: the Kotlin + * ratchet drives a v3 session against the web engine in both directions + * (`flamenet-e2e/test/interop-kotlin.mjs`, ยง13), including the reply + * that only opens if the decapsulate-before-adopt ordering is right. + * + * โš ๏ธ Claiming 3 does not mean sending 3 to everyone. `E2ESession` + * gates the PQ ratchet on the PEER having proven 3 as well, because + * every v2 device offers a PQ prekey and none of them can read a + * ratchet step. */ - const val PROTOCOL_VERSION = 2 + const val PROTOCOL_VERSION = 3 fun create(now: Long = System.currentTimeMillis()): DeviceIdentity = DeviceIdentity( deviceId = java.util.UUID.randomUUID().toString().lowercase(), diff --git a/engine/src/test/kotlin/io/flamenet/e2e/HybridSessionTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/HybridSessionTest.kt index a40b5c6..b736ed8 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/HybridSessionTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/HybridSessionTest.kt @@ -52,6 +52,17 @@ class HybridSessionTest { return PeerBundle.fromWire(bundle) } + /** + * A peer that has proven v2 and only v2 โ€” the released fleet. + * + * โš ๏ธ `copy(proto = 2)` alone would NOT do: the capability signature commits + * to the version, so a bundle whose proto was edited reads as v1 to every + * peer. It has to be re-signed for 2 to be a genuine v2 device. + */ + private fun publishedV2(device: DeviceIdentity, withOneTime: Boolean = true): PeerBundle = + published(device, withOneTime).copy( + proto = 2, capabilitySignature = device.capabilitySignature(2)) + /** A v1 device: exactly what this engine published before ML-KEM existed. */ private fun publishedV1(device: DeviceIdentity): PeerBundle { val opk = device.generateOneTimePreKeys(1).first() @@ -70,12 +81,13 @@ class HybridSessionTest { // MARK: - The happy path @Test - fun `two v2 devices hold a post-quantum conversation`() { + fun `two post-quantum devices hold a post-quantum conversation`() { val alice = DeviceIdentity.create() val bob = DeviceIdentity.create() val session = E2ESession.initiate(alice, published(bob)) - assertEquals(2, session.version, "both ends can, so this must be v2") + assertEquals(3, session.version, + "both ends implement the ratchet, so this must be v3") val envelope = session.encrypt("post-quantum hello") assertNotNull(envelope.pq, "the KEM ciphertext has to travel with the handshake") @@ -108,7 +120,7 @@ class HybridSessionTest { val bob = DeviceIdentity.create() val session = E2ESession.initiate(alice, published(bob, withOneTime = false)) - assertEquals(2, session.version) + assertEquals(3, session.version) val envelope = session.encrypt("still hybrid") assertEquals("spk", envelope.pq!!.kind) assertEquals("still hybrid", E2ESession.respond(bob, envelope).decrypt(envelope)) @@ -184,8 +196,12 @@ class HybridSessionTest { @Test fun `a capability signature does not transfer between versions`() { val bob = DeviceIdentity.create() - val forVersion3 = published(bob).copy(proto = 3) - assertNull(forVersion3.provenPostQuantumPreKey(), + // ๐Ÿ”ด A version this engine does not claim, so the edit cannot be + // accidentally valid. This read `proto = 3` while the engine published + // 2; the constant moving is what makes a hardcoded neighbour like that + // stop testing anything. + val forAnotherVersion = published(bob).copy(proto = DeviceIdentity.PROTOCOL_VERSION + 1) + assertNull(forAnotherVersion.provenPostQuantumPreKey(), "the signature commits to the version it claims") } @@ -203,6 +219,29 @@ class HybridSessionTest { assertEquals("spk", moved.provenPostQuantumPreKey()?.kind) } + /** + * ๐Ÿ”ด **The advertised version and the capability signature must agree.** + * + * A peer verifies `Ed25519(ik_sig, "FlamenetE2E_Caps_v2" โ€– uint8(proto))` + * against the proto in the same bundle. Bumping the constant without the + * signature following it produces a bundle every peer silently reads as v1 + * โ€” `provenPostQuantumPreKey()` returns null and the whole post-quantum + * path disappears, with nothing anywhere reporting an error. + */ + @Test + fun `the published bundle claims v3 and proves it`() { + val device = DeviceIdentity.create() + val wire = device.registrationWire() + assertEquals(3, wire.getInt("proto"), "this engine implements the v3 ratchet") + + // Round-trip through the peer's own view, which is the only reading + // that matters: it re-verifies the signature over the claimed version. + val seen = published(device) + assertEquals(3, seen.proto) + assertNotNull(seen.provenPostQuantumPreKey(), + "the capability signature does not cover the version claimed") + } + /** * ๐Ÿ”ด **A v2 peer must not be sent a v3 ratchet step.** * @@ -223,7 +262,7 @@ class HybridSessionTest { 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 session = E2ESession.initiate(alice, publishedV2(bob)) val envelope = session.encrypt("hello") assertNull(envelope.header.pqdh, "a v2 peer was sent a v3 ratchet step it cannot read") @@ -311,9 +350,9 @@ class HybridSessionTest { * would be picked for v2 by every peer and then fail every handshake. */ @Test - fun `a v2 registration publishes the whole block`() { + fun `a post-quantum registration publishes the whole block`() { val wire = DeviceIdentity.create().registrationWire(pqOneTime = emptyList()) - assertEquals(2, wire.getInt("proto")) + assertEquals(DeviceIdentity.PROTOCOL_VERSION, wire.getInt("proto")) for (field in listOf("caps_sig", "pqspk", "pqspk_sig")) { assertTrue(wire.getString(field).isNotEmpty(), "$field must be published") } diff --git a/engine/src/test/kotlin/io/flamenet/e2e/SealedRosterTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/SealedRosterTest.kt index 9457b05..977005d 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/SealedRosterTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/SealedRosterTest.kt @@ -50,12 +50,40 @@ class SealedRosterTest { /** * ๐Ÿ”ด The property the whole change rests on: the stored bytes say nothing. * A dump that yields the member ids has bought nobody anything. + * + * โš ๏ธ **The values are long on purpose, and the search covers the decoded + * bytes.** This sealed ids `41`/`42` and looked for `"41"`/`"42"` in the + * BASE64 โ€” two characters out of a 64-symbol alphabet, so each had about a + * 1-in-4096 chance at any position and roughly a **14% chance across a blob + * this size**. It failed on exactly that, having passed for days: a coin + * flip wearing the clothes of an assertion. The iOS twin had the same bug + * and was fixed the same way; this one was missed because only one engine + * was looked at. + * + * Nine-digit ids and a real-sounding name make a chance match impossible + * rather than unlikely, and the byte-level search is the one that actually + * states the property โ€” base64 could in principle hide a plaintext run the + * raw bytes reveal. */ @Test fun `the sealed blob reveals nothing`() { - val blob = SealedRoster.seal(contents(), SealedRoster.newGroupKey()) - for (needle in listOf("41", "42", "Book club", "members", "tags")) { + val secret = SealedRoster.Contents( + members = listOf(918273645L, 564738291L), + tags = mapOf("918273645" to SealedRoster.newTag(), + "564738291" to SealedRoster.newTag()), + name = "Chemotherapy support", + ) + val blob = SealedRoster.seal(secret, SealedRoster.newGroupKey()) + val raw = Bytes.unb64(blob) + + for (needle in listOf("918273645", "564738291", "Chemotherapy support", + "members", "tags", "name")) { assertFalse(blob.contains(needle), "$needle is readable in the sealed roster") + assertFalse( + String(raw, Charsets.ISO_8859_1) + .contains(String(needle.toByteArray(Charsets.UTF_8), Charsets.ISO_8859_1)), + "$needle is readable in the sealed bytes", + ) } }