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>
96 lines
4.2 KiB
Kotlin
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.
|
|
}
|