Files
flamenet-messenger-android/engine/src/test/kotlin/io/flamenet/e2e/BackupVectorTest.kt
T
Paul HittandClaude Opus 5 5d6350d157 Encrypted backups in Kotlin, against the published vectors
Spec §16, the third engine, checked against pinned bytes rather than against
either of the other two.

Reuses the module's own primitives: FilesKdf for PBKDF2 and Aead for GCM.
Aead returns nonce || ciphertext || tag and §16 keeps the nonce in its own
field, so the prefix comes off here; what remains is what WebCrypto calls ct.

The AAD is built by interpolation, not by a JSON writer, because it has to
match the other engines down to key order. A JSONObject would be the obvious
choice and would silently disagree, and the AAD is what stops a file being
edited into deriving a cheaper key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 18:00:24 -04:00

161 lines
6.0 KiB
Kotlin

package io.flamenet.e2e
import java.io.File as JFile
import java.util.Base64
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
import org.json.JSONObject
/**
* The Kotlin backup implementation against the published §16 vectors.
*
* Pinned bytes, not agreement with the JavaScript or Swift engines. Two
* implementations checked against each other agree on being wrong as readily
* as on being right.
*/
class BackupVectorTest {
private val vectors: JSONObject by lazy {
val candidates =
listOfNotNull(
System.getenv("FLAMENET_BACKUP_VECTORS"),
"../../engine/test/vectors/backup-v1.json",
"../../../flamenet-e2e/test/vectors/backup-v1.json",
"../../flamenet-e2e/test/vectors/backup-v1.json",
).map { JFile(it) }
val file = candidates.firstOrNull { it.exists() }
// Fails rather than skips: a vector suite that quietly stops checking
// is worth less than none, because it still reports green.
assertTrue(
file != null,
"backup vectors not found. Looked in: " + candidates.joinToString(", ") { it.absolutePath },
)
JSONObject(file!!.readText())
}
private fun cases() = vectors.getJSONArray("cases")
private fun b64(s: String): ByteArray = Base64.getDecoder().decode(s)
private fun fileFrom(o: JSONObject): Backup.File {
val f = o.getJSONObject("file")
val kdf = f.getJSONObject("kdf")
return Backup.File(
v = f.getInt("v"),
alg = kdf.getString("alg"),
salt = kdf.getString("salt"),
iters = kdf.getInt("iters"),
nonce = f.getString("nonce"),
ct = f.getString("ct"),
)
}
/** Guards the rest: an empty file would pass every test below. */
@Test
fun `the vectors are loaded`() {
assertEquals(1, vectors.getInt("version"))
assertTrue(cases().length() > 0, "no cases: these tests would be checking nothing")
assertEquals(Backup.MIN_ITERATIONS, vectors.getInt("minIterations"))
assertEquals(Backup.MIN_PASSPHRASE, vectors.getInt("minPassphraseLength"))
}
/**
* The KDF alone, pinned apart from the sealed file so a failure here says
* the stretch is wrong rather than that something is wrong.
*/
@Test
fun `the derived key matches the vector`() {
for (i in 0 until cases().length()) {
val c = cases().getJSONObject(i)
val key = Backup.key(c.getString("passphrase"), b64(c.getString("salt")), c.getInt("iters"))
assertEquals(
c.getString("derivedKey"),
Base64.getEncoder().encodeToString(key),
"PBKDF2 disagrees on '${c.getString("name")}'",
)
}
}
@Test
fun `the header bytes are canonical`() {
for (i in 0 until cases().length()) {
val c = cases().getJSONObject(i)
val built = Backup.headerBytes(1, c.getString("salt"), c.getInt("iters"))
assertEquals(c.getString("aad"), String(built, Charsets.UTF_8), "AAD differs on '${c.getString("name")}'")
}
}
@Test
fun `opening a vector yields the pinned plaintext`() {
for (i in 0 until cases().length()) {
val c = cases().getJSONObject(i)
val out = Backup.open(fileFrom(c), c.getString("passphrase"))
assertEquals(
c.getString("plaintext"),
String(out, Charsets.UTF_8),
"decrypt disagrees on '${c.getString("name")}'",
)
}
}
/** Sealing with the vector's own salt and nonce must reproduce its bytes. */
@Test
fun `sealing reproduces the vector ciphertext`() {
for (i in 0 until cases().length()) {
val c = cases().getJSONObject(i)
val sealed =
Backup.create(
payload = c.getString("plaintext").toByteArray(Charsets.UTF_8),
passphrase = c.getString("passphrase"),
iters = c.getInt("iters"),
salt = b64(c.getString("salt")),
nonce = b64(c.getString("nonce")),
)
assertEquals(fileFrom(c).ct, sealed.ct, "ciphertext differs on '${c.getString("name")}'")
}
}
@Test
fun `a wrong passphrase is refused`() {
val c = cases().getJSONObject(0)
assertFailsWith<Exception> { Backup.open(fileFrom(c), c.getString("passphrase") + "x") }
}
/** The whole point of the AAD: a file cannot be edited into a cheaper key. */
@Test
fun `a weakened iteration count is refused`() {
val c = cases().getJSONObject(0)
assertFailsWith<Backup.KdfTooWeak> { Backup.open(fileFrom(c).copy(iters = 50), c.getString("passphrase")) }
}
/** Above the floor, so it passes the guard and has to fail on the tag. */
@Test
fun `an iteration count edited above the floor fails the tag`() {
val c = cases().getJSONObject(0)
assertFailsWith<Exception> {
Backup.open(fileFrom(c).copy(iters = Backup.MIN_ITERATIONS), c.getString("passphrase"))
}
}
@Test
fun `an edited salt fails the tag`() {
val c = cases().getJSONObject(0)
val zeroed = Base64.getEncoder().encodeToString(ByteArray(16))
assertFailsWith<Exception> { Backup.open(fileFrom(c).copy(salt = zeroed), c.getString("passphrase")) }
}
@Test
fun `a short passphrase cannot create a backup`() {
assertFailsWith<Backup.WeakPassphrase> { Backup.create("{}".toByteArray(), "short") }
}
@Test
fun `a random round trip`() {
val payload = """{"v":1,"history":[{"with":7,"body":"hello"}]}""".toByteArray(Charsets.UTF_8)
val file = Backup.create(payload, "correct horse battery staple", iters = Backup.MIN_ITERATIONS)
assertContentEquals(payload, Backup.open(file, "correct horse battery staple"))
}
}