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.
202 lines
8.0 KiB
Kotlin
202 lines
8.0 KiB
Kotlin
package io.flamenet.e2e
|
|
|
|
import kotlin.test.Test
|
|
import kotlin.test.assertEquals
|
|
import kotlin.test.assertFailsWith
|
|
import kotlin.test.assertFalse
|
|
import kotlin.test.assertTrue
|
|
|
|
/**
|
|
* §8.9b — the member list as something the relay cannot read.
|
|
*
|
|
* 🔴 The relay used to hold `gid -> user_id` rows: a readable social graph in a
|
|
* table. This covers the client half — what goes into the opaque blob and how
|
|
* it comes back — and the properties that make it worth doing.
|
|
*
|
|
* ⚠️ None of this hides membership from a running relay, which still sees who
|
|
* asks about what. It hides it from a stolen database.
|
|
*/
|
|
class SealedRosterTest {
|
|
|
|
private class MapStorage : Vault.Storage {
|
|
private val records = mutableMapOf<String, ByteArray>()
|
|
override fun read(name: String) = records[name]
|
|
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
|
|
override fun delete(name: String) { records.remove(name) }
|
|
override fun names() = records.keys.toList()
|
|
}
|
|
|
|
private fun vault(): Vault = Vault(
|
|
MapStorage(),
|
|
javax.crypto.KeyGenerator.getInstance("AES").apply { init(256) }.generateKey(),
|
|
).also { it.saveIdentity(DeviceIdentity.create()) }
|
|
|
|
private fun contents() = SealedRoster.Contents(
|
|
members = listOf(41L, 42L, 43L),
|
|
tags = mapOf("41" to SealedRoster.newTag(), "42" to SealedRoster.newTag()),
|
|
name = "Book club",
|
|
)
|
|
|
|
@Test
|
|
fun `a roster survives the round trip`() {
|
|
val key = SealedRoster.newGroupKey()
|
|
val original = contents()
|
|
val opened = SealedRoster.open(SealedRoster.seal(original, key), key)
|
|
assertEquals(original.members, opened.members)
|
|
assertEquals(original.tags, opened.tags)
|
|
assertEquals(original.name, opened.name)
|
|
}
|
|
|
|
/**
|
|
* 🔴 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 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",
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* ⚠️ A blob that will not open is an error, never an empty group. Treating
|
|
* it as empty would silently drop every member from a conversation that
|
|
* still exists.
|
|
*/
|
|
@Test
|
|
fun `the wrong key throws rather than returning nothing`() {
|
|
val blob = SealedRoster.seal(contents(), SealedRoster.newGroupKey())
|
|
assertFailsWith<Aead.DecryptionFailed> {
|
|
SealedRoster.open(blob, SealedRoster.newGroupKey())
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The relay stores these and could change them. Adding a member to
|
|
* somebody's roster is exactly the attack this refuses.
|
|
*/
|
|
@Test
|
|
fun `a tampered blob is refused`() {
|
|
val key = SealedRoster.newGroupKey()
|
|
val raw = Bytes.unb64(SealedRoster.seal(contents(), key))
|
|
raw[raw.size / 2] = (raw[raw.size / 2].toInt() xor 0x01).toByte()
|
|
assertFailsWith<Aead.DecryptionFailed> { SealedRoster.open(Bytes.b64(raw), key) }
|
|
}
|
|
|
|
/**
|
|
* 🔴 Tags are random, not derived from the account. Anything derived would
|
|
* be a pseudonym the relay could link across groups — the same graph
|
|
* wearing a hash.
|
|
*/
|
|
@Test
|
|
fun `tags are unlinkable`() {
|
|
val tags = (0 until 50).map { SealedRoster.newTag() }
|
|
assertEquals(50, tags.toSet().size, "tags repeated")
|
|
tags.forEach { assertEquals(32, Bytes.unb64(it).size) }
|
|
}
|
|
|
|
/**
|
|
* 🔴 **The bytes must match the Swift engine and the relay exactly.** A
|
|
* roster signed here is verified there; one character of difference and
|
|
* every signature this client makes is refused, with nothing in the error
|
|
* pointing at the format.
|
|
*/
|
|
@Test
|
|
fun `the signed bytes match the other implementations`() {
|
|
assertEquals(
|
|
"fngroup-v1|abc|7|Zm9v",
|
|
String(SealedRoster.canonical("abc", 7, "Zm9v")))
|
|
}
|
|
|
|
/**
|
|
* ⚠️ The encoding must not depend on map iteration order, or two devices
|
|
* sealing the same roster produce different bytes — and a signature over
|
|
* one does not verify the other.
|
|
*/
|
|
@Test
|
|
fun `the encoding is stable regardless of tag insertion order`() {
|
|
val key = SealedRoster.newGroupKey()
|
|
val a = SealedRoster.Contents(listOf(1L, 2L), mapOf("1" to "x", "2" to "y"), "G")
|
|
val b = SealedRoster.Contents(listOf(1L, 2L), linkedMapOf("2" to "y", "1" to "x"), "G")
|
|
// Sealing uses a random nonce, so compare what comes back out instead.
|
|
assertEquals(
|
|
SealedRoster.open(SealedRoster.seal(a, key), key).tags,
|
|
SealedRoster.open(SealedRoster.seal(b, key), key).tags)
|
|
}
|
|
|
|
/**
|
|
* 🔴 **A backup that loses the tags loses the groups for good.** The relay
|
|
* cannot say what you are a member of, so the tag is the only way to fetch
|
|
* a group and the key the only way to read it. Restoring the messages
|
|
* without these gives a device that can read old conversations and can
|
|
* never rejoin the groups they came from.
|
|
*/
|
|
@Test
|
|
fun `an exported backup carries sealed group secrets`() {
|
|
val old = vault()
|
|
val secrets = Vault.GroupSecrets(
|
|
tag = SealedRoster.newTag(), key = SealedRoster.newGroupKey(),
|
|
ownerPrivate = null, version = 3)
|
|
old.saveGroupSecrets("g1", secrets)
|
|
val payload = old.exportPayload()
|
|
|
|
val fresh = vault()
|
|
assertEquals(null, fresh.groupSecrets("g1"))
|
|
fresh.mergeImportedPayload(payload, 0.0)
|
|
|
|
assertEquals(secrets, fresh.groupSecrets("g1"))
|
|
}
|
|
|
|
/** ⚠️ Gaps only — a backup is older than the device by definition. */
|
|
@Test
|
|
fun `a restored backup does not overwrite secrets we already hold`() {
|
|
val old = vault()
|
|
old.saveGroupSecrets("g1", Vault.GroupSecrets(
|
|
tag = "old", key = SealedRoster.newGroupKey(), version = 1))
|
|
val payload = old.exportPayload()
|
|
|
|
val current = vault()
|
|
val live = Vault.GroupSecrets(
|
|
tag = "live", key = SealedRoster.newGroupKey(), version = 9)
|
|
current.saveGroupSecrets("g1", live)
|
|
current.mergeImportedPayload(payload, 0.0)
|
|
|
|
assertEquals(live, current.groupSecrets("g1"))
|
|
}
|
|
|
|
/** Two groups minted by the same person share nothing. */
|
|
@Test
|
|
fun `two groups share no key material`() {
|
|
assertFalse(SealedRoster.newGroupKey().contentEquals(SealedRoster.newGroupKey()))
|
|
}
|
|
}
|