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>
161 lines
6.0 KiB
Kotlin
161 lines
6.0 KiB
Kotlin
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"))
|
|
}
|
|
}
|