Merge branch '2-v3-cross-engine' into 'main'

A v2 peer was being sent a post-quantum ratchet step, and v3 was never advertised

See merge request paulhitt/flamenet-messenger-android!4
This commit was merged in pull request #31.
This commit is contained in:
Paul Hitt
2026-08-31 17:01:58 +00:00
5 changed files with 220 additions and 23 deletions
@@ -83,11 +83,26 @@ class DeviceIdentity private constructor(
/** /**
* The highest handshake version this engine implements. * The highest handshake version this engine implements.
* *
* 2 — hybrid X3DH. Not 3: that puts ML-KEM into every ratchet step and * 3 — ML-KEM in every ratchet step (§13), not only the handshake. This
* is a separate piece of work. Claiming 3 here would have peers send * read 2 until the ratchet existed, with the note that claiming 3 early
* `pqdh`/`pqct` on every message and this engine reject the lot. * would have peers send `pqdh`/`pqct` on every message and this engine
* reject the lot. That was right, and it is why the number moves in the
* same change as the evidence rather than ahead of it.
*
* 🔴 **A version is a claim other devices act on.** Peers pin the
* highest proven version and the pin is monotonic (§13.4), so a build
* that advertises 3 and cannot honour it does not degrade — it strands
* every contact that believed it. What backs this number: the Kotlin
* ratchet drives a v3 session against the web engine in both directions
* (`flamenet-e2e/test/interop-kotlin.mjs`, §13), including the reply
* that only opens if the decapsulate-before-adopt ordering is right.
*
* ⚠️ Claiming 3 does not mean sending 3 to everyone. `E2ESession`
* gates the PQ ratchet on the PEER having proven 3 as well, because
* every v2 device offers a PQ prekey and none of them can read a
* ratchet step.
*/ */
const val PROTOCOL_VERSION = 2 const val PROTOCOL_VERSION = 3
fun create(now: Long = System.currentTimeMillis()): DeviceIdentity = DeviceIdentity( fun create(now: Long = System.currentTimeMillis()): DeviceIdentity = DeviceIdentity(
deviceId = java.util.UUID.randomUUID().toString().lowercase(), deviceId = java.util.UUID.randomUUID().toString().lowercase(),
@@ -90,17 +90,30 @@ class E2ESession private constructor(
.put("spk_id", bundle.signedPreKeyId) .put("spk_id", bundle.signedPreKeyId)
.put("opk_id", bundle.oneTimePreKeyId) .put("opk_id", bundle.oneTimePreKeyId)
// 🔴 **Offering a PQ prekey is not a claim to §13.** Every v2 device
// publishes one; only a v3 device can read a PQ ratchet step. Sending
// one to a v2 peer breaks the conversation twice over — their root
// step derives without `ss` under the `_v1` info string while ours
// used `_v3`, and their canonical header omits `pqdh`/`pqct` so the
// AAD does not match either. The first message never opens.
//
// ⚠️ `bundle.proto` alone is the relay's word for it. `chosen` is
// non-null only when the capability signature over THIS proto
// verified (§12.7), so the pair together is a proven claim to v3.
val postQuantumRatchet =
if (chosen != null && bundle.proto >= 3) chosen.publicKey else null
return E2ESession( return E2ESession(
// 🔴 `chosen?.publicKey` is the PQ prekey X3DH just encapsulated // 🔴 `chosen?.publicKey` is the PQ prekey X3DH just encapsulated
// to, and the ratchet has to start from that exact key or the // to, and the ratchet has to start from that exact key or the
// responder cannot decapsulate the first step (§13.2). // responder cannot decapsulate the first step (§13.2).
ratchet = DoubleRatchet.initiator( ratchet = DoubleRatchet.initiator(
initiated.sharedSecret, bundle.signedPreKey, chosen?.publicKey), initiated.sharedSecret, bundle.signedPreKey, postQuantumRatchet),
associatedData = X3DH.associatedData( associatedData = X3DH.associatedData(
identity.identitySigningPublic, bundle.identitySigning, identity.identitySigningPublic, bundle.identitySigning,
initiated.pqCiphertext), initiated.pqCiphertext),
pendingHandshake = handshake, pendingHandshake = handshake,
version = initiated.version, version = if (postQuantumRatchet != null) 3 else initiated.version,
// Rides the first envelope only. Every later `msg` is // Rides the first envelope only. Every later `msg` is
// byte-identical to v1, because the KEM secret is already // byte-identical to v1, because the KEM secret is already
// folded into the root key and the associated data binds the // folded into the root key and the associated data binds the
@@ -52,6 +52,17 @@ class HybridSessionTest {
return PeerBundle.fromWire(bundle) return PeerBundle.fromWire(bundle)
} }
/**
* A peer that has proven v2 and only v2 — the released fleet.
*
* ⚠️ `copy(proto = 2)` alone would NOT do: the capability signature commits
* to the version, so a bundle whose proto was edited reads as v1 to every
* peer. It has to be re-signed for 2 to be a genuine v2 device.
*/
private fun publishedV2(device: DeviceIdentity, withOneTime: Boolean = true): PeerBundle =
published(device, withOneTime).copy(
proto = 2, capabilitySignature = device.capabilitySignature(2))
/** A v1 device: exactly what this engine published before ML-KEM existed. */ /** A v1 device: exactly what this engine published before ML-KEM existed. */
private fun publishedV1(device: DeviceIdentity): PeerBundle { private fun publishedV1(device: DeviceIdentity): PeerBundle {
val opk = device.generateOneTimePreKeys(1).first() val opk = device.generateOneTimePreKeys(1).first()
@@ -70,12 +81,13 @@ class HybridSessionTest {
// MARK: - The happy path // MARK: - The happy path
@Test @Test
fun `two v2 devices hold a post-quantum conversation`() { fun `two post-quantum devices hold a post-quantum conversation`() {
val alice = DeviceIdentity.create() val alice = DeviceIdentity.create()
val bob = DeviceIdentity.create() val bob = DeviceIdentity.create()
val session = E2ESession.initiate(alice, published(bob)) val session = E2ESession.initiate(alice, published(bob))
assertEquals(2, session.version, "both ends can, so this must be v2") assertEquals(3, session.version,
"both ends implement the ratchet, so this must be v3")
val envelope = session.encrypt("post-quantum hello") val envelope = session.encrypt("post-quantum hello")
assertNotNull(envelope.pq, "the KEM ciphertext has to travel with the handshake") assertNotNull(envelope.pq, "the KEM ciphertext has to travel with the handshake")
@@ -108,7 +120,7 @@ class HybridSessionTest {
val bob = DeviceIdentity.create() val bob = DeviceIdentity.create()
val session = E2ESession.initiate(alice, published(bob, withOneTime = false)) val session = E2ESession.initiate(alice, published(bob, withOneTime = false))
assertEquals(2, session.version) assertEquals(3, session.version)
val envelope = session.encrypt("still hybrid") val envelope = session.encrypt("still hybrid")
assertEquals("spk", envelope.pq!!.kind) assertEquals("spk", envelope.pq!!.kind)
assertEquals("still hybrid", E2ESession.respond(bob, envelope).decrypt(envelope)) assertEquals("still hybrid", E2ESession.respond(bob, envelope).decrypt(envelope))
@@ -184,8 +196,12 @@ class HybridSessionTest {
@Test @Test
fun `a capability signature does not transfer between versions`() { fun `a capability signature does not transfer between versions`() {
val bob = DeviceIdentity.create() val bob = DeviceIdentity.create()
val forVersion3 = published(bob).copy(proto = 3) // 🔴 A version this engine does not claim, so the edit cannot be
assertNull(forVersion3.provenPostQuantumPreKey(), // accidentally valid. This read `proto = 3` while the engine published
// 2; the constant moving is what makes a hardcoded neighbour like that
// stop testing anything.
val forAnotherVersion = published(bob).copy(proto = DeviceIdentity.PROTOCOL_VERSION + 1)
assertNull(forAnotherVersion.provenPostQuantumPreKey(),
"the signature commits to the version it claims") "the signature commits to the version it claims")
} }
@@ -203,6 +219,57 @@ class HybridSessionTest {
assertEquals("spk", moved.provenPostQuantumPreKey()?.kind) assertEquals("spk", moved.provenPostQuantumPreKey()?.kind)
} }
/**
* 🔴 **The advertised version and the capability signature must agree.**
*
* A peer verifies `Ed25519(ik_sig, "FlamenetE2E_Caps_v2" ‖ uint8(proto))`
* against the proto in the same bundle. Bumping the constant without the
* signature following it produces a bundle every peer silently reads as v1
* — `provenPostQuantumPreKey()` returns null and the whole post-quantum
* path disappears, with nothing anywhere reporting an error.
*/
@Test
fun `the published bundle claims v3 and proves it`() {
val device = DeviceIdentity.create()
val wire = device.registrationWire()
assertEquals(3, wire.getInt("proto"), "this engine implements the v3 ratchet")
// Round-trip through the peer's own view, which is the only reading
// that matters: it re-verifies the signature over the claimed version.
val seen = published(device)
assertEquals(3, seen.proto)
assertNotNull(seen.provenPostQuantumPreKey(),
"the capability signature does not cover the version claimed")
}
/**
* 🔴 **A v2 peer must not be sent a v3 ratchet step.**
*
* The released fleet advertises `proto: 2`: it does post-quantum key
* AGREEMENT and has no post-quantum ratchet. Attaching `pqdh`/`pqct` to a
* message for one of those devices breaks the conversation twice over —
* their root step derives without `ss` and under the `_v1` info string
* while ours used `_v3`, and their canonical header omits the two fields
* entirely so the AAD does not match either. The first message never opens
* and never will.
*
* ⚠️ Offering a PQ prekey is NOT the same claim as implementing §13. Every
* v2 device offers one. The version has to be what gates this, which is
* what §13.4 says and what the iOS engine already does.
*/
@Test
fun `a peer that only claims v2 is not sent a post-quantum ratchet step`() {
val alice = DeviceIdentity.create()
val bob = DeviceIdentity.create()
// A real v2 bundle: PQ prekeys published, capability signed for 2.
val session = E2ESession.initiate(alice, publishedV2(bob))
val envelope = session.encrypt("hello")
assertNull(envelope.header.pqdh,
"a v2 peer was sent a v3 ratchet step it cannot read")
assertNull(envelope.header.pqct,
"a v2 peer was sent a PQ ciphertext it cannot decapsulate")
}
// MARK: - Persistence // MARK: - Persistence
/** /**
@@ -283,9 +350,9 @@ class HybridSessionTest {
* would be picked for v2 by every peer and then fail every handshake. * would be picked for v2 by every peer and then fail every handshake.
*/ */
@Test @Test
fun `a v2 registration publishes the whole block`() { fun `a post-quantum registration publishes the whole block`() {
val wire = DeviceIdentity.create().registrationWire(pqOneTime = emptyList()) val wire = DeviceIdentity.create().registrationWire(pqOneTime = emptyList())
assertEquals(2, wire.getInt("proto")) assertEquals(DeviceIdentity.PROTOCOL_VERSION, wire.getInt("proto"))
for (field in listOf("caps_sig", "pqspk", "pqspk_sig")) { for (field in listOf("caps_sig", "pqspk", "pqspk_sig")) {
assertTrue(wire.getString(field).isNotEmpty(), "$field must be published") assertTrue(wire.getString(field).isNotEmpty(), "$field must be published")
} }
@@ -50,12 +50,40 @@ class SealedRosterTest {
/** /**
* 🔴 The property the whole change rests on: the stored bytes say nothing. * 🔴 The property the whole change rests on: the stored bytes say nothing.
* A dump that yields the member ids has bought nobody anything. * A dump that yields the member ids has bought nobody anything.
*
* ⚠️ **The values are long on purpose, and the search covers the decoded
* bytes.** This sealed ids `41`/`42` and looked for `"41"`/`"42"` in the
* BASE64 — two characters out of a 64-symbol alphabet, so each had about a
* 1-in-4096 chance at any position and roughly a **14% chance across a blob
* this size**. It failed on exactly that, having passed for days: a coin
* flip wearing the clothes of an assertion. The iOS twin had the same bug
* and was fixed the same way; this one was missed because only one engine
* was looked at.
*
* Nine-digit ids and a real-sounding name make a chance match impossible
* rather than unlikely, and the byte-level search is the one that actually
* states the property — base64 could in principle hide a plaintext run the
* raw bytes reveal.
*/ */
@Test @Test
fun `the sealed blob reveals nothing`() { fun `the sealed blob reveals nothing`() {
val blob = SealedRoster.seal(contents(), SealedRoster.newGroupKey()) val secret = SealedRoster.Contents(
for (needle in listOf("41", "42", "Book club", "members", "tags")) { members = listOf(918273645L, 564738291L),
tags = mapOf("918273645" to SealedRoster.newTag(),
"564738291" to SealedRoster.newTag()),
name = "Chemotherapy support",
)
val blob = SealedRoster.seal(secret, SealedRoster.newGroupKey())
val raw = Bytes.unb64(blob)
for (needle in listOf("918273645", "564738291", "Chemotherapy support",
"members", "tags", "name")) {
assertFalse(blob.contains(needle), "$needle is readable in the sealed roster") assertFalse(blob.contains(needle), "$needle is readable in the sealed roster")
assertFalse(
String(raw, Charsets.ISO_8859_1)
.contains(String(needle.toByteArray(Charsets.UTF_8), Charsets.ISO_8859_1)),
"$needle is readable in the sealed bytes",
)
} }
} }
@@ -6,6 +6,7 @@ import io.flamenet.e2e.DeviceIdentity
import io.flamenet.e2e.DoubleRatchet import io.flamenet.e2e.DoubleRatchet
import io.flamenet.e2e.E2ESession import io.flamenet.e2e.E2ESession
import io.flamenet.e2e.Envelope import io.flamenet.e2e.Envelope
import io.flamenet.e2e.PQKEM
import io.flamenet.e2e.PeerBundle import io.flamenet.e2e.PeerBundle
import io.flamenet.e2e.Ed25519 import io.flamenet.e2e.Ed25519
import io.flamenet.e2e.Hkdf import io.flamenet.e2e.Hkdf
@@ -51,10 +52,24 @@ private class Device(
val signedPreKey: KeyPair, val signedPreKey: KeyPair,
val oneTimePreKeyId: Int, val oneTimePreKeyId: Int,
val oneTimePreKey: KeyPair, val oneTimePreKey: KeyPair,
/**
* The post-quantum signed prekey (§12.5). Published in the bundle, and the
* key an initiator encapsulates to — which is also where the v3 ratchet
* starts, because `PQr` must be exactly the key X3DH targeted (§13.2).
*/
val pqSignedPreKeyId: Int = 1,
val pqSignedPreKey: PQKEM.KeyPair = PQKEM.generate(),
) )
private class Session(val ratchet: DoubleRatchet, val ad: ByteArray) private class Session(val ratchet: DoubleRatchet, val ad: ByteArray)
// ⚠️ Spelled out here rather than imported: `DeviceIdentity.PQDomain` is
// private, and a driver that reached into the engine's internals to build a
// bundle would be checking the engine against itself. These are the strings the
// SPEC names, so a divergence between engine and spec shows up as a failure.
private val PQ_SPK_DOMAIN = "FlamenetE2E_PQSPK_v2".toByteArray(Charsets.UTF_8)
private val CAPS_DOMAIN = "FlamenetE2E_Caps_v2".toByteArray(Charsets.UTF_8)
fun main() { fun main() {
val out = System.out.bufferedWriter() val out = System.out.bufferedWriter()
generateSequence(::readLine).forEach { line -> generateSequence(::readLine).forEach { line ->
@@ -86,7 +101,7 @@ private fun unb64(text: String): ByteArray = java.util.Base64.getDecoder().decod
private fun JSONObject.bytes(key: String): ByteArray = unb64(getString(key)) private fun JSONObject.bytes(key: String): ByteArray = unb64(getString(key))
private fun handle(req: JSONObject): JSONObject = when (req.getString("cmd")) { private fun handle(req: JSONObject): JSONObject = when (req.getString("cmd")) {
"device" -> newDevice() "device" -> newDevice(req)
"initiate" -> initiate(req) "initiate" -> initiate(req)
"respond" -> respond(req) "respond" -> respond(req)
"encrypt" -> encrypt(req) "encrypt" -> encrypt(req)
@@ -122,7 +137,13 @@ private fun handle(req: JSONObject): JSONObject = when (req.getString("cmd")) {
* exactly that — so the day Kotlin gains PQ, this bundle changing is what makes * exactly that — so the day Kotlin gains PQ, this bundle changing is what makes
* the assertion fail and get updated. * the assertion fail and get updated.
*/ */
private fun newDevice(): JSONObject { private fun newDevice(req: JSONObject): JSONObject {
// 🔴 A v1 bundle is still a thing this engine must be able to publish, and
// still a thing the harness must be able to test against. The fleet holds
// released builds that advertise 1 and 2; if this driver could only speak
// v3, the legacy paths would stop being covered on the day v3 landed —
// which is the day they matter most.
val postQuantum = req.optBoolean("pq", true)
val id = next("kotlin-device-") val id = next("kotlin-device-")
val device = Device( val device = Device(
deviceId = id, deviceId = id,
@@ -146,7 +167,20 @@ private fun newDevice(): JSONObject {
.put("opk", JSONObject() .put("opk", JSONObject()
.put("id", device.oneTimePreKeyId) .put("id", device.oneTimePreKeyId)
.put("pub", b64(X25519.rawFromPublicKey(device.oneTimePreKey.public)))) .put("pub", b64(X25519.rawFromPublicKey(device.oneTimePreKey.public))))
.put("proto", 1) // §12.3/§12.5 — the PQ signed prekey, under its OWN domain string.
// Signing the bare key would verify against nothing the peer computes:
// the web engine checks `Ed25519(ik_sig, "FlamenetE2E_PQSPK_v2" ‖ pub)`.
.putOpt("pqspk_id", if (postQuantum) device.pqSignedPreKeyId else null)
.putOpt("pqspk", if (postQuantum) b64(device.pqSignedPreKey.publicKey) else null)
.putOpt("pqspk_sig", if (postQuantum) b64(Ed25519.sign(
device.ikSig, PQ_SPK_DOMAIN + device.pqSignedPreKey.publicKey)) else null)
// 🔴 §13.4 — advertising a version is a SIGNED claim, not a number.
// Without `caps_sig` the web engine's `bundleFromWire` discards the
// whole v2 block and `negotiate` returns v1, which is exactly what this
// harness was quietly doing before.
.putOpt("caps_sig", if (postQuantum) b64(Ed25519.sign(
device.ikSig, CAPS_DOMAIN + byteArrayOf(3.toByte()))) else null)
.put("proto", if (postQuantum) 3 else 1)
return JSONObject().put("device", id).put("bundle", bundle) return JSONObject().put("device", id).put("bundle", bundle)
} }
@@ -166,6 +200,18 @@ private fun initiate(req: JSONObject): JSONObject {
} }
val opk = bundle.optJSONObject("opk") val opk = bundle.optJSONObject("opk")
// §12.5 — the peer's PQ prekey, verified against the same identity key that
// signed the classical one. An unverified PQ prekey is worth nothing: a
// relay that can substitute it chooses the secret.
val pqspk = bundle.optString("pqspk").ifEmpty { null }?.let(::unb64)
if (pqspk != null) {
val sig = bundle.optString("pqspk_sig").ifEmpty { null }?.let(::unb64)
if (sig == null || !Ed25519.verify(theirIkSig, sig, pqspk)) {
return JSONObject().put("error", "badPQSignature")
}
}
val initiated = X3DH.initiate( val initiated = X3DH.initiate(
identityPrivateDH = me.ikDH.private, identityPrivateDH = me.ikDH.private,
bundle = X3DH.Bundle( bundle = X3DH.Bundle(
@@ -173,11 +219,16 @@ private fun initiate(req: JSONObject): JSONObject {
signedPreKey = spk, signedPreKey = spk,
oneTimePreKey = opk?.let { unb64(it.getString("pub")) }, oneTimePreKey = opk?.let { unb64(it.getString("pub")) },
), ),
bobPQ = pqspk,
) )
// v1 associated data: the two signing identities, initiator first. // The AD binds the PQ ciphertext on v2+, so a substituted one fails the
val ad = me.ikSig.publicKey + theirIkSig // AEAD open rather than silently deriving a different key.
val ratchet = DoubleRatchet.initiator(initiated.sharedSecret, spk) val ad = X3DH.associatedData(me.ikSig.publicKey, theirIkSig, initiated.pqCiphertext)
// 🔴 `pqspk` — the SAME key X3DH just encapsulated to. The responder holds
// its seed, so this is the only value the first ratchet step can be
// decapsulated against (§13.2).
val ratchet = DoubleRatchet.initiator(initiated.sharedSecret, spk, pqspk)
val id = next("kotlin-session-") val id = next("kotlin-session-")
sessions[id] = Session(ratchet, ad) sessions[id] = Session(ratchet, ad)
@@ -188,6 +239,12 @@ private fun initiate(req: JSONObject): JSONObject {
.put("ek", b64(initiated.ephemeralPublic)) .put("ek", b64(initiated.ephemeralPublic))
.put("opk_id", opk?.getInt("id") ?: -1) .put("opk_id", opk?.getInt("id") ?: -1)
.put("spk_id", bundle.optInt("spk_id", 0)) .put("spk_id", bundle.optInt("spk_id", 0))
.put("version", initiated.version)
.put("pq", initiated.pqCiphertext?.let {
JSONObject().put("kind", "spk")
.put("id", bundle.optInt("pqspk_id", 0))
.put("ct", b64(it))
})
.put("header", headerJson(message.header)) .put("header", headerJson(message.header))
.put("ct", b64(message.ciphertext)) .put("ct", b64(message.ciphertext))
} }
@@ -199,16 +256,25 @@ private fun respond(req: JSONObject): JSONObject {
val theirIkDH = req.bytes("ik_dh") val theirIkDH = req.bytes("ik_dh")
val theirIkSig = req.bytes("ik_sig") val theirIkSig = req.bytes("ik_sig")
// The PQ half, when the initiator sent one. `pq.ct` was encapsulated to our
// published PQ prekey, so the seed is what opens it.
val pqBlock = req.optJSONObject("pq")
val pq = pqBlock?.let { me.pqSignedPreKey.seed to unb64(it.getString("ct")) }
val sharedSecret = X3DH.respond( val sharedSecret = X3DH.respond(
identityPrivateDH = me.ikDH.private, identityPrivateDH = me.ikDH.private,
signedPreKeyPrivate = me.signedPreKey.private, signedPreKeyPrivate = me.signedPreKey.private,
oneTimePrivate = if (usedOpk) me.oneTimePreKey.private else null, oneTimePrivate = if (usedOpk) me.oneTimePreKey.private else null,
initiatorIdentityDH = theirIkDH, initiatorIdentityDH = theirIkDH,
initiatorEphemeral = req.bytes("ek"), initiatorEphemeral = req.bytes("ek"),
pq = pq,
) )
val ad = theirIkSig + me.ikSig.publicKey val ad = X3DH.associatedData(theirIkSig, me.ikSig.publicKey, pq?.second)
val ratchet = DoubleRatchet.responder(sharedSecret, me.signedPreKey) // 🔴 The seed, not the public key. The initiator encapsulated to our PQ
// prekey, so decapsulating the first ratchet step needs the private half —
// and `PQr` is adopted from that first header, not set here (§13.2).
val ratchet = DoubleRatchet.responder(sharedSecret, me.signedPreKey, pq?.first)
val id = next("kotlin-session-") val id = next("kotlin-session-")
sessions[id] = Session(ratchet, ad) sessions[id] = Session(ratchet, ad)
@@ -216,6 +282,7 @@ private fun respond(req: JSONObject): JSONObject {
return JSONObject() return JSONObject()
.put("session", id) .put("session", id)
.put("ad", b64(ad)) .put("ad", b64(ad))
.put("version", if (pq == null) 1 else 2)
.put("text", String(text, Charsets.UTF_8)) .put("text", String(text, Charsets.UTF_8))
} }
@@ -234,10 +301,15 @@ private fun decrypt(req: JSONObject): JSONObject {
return JSONObject().put("text", String(text, Charsets.UTF_8)) return JSONObject().put("text", String(text, Charsets.UTF_8))
} }
// §13.3 — `pqdh`/`pqct` present only on a PQ step, omitted entirely otherwise.
// `JSONObject.put(String, null)` drops the key, which is the shape the canonical
// header needs.
private fun headerJson(header: DoubleRatchet.Header): JSONObject = JSONObject() private fun headerJson(header: DoubleRatchet.Header): JSONObject = JSONObject()
.put("dh", b64(header.dh)) .put("dh", b64(header.dh))
.put("pn", header.pn) .put("pn", header.pn)
.put("n", header.n) .put("n", header.n)
.put("pqdh", header.pqdh?.let(::b64))
.put("pqct", header.pqct?.let(::b64))
private fun messageFrom(req: JSONObject): DoubleRatchet.Message { private fun messageFrom(req: JSONObject): DoubleRatchet.Message {
val header = req.getJSONObject("header") val header = req.getJSONObject("header")
@@ -246,6 +318,8 @@ private fun messageFrom(req: JSONObject): DoubleRatchet.Message {
dh = unb64(header.getString("dh")), dh = unb64(header.getString("dh")),
pn = header.getInt("pn"), pn = header.getInt("pn"),
n = header.getInt("n"), n = header.getInt("n"),
pqdh = header.optString("pqdh").ifEmpty { null }?.let(::unb64),
pqct = header.optString("pqct").ifEmpty { null }?.let(::unb64),
), ),
ciphertext = req.bytes("ct"), ciphertext = req.bytes("ct"),
) )