Files
flamenet-messenger-android/engine/src/test/kotlin/io/flamenet/e2e/AccountKdfTest.kt
T
Paul HittandClaude Opus 5 c893d412d2 Kotlin: X3DH, and the account KDF beneath it
The Android engine had primitives and no protocol: no handshake, no ratchet, no
session. It could verify a safety number and could not start a conversation.
X3DH is the first half of fixing that.

The four agreements are mixed in the order the spec fixes, and DH4 is omitted
rather than zero-filled when the one-time prekey pool is drained. Both are
choices that produce 32 plausible bytes when wrong — the failure lands at the
far end as a message that will not decrypt, pointing nowhere near here, so the
tests check that the two sides AGREE rather than that either looks right.

Keys are JCA objects rather than raw bytes throughout, because Conscrypt will
not build an XDH key from raw bytes and wants a DER spec. Passing raw bytes
would work on this JVM and throw on the first tap on a phone, which this engine
has already been bitten by once.

Also here: AccountKdf, so Android can derive an account key and an address.
Checked against the vectors pinned from the enclave client — including the
non-ASCII and empty-passphrase cases, where a CharArray API and a UTF-8 wire
format quietly disagree.

⚠️ Still missing before an Android app can send a message: the Double Ratchet,
a session layer, a wire client, and the app itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:47:12 -04:00

96 lines
4.2 KiB
Kotlin

package io.flamenet.e2e
import org.json.JSONObject
import java.io.File
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* The Kotlin account KDF against the vectors pinned from the enclave client.
*
* Not against the JavaScript or Swift engines: those were pinned from the same
* source, and an implementation checked only against our other implementations
* inherits whatever we all misread. The cases include a non-ASCII passphrase
* and an empty one, which is where a `CharArray` API and a UTF-8 wire format
* quietly disagree.
*
* ⚠️ Passing here says nothing about a phone. The provider on a device is
* Conscrypt, not the JDK's, and this engine already has one case — X25519 —
* that passed twenty-one JVM tests and threw on the first tap on real hardware.
* The on-device suite in `:engine-android` is what settles Android.
*/
class AccountKdfTest {
private val vectors: JSONObject by lazy {
val candidates = listOfNotNull(
System.getenv("FLAMENET_ACCOUNT_VECTORS"),
"../../engine/test/vectors/account-kdf-v1.json",
"../../../flamenet-e2e/test/vectors/account-kdf-v1.json",
"../../flamenet-e2e/test/vectors/account-kdf-v1.json",
).map { File(it) }
val file = candidates.firstOrNull { it.exists() }
// Fails rather than skips: a vector suite that quietly stops checking is
// worth less than none, because it still reports green.
assertTrue(
file != null,
"account KDF vectors not found. Looked in: " +
candidates.joinToString(", ") { it.absolutePath },
)
JSONObject(file!!.readText())
}
private fun hex(bytes: ByteArray) = bytes.joinToString("") { "%02x".format(it) }
private fun unhex(s: String) =
ByteArray(s.length / 2) { s.substring(it * 2, it * 2 + 2).toInt(16).toByte() }
@Test
fun `derives exactly the published bytes for every case`() {
val kdf = vectors.getJSONObject("kdf")
val params = AccountKdf.Params.withId(kdf.getInt("id"))
assertEquals(kdf.getInt("iterations"), params.iterations)
assertEquals(kdf.getInt("saltBytes"), params.saltBytes)
val cases = vectors.getJSONArray("cases")
assertTrue(cases.length() > 0, "the vector file carries no cases")
for (i in 0 until cases.length()) {
val c = cases.getJSONObject(i)
val out = AccountKdf.derive(
passphrase = c.getString("passphrase"),
salt = unhex(c.getString("saltHex")),
context = c.getString("context"),
params = params,
)
assertEquals(c.getString("expectedHex"), hex(out), "case: ${c.getString("name")}")
assertEquals(c.getString("expectedB64"), Bytes.b64(out), "case: ${c.getString("name")}")
}
}
@Test
fun `the context is what separates one secret from another`() {
val salt = unhex("000102030405060708090a0b0c0d0e0f")
val pass = "correct horse battery staple"
val auth = AccountKdf.authSecret(pass, salt)
val identity = AccountKdf.identityKey(pass, salt)
val account = AccountKdf.accountKeySeed(pass, salt)
// Same passphrase, same salt, same stretch — three unrelated secrets.
// If any pair matched, a compromise of one product's key would hand over
// another's, which is the entire reason the contexts exist.
assertFalse(auth.contentEquals(identity), "auth and identity keys collided")
assertFalse(auth.contentEquals(account), "auth and account keys collided")
assertFalse(identity.contentEquals(account), "identity and account keys collided")
assertEquals(32, account.size)
}
// The tie between this KDF and FNID.encode — that a correctly derived seed
// also produces the same ADDRESS on every client — belongs here too, and
// cannot be written yet: the Kotlin FNID lives on the multi-homing branch
// and is not on main. Add it when that lands, because a seed that derives
// correctly and encodes differently still gives one account a different
// name on every platform.
}