Advertise v3, now that there is evidence for the claim
PROTOCOL_VERSION read 2 with a note saying 3 would be wrong until ML-KEM was in
every ratchet step, "a separate piece of work". That work is done and proven
against another engine, so the number moves in the same change as the evidence
rather than ahead of it.
Until now the ratchet was inert in the field: iOS advertises 3 and gates on the
peer doing the same, so an iOS-to-Android session negotiated v2 and no message
ever carried a ratchet step, whatever either engine implemented.
🔴 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.
Three tests moved with it, and one is new. `a v2 registration publishes the
whole block` asserted 2 and now asserts the constant. `a capability signature
does not transfer between versions` edited a bundle to `proto = 3` to prove the
signature would not follow — a hardcoded neighbour that stopped testing anything
the moment the engine published 3, so it uses `PROTOCOL_VERSION + 1`. The v2
gating test needed a peer that has genuinely proven 2, which this engine no
longer produces: `copy(proto = 2)` is not enough, because the capability
signature commits to the version, so `publishedV2` re-signs for it.
The new one checks the published bundle claims 3 AND proves it. Bumping the
constant without the signature following would produce a bundle every peer
silently reads as v1, with nothing reporting an error anywhere.
Also: `the sealed blob reveals nothing` was the same coin flip as its iOS twin —
sealing ids 41 and 42 and searching the BASE64 for "41" and "42", about a 14%
chance of a meaningless failure. It fired during this run. The iOS one was fixed
this morning and this was missed because only one engine was looked at.
606 tests, 0 failures. Cross-engine: 56 passed, 0 failed.
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user