Files
flamenet-messenger-android/engine/src/main/kotlin/io/flamenet/e2e/KeyLog.kt
T
Paul HittandClaude Opus 5 06b97ca69b 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>
2026-08-21 13:00:13 -04:00

99 lines
3.4 KiB
Kotlin

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)
}
}