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? { 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, root: ByteArray, ): Boolean { val got = rootFromInclusion(leaf, index, size, proof) ?: return false return Bytes.constantTimeEquals(got, root) } }