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

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