Files
flamenet-messenger-android/engine/src/test/kotlin/io/flamenet/e2e/PQVectorTest.kt
T
Paul HittandClaude Fable 5.1 2fa430f21c API-style comments and ktlint formatting
Every comment is reduced to its first sentence plus @param, @return
and @throws tags. Comment lines: 20040 to 8526. ktlint 1.8 applied
with its default (official Kotlin) style across all modules; 262 files
reformatted. The 264 remaining ktlint findings are naming and filename
rules, which are code changes, and are left alone.

Comments and formatting only. 1208 tests pass, counted from the XML.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 20:02:07 -04:00

146 lines
5.8 KiB
Kotlin

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.
*/
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.
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.
*/
@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.
*/
@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).
*/
@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.
*/
@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))
}
}