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,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),
)
}
}
@@ -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"))
}
}