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,61 @@
package io.flamenet.e2e
import java.security.MessageDigest
/**
* Byte helpers shared by the rest of the engine.
*
* Kotlin's `ByteArray` is signed, which is a recurring source of quiet bugs
* when porting from JavaScript's `Uint8Array`: `byte` values above 127 read as
* negative, so an unguarded comparison or shift silently disagrees with the
* reference implementation. Everything here converts through `and 0xFF`.
*/
internal object Bytes {
fun concat(vararg parts: ByteArray): ByteArray {
val out = ByteArray(parts.sumOf { it.size })
var at = 0
for (p in parts) { p.copyInto(out, at); at += p.size }
return out
}
/**
* Unsigned lexicographic comparison.
*
* The signed default would order 0x80 before 0x00, which for safety numbers
* means two devices pick opposite orderings and read out different numbers
* for the same conversation — a false mismatch, and the worst possible
* failure for a check whose entire purpose is detecting a real one.
*/
fun compareUnsigned(a: ByteArray, b: ByteArray): Int {
val len = minOf(a.size, b.size)
for (i in 0 until len) {
val d = (a[i].toInt() and 0xFF) - (b[i].toInt() and 0xFF)
if (d != 0) return d
}
return a.size - b.size
}
/**
* Comparison that does not stop early.
*
* Used wherever a mismatch is attacker-visible; `contentEquals` returns as
* soon as it can, which leaks how much of a value was right.
*/
fun constantTimeEquals(a: ByteArray, b: ByteArray): Boolean {
if (a.size != b.size) return false
var diff = 0
for (i in a.indices) diff = diff or (a[i].toInt() xor b[i].toInt())
return diff == 0
}
fun sha256(data: ByteArray): ByteArray =
MessageDigest.getInstance("SHA-256").digest(data)
fun sha512(data: ByteArray): ByteArray =
MessageDigest.getInstance("SHA-512").digest(data)
fun b64(data: ByteArray): String = java.util.Base64.getEncoder().encodeToString(data)
fun unb64(text: String): ByteArray = java.util.Base64.getDecoder().decode(text)
}
@@ -0,0 +1,98 @@
package io.flamenet.e2e
/**
* Key transparency verification (spec §19), client side.
*
* Verification only, deliberately. There is nothing here that can *build* a
* tree, because a client able to construct a proof is a client that can be
* talked into believing one it constructed itself.
*
* A transparency log without a client that refuses unproven keys is just a
* log: it faithfully records the substitution, and nobody reads it.
*/
object KeyLog {
/** RFC 6962 domain separation: leaves under 0x00, interior nodes under 0x01. */
fun leafHash(data: ByteArray): ByteArray = Bytes.sha256(Bytes.concat(byteArrayOf(0x00), data))
fun nodeHash(left: ByteArray, right: ByteArray): ByteArray =
Bytes.sha256(Bytes.concat(byteArrayOf(0x01), left, right))
/**
* The largest power of two strictly below [n].
*
* Not n/2. The unbalanced split is what makes the tree's shape depend only
* on its size, which is what lets two verifiers agree without exchanging
* anything about its structure.
*/
fun split(n: Int): Int {
var k = 1
while (k shl 1 < n) k = k shl 1
return k
}
/**
* The exact bytes the relay commits to for one key publication (§19.2).
*
* This binding is the part that matters. Proving that *some* leaf sits in
* the log proves nothing at all; what has to hold is that the leaf at the
* claimed index is this device's published identity and no other.
*/
fun canonicalLeaf(
kind: String,
userId: Long,
deviceId: String,
ikSigB64: String,
ikDhB64: String,
): ByteArray = "v1|$kind|$userId|$deviceId|$ikSigB64|$ikDhB64".toByteArray(Charsets.UTF_8)
/**
* Recompute the root from a leaf and its audit path.
*
* The path is consumed **bottom-up** — the leaf's own sibling first, the
* highest sibling last. That is the order RFC 6962 specifies and the order
* the relay emits, and getting it backwards is not a loud failure: a
* top-down reading produces the correct root for any left-spine index, so
* it passes any test that only ever checks index 0. The vector suite pins a
* right-descending index precisely to catch that.
*
* Returns null rather than throwing: a bad proof is an expected answer from
* a hostile server, not an exceptional condition.
*/
fun rootFromInclusion(
leaf: ByteArray,
index: Int,
size: Int,
proof: List<ByteArray>,
): ByteArray? {
if (index < 0 || index >= size) return null
var fn = index
var sn = size - 1
var r = leaf
var i = 0
while (sn > 0) {
if (i >= proof.size) return null
if ((fn and 1) != 0 || fn == sn) {
r = nodeHash(proof[i++], r)
while (fn != 0 && (fn and 1) == 0) { fn = fn shr 1; sn = sn shr 1 }
} else {
r = nodeHash(r, proof[i++])
}
fn = fn shr 1
sn = sn shr 1
}
// Leftover path elements mean the proof does not describe this tree.
return if (i == proof.size) r else null
}
fun verifyInclusion(
leaf: ByteArray,
index: Int,
size: Int,
proof: List<ByteArray>,
root: ByteArray,
): Boolean {
val got = rootFromInclusion(leaf, index, size, proof) ?: return false
return Bytes.constantTimeEquals(got, root)
}
}
@@ -0,0 +1,61 @@
package io.flamenet.e2e
import java.math.BigInteger
/**
* Safety numbers (spec §7).
*
* The one check in the whole system that does not depend on trusting the
* server: two people read the same sixty digits aloud, and if they match there
* is nobody in the middle. Everything else here can be verified by machines;
* this is verified by humans, which is why the output is grouped for reading
* rather than rendered as hex.
*/
object SafetyNumber {
/** 5200 iterations, matching the reference engine. */
private const val ITERATIONS = 5200
/**
* Thirty digits for one identity key.
*
* The iteration count is the point: it makes searching for a key whose
* fingerprint collides in the digits a person actually compares expensive
* rather than instant.
*/
fun fingerprint(publicKey: ByteArray): String {
var h = publicKey
repeat(ITERATIONS) {
h = Bytes.sha512(Bytes.concat(h, publicKey)).copyOfRange(0, 32)
}
val bytes = h.copyOfRange(0, 30)
val out = StringBuilder(30)
var c = 0
while (c < 30) {
var v = BigInteger.ZERO
for (i in 0 until 5) {
v = v.shiftLeft(8).or(BigInteger.valueOf((bytes[c + i].toInt() and 0xFF).toLong()))
}
out.append(v.mod(BigInteger.valueOf(100_000L)).toString().padStart(5, '0'))
c += 5
}
return out.toString()
}
/**
* The sixty digits for a conversation.
*
* Ordered by the keys themselves rather than by who is asking, so both ends
* read out the same string. Unsigned comparison — see [Bytes.compareUnsigned].
*/
fun compute(localIdentity: ByteArray, remoteIdentity: ByteArray): String {
val local = fingerprint(localIdentity)
val remote = fingerprint(remoteIdentity)
return if (Bytes.compareUnsigned(localIdentity, remoteIdentity) < 0) local + remote
else remote + local
}
/** Grouped into fives, which is how it is meant to be read aloud. */
fun format(number: String): String =
number.chunked(5).joinToString(" ")
}