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:
Paul Hitt
2026-08-31 13:00:22 -04:00
parent 1e96af0122
commit eaf860e794
3 changed files with 96 additions and 14 deletions
@@ -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",
)
}
}