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,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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user