diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt index 8605863..39fcce1 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -69,6 +69,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.style.TextOverflow diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index 9ed36b3..9dc4fe7 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -485,7 +485,12 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { // Publishing prekeys is what makes this phone reachable. A device that // registers without them can send and can never be started with. val preKeys = device.generateOneTimePreKeys(20) - relay.registerDevice(device, preKeys) + // 🔴 **The same size as the classical pool, not a smaller one.** Both + // drain at one key per new session, so a shorter post-quantum pool + // empties first and pushes every later session onto the last-resort key + // — every one of them then sharing a single KEM key, silently. + val pqPreKeys = device.generatePQOneTimePreKeys(20) + relay.registerDevice(device, preKeys, pqPreKeys) vault.saveIdentity(device) // 🔴 **Register the address, don't just derive it.** Deriving it makes diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/OutboxTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/OutboxTest.kt index 0a39756..b723e36 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/OutboxTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/OutboxTest.kt @@ -45,7 +45,27 @@ class OutboxTest { private lateinit var server: MockWebServer - @BeforeTest fun start() { server = MockWebServer().also { it.start() } } + /** + * A relay from before §19, answering the queue for everything else. + * + * ⚠️ Not incidental setup. Sending now asks for a transparency-log head on + * its own, because key checking is automatic on any relay that serves one — + * so a queue matched purely by ORDER would hand the head request the device + * listing and fail on a parse error miles from the cause. This says plainly + * what the fixture is: a deployment with no log, where these tests are + * about the outbox and nothing else. + */ + private class NoKeyLog : okhttp3.mockwebserver.QueueDispatcher() { + override fun dispatch(request: okhttp3.mockwebserver.RecordedRequest): MockResponse = + // `contains`, not `startsWith`: every route sits under the REST + // mount, so the recorded path begins with that prefix. + if ("/e2e/log/" in request.path.orEmpty()) MockResponse().setResponseCode(404) + else super.dispatch(request) + } + + @BeforeTest fun start() { + server = MockWebServer().also { it.dispatcher = NoKeyLog(); it.start() } + } @AfterTest fun stop() { server.shutdown() } private class MapStorage : Vault.Storage { diff --git a/client/src/main/kotlin/io/flamenet/client/Conversations.kt b/client/src/main/kotlin/io/flamenet/client/Conversations.kt index 871ab24..da75ce4 100644 --- a/client/src/main/kotlin/io/flamenet/client/Conversations.kt +++ b/client/src/main/kotlin/io/flamenet/client/Conversations.kt @@ -984,9 +984,17 @@ class Conversations( .firstOrNull { it.deviceId == identity.deviceId } ?: return 0 // The relay pops one per fetch, so seeing none here means the pool was // empty before this call, not that this call drained it. - if (mine.oneTimePreKey != null) return 0 + // 🔴 **Either pool being empty triggers a refill of BOTH.** The + // post-quantum pool drains at exactly the same rate — one key per new + // session — so a refill that topped up only the classical one would let + // the PQ pool run dry and push every new session onto the last-resort + // key. That is a real weakening (they then all share one KEM key) and + // it is completely silent: sessions still establish, still encrypt, and + // nothing anywhere reports a downgrade. + if (mine.oneTimePreKey != null && mine.pqOneTimePreKey != null) return 0 val fresh = identity.generateOneTimePreKeys(batch) - relay.uploadPreKeys(identity.deviceId, fresh) + val pqFresh = identity.generatePQOneTimePreKeys(batch) + relay.uploadPreKeys(identity.deviceId, fresh, pqFresh) vault.saveIdentity(identity) return fresh.size } diff --git a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt index 0e9e903..ca93d42 100644 --- a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt +++ b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt @@ -79,18 +79,45 @@ class RelayClient( // -- Devices and keys ---------------------------------------------------- /** `POST /e2e/devices` — publish this device's identity and prekeys. */ - fun registerDevice(identity: DeviceIdentity, oneTimePreKeys: List): JSONObject = - postJson("/e2e/devices", identity.registrationWire(oneTimePreKeys)) + fun registerDevice( + identity: DeviceIdentity, + oneTimePreKeys: List, + pqOneTimePreKeys: List = emptyList(), + ): JSONObject = + postJson("/e2e/devices", identity.registrationWire(oneTimePreKeys, pqOneTimePreKeys)) - /** `POST /e2e/prekeys` — top the pool back up. */ - fun uploadPreKeys(deviceId: String, preKeys: List) { - postJson("/e2e/prekeys", JSONObject() + /** + * `POST /e2e/prekeys` — top the pools back up. + * + * ⚠️ **Both pools, in one request.** A post-quantum pool that drains ahead + * of the classical one silently pushes every new session onto the + * last-resort key — a real weakening, since they then all share one KEM key + * — with nothing on either end saying so. Refilling them together is what + * keeps that from happening by omission. + */ + fun uploadPreKeys( + deviceId: String, + preKeys: List, + pqPreKeys: List = emptyList(), + ) { + val body = JSONObject() .put("device_id", deviceId) .put("prekeys", JSONArray().also { array -> preKeys.forEach { array.put(JSONObject().put("id", it.id).put("pub", Wire.b64(it.publicKey))) } - })) + }) + if (pqPreKeys.isNotEmpty()) { + body.put("pq_prekeys", JSONArray().also { array -> + pqPreKeys.forEach { + array.put(JSONObject() + .put("id", it.id) + .put("pub", Wire.b64(it.publicKey)) + .put("sig", Wire.b64(it.signature))) + } + }) + } + postJson("/e2e/prekeys", body) } /** diff --git a/engine/src/main/kotlin/io/flamenet/e2e/DeviceIdentity.kt b/engine/src/main/kotlin/io/flamenet/e2e/DeviceIdentity.kt index 0636e40..162c128 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/DeviceIdentity.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/DeviceIdentity.kt @@ -20,9 +20,21 @@ import java.security.KeyPair * attack. That is why [retiredSignedPreKeys] exists and why the retention * window here is longer than the relay's. * - * A port of `engine/src/e2e.js`, which is normative. No post-quantum material - * yet: this engine has no ML-KEM, so it publishes a v1 bundle and the peer's - * `negotiate()` sees a v1 device, which is true. + * A port of `engine/src/e2e.js`, which is normative. + * + * ## Post-quantum material (§12.3) + * + * A v2 device publishes two more things: a **last-resort** ML-KEM prekey that + * is never consumed, and a pool of one-time ones. Both are signed by the + * Ed25519 identity, and a peer verifies that signature before encapsulating — + * without it a relay could hand out a KEM key of its own and read the + * post-quantum half of every new session, which is exactly the substitution + * `spk_sig` exists to prevent classically. + * + * ⚠️ **The two pools are kept the same size.** A smaller PQ pool drains first + * and silently downgrades new sessions to the last-resort key, with no signal + * to either party — a real weakening (every session sharing one KEM key) that + * looks like nothing at all. */ class DeviceIdentity private constructor( val deviceId: String, @@ -40,6 +52,19 @@ class DeviceIdentity private constructor( private val oneTimePreKeys = LinkedHashMap() private var nextPreKeyId = 1 + /** + * The last-resort post-quantum prekey, and the one-time pool. + * + * Held as seeds. Both the private key and its published half re-derive from + * 64 bytes, so the vault stores 64 rather than 2400 per key — and it is the + * same representation iOS persists, which is what lets a history export + * move between the two. + */ + private var pqSignedPreKeySeed: ByteArray? = null + private var pqSignedPreKeyId = 1 + private val pqOneTimePreKeys = LinkedHashMap() + private var nextPQPreKeyId = 1 + /** One published prekey: the id a sender quotes back, and the public half. */ data class PreKey(val id: Int, val publicKey: ByteArray) @@ -55,6 +80,15 @@ class DeviceIdentity private constructor( */ const val RETAIN_AFTER_MS = 45L * 24 * 60 * 60 * 1000 + /** + * The highest handshake version this engine implements. + * + * 2 — hybrid X3DH. Not 3: that puts ML-KEM into every ratchet step and + * is a separate piece of work. Claiming 3 here would have peers send + * `pqdh`/`pqct` on every message and this engine reject the lot. + */ + const val PROTOCOL_VERSION = 2 + fun create(now: Long = System.currentTimeMillis()): DeviceIdentity = DeviceIdentity( deviceId = java.util.UUID.randomUUID().toString().lowercase(), identityDH = X25519.generate(), @@ -93,6 +127,20 @@ class DeviceIdentity private constructor( identity.retiredSignedPreKeys[key.toInt()] = Retired(x(entry), entry.optLong("retiredAt", 0)) } + + // ⚠️ Absent for a device archived before this engine had ML-KEM, + // which is every existing install. It reads back as a v1 device and + // publishes post-quantum material the next time it registers, + // rather than failing to load. + archive.optJSONObject("pqSpk")?.let { + identity.pqSignedPreKeySeed = Bytes.unb64(it.getString("seed")) + identity.pqSignedPreKeyId = it.optInt("id", 1) + } + val pqOtp = archive.optJSONObject("pqOtp") ?: JSONObject() + for (key in pqOtp.keys()) { + identity.pqOneTimePreKeys[key.toInt()] = Bytes.unb64(pqOtp.getString(key)) + } + identity.nextPQPreKeyId = archive.optInt("nextPQId", 1) return identity } } @@ -117,6 +165,82 @@ class DeviceIdentity private constructor( PreKey(id, X25519.rawFromPublicKey(pair.public)) } + // -- Post-quantum material (§12.3) --------------------------------------- + + /** One published ML-KEM prekey, with the signature a peer must check. */ + data class PQPreKey(val id: Int, val publicKey: ByteArray, val signature: ByteArray) { + override fun equals(other: Any?): Boolean = + other is PQPreKey && id == other.id && publicKey.contentEquals(other.publicKey) + override fun hashCode(): Int = publicKey.contentHashCode() + } + + private object PQDomain { + val SPK = "FlamenetE2E_PQSPK_v2".toByteArray(Charsets.UTF_8) + val OPK = "FlamenetE2E_PQOPK_v2".toByteArray(Charsets.UTF_8) + val CAPS = "FlamenetE2E_Caps_v2".toByteArray(Charsets.UTF_8) + + /** + * ⚠️ The id is signed **big-endian** (§12.3), which is not the + * little-endian encoding used on-device. Mixing the two produces + * signatures every peer rejects — and only peers, so it looks like a + * network problem. + */ + fun be32(v: Int) = byteArrayOf( + (v ushr 24).toByte(), (v ushr 16).toByte(), (v ushr 8).toByte(), v.toByte()) + } + + /** + * The last-resort post-quantum prekey, created on first use. + * + * ⚠️ Never consumed, so a session can always be established with + * post-quantum protection even when the one-time pool has drained. A device + * advertising `proto: 2` without one would be chosen for v2 by peers and + * then fail every handshake (§12.8). + */ + fun pqSignedPreKey(): PQPreKey { + val seed = pqSignedPreKeySeed ?: PQKEM.generate().seed.also { pqSignedPreKeySeed = it } + val pub = PQKEM.fromSeed(seed).publicKey + return PQPreKey(pqSignedPreKeyId, pub, + Ed25519.sign(identitySigning, PQDomain.SPK + pub)) + } + + fun generatePQOneTimePreKeys(count: Int): List = (0 until count).map { + val id = nextPQPreKeyId++ + val pair = PQKEM.generate() + pqOneTimePreKeys[id] = pair.seed + PQPreKey(id, pair.publicKey, + Ed25519.sign(identitySigning, PQDomain.OPK + PQDomain.be32(id) + pair.publicKey)) + } + + /** + * The seed for whichever prekey a peer encapsulated to. + * + * ⚠️ A one-time key is **removed**, which is the entire point of it; the + * last-resort one is not. Returns null when the id names neither, which a + * responder must treat as "this handshake cannot be v2" rather than as an + * error — an envelope naming a prekey we already spent is what a duplicate + * delivery looks like. + */ + fun takePQPreKey(kind: String, id: Int): ByteArray? = when (kind) { + "spk" -> pqSignedPreKeySeed?.takeIf { id == pqSignedPreKeyId } + "opk" -> pqOneTimePreKeys.remove(id) + else -> null + } + + val pqOneTimePreKeyCount: Int get() = pqOneTimePreKeys.size + + /** Whether this device has published post-quantum material at all. */ + val hasPQMaterial: Boolean get() = pqSignedPreKeySeed != null + + /** + * Proves this device really claims [proto], so a relay cannot advertise a + * v2 device as v1-only and quietly strip the post-quantum half off every + * new session. It can still omit the claim entirely — per-device pinning is + * what closes that (§12.4, §12.7). + */ + fun capabilitySignature(proto: Int = PROTOCOL_VERSION): ByteArray = + Ed25519.sign(identitySigning, PQDomain.CAPS + byteArrayOf(proto.toByte())) + /** Consume a one-time prekey. Consumed exactly once — that is the point of it. */ fun takeOneTimePreKey(id: Int): KeyPair? = if (id < 0) null else oneTimePreKeys.remove(id) @@ -163,24 +287,61 @@ class DeviceIdentity private constructor( retiredSignedPreKeys.entries.removeAll { now - it.value.retiredAt > RETAIN_AFTER_MS } } - /** The body of `POST /e2e/devices`. */ - fun registrationWire(oneTime: List = emptyList()): JSONObject = JSONObject() - .put("device_id", deviceId) - .put("ik_dh", Bytes.b64(identityDHPublic)) - .put("ik_sig", Bytes.b64(identitySigningPublic)) - .put("spk_id", signedPreKeyId) - .put("spk", Bytes.b64(X25519.rawFromPublicKey(signedPreKey.public))) - .put("spk_sig", Bytes.b64(signedPreKeySignature())) - .put("proto", 1) - // ⚠️ `prekeys`, NOT `opks`. Guessed wrong once; the relay accepted the - // registration, returned 200, and silently stored no prekeys — so the - // device was published and unreachable, which is the worst of both. A - // live test against a real relay is what caught it. - .put("prekeys", org.json.JSONArray().also { array -> - oneTime.forEach { - array.put(JSONObject().put("id", it.id).put("pub", Bytes.b64(it.publicKey))) - } - }) + /** + * The body of `POST /e2e/devices`. + * + * ⚠️ **The v2 block is all-or-nothing.** The relay refuses a partial one, + * and rightly: a device advertising `proto: 2` without a usable last-resort + * prekey would be chosen for v2 by every peer and then fail every handshake + * (§12.8). So either every post-quantum field is present or none is, and + * [pqOneTime] being empty is not a reason to drop the last-resort key. + */ + fun registrationWire( + oneTime: List = emptyList(), + pqOneTime: List = emptyList(), + /** + * Whether to publish post-quantum material at all. + * + * A caller can turn it off — an old relay that rejects the block, a + * test that wants a v1 device — and then this registers exactly as it + * always did. + */ + postQuantum: Boolean = true, + ): JSONObject { + val wire = JSONObject() + .put("device_id", deviceId) + .put("ik_dh", Bytes.b64(identityDHPublic)) + .put("ik_sig", Bytes.b64(identitySigningPublic)) + .put("spk_id", signedPreKeyId) + .put("spk", Bytes.b64(X25519.rawFromPublicKey(signedPreKey.public))) + .put("spk_sig", Bytes.b64(signedPreKeySignature())) + .put("proto", if (postQuantum) PROTOCOL_VERSION else 1) + // ⚠️ `prekeys`, NOT `opks`. Guessed wrong once; the relay accepted + // the registration, returned 200, and silently stored no prekeys — + // so the device was published and unreachable, which is the worst + // of both. A live test against a real relay is what caught it. + .put("prekeys", org.json.JSONArray().also { array -> + oneTime.forEach { + array.put(JSONObject().put("id", it.id).put("pub", Bytes.b64(it.publicKey))) + } + }) + if (!postQuantum) return wire + + val spk = pqSignedPreKey() + return wire + .put("caps_sig", Bytes.b64(capabilitySignature())) + .put("pqspk_id", spk.id) + .put("pqspk", Bytes.b64(spk.publicKey)) + .put("pqspk_sig", Bytes.b64(spk.signature)) + .put("pq_prekeys", org.json.JSONArray().also { array -> + pqOneTime.forEach { + array.put(JSONObject() + .put("id", it.id) + .put("pub", Bytes.b64(it.publicKey)) + .put("sig", Bytes.b64(it.signature))) + } + }) + } /** * Everything that must survive a restart. @@ -218,5 +379,21 @@ class DeviceIdentity private constructor( .put("nextId", nextPreKeyId) .put("otp", otp) .put("prevSpk", previous) + // Post-quantum material as SEEDS. 64 bytes each rather than the + // 2400-byte expanded key, and the same representation iOS writes, + // which is what lets a history export cross platforms. + .also { out -> + pqSignedPreKeySeed?.let { + out.put("pqSpk", JSONObject() + .put("seed", Bytes.b64(it)) + .put("id", pqSignedPreKeyId)) + } + if (pqOneTimePreKeys.isNotEmpty()) { + val pq = JSONObject() + pqOneTimePreKeys.forEach { (id, seed) -> pq.put(id.toString(), Bytes.b64(seed)) } + out.put("pqOtp", pq) + } + out.put("nextPQId", nextPQPreKeyId) + } } } diff --git a/engine/src/main/kotlin/io/flamenet/e2e/E2ESession.kt b/engine/src/main/kotlin/io/flamenet/e2e/E2ESession.kt index 586e51e..0b1f514 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/E2ESession.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/E2ESession.kt @@ -22,12 +22,37 @@ import org.json.JSONObject */ class E2ESession private constructor( private val ratchet: DoubleRatchet, - /** Bound into every message's AEAD tag: the two identity keys, initiator first. */ + /** + * Bound into every message's AEAD tag: the two identity keys, initiator + * first, plus a digest of the KEM ciphertext on a v2 session (§12.5). + */ val associatedData: ByteArray, private var pendingHandshake: JSONObject?, val version: Int, + /** + * The post-quantum block that rides the prekey envelope, on v2. + * + * Held beside [pendingHandshake] and dropped with it, because it belongs to + * the same handshake: every message before the peer answers must carry it, + * and no message after needs to. + */ + private var pq: PQEnvelope? = null, ) { + /** + * Which of the peer's two post-quantum prekeys a ciphertext went to, and + * the ciphertext. + * + * ⚠️ [id] and the classical `opk_id` are **independent id spaces**. They + * may coincide numerically and must never be assumed equal (§12.6). + */ + data class PQEnvelope(val kind: String, val id: Int, val ciphertext: ByteArray) { + override fun equals(other: Any?): Boolean = + other is PQEnvelope && kind == other.kind && id == other.id && + ciphertext.contentEquals(other.ciphertext) + override fun hashCode(): Int = ciphertext.contentHashCode() + } + companion object { /** @@ -44,6 +69,10 @@ class E2ESession private constructor( throw SecurityException("the peer's signed prekey is not signed by its identity key") } + // §12.7 — negotiate down to what the peer has PROVEN, never to what + // the relay says about them. + val chosen = bundle.provenPostQuantumPreKey() + val initiated = X3DH.initiate( identityPrivateDH = identity.identityDH.private, bundle = X3DH.Bundle( @@ -51,6 +80,7 @@ class E2ESession private constructor( signedPreKey = bundle.signedPreKey, oneTimePreKey = bundle.oneTimePreKey, ), + bobPQ = chosen?.publicKey, ) val handshake = JSONObject() @@ -62,9 +92,18 @@ class E2ESession private constructor( return E2ESession( ratchet = DoubleRatchet.initiator(initiated.sharedSecret, bundle.signedPreKey), - associatedData = identity.identitySigningPublic + bundle.identitySigning, + associatedData = X3DH.associatedData( + identity.identitySigningPublic, bundle.identitySigning, + initiated.pqCiphertext), pendingHandshake = handshake, - version = 1, + version = initiated.version, + // Rides the first envelope only. Every later `msg` is + // byte-identical to v1, because the KEM secret is already + // folded into the root key and the associated data binds the + // ciphertext for the life of the session (§12.6). + pq = chosen?.let { + PQEnvelope(it.kind, it.id, initiated.pqCiphertext ?: return@let null) + }, ) } @@ -88,19 +127,34 @@ class E2ESession private constructor( val theirIdentityDH = Bytes.unb64(handshake.getString("ik_dh")) val theirIdentitySigning = Bytes.unb64(handshake.getString("ik_sig")) + // §12.6 — the post-quantum block, when the initiator sent one. + // + // ⚠️ A prekey we no longer hold is NOT an error and must not throw. + // A duplicate delivery of a v2 prekey envelope names a one-time key + // this device already spent, and refusing it outright would turn a + // retransmission into a dead conversation. The handshake then + // simply cannot be v2 — and it will fail to decrypt, which is the + // correct outcome and the one the caller already handles. + val pqBlock = envelope.pq + val pq = pqBlock?.let { block -> + identity.takePQPreKey(block.kind, block.id)?.let { seed -> seed to block.ciphertext } + } + val sharedSecret = X3DH.respond( identityPrivateDH = identity.identityDH.private, signedPreKeyPrivate = signedPreKey.private, oneTimePrivate = identity.takeOneTimePreKey(handshake.optInt("opk_id", -1))?.private, initiatorIdentityDH = theirIdentityDH, initiatorEphemeral = Bytes.unb64(handshake.getString("ek")), + pq = pq, ) val session = E2ESession( ratchet = DoubleRatchet.responder(sharedSecret, signedPreKey), - associatedData = theirIdentitySigning + identity.identitySigningPublic, + associatedData = X3DH.associatedData( + theirIdentitySigning, identity.identitySigningPublic, pq?.second), pendingHandshake = null, - version = 1, + version = if (pq == null) 1 else 2, ) return session } @@ -111,6 +165,14 @@ class E2ESession private constructor( associatedData = Bytes.unb64(archive.getString("ad")), pendingHandshake = archive.optJSONObject("pending"), version = archive.optInt("version", 1), + // ⚠️ Archived for the same reason `pending` is: it is part of the + // handshake block, and a session restored without it would go on + // sending prekey envelopes with the post-quantum half missing — + // which the peer would answer by deriving a different key. + pq = archive.optJSONObject("pq")?.let { + PQEnvelope(it.getString("kind"), it.getInt("id"), + Bytes.unb64(it.getString("ct"))) + }, ) } @@ -122,6 +184,10 @@ class E2ESession private constructor( header = message.header, handshake = pendingHandshake, ciphertext = message.ciphertext, + // Only on a prekey envelope. Sending it after the handshake is + // answered would republish the ciphertext for no reason on every + // message of the conversation. + pq = if (pendingHandshake != null) pq else null, ) } @@ -131,6 +197,7 @@ class E2ESession private constructor( // Only now — a reply proves the peer has the handshake, which is the // one moment it is safe to stop repeating it. pendingHandshake = null + pq = null return String(plaintext, Charsets.UTF_8) } @@ -145,6 +212,10 @@ class E2ESession private constructor( .put("ad", Bytes.b64(associatedData)) .put("pending", pendingHandshake) .put("version", version) + .put("pq", pq?.let { + JSONObject().put("kind", it.kind).put("id", it.id) + .put("ct", Bytes.b64(it.ciphertext)) + }) } /** @@ -164,6 +235,24 @@ data class PeerBundle( val oneTimePreKeyId: Int, val oneTimePreKey: ByteArray?, val proto: Int, + /** + * The peer's signed claim to [proto]. + * + * ⚠️ `proto` itself is relayed by the server and so is untrusted — a relay + * can advertise a v2 device as v1-only and quietly strip the post-quantum + * half off every new session. This signature is what makes the claim + * checkable; absent, the peer is treated as v1, which is the safe reading + * of "we cannot tell". + */ + val capabilitySignature: ByteArray? = null, + /** The last-resort ML-KEM prekey, never consumed. Null on a v1 device. */ + val pqSignedPreKeyId: Int = 0, + val pqSignedPreKey: ByteArray? = null, + val pqSignedPreKeySignature: ByteArray? = null, + /** A one-time ML-KEM prekey, null once that pool has drained. */ + val pqOneTimePreKeyId: Int = -1, + val pqOneTimePreKey: ByteArray? = null, + val pqOneTimePreKeySignature: ByteArray? = null, ) { companion object { /** @@ -176,6 +265,7 @@ data class PeerBundle( */ fun fromWire(wire: JSONObject): PeerBundle { val oneTime = wire.optJSONObject("opk") + val pqOneTime = wire.optJSONObject("pqopk") return PeerBundle( deviceId = wire.getString("device_id"), identityDH = Bytes.unb64(wire.getString("ik_dh")), @@ -186,9 +276,79 @@ data class PeerBundle( oneTimePreKeyId = oneTime?.optInt("id", -1) ?: -1, oneTimePreKey = oneTime?.let { Bytes.unb64(it.getString("pub")) }, proto = wire.optInt("proto", 1), + capabilitySignature = wire.optString("caps_sig").ifEmpty { null } + ?.let(Bytes::unb64), + pqSignedPreKeyId = wire.optInt("pqspk_id", 0), + pqSignedPreKey = wire.optString("pqspk").ifEmpty { null }?.let(Bytes::unb64), + pqSignedPreKeySignature = + wire.optString("pqspk_sig").ifEmpty { null }?.let(Bytes::unb64), + pqOneTimePreKeyId = pqOneTime?.optInt("id", -1) ?: -1, + pqOneTimePreKey = pqOneTime?.let { Bytes.unb64(it.getString("pub")) }, + pqOneTimePreKeySignature = pqOneTime?.optString("sig")?.ifEmpty { null } + ?.let(Bytes::unb64), ) } + + private val PQ_SPK_DOMAIN = "FlamenetE2E_PQSPK_v2".toByteArray(Charsets.UTF_8) + private val PQ_OPK_DOMAIN = "FlamenetE2E_PQOPK_v2".toByteArray(Charsets.UTF_8) + private val CAPS_DOMAIN = "FlamenetE2E_Caps_v2".toByteArray(Charsets.UTF_8) } + + /** One post-quantum prekey this bundle offers, once its signature checks out. */ + data class ChosenPQ(val kind: String, val id: Int, val publicKey: ByteArray) + + /** + * The post-quantum prekey to encapsulate to, or null for a v1 session. + * + * ## Every step here is a refusal waiting to happen, deliberately + * + * 🔴 **The signature is checked before anything else.** These keys come + * from a relay, and a relay that can substitute an unverified ML-KEM prekey + * reads the post-quantum half of every new session — the exact substitution + * `spk_sig` exists to stop classically. An unsigned or wrongly-signed key is + * treated as absent, so the session falls back to v1 rather than to a KEM + * key of the relay's choosing. + * + * ⚠️ **The claimed `proto` is checked against a signature too.** A relay can + * otherwise advertise a v2 device as v1-only and quietly strip the + * post-quantum half off every new session, with nothing on either screen + * saying so. `caps_sig` makes the claim checkable; where it is missing, the + * peer is read as v1, which is the safe reading of "we cannot tell". + * + * ⚠️ **The one-time key is preferred.** Falling back to the last-resort key + * is correct when the pool has drained and is a real weakening — every + * session then shares one KEM key — so it is a fallback and not a choice. + */ + fun provenPostQuantumPreKey(): ChosenPQ? { + val caps = capabilitySignature ?: return null + if (proto < 2) return null + if (!Ed25519.verify(identitySigning, caps, + CAPS_DOMAIN + byteArrayOf(proto.toByte()))) { + return null + } + + val oneTime = pqOneTimePreKey + val oneTimeSig = pqOneTimePreKeySignature + if (oneTime != null && oneTimeSig != null && pqOneTimePreKeyId >= 0) { + val signed = PQ_OPK_DOMAIN + be32(pqOneTimePreKeyId) + oneTime + if (Ed25519.verify(identitySigning, oneTimeSig, signed)) { + return ChosenPQ("opk", pqOneTimePreKeyId, oneTime) + } + } + + val lastResort = pqSignedPreKey ?: return null + val lastResortSig = pqSignedPreKeySignature ?: return null + if (!Ed25519.verify(identitySigning, lastResortSig, PQ_SPK_DOMAIN + lastResort)) { + return null + } + return ChosenPQ("spk", pqSignedPreKeyId, lastResort) + } + + /** ⚠️ Big-endian, per §12.3. The on-device encoding is little-endian; the + * two are not interchangeable and mixing them yields signatures every peer + * rejects — and only peers, so it reads as a network fault. */ + private fun be32(v: Int) = byteArrayOf( + (v ushr 24).toByte(), (v ushr 16).toByte(), (v ushr 8).toByte(), v.toByte()) } /** One message on the wire (spec §8.2). */ @@ -198,6 +358,14 @@ data class Envelope( val header: DoubleRatchet.Header, val handshake: JSONObject?, val ciphertext: ByteArray, + /** + * Present iff `version >= 2` and `type == "prekey"` (§12.6). + * + * Rides the first envelope only. Every later `msg` is byte-identical to v1, + * because the KEM secret is already folded into the root key and the + * associated data binds the ciphertext for the life of the session. + */ + val pq: E2ESession.PQEnvelope? = null, ) { fun toJson(): JSONObject = JSONObject() .put("v", version) @@ -207,6 +375,10 @@ data class Envelope( .put("pn", header.pn) .put("n", header.n)) .put("x3dh", handshake) + .put("pq", pq?.let { + JSONObject().put("kind", it.kind).put("id", it.id) + .put("ct", Bytes.b64(it.ciphertext)) + }) .put("ct", Bytes.b64(ciphertext)) /** @@ -227,6 +399,10 @@ data class Envelope( n = header.getInt("n"), ), handshake = json.optJSONObject("x3dh"), + pq = json.optJSONObject("pq")?.let { + E2ESession.PQEnvelope(it.getString("kind"), it.getInt("id"), + Bytes.unb64(it.getString("ct"))) + }, ciphertext = Bytes.unb64(json.getString("ct")), ) } diff --git a/engine/src/main/kotlin/io/flamenet/e2e/PQKEM.kt b/engine/src/main/kotlin/io/flamenet/e2e/PQKEM.kt new file mode 100644 index 0000000..91d1dbf --- /dev/null +++ b/engine/src/main/kotlin/io/flamenet/e2e/PQKEM.kt @@ -0,0 +1,140 @@ +package io.flamenet.e2e + +import org.bouncycastle.pqc.crypto.mlkem.MLKEMExtractor +import org.bouncycastle.pqc.crypto.mlkem.MLKEMGenerator +import org.bouncycastle.pqc.crypto.mlkem.MLKEMKeyGenerationParameters +import org.bouncycastle.pqc.crypto.mlkem.MLKEMKeyPairGenerator +import org.bouncycastle.pqc.crypto.mlkem.MLKEMParameters +import org.bouncycastle.pqc.crypto.mlkem.MLKEMPrivateKeyParameters +import org.bouncycastle.pqc.crypto.mlkem.MLKEMPublicKeyParameters +import java.security.SecureRandom + +/** + * ML-KEM-768 (FIPS 203) for the hybrid post-quantum key agreement, spec §12.2. + * + * ## Why this exists + * + * Traffic recorded today is decryptable tomorrow by anybody who eventually + * builds a quantum computer, because X25519 alone does not survive one. Mixing + * a KEM secret into the handshake means an adversary must break *both* — and a + * flaw later found in ML-KEM leaves a session no worse off than a purely + * classical one, which is why this is hybrid and never a replacement. + * + * 🔴 **Android had none of this, and that made the feature nobody's.** A + * session is post-quantum only when both ends are, so one v1 client in a + * conversation makes the whole conversation v1 — and with every Android device + * registering `proto: 1`, iPhone's implementation protected approximately no + * real conversation. This is the other half. + * + * ## The seed, not the expanded key + * + * Key material is the **64-byte FIPS 203 seed** (`d ‖ z`), never the expanded + * decapsulation key. Both the private key and its public half are re-derived + * from it, so the seed is the only thing worth storing — 64 bytes in the vault + * instead of 2400, and the same representation iOS persists, which is what lets + * a history export move between the two. + * + * ## Where the implementation comes from + * + * Bouncy Castle's low-level `pqc.crypto.mlkem` classes, the same way [Ed25519] + * uses its low-level `rfc8032` class: not the JCA provider, which is awkward to + * register on Android and which R8 cannot shrink. The platform offers nothing + * here — Conscrypt has no ML-KEM at any API level — and hand-rolling a lattice + * KEM to avoid a dependency would be a far worse trade than the one Ed25519 + * already made. + */ +object PQKEM { + + /** Sizes are fixed by FIPS 203 for the 768 parameter set. */ + const val SEED_LEN = 64 + const val PUBLIC_KEY_LEN = 1184 + const val CIPHERTEXT_LEN = 1088 + const val SHARED_SECRET_LEN = 32 + + private val random = SecureRandom() + + /** + * Sizes are asserted rather than assumed. A wrong-length key can only ever + * fail on every peer, and it fails as an undecryptable message at the far + * end — so naming it here is the difference between a clear error and a + * contact who has silently stopped working. + */ + class Malformed(what: String, expected: Int, got: Int) : + IllegalArgumentException("ML-KEM $what should be $expected bytes, got $got") + + /** A keypair. Only [seed] needs persisting; [publicKey] is what is published. */ + data class KeyPair(val seed: ByteArray, val publicKey: ByteArray) { + override fun equals(other: Any?): Boolean = + other is KeyPair && seed.contentEquals(other.seed) + override fun hashCode(): Int = seed.contentHashCode() + } + + /** What an encapsulation produces: bytes for the wire, and a secret for the KDF. */ + data class Encapsulation(val ciphertext: ByteArray, val sharedSecret: ByteArray) { + override fun equals(other: Any?): Boolean = + other is Encapsulation && ciphertext.contentEquals(other.ciphertext) + override fun hashCode(): Int = ciphertext.contentHashCode() + } + + fun generate(): KeyPair = fromSeed(ByteArray(SEED_LEN).also(random::nextBytes)) + + /** + * Re-derive a keypair from a stored seed. Used on every load. + * + * ⚠️ `d ‖ z` in FIPS 203 order, which is what iOS stores as CryptoKit's + * `seedRepresentation`. The two must agree byte for byte or a device that + * restored a history export would publish a key it cannot decapsulate with. + */ + fun fromSeed(seed: ByteArray): KeyPair { + if (seed.size != SEED_LEN) throw Malformed("seed", SEED_LEN, seed.size) + val key = MLKEMPrivateKeyParameters(MLKEMParameters.ml_kem_768, seed) + return KeyPair(seed, key.publicKey) + } + + /** Encapsulate to a peer's published encapsulation key. */ + fun encapsulate(publicKey: ByteArray): Encapsulation { + if (publicKey.size != PUBLIC_KEY_LEN) { + throw Malformed("public key", PUBLIC_KEY_LEN, publicKey.size) + } + val out = MLKEMGenerator(random).generateEncapsulated( + MLKEMPublicKeyParameters(MLKEMParameters.ml_kem_768, publicKey)) + return Encapsulation(out.encapsulation, out.secret) + } + + /** + * Decapsulate with a stored seed. + * + * ⚠️ **This never fails on a substituted ciphertext.** ML-KEM's implicit + * rejection returns a well-formed but different secret rather than an + * error, by design — so a tampered ciphertext surfaces as the first message + * failing to decrypt, not as an exception here. That is why §12.5 binds the + * ciphertext into the session's associated data as well: the mismatch + * becomes explicit and permanent instead of being discovered once. + */ + fun decapsulate(ciphertext: ByteArray, seed: ByteArray): ByteArray { + if (ciphertext.size != CIPHERTEXT_LEN) { + throw Malformed("ciphertext", CIPHERTEXT_LEN, ciphertext.size) + } + if (seed.size != SEED_LEN) throw Malformed("seed", SEED_LEN, seed.size) + return MLKEMExtractor(MLKEMPrivateKeyParameters(MLKEMParameters.ml_kem_768, seed)) + .extractSecret(ciphertext) + } + + /** + * A keypair from the library's own generator, for cross-checking that the + * seed path above produces the same thing the reference construction does. + * + * ⚠️ Test-facing. Kept here rather than in the test so it uses the same + * parameter set as everything else in this file — a cross-check that + * silently compared two different parameter sets would pass and mean + * nothing. + */ + internal fun generateWithLibrary(): Pair { + val gen = MLKEMKeyPairGenerator() + gen.init(MLKEMKeyGenerationParameters(random, MLKEMParameters.ml_kem_768)) + val pair = gen.generateKeyPair() + val priv = pair.private as MLKEMPrivateKeyParameters + val pub = pair.public as MLKEMPublicKeyParameters + return priv.seed to pub.encoded + } +} diff --git a/engine/src/main/kotlin/io/flamenet/e2e/X3DH.kt b/engine/src/main/kotlin/io/flamenet/e2e/X3DH.kt index a65e07b..83f1aa5 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/X3DH.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/X3DH.kt @@ -38,6 +38,14 @@ object X3DH { /** Domain separation, and a wire constant. Changing it strands every session. */ private val INFO_V1 = "FlamenetE2E_X3DH_v1".toByteArray(Charsets.UTF_8) + /** + * 🔴 **The info string changes with the version, and it has to.** Without + * that change, v1's `DH1‖DH2‖DH3‖DH4` and v2's `DH1‖DH2‖DH3‖SS` are both + * 128 bytes and are indistinguishable KDF inputs — so a downgrade would + * produce a key the other side accepts (spec §12.5). + */ + private val INFO_V2 = "FlamenetE2E_X3DH_v2".toByteArray(Charsets.UTF_8) + /** RFC 5869's "no salt" is HashLen zeros, not an absent salt. */ private val ZERO_SALT = ByteArray(32) @@ -51,8 +59,49 @@ object X3DH { val oneTimePreKey: ByteArray?, ) - /** Alice's side. Returns the shared secret and the ephemeral Bob will need. */ - data class Initiated(val sharedSecret: ByteArray, val ephemeralPublic: ByteArray) + /** + * Alice's side. Returns the shared secret and the ephemeral Bob will need. + * + * On a v2 handshake it also carries the KEM ciphertext, which travels in + * the prekey envelope and which BOTH sides fold into the session's + * associated data — so [associatedData] needs it and the caller has to keep + * it rather than send and forget. + */ + data class Initiated( + val sharedSecret: ByteArray, + val ephemeralPublic: ByteArray, + /** null on v1. */ + val pqCiphertext: ByteArray? = null, + val version: Int = 1, + ) + + /** + * Which of Bob's two post-quantum prekeys a ciphertext was encapsulated to. + * + * ⚠️ `id` here and the classical `opk_id` are **independent id spaces**. + * They may coincide numerically and must never be assumed equal (§12.6). + */ + data class PQPart(val kind: String, val id: Long, val ciphertext: ByteArray) { + override fun equals(other: Any?): Boolean = + other is PQPart && kind == other.kind && id == other.id && + ciphertext.contentEquals(other.ciphertext) + override fun hashCode(): Int = ciphertext.contentHashCode() + } + + /** + * `AD = IK_sig_A ‖ IK_sig_B` on v1, plus `SHA-256(domain ‖ PQ_CT)` on v2. + * + * ML-KEM's implicit rejection already makes a tampered ciphertext yield a + * different key. Binding it here makes that explicit and permanent, because + * AD prefixes the AAD of every message for the life of the session — so the + * mismatch is discovered on the first message rather than depending on + * whatever the changed key happens to decrypt (§12.5). + */ + fun associatedData(first: ByteArray, second: ByteArray, pqCiphertext: ByteArray?): ByteArray = + if (pqCiphertext == null) first + second + else first + second + Bytes.sha256(PQ_AD_DOMAIN + pqCiphertext) + + private val PQ_AD_DOMAIN = "FlamenetE2E_PQAD_v2".toByteArray(Charsets.UTF_8) /** * Keys are JCA objects rather than raw bytes throughout. @@ -67,16 +116,39 @@ object X3DH { identityPrivateDH: java.security.PrivateKey, bundle: Bundle, ephemeral: java.security.KeyPair = X25519.generate(), + /** + * Bob's chosen post-quantum prekey, already signature-verified by the + * caller, or null for a v1 session. + * + * ⚠️ Verified by the CALLER rather than here, deliberately: this object + * holds no identity key to check a signature against, and taking one + * just for this would mean two places could disagree about what counts + * as a valid prekey. + */ + bobPQ: ByteArray? = null, ): Initiated { val dh1 = X25519.agree(identityPrivateDH, bundle.signedPreKey) val dh2 = X25519.agree(ephemeral.private, bundle.identityKeyDH) val dh3 = X25519.agree(ephemeral.private, bundle.signedPreKey) val dh4 = bundle.oneTimePreKey?.let { X25519.agree(ephemeral.private, it) } - val ikm = if (dh4 != null) dh1 + dh2 + dh3 + dh4 else dh1 + dh2 + dh3 + var ikm = if (dh4 != null) dh1 + dh2 + dh3 + dh4 else dh1 + dh2 + dh3 + // 🔴 **The KEM secret goes LAST.** Order is protocol, and two engines + // that disagree about it agree about everything else — the failure + // arrives at the far end as a message that will not decrypt, pointing + // nowhere near here. + var ciphertext: ByteArray? = null + if (bobPQ != null) { + val out = PQKEM.encapsulate(bobPQ) + ikm += out.sharedSecret + ciphertext = out.ciphertext + } return Initiated( - sharedSecret = Hkdf.derive(ikm, ZERO_SALT, INFO_V1, 32), + sharedSecret = Hkdf.derive( + ikm, ZERO_SALT, if (ciphertext == null) INFO_V1 else INFO_V2, 32), ephemeralPublic = X25519.rawFromPublicKey(ephemeral.public), + pqCiphertext = ciphertext, + version = if (ciphertext == null) 1 else 2, ) } @@ -96,13 +168,25 @@ object X3DH { oneTimePrivate: java.security.PrivateKey?, initiatorIdentityDH: ByteArray, initiatorEphemeral: ByteArray, + /** + * The seed of whichever post-quantum prekey Alice named, and the + * ciphertext she sent — or null when she ran v1. + * + * ⚠️ Decapsulation never fails on a substituted ciphertext: ML-KEM's + * implicit rejection yields a well-formed but different secret. So a + * tampered one is not caught here; it surfaces as the first message + * failing to open, which is why the ciphertext is also bound into the + * associated data (§12.5). + */ + pq: Pair? = null, ): ByteArray { val dh1 = X25519.agree(signedPreKeyPrivate, initiatorIdentityDH) val dh2 = X25519.agree(identityPrivateDH, initiatorEphemeral) val dh3 = X25519.agree(signedPreKeyPrivate, initiatorEphemeral) val dh4 = oneTimePrivate?.let { X25519.agree(it, initiatorEphemeral) } - val ikm = if (dh4 != null) dh1 + dh2 + dh3 + dh4 else dh1 + dh2 + dh3 - return Hkdf.derive(ikm, ZERO_SALT, INFO_V1, 32) + var ikm = if (dh4 != null) dh1 + dh2 + dh3 + dh4 else dh1 + dh2 + dh3 + if (pq != null) ikm += PQKEM.decapsulate(pq.second, pq.first) + return Hkdf.derive(ikm, ZERO_SALT, if (pq == null) INFO_V1 else INFO_V2, 32) } } diff --git a/engine/src/test/kotlin/io/flamenet/e2e/HybridSessionTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/HybridSessionTest.kt new file mode 100644 index 0000000..7d6c938 --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/HybridSessionTest.kt @@ -0,0 +1,332 @@ +package io.flamenet.e2e + +import org.json.JSONObject +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Two devices negotiating the hybrid handshake, end to end. + * + * 🔴 **A session is post-quantum only when both ends are.** The primitive + * working proves nothing on its own: what matters is that a real registration + * publishes usable material, that a real peer picks it up, that the ciphertext + * survives the envelope, and that the responder finds the seed again. Any one + * of those failing leaves a device that advertises `proto: 2` and derives a + * different key from everybody — which surfaces as messages that will not + * decrypt and points nowhere near the cause. + * + * The other half of these tests is about what happens when the relay lies, + * because everything in a bundle passes through one. + */ +class HybridSessionTest { + + /** A device that has published post-quantum material, as a peer sees it. */ + private fun published(device: DeviceIdentity, withOneTime: Boolean = true): PeerBundle { + val opk = device.generateOneTimePreKeys(1).first() + val wire = device.registrationWire() + val pqOtp = if (withOneTime) device.generatePQOneTimePreKeys(1).first() else null + val bundle = JSONObject() + .put("device_id", wire.getString("device_id")) + .put("ik_dh", wire.getString("ik_dh")) + .put("ik_sig", wire.getString("ik_sig")) + .put("spk_id", wire.getInt("spk_id")) + .put("spk", wire.getString("spk")) + .put("spk_sig", wire.getString("spk_sig")) + .put("proto", wire.getInt("proto")) + .put("caps_sig", wire.getString("caps_sig")) + .put("pqspk_id", wire.getInt("pqspk_id")) + .put("pqspk", wire.getString("pqspk")) + .put("pqspk_sig", wire.getString("pqspk_sig")) + .put("opk", JSONObject().put("id", opk.id).put("pub", Bytes.b64(opk.publicKey))) + if (pqOtp != null) { + bundle.put("pqopk", JSONObject() + .put("id", pqOtp.id) + .put("pub", Bytes.b64(pqOtp.publicKey)) + .put("sig", Bytes.b64(pqOtp.signature))) + } + return PeerBundle.fromWire(bundle) + } + + /** A v1 device: exactly what this engine published before ML-KEM existed. */ + private fun publishedV1(device: DeviceIdentity): PeerBundle { + val opk = device.generateOneTimePreKeys(1).first() + val wire = device.registrationWire(postQuantum = false) + return PeerBundle.fromWire(JSONObject() + .put("device_id", wire.getString("device_id")) + .put("ik_dh", wire.getString("ik_dh")) + .put("ik_sig", wire.getString("ik_sig")) + .put("spk_id", wire.getInt("spk_id")) + .put("spk", wire.getString("spk")) + .put("spk_sig", wire.getString("spk_sig")) + .put("proto", wire.getInt("proto")) + .put("opk", JSONObject().put("id", opk.id).put("pub", Bytes.b64(opk.publicKey)))) + } + + // MARK: - The happy path + + @Test + fun `two v2 devices hold a post-quantum conversation`() { + val alice = DeviceIdentity.create() + val bob = DeviceIdentity.create() + + val session = E2ESession.initiate(alice, published(bob)) + assertEquals(2, session.version, "both ends can, so this must be v2") + + val envelope = session.encrypt("post-quantum hello") + assertNotNull(envelope.pq, "the KEM ciphertext has to travel with the handshake") + assertEquals("opk", envelope.pq!!.kind, "a fresh pool means the one-time key") + assertEquals(PQKEM.CIPHERTEXT_LEN, envelope.pq!!.ciphertext.size) + + // Through the wire, because that is where a missing field hides. + val delivered = Envelope.fromPayload(envelope.toPayload()) + val bobSide = E2ESession.respond(bob, delivered) + + assertEquals(2, bobSide.version) + assertEquals("post-quantum hello", bobSide.decrypt(delivered)) + assertContentEquals(session.associatedData, bobSide.associatedData, + "both sides must bind the same ciphertext digest") + + // And the reply comes back on the same session. + val reply = bobSide.encrypt("and hello back") + assertEquals("and hello back", session.decrypt(Envelope.fromPayload(reply.toPayload()))) + } + + /** + * ⚠️ **The last-resort key is a fallback, not a choice.** Every session + * then shares one KEM key, which is a real weakening — but it is far better + * than dropping to v1 silently, which is what a device with no last-resort + * key would force. + */ + @Test + fun `a drained one-time pool still gives a post-quantum session`() { + val alice = DeviceIdentity.create() + val bob = DeviceIdentity.create() + + val session = E2ESession.initiate(alice, published(bob, withOneTime = false)) + assertEquals(2, session.version) + val envelope = session.encrypt("still hybrid") + assertEquals("spk", envelope.pq!!.kind) + assertEquals("still hybrid", E2ESession.respond(bob, envelope).decrypt(envelope)) + } + + // MARK: - Mixed fleets + + /** Somebody on an older build is still reachable, on v1. */ + @Test + fun `a v2 device talking to a v1 device falls back cleanly`() { + val alice = DeviceIdentity.create() + val bob = DeviceIdentity.create() + + val session = E2ESession.initiate(alice, publishedV1(bob)) + assertEquals(1, session.version) + val envelope = session.encrypt("classical hello") + assertNull(envelope.pq) + assertEquals("classical hello", E2ESession.respond(bob, envelope).decrypt(envelope)) + } + + /** And the other direction: a v1 initiator reaching a v2 device. */ + @Test + fun `a v1 initiator can still start a session with a v2 device`() { + val alice = DeviceIdentity.create() + val bob = DeviceIdentity.create() + // Alice's engine has ML-KEM but the bundle she was served does not + // offer any, which is what a v1 peer looks like. + val bundleWithoutPQ = publishedV1(bob) + val session = E2ESession.initiate(alice, bundleWithoutPQ) + val envelope = session.encrypt("hello") + assertEquals(1, E2ESession.respond(bob, envelope).version) + } + + // MARK: - What the relay is not allowed to do + + /** + * 🔴 **The substitution this whole layer exists to prevent.** A relay that + * can swap in an ML-KEM key of its own reads the post-quantum half of every + * new session — exactly what `spk_sig` stops classically. A key whose + * signature does not check out is treated as absent, so the session drops + * to v1 rather than to a key the relay chose. + */ + @Test + fun `a substituted post-quantum prekey is ignored, not used`() { + val alice = DeviceIdentity.create() + val bob = DeviceIdentity.create() + val honest = published(bob) + val relaysOwnKey = PQKEM.generate() + + val tampered = honest.copy( + pqOneTimePreKey = relaysOwnKey.publicKey, + pqSignedPreKey = relaysOwnKey.publicKey, + ) + assertNull(tampered.provenPostQuantumPreKey(), + "an unsigned KEM key must not be encapsulated to") + assertEquals(1, E2ESession.initiate(alice, tampered).version, + "and the session falls back to v1, not to the relay's key") + } + + /** + * ⚠️ A relay can also lie the other way — advertise a v2 device as v1 and + * quietly strip the post-quantum half off every new session. `caps_sig` is + * what makes the claim checkable; where the signature is missing the peer + * reads as v1, which is the safe reading of "we cannot tell". + */ + @Test + fun `a bundle with no capability signature is treated as v1`() { + val bob = DeviceIdentity.create() + assertNull(published(bob).copy(capabilitySignature = null).provenPostQuantumPreKey()) + } + + /** A capability signature for a different version does not verify. */ + @Test + fun `a capability signature does not transfer between versions`() { + val bob = DeviceIdentity.create() + val forVersion3 = published(bob).copy(proto = 3) + assertNull(forVersion3.provenPostQuantumPreKey(), + "the signature commits to the version it claims") + } + + /** + * ⚠️ The one-time id is signed **big-endian**, and the id is inside the + * signature so a relay cannot renumber a key to make a peer spend the wrong + * one. + */ + @Test + fun `a renumbered one-time prekey does not verify`() { + val bob = DeviceIdentity.create() + val moved = published(bob).copy(pqOneTimePreKeyId = 99) + // It falls back to the last-resort key rather than accepting the + // renumbered one, which is exactly the intended degradation. + assertEquals("spk", moved.provenPostQuantumPreKey()?.kind) + } + + // MARK: - Persistence + + /** + * 🔴 The seed is the only thing stored, so a device that could not find it + * again would publish a key it cannot decapsulate with — and every peer who + * used that key would be silently unreachable. + */ + @Test + fun `post-quantum material survives an archive round trip`() { + val bob = DeviceIdentity.create() + val bundle = published(bob) + val restored = DeviceIdentity.fromArchive(JSONObject(bob.archive().toString())) + + val session = E2ESession.initiate(DeviceIdentity.create(), bundle) + val envelope = session.encrypt("after a restart") + assertEquals("after a restart", E2ESession.respond(restored, envelope).decrypt(envelope)) + } + + /** + * ⚠️ A device archived before this engine had ML-KEM has no such block, and + * every existing install is one. It must load as a v1 device rather than + * fail, and publish post-quantum material the next time it registers. + */ + @Test + fun `an archive from before post-quantum still loads`() { + val old = DeviceIdentity.create() + val archive = old.archive() + archive.remove("pqSpk"); archive.remove("pqOtp"); archive.remove("nextPQId") + + val loaded = DeviceIdentity.fromArchive(JSONObject(archive.toString())) + assertTrue(!loaded.hasPQMaterial) + // Registering makes one, so the upgrade needs nothing from the user. + assertTrue(loaded.registrationWire().getString("pqspk").isNotEmpty()) + assertTrue(loaded.hasPQMaterial) + } + + /** + * ⚠️ **A prekey we already spent is not an error.** A duplicate delivery of + * a v2 prekey envelope names a one-time key this device has consumed, and + * throwing there would turn a retransmission into a dead conversation. It + * simply cannot be v2 — and then fails to decrypt, which the caller's three + * chains already handle. + */ + @Test + fun `a replayed prekey envelope does not throw`() { + val alice = DeviceIdentity.create() + val bob = DeviceIdentity.create() + val envelope = E2ESession.initiate(alice, published(bob)).encrypt("once") + + assertEquals("once", E2ESession.respond(bob, envelope).decrypt(envelope)) + // The one-time key is gone now. The second attempt must fail as a + // decrypt, not as an exception out of the handshake. + val second = E2ESession.respond(bob, envelope) + assertEquals(1, second.version, "no PQ seed left, so this cannot be v2") + assertFailsWith { second.decrypt(envelope) } + } + + /** The one-time key is spent exactly once. That is what makes it one-time. */ + @Test + fun `a one-time post-quantum prekey is consumed`() { + val bob = DeviceIdentity.create() + bob.generatePQOneTimePreKeys(2) + assertEquals(2, bob.pqOneTimePreKeyCount) + assertNotNull(bob.takePQPreKey("opk", 1)) + assertEquals(1, bob.pqOneTimePreKeyCount) + assertNull(bob.takePQPreKey("opk", 1)) + // The last-resort key is not consumed, ever. + bob.pqSignedPreKey() + assertNotNull(bob.takePQPreKey("spk", 1)) + assertNotNull(bob.takePQPreKey("spk", 1)) + } + + // MARK: - The wire + + /** + * ⚠️ **The v2 block is all-or-nothing**, and the relay refuses a partial + * one. A device advertising `proto: 2` without a usable last-resort prekey + * would be picked for v2 by every peer and then fail every handshake. + */ + @Test + fun `a v2 registration publishes the whole block`() { + val wire = DeviceIdentity.create().registrationWire(pqOneTime = emptyList()) + assertEquals(2, wire.getInt("proto")) + for (field in listOf("caps_sig", "pqspk", "pqspk_sig")) { + assertTrue(wire.getString(field).isNotEmpty(), "$field must be published") + } + assertEquals(PQKEM.PUBLIC_KEY_LEN, Bytes.unb64(wire.getString("pqspk")).size) + } + + /** And a caller that asks for v1 gets exactly the old body back. */ + @Test + fun `a v1 registration publishes no post-quantum fields`() { + val wire = DeviceIdentity.create().registrationWire(postQuantum = false) + assertEquals(1, wire.getInt("proto")) + for (field in listOf("caps_sig", "pqspk", "pqspk_sig", "pq_prekeys")) { + assertTrue(wire.isNull(field) || !wire.has(field), "$field must be absent") + } + } + + /** + * The KEM ciphertext rides the prekey envelope and nothing after it. Every + * later message is byte-identical to v1, because the secret is already in + * the root key and the associated data binds the ciphertext for the life of + * the session (§12.6) — republishing 1088 bytes per message would be pure + * cost. + */ + @Test + fun `the ciphertext travels once, not on every message`() { + val alice = DeviceIdentity.create() + val bob = DeviceIdentity.create() + val session = E2ESession.initiate(alice, published(bob)) + + val first = session.encrypt("one") + assertNotNull(first.pq) + // Still pending: the handshake repeats until the peer answers, because + // any single message may be the first to arrive. + assertNotNull(session.encrypt("two").pq) + + val bobSide = E2ESession.respond(bob, first) + // ⚠️ Read it before replying. A responder that answers without + // decrypting has no receiving chain to step from — the same mistake a + // caller would make, so the test makes it once and not again. + bobSide.decrypt(first) + session.decrypt(bobSide.encrypt("answered")) + assertNull(session.encrypt("three").pq, "answered, so the handshake stops repeating") + } +} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/PQKEMTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/PQKEMTest.kt new file mode 100644 index 0000000..20cf11f --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/PQKEMTest.kt @@ -0,0 +1,141 @@ +package io.flamenet.e2e + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * ML-KEM-768, the half of the hybrid handshake Android did not have. + * + * 🔴 **A session is post-quantum only when both ends are.** Every Android + * device registered `proto: 1`, so iPhone's implementation protected no real + * conversation — the property was true of the code and false of the product. + * These tests are about the things that would let that keep being true while + * looking fixed: a seed that round-trips to a different key, a size that is + * wrong in a way nothing notices until a peer cannot decrypt. + */ +class PQKEMTest { + + @Test + fun `a fresh keypair has the sizes FIPS 203 fixes`() { + val pair = PQKEM.generate() + assertEquals(PQKEM.SEED_LEN, pair.seed.size) + assertEquals(PQKEM.PUBLIC_KEY_LEN, pair.publicKey.size) + } + + @Test + fun `encapsulate and decapsulate agree on a secret`() { + val bob = PQKEM.generate() + val out = PQKEM.encapsulate(bob.publicKey) + + assertEquals(PQKEM.CIPHERTEXT_LEN, out.ciphertext.size) + assertEquals(PQKEM.SHARED_SECRET_LEN, out.sharedSecret.size) + assertContentEquals(out.sharedSecret, PQKEM.decapsulate(out.ciphertext, bob.seed)) + } + + /** + * 🔴 **The seed is the whole persistence story.** Only 64 bytes go into the + * vault, and both the private key and the published public half are + * re-derived from them on every load — so a seed that produced a different + * public key on the second derivation would mean a device publishing a key + * it cannot decapsulate with, and every peer who used it silently + * unreachable. + */ + @Test + fun `a seed derives the same keypair every time`() { + val first = PQKEM.generate() + val again = PQKEM.fromSeed(first.seed) + + assertContentEquals(first.publicKey, again.publicKey) + // And a secret encapsulated to one opens with the other. + val out = PQKEM.encapsulate(first.publicKey) + assertContentEquals(out.sharedSecret, PQKEM.decapsulate(out.ciphertext, again.seed)) + } + + /** + * The seed path and the library's own generator must build the same key. + * + * ⚠️ Not a tautology. `fromSeed` reconstructs a private key from `d ‖ z` by + * a different route than `generateKeyPair` takes, and a disagreement + * between the two would show up only as a peer on another implementation + * being unable to talk to us. + */ + @Test + fun `the seed constructor matches the library's own generator`() { + val (seed, publicKey) = PQKEM.generateWithLibrary() + assertEquals(PQKEM.SEED_LEN, seed.size, "the library stores a 64-byte FIPS 203 seed") + assertContentEquals(publicKey, PQKEM.fromSeed(seed).publicKey) + } + + @Test + fun `two keypairs are not the same keypair`() { + assertFalse(PQKEM.generate().publicKey.contentEquals(PQKEM.generate().publicKey)) + } + + /** + * ⚠️ **Decapsulation does not fail on a tampered ciphertext**, by design: + * ML-KEM's implicit rejection returns a well-formed but different secret + * rather than an error. Asserted rather than assumed, because every caller + * has to be written for it — the mismatch surfaces as the first message + * failing to decrypt, which is why §12.5 also binds the ciphertext into the + * session's associated data. + */ + @Test + fun `a tampered ciphertext yields a different secret, not an error`() { + val bob = PQKEM.generate() + val out = PQKEM.encapsulate(bob.publicKey) + val tampered = out.ciphertext.copyOf().also { it[0] = (it[0].toInt() xor 1).toByte() } + + val got = PQKEM.decapsulate(tampered, bob.seed) + assertEquals(PQKEM.SHARED_SECRET_LEN, got.size) + assertFalse(got.contentEquals(out.sharedSecret)) + } + + @Test + fun `the wrong seed opens nothing`() { + val bob = PQKEM.generate() + val out = PQKEM.encapsulate(bob.publicKey) + assertFalse(PQKEM.decapsulate(out.ciphertext, PQKEM.generate().seed) + .contentEquals(out.sharedSecret)) + } + + /** + * Sizes are checked at the door. A wrong length can only ever fail on every + * peer, and it fails far away from here — so it is named where it happens + * rather than discovered as a contact who stopped working. + */ + @Test + fun `wrong sizes are refused where they can still be explained`() { + assertFailsWith { PQKEM.fromSeed(ByteArray(32)) } + assertFailsWith { PQKEM.encapsulate(ByteArray(1183)) } + assertFailsWith { + PQKEM.decapsulate(ByteArray(1087), PQKEM.generate().seed) + } + assertFailsWith { + PQKEM.decapsulate(ByteArray(PQKEM.CIPHERTEXT_LEN), ByteArray(63)) + } + } + + /** Every encapsulation to one key is fresh. A repeat would be a broken RNG. */ + @Test + fun `encapsulating twice to the same key gives two different ciphertexts`() { + val bob = PQKEM.generate() + val a = PQKEM.encapsulate(bob.publicKey) + val b = PQKEM.encapsulate(bob.publicKey) + assertFalse(a.ciphertext.contentEquals(b.ciphertext)) + assertFalse(a.sharedSecret.contentEquals(b.sharedSecret)) + // Both still open. + assertContentEquals(a.sharedSecret, PQKEM.decapsulate(a.ciphertext, bob.seed)) + assertContentEquals(b.sharedSecret, PQKEM.decapsulate(b.ciphertext, bob.seed)) + } + + /** A secret with no entropy in it would pass every test above. */ + @Test + fun `the shared secret is not all zeroes`() { + val out = PQKEM.encapsulate(PQKEM.generate().publicKey) + assertTrue(out.sharedSecret.any { it != 0.toByte() }) + } +} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/PQVectorTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/PQVectorTest.kt new file mode 100644 index 0000000..3ca60a0 --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/PQVectorTest.kt @@ -0,0 +1,158 @@ +package io.flamenet.e2e + +import org.json.JSONObject +import java.io.File +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +/** + * The hybrid handshake against the published known-answer vector. + * + * 🔴 **This is the only test here worth much.** Every other assertion about + * ML-KEM in this module compares Bouncy Castle to itself: seeds round-trip, + * ciphertexts open, sizes are right — all true of an implementation that is + * internally consistent and agrees with nobody. The vector was generated by the + * normative JavaScript engine and is replayed by the Swift one, so matching it + * is the claim that actually matters: a phone can start a post-quantum session + * with an iPhone, and neither has to take our word for it. + * + * The specific thing at risk is the **seed**. FIPS 203 keygen takes `d ‖ z`, + * and this engine stores only those 64 bytes and re-derives everything from + * them — so if BC ordered or expanded them differently from CryptoKit, every + * cross-platform session would fail at the first message with nothing pointing + * at the cause. The vector settles it byte for byte. + */ +class PQVectorTest { + + private val vector: JSONObject by lazy { + // Resolved rather than hardcoded, for the same reason PublishedVectorsTest + // does it: this module lives both as a subtree of the app repo and as a + // repo of its own beside the engine. + // + // ⚠️ Fails rather than skips when nothing is found. A conformance test + // that quietly stops conforming is worth less than no test at all — + // the file listing still says the handshake is checked. + val candidates = listOfNotNull( + System.getenv("FLAMENET_PQ_VECTORS"), + "../../engine/test/vectors/pqx3dh-v2.json", + "../../../flamenet-e2e/test/vectors/pqx3dh-v2.json", + "../../flamenet-e2e/test/vectors/pqx3dh-v2.json", + ) + val found = candidates.map(::File).firstOrNull { it.isFile } + ?: error("no pqx3dh-v2.json found; looked in $candidates. " + + "Set FLAMENET_PQ_VECTORS to point at it.") + JSONObject(found.readText()) + } + + private fun hex(bytes: ByteArray) = bytes.joinToString("") { "%02x".format(it) } + + /** + * 🔴 **The cross-implementation check.** The vector publishes a seed and + * the public key the normative engine derived from it; deriving a different + * one here is the failure that would make every Android↔iPhone post-quantum + * session fail on the first message, and nowhere near this code. + */ + @Test + fun `a published seed derives the published public key`() { + val archive = vector.getJSONObject("bobArchive") + for (which in listOf("pqSpk", "pqOtp")) { + val entry = if (which == "pqSpk") archive.getJSONObject("pqSpk") + else archive.getJSONObject("pqOtp").getJSONObject("1") + val seed = Bytes.unb64(entry.getString("seed")) + val published = Bytes.unb64(entry.getString("pub")) + + assertEquals(PQKEM.SEED_LEN, seed.size, "$which seed is d‖z") + assertContentEquals(published, PQKEM.fromSeed(seed).publicKey, + "$which: this engine derives a different key from the same seed") + } + } + + /** + * The whole responder side, end to end, against the published answer. + * + * Everything is exercised at once and that is the point: the four + * agreements, their order, the KEM secret going last, the v2 info string, + * and the associated data. A vector that only checked the KEM would pass + * while the concatenation order was wrong — and the two engines would still + * never talk. + */ + @Test + fun `the responder derives the published session key and associated data`() { + val archive = vector.getJSONObject("bobArchive") + val env = vector.getJSONObject("env") + val x3dh = env.getJSONObject("x3dh") + val expect = vector.getJSONObject("expect") + + fun priv(path: JSONObject) = X25519.privateKeyFromPkcs8(Bytes.unb64(path.getString("priv"))) + + val ciphertext = Bytes.unb64(env.getJSONObject("pq").getString("ct")) + val pqSeed = Bytes.unb64(archive.getJSONObject("pqOtp").getJSONObject("1").getString("seed")) + assertEquals("opk", env.getJSONObject("pq").getString("kind"), + "this vector uses the one-time PQ prekey") + + val sk = X3DH.respond( + identityPrivateDH = priv(archive.getJSONObject("ikDH")), + signedPreKeyPrivate = priv(archive.getJSONObject("spk")), + oneTimePrivate = priv(archive.getJSONObject("otp").getJSONObject("1")), + initiatorIdentityDH = Bytes.unb64(x3dh.getString("ik_dh")), + initiatorEphemeral = Bytes.unb64(x3dh.getString("ek")), + pq = pqSeed to ciphertext, + ) + assertEquals(expect.getString("sk"), hex(sk)) + + val ad = X3DH.associatedData( + first = Bytes.unb64(x3dh.getString("ik_sig")), + second = Bytes.unb64(archive.getJSONObject("ikSig").getString("pub")), + pqCiphertext = ciphertext, + ) + assertEquals(expect.getString("ad"), hex(ad)) + } + + /** + * 🔴 **A downgrade must not produce a key the other side accepts.** v1's + * `DH1‖DH2‖DH3‖DH4` and v2's `DH1‖DH2‖DH3‖SS` are both 128 bytes, so + * without the info string changing with the version they are + * indistinguishable KDF inputs (§12.5). Asserted against the published + * answer rather than against another run of our own code. + */ + @Test + fun `running the same handshake without the KEM derives a different key`() { + val archive = vector.getJSONObject("bobArchive") + val x3dh = vector.getJSONObject("env").getJSONObject("x3dh") + + fun priv(path: JSONObject) = X25519.privateKeyFromPkcs8(Bytes.unb64(path.getString("priv"))) + + val classical = X3DH.respond( + identityPrivateDH = priv(archive.getJSONObject("ikDH")), + signedPreKeyPrivate = priv(archive.getJSONObject("spk")), + oneTimePrivate = priv(archive.getJSONObject("otp").getJSONObject("1")), + initiatorIdentityDH = Bytes.unb64(x3dh.getString("ik_dh")), + initiatorEphemeral = Bytes.unb64(x3dh.getString("ek")), + ) + assertNotEquals(vector.getJSONObject("expect").getString("sk"), hex(classical)) + } + + /** + * ⚠️ The associated data binds the ciphertext, so a relay that swapped one + * cannot leave the session looking untouched. ML-KEM's implicit rejection + * means decapsulation itself will not complain. + */ + @Test + fun `changing one byte of the ciphertext changes the associated data`() { + val ciphertext = Bytes.unb64(vector.getJSONObject("env").getJSONObject("pq").getString("ct")) + val tampered = ciphertext.copyOf().also { it[7] = (it[7].toInt() xor 1).toByte() } + val a = X3DH.associatedData(ByteArray(32), ByteArray(32), ciphertext) + val b = X3DH.associatedData(ByteArray(32), ByteArray(32), tampered) + assertNotEquals(hex(a), hex(b)) + } + + /** A v1 session's AD is the two identity keys and nothing else. */ + @Test + fun `without a ciphertext the associated data is just the two identities`() { + val a = ByteArray(32) { 1 } + val b = ByteArray(32) { 2 } + assertContentEquals(a + b, X3DH.associatedData(a, b, null)) + } +}