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,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(" ")
|
||||
}
|
||||
Reference in New Issue
Block a user