Start Android with the engine, held to the published vectors
Android does not exist, and the first thing it needs is not a screen. It needs the protocol implemented a third time — which is only worth anything if the third implementation is checked against the spec and the published vectors rather than against a reading of our own Swift. So `android/engine` is a plain Kotlin/JVM library with no Android in it: no SDK, no emulator, no device, and therefore gateable in CI on Linux like everything else. It reads engine/test/vectors/protocol-v1.json from its one location rather than carrying a copy, because a vector transcribed into the test that checks it is a vector that agrees with itself. Covered so far: safety numbers (§7) and key transparency verification (§19) — RFC 6962 leaf and node hashing, the unbalanced split, and bottom-up audit paths. Nine tests, all green against the same file the JavaScript and Swift engines answer to. The §19 tests are the ones worth having. A top-down reading of a bottom-up audit path yields the correct root for every left-spine index, so it passes any suite that only checks index 0 — the vectors pin a right-descending index, and the tests assert both that the published path verifies and that the same path reversed does not. android/README.md lists what is missing — X25519, X3DH, the ratchet, sealed sender, both post-quantum halves, the relay client, the app — as a table rather than as an omission. Android stays honestly marked "not started" on the site until that list is shorter. Bytecode targets 17 without demanding a JDK 17 be installed: a jvmToolchain(17) fails on any newer JDK, which is a poor trade for a library with no JDK 17-specific behaviour. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The Kotlin engine against the published vectors.
|
||||
*
|
||||
* These are the same bytes the JavaScript and Swift engines are held to, read
|
||||
* from the one file rather than copied here — a vector transcribed into the
|
||||
* test that checks it is a vector that agrees with itself.
|
||||
*
|
||||
* This is the whole point of starting Android with the engine. A third
|
||||
* implementation is only worth anything if it can be checked against something
|
||||
* other than a reading of our own code.
|
||||
*/
|
||||
class PublishedVectorsTest {
|
||||
|
||||
private val vectors: JSONObject by lazy {
|
||||
// From android/engine/ up to the repository root.
|
||||
val file = File("../../engine/test/vectors/protocol-v1.json")
|
||||
assertTrue(file.exists(), "published vectors not found at ${file.absolutePath}")
|
||||
JSONObject(file.readText())
|
||||
}
|
||||
|
||||
private fun JSONArray.bytesAt(i: Int) = Bytes.unb64(getString(i))
|
||||
|
||||
@Test
|
||||
fun `safety number fingerprints match the published vectors`() {
|
||||
val v = vectors.getJSONObject("safetyNumber")
|
||||
val a = Bytes.unb64(v.getString("identityA"))
|
||||
val b = Bytes.unb64(v.getString("identityB"))
|
||||
|
||||
assertEquals(v.getString("fingerprintA"), SafetyNumber.fingerprint(a))
|
||||
assertEquals(v.getString("fingerprintB"), SafetyNumber.fingerprint(b))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `safety number ordering does not depend on who is asking`() {
|
||||
val v = vectors.getJSONObject("safetyNumber")
|
||||
val a = Bytes.unb64(v.getString("identityA"))
|
||||
val b = Bytes.unb64(v.getString("identityB"))
|
||||
val expected = v.getString("combined")
|
||||
|
||||
// Both ends have to read out the same string, or the check reports a
|
||||
// mismatch that isn't one — which is the worst failure available to a
|
||||
// control whose only job is spotting a real mismatch.
|
||||
assertEquals(expected, SafetyNumber.compute(a, b))
|
||||
assertEquals(expected, SafetyNumber.compute(b, a))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `safety number formats into readable groups`() {
|
||||
val n = vectors.getJSONObject("safetyNumber").getString("combined")
|
||||
val formatted = SafetyNumber.format(n)
|
||||
assertEquals(n, formatted.replace(" ", ""))
|
||||
assertEquals(12, formatted.split(" ").size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `canonical leaf encodes and hashes exactly as published`() {
|
||||
val leaf = vectors.getJSONObject("keyLog").getJSONObject("canonicalLeaf")
|
||||
val input = leaf.getJSONObject("input")
|
||||
|
||||
val encoded = KeyLog.canonicalLeaf(
|
||||
kind = input.getString("kind"),
|
||||
userId = input.getLong("userId"),
|
||||
deviceId = input.getString("deviceId"),
|
||||
ikSigB64 = input.getString("ikSigB64"),
|
||||
ikDhB64 = input.getString("ikDhB64"),
|
||||
)
|
||||
assertEquals(leaf.getString("encoded"), String(encoded, Charsets.UTF_8))
|
||||
assertEquals(leaf.getString("leafHash"), Bytes.b64(KeyLog.leafHash(encoded)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `merkle roots match at every prefix size`() {
|
||||
val keyLog = vectors.getJSONObject("keyLog")
|
||||
val leaves = keyLog.getJSONArray("leafHashes")
|
||||
val roots = keyLog.getJSONArray("rootsBySize")
|
||||
|
||||
// Every size, not just the last: the unbalanced split means each size
|
||||
// has a different tree shape, and an implementation that only ever
|
||||
// builds a power-of-two tree agrees with this one at sizes 1, 2 and 4
|
||||
// while disagreeing at 3 and 5.
|
||||
for (size in 1..leaves.length()) {
|
||||
val prefix = (0 until size).map { leaves.bytesAt(it) }
|
||||
assertEquals(
|
||||
roots.getString(size - 1),
|
||||
Bytes.b64(rootOf(prefix)),
|
||||
"root disagrees at size $size",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `inclusion proof verifies at a right-descending index`() {
|
||||
val inc = vectors.getJSONObject("keyLog").getJSONObject("inclusion")
|
||||
val leaf = Bytes.unb64(inc.getString("leaf"))
|
||||
val proof = inc.getJSONArray("proof").let { a -> (0 until a.length()).map { a.bytesAt(it) } }
|
||||
val root = Bytes.unb64(inc.getString("root"))
|
||||
|
||||
val got = KeyLog.rootFromInclusion(leaf, inc.getInt("index"), inc.getInt("size"), proof)
|
||||
assertNotNull(got, "the published proof did not verify")
|
||||
assertEquals(inc.getString("root"), Bytes.b64(got))
|
||||
assertTrue(KeyLog.verifyInclusion(leaf, inc.getInt("index"), inc.getInt("size"), proof, root))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same proof read the other way round is rejected`() {
|
||||
val inc = vectors.getJSONObject("keyLog").getJSONObject("inclusion")
|
||||
val leaf = Bytes.unb64(inc.getString("leaf"))
|
||||
val proof = inc.getJSONArray("proof").let { a -> (0 until a.length()).map { a.bytesAt(it) } }
|
||||
val root = Bytes.unb64(inc.getString("root"))
|
||||
|
||||
// The audit path is bottom-up. Reading it top-down still produces the
|
||||
// right root for any left-spine index, so this assertion — at a
|
||||
// right-descending index, with the path reversed — is the one that
|
||||
// actually catches the mistake.
|
||||
assertFalse(
|
||||
KeyLog.verifyInclusion(leaf, inc.getInt("index"), inc.getInt("size"), proof.reversed(), root),
|
||||
"a reversed audit path verified, so the path order is not being honoured",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a proof for the wrong index does not verify`() {
|
||||
val inc = vectors.getJSONObject("keyLog").getJSONObject("inclusion")
|
||||
val leaf = Bytes.unb64(inc.getString("leaf"))
|
||||
val proof = inc.getJSONArray("proof").let { a -> (0 until a.length()).map { a.bytesAt(it) } }
|
||||
val root = Bytes.unb64(inc.getString("root"))
|
||||
val size = inc.getInt("size")
|
||||
val index = inc.getInt("index")
|
||||
|
||||
for (other in 0 until size) {
|
||||
if (other == index) continue
|
||||
assertFalse(
|
||||
KeyLog.verifyInclusion(leaf, other, size, proof, root),
|
||||
"the proof verified at index $other as well as $index",
|
||||
)
|
||||
}
|
||||
assertNull(KeyLog.rootFromInclusion(leaf, size, size, proof), "index == size was accepted")
|
||||
assertNull(KeyLog.rootFromInclusion(leaf, -1, size, proof), "a negative index was accepted")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `split picks the largest power of two strictly below n`() {
|
||||
assertEquals(1, KeyLog.split(2))
|
||||
assertEquals(2, KeyLog.split(3))
|
||||
assertEquals(2, KeyLog.split(4))
|
||||
assertEquals(4, KeyLog.split(5))
|
||||
assertEquals(4, KeyLog.split(8))
|
||||
assertEquals(8, KeyLog.split(9))
|
||||
}
|
||||
|
||||
/**
|
||||
* The reference tree, built here rather than in the engine.
|
||||
*
|
||||
* [KeyLog] verifies and cannot construct, on purpose. The test needs a
|
||||
* builder to check the published roots, so it carries its own — which also
|
||||
* means the roots are checked against an independent construction rather
|
||||
* than against the same code that produced them.
|
||||
*/
|
||||
private fun rootOf(leaves: List<ByteArray>): ByteArray {
|
||||
require(leaves.isNotEmpty())
|
||||
if (leaves.size == 1) return leaves[0]
|
||||
val k = KeyLog.split(leaves.size)
|
||||
return KeyLog.nodeHash(rootOf(leaves.subList(0, k)), rootOf(leaves.subList(k, leaves.size)))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user