age crypto via kage, verified against the CCTV scrypt vectors

Milestone 6. kage 0.4.0 over Jagged: Android-first (explicitly supports
API 26, matching minSdk), Kotlin, ships its own primitives through
BouncyCastle instead of relying on JCA ChaCha20-Poly1305 which Android
only provides from API 28. AgeBackupCrypto sits behind the same
BackupCrypto interface as the JCE baseline; age is now first in all()
so new encrypted backups are written as binary age v1 files,
decryptable anywhere with 'age -d' (decision #7). Old MDS1/JCE backups
keep decrypting via format auto-detection. The C2SP/CCTV scrypt vector
subset (19 files) is vendored into test resources: both success
vectors decrypt with matching payload SHA-256, all 17 failure vectors
are rejected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-06-10 14:00:34 +02:00
commit c66bee5cf0
25 changed files with 245 additions and 2 deletions

View file

@ -62,6 +62,7 @@ dependencies {
implementation(libs.work.runtime)
implementation(libs.okhttp)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kage)
testImplementation(libs.junit)
}

View file

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

View file

@ -27,8 +27,8 @@ interface BackupCrypto {
fun matches(data: ByteArray): Boolean
companion object {
/** All known formats, preferred encryption format first. */
fun all(): List<BackupCrypto> = listOf(JceBackupCrypto)
/** All known formats, preferred encryption format first: age is the target. */
fun all(): List<BackupCrypto> = listOf(AgeBackupCrypto, JceBackupCrypto)
fun detect(data: ByteArray): BackupCrypto? = all().firstOrNull { it.matches(data) }
}

View file

@ -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())
}
}

View file

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

View file

@ -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-----

Binary file not shown.

Binary file not shown.

View file

@ -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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -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ŠóÎ|

Binary file not shown.

Binary file not shown.

View file

@ -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ŠóÎ|

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -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]