Post-quantum key agreement on Android
A session is post-quantum only when both ends are, so with every Android device registering `proto: 1` the iPhone implementation protected approximately no real conversation. The property was true of the code and false of the product. This is the other half. ML-KEM-768 over Bouncy Castle's low-level `pqc.crypto.mlkem`, the same way Ed25519 already uses its `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. No new dependency: the jar was already in the build for Ed25519. Keys are stored as the 64-byte FIPS 203 seed, never the expanded decapsulation key — 64 bytes in the vault instead of 2400, and the same representation iOS persists, which is what lets a history export cross platforms. That is also the thing most likely to be silently wrong, so it is checked against the published vector rather than against another run of our own code: the seed in `pqx3dh-v2.json` derives the public key the normative JavaScript engine derived, and the full responder side reproduces the published session key and associated data byte for byte. Everything else here would pass with an implementation that is internally consistent and agrees with nobody. Negotiation refuses in three places, and each is a substitution somebody would otherwise get for free. A KEM prekey whose signature does not verify is treated as ABSENT, so a relay handing out a key of its own drops the session to v1 rather than to its key — the same substitution `spk_sig` stops classically, and worth more here because it buys the whole post-quantum half. A `proto` claim with no `caps_sig` reads as v1, so a relay cannot advertise a v2 device as v1-only and strip the post-quantum half off every new session. And the one-time id is signed big-endian and inside the signature, so a renumbered key falls back to the last-resort one instead of being spent. Both pools are refilled together, and that is not tidiness. They drain at the same rate — one key per new session — so a refill that topped up only the classical pool would let the post-quantum one run dry and push every later session onto the last-resort key, every one of them sharing a single KEM key, with nothing anywhere reporting a downgrade. Mixed fleets work in both directions and a spent prekey is not an error: a duplicate delivery of a v2 prekey envelope names a one-time key this device has already consumed, and throwing there would turn a retransmission into a dead conversation. Two test fixtures had to say what they are rather than answer by order. Sending now asks for a transparency-log head on its own, so a queue matched purely by position handed that request the device listing and failed on a parse error nowhere near the cause. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
91f58956cf
commit
444ebf0862
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<DeviceIdentity.PreKey>): JSONObject =
|
||||
postJson("/e2e/devices", identity.registrationWire(oneTimePreKeys))
|
||||
fun registerDevice(
|
||||
identity: DeviceIdentity,
|
||||
oneTimePreKeys: List<DeviceIdentity.PreKey>,
|
||||
pqOneTimePreKeys: List<DeviceIdentity.PQPreKey> = emptyList(),
|
||||
): JSONObject =
|
||||
postJson("/e2e/devices", identity.registrationWire(oneTimePreKeys, pqOneTimePreKeys))
|
||||
|
||||
/** `POST /e2e/prekeys` — top the pool back up. */
|
||||
fun uploadPreKeys(deviceId: String, preKeys: List<DeviceIdentity.PreKey>) {
|
||||
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<DeviceIdentity.PreKey>,
|
||||
pqPreKeys: List<DeviceIdentity.PQPreKey> = 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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<Int, KeyPair>()
|
||||
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<Int, ByteArray>()
|
||||
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<PQPreKey> = (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<PreKey> = 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<PreKey> = emptyList(),
|
||||
pqOneTime: List<PQPreKey> = 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<ByteArray, ByteArray> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<ByteArray, ByteArray>? = 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Exception> { 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")
|
||||
}
|
||||
}
|
||||
@@ -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.Malformed> { PQKEM.fromSeed(ByteArray(32)) }
|
||||
assertFailsWith<PQKEM.Malformed> { PQKEM.encapsulate(ByteArray(1183)) }
|
||||
assertFailsWith<PQKEM.Malformed> {
|
||||
PQKEM.decapsulate(ByteArray(1087), PQKEM.generate().seed)
|
||||
}
|
||||
assertFailsWith<PQKEM.Malformed> {
|
||||
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() })
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user