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,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 = <empty>, 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)
}
@@ -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.
@@ -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))
}
}
@@ -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"),