Merge remote-tracking branch 'origin/main' into feat/android-account-kdf

# Conflicts:
#	Sources/Views/SignInView.swift
This commit is contained in:
Paul Hitt
2026-08-23 12:23:21 -04:00
4 changed files with 287 additions and 3 deletions
@@ -0,0 +1,107 @@
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)
}
@Test
fun `the account seed produces the address the other engines publish`() {
// Ties the two halves together: the KDF above and the encoder in FNID.
// A seed that derives correctly but encodes differently would still
// give this account a different name on every client.
val seed = ByteArray(32) { 0x2a }
val ai = JSONObject(
File(
listOfNotNull(
System.getenv("FLAMENET_VECTORS"),
"../../engine/test/vectors/protocol-v1.json",
"../../../flamenet-e2e/test/vectors/protocol-v1.json",
).first { File(it).exists() },
).readText(),
).getJSONObject("accountIdentity")
assertEquals(ai.getString("address"), FNID.address(seed))
}
}