Merge remote-tracking branch 'origin/main' into feat/android-account-kdf
# Conflicts: # Sources/Views/SignInView.swift
This commit is contained in:
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user