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 { 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.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 { 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 { Backup.open(fileFrom(c).copy(salt = zeroed), c.getString("passphrase")) } } @Test fun `a short passphrase cannot create a backup`() { assertFailsWith { 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")) } }