diff --git a/engine/src/main/kotlin/io/flamenet/e2e/AccountKdf.kt b/engine/src/main/kotlin/io/flamenet/e2e/AccountKdf.kt new file mode 100644 index 0000000..c294c8b --- /dev/null +++ b/engine/src/main/kotlin/io/flamenet/e2e/AccountKdf.kt @@ -0,0 +1,167 @@ +package io.flamenet.e2e + +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.PBEKeySpec + +/** + * Key derivation for the shared FlameNet account service. + * + * One passphrase produces several unrelated secrets: an **authentication + * secret**, which is sent to the account service, and per-product **identity + * keys**, which never leave the device. Both come from the same expensive + * stretch and are separated afterwards by an HKDF context string, so a fully + * compromised account service can impersonate someone to itself and decrypt + * nothing of theirs. + * + * ``` + * master = PBKDF2-SHA256(utf8(passphrase), salt, iterations, 32 bytes) + * derived = HKDF-SHA256(ikm = master, salt = , info = utf8(context), 32) + * ``` + * + * This must agree byte for byte with the JavaScript and Swift engines. It is + * checked against `engine/test/vectors/account-kdf-v1.json`, which was pinned + * from the enclave client rather than from either of them — an implementation + * validated only against our other implementations inherits our + * misunderstandings, which is the whole reason a third engine is worth writing. + * + * ⚠️ **A JVM test proves nothing about Android here.** The provider on a device + * is Conscrypt and it disagrees with the JDK's: the obvious X25519 code passed + * twenty-one JVM tests and threw on the first tap on a phone. `PBKDF2WithHmac + * SHA256` is present from API 26, but "present" and "produces the same bytes" + * are separate claims and only the on-device suite in `:engine-android` can + * settle the second. + */ +object AccountKdf { + + /** + * The registry the account service's `/params` route selects from by id. + * + * The iteration count is not a local tuning knob: the salt and count come + * back from the server per account, so raising this constant on one client + * only breaks accounts created under the other value. + */ + data class Params(val id: Int, val iterations: Int, val saltBytes: Int) { + companion object { + val PBKDF2_V1 = Params(id = 1, iterations = 650_000, saltBytes = 16) + + fun withId(id: Int): Params = when (id) { + 1 -> PBKDF2_V1 + else -> throw IllegalArgumentException( + "unsupported KDF id $id — this client cannot derive keys that way", + ) + } + } + } + + /** HKDF context strings. Wire constants, not labels. */ + object Context { + /** + * **Authentication is account-wide, not per product.** + * + * The service stores one verifier per account under this one context; + * `product` on a session request scopes the pseudonym it returns, not + * the secret it checks. Every FlameNet product must therefore + * authenticate with the same string, or a passphrase registered by one + * cannot sign in from another — which is the entire premise of a shared + * account. + * + * It says `files` for historical reasons: the enclave client defaults + * to it and every existing account was registered with it. + * + * ⚠️ `flamenet/messenger/auth/v1` also exists and is a trap. It reads + * like the obvious constant for a messenger client, derives cleanly, + * and produces a secret the service answers with "handle or passphrase + * is incorrect". + */ + const val ACCOUNT_AUTH = "flamenet/files/auth/v1" + + /** Per product, and never sent anywhere. */ + const val MESSENGER_IDENTITY = "flamenet/messenger/identity/v1" + + /** + * **The account key — the one identity that is not per product.** + * + * Everything else here is scoped: the auth secret to the service, the + * identity key to Messenger. This one is neither, because it answers a + * question no scoped key can: who are you, to a relay that has never + * heard of you? Its public half, Crockford base32, is the `fnid`. + * + * ⚠️ Changing this string strands every address ever issued — the + * address IS the public key, so a different derivation is a different + * person. + */ + const val ACCOUNT_IDENTITY = "flamenet/account/identity/v1" + } + + /** + * Stage one: the expensive stretch, shared across products and never used + * directly. + * + * `PBEKeySpec` takes a `CharArray`, and the JCA encodes it as UTF-8 for + * `PBKDF2WithHmacSHA256`. A passphrase may legitimately contain astral + * characters, which are two chars in a Kotlin `CharArray` and four UTF-8 + * bytes — the vector suite carries a non-ASCII case and an emoji case for + * exactly this reason. + */ + fun master( + passphrase: String, + salt: ByteArray, + params: Params = Params.PBKDF2_V1, + ): ByteArray { + val spec = PBEKeySpec(passphrase.toCharArray(), salt, params.iterations, 256) + try { + return SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256") + .generateSecret(spec) + .encoded + } finally { + // The spec holds a copy of the passphrase; clearing it is cheap and + // shortens the window in which it sits in a heap dump. + spec.clearPassword() + } + } + + /** + * Raw bytes for one context. + * + * The HKDF salt is deliberately empty: the per-account salt has already + * done its work in the stretch above, and the context string is what + * separates one derived secret from another. + */ + fun derive( + passphrase: String, + salt: ByteArray, + context: String, + length: Int = 32, + params: Params = Params.PBKDF2_V1, + ): ByteArray = Hkdf.derive( + ikm = master(passphrase, salt, params), + salt = ByteArray(0), + info = context.toByteArray(Charsets.UTF_8), + length = length, + ) + + /** What gets sent to the account service. */ + fun authSecret(passphrase: String, salt: ByteArray, params: Params = Params.PBKDF2_V1): ByteArray = + derive(passphrase, salt, Context.ACCOUNT_AUTH, 32, params) + + /** What never leaves the device. */ + fun identityKey(passphrase: String, salt: ByteArray, params: Params = Params.PBKDF2_V1): ByteArray = + derive(passphrase, salt, Context.MESSENGER_IDENTITY, 32, params) + + /** + * The 32-byte Ed25519 **seed** whose public half is this account's address. + * + * Returns the seed rather than a key object on purpose. Turning a raw seed + * into a usable Ed25519 keypair through the JCA needs `EdECPrivateKeySpec`, + * which Android exposes only from API 33 — the same platform floor that + * X25519 already imposes on this engine, and the same shape of problem: + * available on the JDK, absent on the devices that matter. + * + * So this engine can derive an address and cannot yet sign a claim for one. + * A caller on a new enough device can build the keypair itself; the signing + * helper belongs with the rest of the Conscrypt-specific code, beside + * X25519, not here. + */ + fun accountKeySeed(passphrase: String, salt: ByteArray, params: Params = Params.PBKDF2_V1): ByteArray = + derive(passphrase, salt, Context.ACCOUNT_IDENTITY, 32, params) +} diff --git a/engine/src/main/kotlin/io/flamenet/e2e/KeyLog.kt b/engine/src/main/kotlin/io/flamenet/e2e/KeyLog.kt index 6f92e8e..08ba927 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/KeyLog.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/KeyLog.kt @@ -40,11 +40,21 @@ object KeyLog { */ fun canonicalLeaf( kind: String, - userId: Long, + subject: String, deviceId: String, ikSigB64: String, ikDhB64: String, - ): ByteArray = "v1|$kind|$userId|$deviceId|$ikSigB64|$ikDhB64".toByteArray(Charsets.UTF_8) + ): ByteArray = "v2|$kind|$subject|$deviceId|$ikSigB64|$ikDhB64".toByteArray(Charsets.UTF_8) + + /** + * The subject a leaf names — the account's fnid where one has been + * published, and the relay-qualified row number where it has not. + * + * Shared rather than rebuilt per call site, so the three engines cannot + * drift on the fallback format. + */ + fun logSubject(fnid: String?, audience: String, userId: Long): String = + if (!fnid.isNullOrEmpty()) fnid else "local:$audience/$userId" /** * Recompute the root from a leaf and its audit path. diff --git a/engine/src/test/kotlin/io/flamenet/e2e/AccountKdfTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/AccountKdfTest.kt new file mode 100644 index 0000000..43e0ada --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/AccountKdfTest.kt @@ -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)) + } +} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/PublishedVectorsTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/PublishedVectorsTest.kt index 2cc6de5..bb87599 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/PublishedVectorsTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/PublishedVectorsTest.kt @@ -91,7 +91,7 @@ class PublishedVectorsTest { val encoded = KeyLog.canonicalLeaf( kind = input.getString("kind"), - userId = input.getLong("userId"), + subject = input.getString("subject"), deviceId = input.getString("deviceId"), ikSigB64 = input.getString("ikSigB64"), ikDhB64 = input.getString("ikDhB64"),