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:
parent
8f5b18cf68
commit
c66bee5cf0
25 changed files with 245 additions and 2 deletions
|
|
@ -62,6 +62,7 @@ dependencies {
|
|||
implementation(libs.work.runtime)
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.kage)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
12
app/src/test/resources/cctv/armor_scrypt
Normal file
12
app/src/test/resources/cctv/armor_scrypt
Normal 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-----
|
||||
BIN
app/src/test/resources/cctv/scrypt
Normal file
BIN
app/src/test/resources/cctv/scrypt
Normal file
Binary file not shown.
BIN
app/src/test/resources/cctv/scrypt_bad_tag
Normal file
BIN
app/src/test/resources/cctv/scrypt_bad_tag
Normal file
Binary file not shown.
13
app/src/test/resources/cctv/scrypt_double
Normal file
13
app/src/test/resources/cctv/scrypt_double
Normal 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
|
||||
BIN
app/src/test/resources/cctv/scrypt_extra_argument
Normal file
BIN
app/src/test/resources/cctv/scrypt_extra_argument
Normal file
Binary file not shown.
BIN
app/src/test/resources/cctv/scrypt_long_file_key
Normal file
BIN
app/src/test/resources/cctv/scrypt_long_file_key
Normal file
Binary file not shown.
BIN
app/src/test/resources/cctv/scrypt_no_match
Normal file
BIN
app/src/test/resources/cctv/scrypt_no_match
Normal file
Binary file not shown.
BIN
app/src/test/resources/cctv/scrypt_not_canonical_body
Normal file
BIN
app/src/test/resources/cctv/scrypt_not_canonical_body
Normal file
Binary file not shown.
BIN
app/src/test/resources/cctv/scrypt_not_canonical_salt
Normal file
BIN
app/src/test/resources/cctv/scrypt_not_canonical_salt
Normal file
Binary file not shown.
BIN
app/src/test/resources/cctv/scrypt_salt_long
Normal file
BIN
app/src/test/resources/cctv/scrypt_salt_long
Normal file
Binary file not shown.
9
app/src/test/resources/cctv/scrypt_salt_missing
Normal file
9
app/src/test/resources/cctv/scrypt_salt_missing
Normal 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ŠóÎ|
|
||||
BIN
app/src/test/resources/cctv/scrypt_salt_short
Normal file
BIN
app/src/test/resources/cctv/scrypt_salt_short
Normal file
Binary file not shown.
BIN
app/src/test/resources/cctv/scrypt_uppercase
Normal file
BIN
app/src/test/resources/cctv/scrypt_uppercase
Normal file
Binary file not shown.
10
app/src/test/resources/cctv/scrypt_work_factor_23
Normal file
10
app/src/test/resources/cctv/scrypt_work_factor_23
Normal 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ŠóÎ|
|
||||
BIN
app/src/test/resources/cctv/scrypt_work_factor_missing
Normal file
BIN
app/src/test/resources/cctv/scrypt_work_factor_missing
Normal file
Binary file not shown.
BIN
app/src/test/resources/cctv/scrypt_work_factor_negative
Normal file
BIN
app/src/test/resources/cctv/scrypt_work_factor_negative
Normal file
Binary file not shown.
BIN
app/src/test/resources/cctv/scrypt_work_factor_overflow
Normal file
BIN
app/src/test/resources/cctv/scrypt_work_factor_overflow
Normal file
Binary file not shown.
BIN
app/src/test/resources/cctv/scrypt_work_factor_wrong
Normal file
BIN
app/src/test/resources/cctv/scrypt_work_factor_wrong
Normal file
Binary file not shown.
BIN
app/src/test/resources/cctv/scrypt_work_factor_zero
Normal file
BIN
app/src/test/resources/cctv/scrypt_work_factor_zero
Normal file
Binary file not shown.
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue