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:
Paul Hitt
2026-08-21 13:00:13 -04:00
co-authored by Claude Opus 5
commit 06b97ca69b
12 changed files with 844 additions and 0 deletions
@@ -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)))
}
}