From 5d6350d157065518aa68f5a80d91d118f4a44a45 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Wed, 9 Sep 2026 18:00:24 -0400 Subject: [PATCH] Encrypted backups in Kotlin, against the published vectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/main/kotlin/io/flamenet/e2e/Backup.kt | 127 ++++++++++++++ .../io/flamenet/e2e/BackupVectorTest.kt | 160 ++++++++++++++++++ 2 files changed, 287 insertions(+) create mode 100644 engine/src/main/kotlin/io/flamenet/e2e/Backup.kt create mode 100644 engine/src/test/kotlin/io/flamenet/e2e/BackupVectorTest.kt diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Backup.kt b/engine/src/main/kotlin/io/flamenet/e2e/Backup.kt new file mode 100644 index 0000000..f730f4f --- /dev/null +++ b/engine/src/main/kotlin/io/flamenet/e2e/Backup.kt @@ -0,0 +1,127 @@ +package io.flamenet.e2e + +import java.security.SecureRandom +import java.util.Base64 + +/** + * Encrypted backups, spec §16. + * + * A backup carries conversation history and the trust already built, and + * deliberately carries **no ratchet state and no device identity**. Restoring a + * serialised vault onto a second device while the first still runs makes both + * derive the same message keys for the same message numbers, and two AES-GCM + * encryptions under one key and nonce leak the XOR of the plaintexts and + * destroy authentication. A restored device registers fresh (§16.1). + * + * The payload crosses this API as bytes rather than as a model. Whoever writes + * the JSON owns its shape; a decrypt that round-tripped through a Kotlin type + * would reorder keys and stop matching what another engine sealed. + */ +object Backup { + /** OWASP guidance for PBKDF2-HMAC-SHA256. */ + const val ITERATIONS = 600_000 + + /** + * Below this a file is either corrupt or an attempt to make an offline + * guess cheap, so opening refuses it (§16.2). + */ + const val MIN_ITERATIONS = 100_000 + const val MIN_PASSPHRASE = 8 + + private const val ALG = "PBKDF2-HMAC-SHA256" + + class WeakPassphrase : IllegalArgumentException("passphrase must be at least $MIN_PASSPHRASE characters") + + class UnsupportedBackup(what: String) : IllegalArgumentException(what) + + class KdfTooWeak(iters: Int) : IllegalArgumentException("$iters iterations is below the $MIN_ITERATIONS floor") + + /** The file, exactly as §16.2 writes it. */ + data class File( + val v: Int, + val alg: String, + val salt: String, + val iters: Int, + val nonce: String, + val ct: String, + ) + + /** + * The AAD: the canonical header, byte for byte. + * + * Built by interpolation rather than by a JSON writer, because it has to + * match what every other engine produces down to key order and spacing. A + * JSONObject would be the obvious choice and would silently disagree. + */ + internal fun headerBytes( + v: Int, + salt: String, + iters: Int, + ): ByteArray = """{"v":$v,"kdf":{"alg":"$ALG","salt":"$salt","iters":$iters}}""".toByteArray(Charsets.UTF_8) + + internal fun key( + passphrase: String, + salt: ByteArray, + iters: Int, + ): ByteArray = + FilesKdf.pbkdf2HmacSha256( + // UTF-8 bytes: a passphrase may contain characters outside ASCII, + // and the vectors include one that does. + password = passphrase.toByteArray(Charsets.UTF_8), + salt = salt, + iterations = iters, + length = 32, + ) + + /** + * Seal [payload] under [passphrase]. + * + * @param salt fixed only by the vector tests; leave it null. + */ + fun create( + payload: ByteArray, + passphrase: String, + iters: Int = ITERATIONS, + salt: ByteArray? = null, + nonce: ByteArray? = null, + ): File { + if (passphrase.length < MIN_PASSPHRASE) throw WeakPassphrase() + val rng = SecureRandom() + val s = salt ?: ByteArray(16).also { rng.nextBytes(it) } + val n = nonce ?: ByteArray(12).also { rng.nextBytes(it) } + val saltB64 = Base64.getEncoder().encodeToString(s) + // Aead.seal returns nonce || ciphertext || tag; §16 keeps the nonce in + // its own field, so the prefix comes off here. WebCrypto's `ct` is the + // ciphertext with the tag appended, which is what remains. + val box = Aead.seal(key(passphrase, s, iters), n, payload, headerBytes(1, saltB64, iters)) + return File( + v = 1, + alg = ALG, + salt = saltB64, + iters = iters, + nonce = Base64.getEncoder().encodeToString(n), + ct = Base64.getEncoder().encodeToString(box.copyOfRange(n.size, box.size)), + ) + } + + /** Open a backup, returning the payload bytes. */ + fun open( + file: File, + passphrase: String, + ): ByteArray { + if (file.v != 1 || file.alg != ALG) throw UnsupportedBackup("not a v1 $ALG backup") + if (file.iters < MIN_ITERATIONS) throw KdfTooWeak(file.iters) + val decoder = Base64.getDecoder() + val salt = decoder.decode(file.salt) + val nonce = decoder.decode(file.nonce) + val ct = decoder.decode(file.ct) + if (ct.size <= 16) throw UnsupportedBackup("ciphertext is shorter than a GCM tag") + // The AAD is rebuilt from the file's OWN header, so an edited salt or + // iteration count fails the tag rather than deriving a weaker key. + return Aead.open( + key(passphrase, salt, file.iters), + nonce + ct, + headerBytes(1, file.salt, file.iters), + ) + } +} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/BackupVectorTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/BackupVectorTest.kt new file mode 100644 index 0000000..e351b54 --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/BackupVectorTest.kt @@ -0,0 +1,160 @@ +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")) + } +}