diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5582296..bfa15ec 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -62,6 +62,7 @@ dependencies { implementation(libs.work.runtime) implementation(libs.okhttp) implementation(libs.kotlinx.serialization.json) + implementation(libs.kage) testImplementation(libs.junit) } diff --git a/app/src/main/java/no/naiv/meddetsamme/backup/AgeBackupCrypto.kt b/app/src/main/java/no/naiv/meddetsamme/backup/AgeBackupCrypto.kt new file mode 100644 index 0000000..ffff681 --- /dev/null +++ b/app/src/main/java/no/naiv/meddetsamme/backup/AgeBackupCrypto.kt @@ -0,0 +1,55 @@ +package no.naiv.meddetsamme.backup + +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import kage.Age +import kage.crypto.scrypt.ScryptIdentity +import kage.crypto.scrypt.ScryptRecipient + +/** + * age (age-encryption.org/v1) in scrypt/passphrase mode via kage — the target + * format (brief decision #7): backups are decryptable on any machine with + * plain `age -d`, so this app can die without taking the data with it. + * + * kage is verified in unit tests against the age Community Cryptography Test + * Vectors (C2SP/CCTV) before being trusted here. + */ +object AgeBackupCrypto : BackupCrypto { + + private val BINARY_MAGIC = "age-encryption.org/v1".toByteArray(Charsets.US_ASCII) + private val ARMOR_MAGIC = "-----BEGIN AGE ENCRYPTED FILE-----".toByteArray(Charsets.US_ASCII) + + /** age default work factor (2^18) — same cost `age -p` uses. */ + private const val WORK_FACTOR = 18 + + override val formatId = "age" + + override fun matches(data: ByteArray): Boolean = + data.startsWith(BINARY_MAGIC) || data.startsWith(ARMOR_MAGIC) + + override fun encrypt(plaintext: ByteArray, passphrase: CharArray): ByteArray { + val out = ByteArrayOutputStream() + Age.encryptStream( + listOf(ScryptRecipient(passphrase.toUtf8Bytes(), WORK_FACTOR)), + ByteArrayInputStream(plaintext), + out, + ) + return out.toByteArray() + } + + override fun decrypt(ciphertext: ByteArray, passphrase: CharArray): ByteArray { + val out = ByteArrayOutputStream() + // kage throws kage.errors.* on wrong passphrase or malformed input. + Age.decryptStream( + listOf(ScryptIdentity(passphrase.toUtf8Bytes())), + ByteArrayInputStream(ciphertext), + out, + ) + return out.toByteArray() + } + + private fun ByteArray.startsWith(prefix: ByteArray): Boolean = + size >= prefix.size && copyOfRange(0, prefix.size).contentEquals(prefix) + + private fun CharArray.toUtf8Bytes(): ByteArray = String(this).toByteArray(Charsets.UTF_8) +} diff --git a/app/src/main/java/no/naiv/meddetsamme/backup/BackupCrypto.kt b/app/src/main/java/no/naiv/meddetsamme/backup/BackupCrypto.kt index 01c50bb..0686950 100644 --- a/app/src/main/java/no/naiv/meddetsamme/backup/BackupCrypto.kt +++ b/app/src/main/java/no/naiv/meddetsamme/backup/BackupCrypto.kt @@ -27,8 +27,8 @@ interface BackupCrypto { fun matches(data: ByteArray): Boolean companion object { - /** All known formats, preferred encryption format first. */ - fun all(): List = listOf(JceBackupCrypto) + /** All known formats, preferred encryption format first: age is the target. */ + fun all(): List = listOf(AgeBackupCrypto, JceBackupCrypto) fun detect(data: ByteArray): BackupCrypto? = all().firstOrNull { it.matches(data) } } diff --git a/app/src/test/java/no/naiv/meddetsamme/backup/AgeBackupCryptoTest.kt b/app/src/test/java/no/naiv/meddetsamme/backup/AgeBackupCryptoTest.kt new file mode 100644 index 0000000..3561836 --- /dev/null +++ b/app/src/test/java/no/naiv/meddetsamme/backup/AgeBackupCryptoTest.kt @@ -0,0 +1,46 @@ +package no.naiv.meddetsamme.backup + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +class AgeBackupCryptoTest { + + private val plaintext = """{"version":1,"medications":[]}""".toByteArray() + private val passphrase = "korrekt hest batteri stift".toCharArray() + + @Test + fun `round trip with correct passphrase`() { + val ct = AgeBackupCrypto.encrypt(plaintext, passphrase) + assertArrayEquals(plaintext, AgeBackupCrypto.decrypt(ct, passphrase)) + } + + @Test + fun `output is a binary age v1 file`() { + val ct = AgeBackupCrypto.encrypt(plaintext, passphrase) + assertTrue(ct.decodeToString(0, 21) == "age-encryption.org/v1") + } + + @Test + fun `wrong passphrase throws`() { + val ct = AgeBackupCrypto.encrypt(plaintext, passphrase) + assertThrows(Exception::class.java) { + AgeBackupCrypto.decrypt(ct, "feil passord".toCharArray()) + } + } + + @Test + fun `format detection prefers age and routes correctly`() { + val ageCt = AgeBackupCrypto.encrypt(plaintext, passphrase) + val jceCt = JceBackupCrypto.encrypt(plaintext, passphrase) + assertEquals(AgeBackupCrypto, BackupCrypto.detect(ageCt)) + assertEquals(JceBackupCrypto, BackupCrypto.detect(jceCt)) + assertFalse(JceBackupCrypto.matches(ageCt)) + assertFalse(AgeBackupCrypto.matches(jceCt)) + // age first = the format new backups are written in (the brief's target). + assertEquals(AgeBackupCrypto, BackupCrypto.all().first()) + } +} diff --git a/app/src/test/java/no/naiv/meddetsamme/backup/CctvScryptVectorTest.kt b/app/src/test/java/no/naiv/meddetsamme/backup/CctvScryptVectorTest.kt new file mode 100644 index 0000000..37592c5 --- /dev/null +++ b/app/src/test/java/no/naiv/meddetsamme/backup/CctvScryptVectorTest.kt @@ -0,0 +1,95 @@ +package no.naiv.meddetsamme.backup + +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.security.MessageDigest +import kage.Age +import kage.crypto.scrypt.ScryptIdentity +import org.junit.Assert.assertEquals +import org.junit.Assert.fail +import org.junit.Test + +/** + * Runs kage against the age Community Cryptography Test Vectors + * (github.com/C2SP/CCTV, `age/testdata`, scrypt subset) — the brief's gate + * for trusting the age implementation with medication data. + * + * Vector format: "key: value" header lines, blank line, then the raw age file. + * expect: success → decrypt must succeed AND the payload SHA-256 must match; + * anything else (header failure / no match / …) → decrypt must throw. + */ +class CctvScryptVectorTest { + + private val successVectors = listOf("scrypt", "armor_scrypt") + private val failureVectors = listOf( + "scrypt_bad_tag", "scrypt_double", "scrypt_extra_argument", + "scrypt_long_file_key", "scrypt_no_match", "scrypt_not_canonical_body", + "scrypt_not_canonical_salt", "scrypt_salt_long", "scrypt_salt_missing", + "scrypt_salt_short", "scrypt_uppercase", "scrypt_work_factor_23", + "scrypt_work_factor_missing", "scrypt_work_factor_negative", + "scrypt_work_factor_overflow", "scrypt_work_factor_wrong", + "scrypt_work_factor_zero", + ) + + @Test + fun `success vectors decrypt and payload hashes match`() { + for (name in successVectors) { + val v = load(name) + assertEquals("$name: expect header", "success", v.headers["expect"]) + val out = ByteArrayOutputStream() + Age.decryptStream( + listOf(ScryptIdentity(v.headers.getValue("passphrase").toByteArray())), + ByteArrayInputStream(v.body), + out, + ) + val hash = MessageDigest.getInstance("SHA-256").digest(out.toByteArray()) + .joinToString("") { "%02x".format(it) } + assertEquals("$name: payload hash", v.headers["payload"], hash) + } + } + + @Test + fun `failure vectors are all rejected`() { + for (name in failureVectors) { + val v = load(name) + val passphrase = v.headers["passphrase"] ?: "password" + try { + val out = ByteArrayOutputStream() + Age.decryptStream( + listOf(ScryptIdentity(passphrase.toByteArray())), + ByteArrayInputStream(v.body), + out, + ) + fail("$name (expect: ${v.headers["expect"]}) decrypted but must be rejected") + } catch (_: Exception) { + // expected — any rejection is a pass for non-success vectors + } + } + } + + private class Vector(val headers: Map, val body: ByteArray) + + private fun load(name: String): Vector { + val bytes = checkNotNull(javaClass.classLoader.getResourceAsStream("cctv/$name")) { + "missing test resource cctv/$name" + }.readBytes() + + // Header/body split at the first blank line; body is binary, so no + // string round-trip for it. + var split = -1 + for (i in 0 until bytes.size - 1) { + if (bytes[i] == '\n'.code.toByte() && bytes[i + 1] == '\n'.code.toByte()) { + split = i; break + } + } + check(split > 0) { "$name: no blank line found" } + + val headers = String(bytes, 0, split, Charsets.UTF_8).lines() + .filter { it.isNotBlank() } + .associate { line -> + val (k, v) = line.split(": ", limit = 2) + k to v + } + return Vector(headers, bytes.copyOfRange(split + 2, bytes.size)) + } +} diff --git a/app/src/test/resources/cctv/armor_scrypt b/app/src/test/resources/cctv/armor_scrypt new file mode 100644 index 0000000..51e4092 --- /dev/null +++ b/app/src/test/resources/cctv/armor_scrypt @@ -0,0 +1,12 @@ +expect: success +payload: 013f54400c82da08037759ada907a8b864e97de81c088a182062c4b5622fd2ab +file key: 59454c4c4f57205355424d4152494e45 +passphrase: password +armored: yes + +-----BEGIN AGE ENCRYPTED FILE----- +YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHNjcnlwdCByRjAvTndibFVISFRwZ1Fn +UnBlNUNRIDEwCmdVakV5bUZLTVZYUUVLZE1NSEwyNG9ZZXhqRTNUSUMwTzB6R1Nx +SjJhVVkKLS0tIElPWGlRWVN0a29UMW12WlcydEZPcVpkaFJWdmo1OGVnQUJ4L3NX +ZlpRYmMKGzXG5ofdANo6w3msn3QsIf0YWhuePe1znRSsappQEk24Ztg= +-----END AGE ENCRYPTED FILE----- diff --git a/app/src/test/resources/cctv/scrypt b/app/src/test/resources/cctv/scrypt new file mode 100644 index 0000000..57dd08f Binary files /dev/null and b/app/src/test/resources/cctv/scrypt differ diff --git a/app/src/test/resources/cctv/scrypt_bad_tag b/app/src/test/resources/cctv/scrypt_bad_tag new file mode 100644 index 0000000..b47512f Binary files /dev/null and b/app/src/test/resources/cctv/scrypt_bad_tag differ diff --git a/app/src/test/resources/cctv/scrypt_double b/app/src/test/resources/cctv/scrypt_double new file mode 100644 index 0000000..da069ed --- /dev/null +++ b/app/src/test/resources/cctv/scrypt_double @@ -0,0 +1,13 @@ +expect: header failure +file key: 59454c4c4f57205355424d4152494e45 +passphrase: password +passphrase: hunter2 +comment: scrypt stanzas must be alone in the header + +age-encryption.org/v1 +-> scrypt rF0/NwblUHHTpgQgRpe5CQ 10 +gUjEymFKMVXQEKdMMHL24oYexjE3TIC0O0zGSqJ2aUY +-> scrypt GzXG5ofdANo6w3msn3QsIQ 10 +OveITuwxakv7k2oLnioNYF4Bhgz9KZ36pb098wDoAv8 +--- a5d+4Ay1evJhoDskIzuTZV9bBgKk4573VZNfuoWJDPE +îÏbÇΑ´3'NhÔòùL·L[þ÷¾ªRÈð¼™,ƒ1ûf \ No newline at end of file diff --git a/app/src/test/resources/cctv/scrypt_extra_argument b/app/src/test/resources/cctv/scrypt_extra_argument new file mode 100644 index 0000000..3fa92ee Binary files /dev/null and b/app/src/test/resources/cctv/scrypt_extra_argument differ diff --git a/app/src/test/resources/cctv/scrypt_long_file_key b/app/src/test/resources/cctv/scrypt_long_file_key new file mode 100644 index 0000000..669c222 Binary files /dev/null and b/app/src/test/resources/cctv/scrypt_long_file_key differ diff --git a/app/src/test/resources/cctv/scrypt_no_match b/app/src/test/resources/cctv/scrypt_no_match new file mode 100644 index 0000000..3d090e1 Binary files /dev/null and b/app/src/test/resources/cctv/scrypt_no_match differ diff --git a/app/src/test/resources/cctv/scrypt_not_canonical_body b/app/src/test/resources/cctv/scrypt_not_canonical_body new file mode 100644 index 0000000..65298af Binary files /dev/null and b/app/src/test/resources/cctv/scrypt_not_canonical_body differ diff --git a/app/src/test/resources/cctv/scrypt_not_canonical_salt b/app/src/test/resources/cctv/scrypt_not_canonical_salt new file mode 100644 index 0000000..50741d5 Binary files /dev/null and b/app/src/test/resources/cctv/scrypt_not_canonical_salt differ diff --git a/app/src/test/resources/cctv/scrypt_salt_long b/app/src/test/resources/cctv/scrypt_salt_long new file mode 100644 index 0000000..66e37f9 Binary files /dev/null and b/app/src/test/resources/cctv/scrypt_salt_long differ diff --git a/app/src/test/resources/cctv/scrypt_salt_missing b/app/src/test/resources/cctv/scrypt_salt_missing new file mode 100644 index 0000000..880b0e1 --- /dev/null +++ b/app/src/test/resources/cctv/scrypt_salt_missing @@ -0,0 +1,9 @@ +expect: header failure +file key: 59454c4c4f57205355424d4152494e45 +passphrase: password + +age-encryption.org/v1 +-> scrypt 10 +W0mMthyhNJOV3debCwkQcUlNx/i6Ss/A07aQCrG5Gcw +--- 1QsPcEbBSylfP4apakJqtDBJMrpd81rPuSLTCvdZx6E +¬]?7åPqÓ¦ F—¹ •Â÷õÛ®è zŒ(rŠóÎ| \ No newline at end of file diff --git a/app/src/test/resources/cctv/scrypt_salt_short b/app/src/test/resources/cctv/scrypt_salt_short new file mode 100644 index 0000000..a95f460 Binary files /dev/null and b/app/src/test/resources/cctv/scrypt_salt_short differ diff --git a/app/src/test/resources/cctv/scrypt_uppercase b/app/src/test/resources/cctv/scrypt_uppercase new file mode 100644 index 0000000..88bd1e4 Binary files /dev/null and b/app/src/test/resources/cctv/scrypt_uppercase differ diff --git a/app/src/test/resources/cctv/scrypt_work_factor_23 b/app/src/test/resources/cctv/scrypt_work_factor_23 new file mode 100644 index 0000000..4d286be --- /dev/null +++ b/app/src/test/resources/cctv/scrypt_work_factor_23 @@ -0,0 +1,10 @@ +expect: header failure +file key: 59454c4c4f57205355424d4152494e45 +passphrase: password +comment: work factor is very high, would take a long time to compute + +age-encryption.org/v1 +-> scrypt rF0/NwblUHHTpgQgRpe5CQ 23 +qW9eVsT0NVb/Vswtw8kPIxUnaYmm9Px1dYmq2+4+qZA +--- 38TpQMxQRRNMfmYYpBX6DDrPx4/QY5UmJnhPyVoX/cw +¬]?7åPqÓ¦ F—¹ •Â÷õÛ®è zŒ(rŠóÎ| \ No newline at end of file diff --git a/app/src/test/resources/cctv/scrypt_work_factor_missing b/app/src/test/resources/cctv/scrypt_work_factor_missing new file mode 100644 index 0000000..62c14e2 Binary files /dev/null and b/app/src/test/resources/cctv/scrypt_work_factor_missing differ diff --git a/app/src/test/resources/cctv/scrypt_work_factor_negative b/app/src/test/resources/cctv/scrypt_work_factor_negative new file mode 100644 index 0000000..eaf5400 Binary files /dev/null and b/app/src/test/resources/cctv/scrypt_work_factor_negative differ diff --git a/app/src/test/resources/cctv/scrypt_work_factor_overflow b/app/src/test/resources/cctv/scrypt_work_factor_overflow new file mode 100644 index 0000000..03aeb03 Binary files /dev/null and b/app/src/test/resources/cctv/scrypt_work_factor_overflow differ diff --git a/app/src/test/resources/cctv/scrypt_work_factor_wrong b/app/src/test/resources/cctv/scrypt_work_factor_wrong new file mode 100644 index 0000000..209e1f6 Binary files /dev/null and b/app/src/test/resources/cctv/scrypt_work_factor_wrong differ diff --git a/app/src/test/resources/cctv/scrypt_work_factor_zero b/app/src/test/resources/cctv/scrypt_work_factor_zero new file mode 100644 index 0000000..9d8c278 Binary files /dev/null and b/app/src/test/resources/cctv/scrypt_work_factor_zero differ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ed94c2c..a97398e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,7 @@ work = "2.11.2" okhttp = "5.4.0" coroutines = "1.11.0" serializationJson = "1.11.0" +kage = "0.4.0" junit = "4.13.2" [libraries] @@ -33,6 +34,7 @@ work-runtime = { group = "androidx.work", name = "work-runtime", version.ref = " okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serializationJson" } +kage = { group = "com.github.android-password-store", name = "kage", version.ref = "kage" } junit = { group = "junit", name = "junit", version.ref = "junit" } [plugins]