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>
This commit is contained in:
Paul Hitt
2026-09-09 18:00:24 -04:00
co-authored by Claude Opus 5
parent f3a0495191
commit 5d6350d157
2 changed files with 287 additions and 0 deletions
@@ -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<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"))
}
}