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:
co-authored by
Claude Opus 5
parent
f3a0495191
commit
5d6350d157
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user