Backup: versioned JSON serializer, SigV4 + S3 client, JCE crypto, daily worker

Milestone 5. One BackupManager entry point so export, import and
auto-backup share the same serializer and crypto by construction.
Explicit DTOs decouple the backup contract from the Room schema.
JCE baseline (PBKDF2-HMAC-SHA256 600k → AES-256-GCM) behind the
BackupCrypto interface with format auto-detection on decrypt; age
lands behind the same interface in milestone 6. SigV4 signer verified
against AWS's published documentation example signature. Settings in
SharedPreferences encrypted under a non-exportable AndroidKeyStore key
— replaces the now fully deprecated androidx security-crypto with the
same construction (~70 lines, zero deps); threat-model comments state
plainly that the stored auto-backup passphrase only protects against
bucket compromise. WorkManager daily periodic job, inexact by design.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-06-10 13:54:13 +02:00
commit 8f5b18cf68
14 changed files with 892 additions and 0 deletions

View file

@ -3,6 +3,7 @@ plugins {
// AGP 9 has built-in Kotlin (org.jetbrains.kotlin.android is gone); only the
// compiler sub-plugins are applied separately, pinned to AGP's bundled KGP version.
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
}
@ -58,5 +59,9 @@ dependencies {
implementation(libs.room.runtime)
ksp(libs.room.compiler)
implementation(libs.work.runtime)
implementation(libs.okhttp)
implementation(libs.kotlinx.serialization.json)
testImplementation(libs.junit)
}

View file

@ -6,6 +6,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import no.naiv.meddetsamme.alarm.AlarmScheduler
import no.naiv.meddetsamme.backup.BackupWorker
import no.naiv.meddetsamme.notify.Notifications
/**
@ -23,5 +24,6 @@ class MedDetSammeApp : Application() {
// Defensive re-arm on every app start: free, idempotent (alarms replace,
// never stack), and catches anything boot/update broadcasts missed.
appScope.launch { AlarmScheduler(this@MedDetSammeApp).armAll() }
BackupWorker.schedule(this)
}
}

View file

@ -0,0 +1,103 @@
package no.naiv.meddetsamme.backup
import java.nio.ByteBuffer
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.PBEKeySpec
import javax.crypto.spec.SecretKeySpec
/**
* Passphrase-based encryption for backups, behind an interface so the JCE
* baseline can be swapped for age (brief decision #7) without touching
* callers. [detect] picks the right implementation on decrypt, so a mixed
* history of old and new backups always restores.
*/
interface BackupCrypto {
/** Stable id written into backup filenames ("jce" → .enc, "age" → .age). */
val formatId: String
fun encrypt(plaintext: ByteArray, passphrase: CharArray): ByteArray
/** @throws javax.crypto.AEADBadTagException wrong passphrase or tampered data. */
fun decrypt(ciphertext: ByteArray, passphrase: CharArray): ByteArray
/** Does [data] look like this implementation's output? */
fun matches(data: ByteArray): Boolean
companion object {
/** All known formats, preferred encryption format first. */
fun all(): List<BackupCrypto> = listOf(JceBackupCrypto)
fun detect(data: ByteArray): BackupCrypto? = all().firstOrNull { it.matches(data) }
}
}
/**
* Dependency-free baseline: PBKDF2-HMAC-SHA256 AES-256-GCM.
*
* Self-describing container so a future reader needs no out-of-band knowledge:
*
* magic "MDS1" | iterations (int32 BE) | salt (16) | IV (12) | GCM ciphertext+tag
*
* Honest limitation (also why age is the target): you can decrypt this with
* ~15 lines of Python, but not with an off-the-shelf CLI one-liner the way
* `age -d` works. It exists so encrypted backups work from day one.
*/
object JceBackupCrypto : BackupCrypto {
private val MAGIC = "MDS1".toByteArray(Charsets.US_ASCII)
/** OWASP 2023+ floor for PBKDF2-HMAC-SHA256. ~0.5 s on a phone is fine for backup. */
private const val ITERATIONS = 600_000
private const val SALT_BYTES = 16
private const val IV_BYTES = 12
private const val KEY_BITS = 256
private const val TAG_BITS = 128
override val formatId = "jce"
override fun matches(data: ByteArray): Boolean =
data.size > MAGIC.size && data.copyOfRange(0, MAGIC.size).contentEquals(MAGIC)
override fun encrypt(plaintext: ByteArray, passphrase: CharArray): ByteArray {
val random = SecureRandom()
val salt = ByteArray(SALT_BYTES).also(random::nextBytes)
val iv = ByteArray(IV_BYTES).also(random::nextBytes)
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, deriveKey(passphrase, salt, ITERATIONS), GCMParameterSpec(TAG_BITS, iv))
val ct = cipher.doFinal(plaintext)
return ByteBuffer.allocate(MAGIC.size + 4 + SALT_BYTES + IV_BYTES + ct.size)
.put(MAGIC).putInt(ITERATIONS).put(salt).put(iv).put(ct)
.array()
}
override fun decrypt(ciphertext: ByteArray, passphrase: CharArray): ByteArray {
require(matches(ciphertext)) { "Ikke et MDS1-kryptert format" }
// Not chained: pre-API-34 Buffer.position() returns Buffer, losing the type.
val buf = ByteBuffer.wrap(ciphertext)
buf.position(MAGIC.size)
val iterations = buf.int // read from header: encrypt-side changes stay decryptable
require(iterations in 1..10_000_000) { "Urimelig iterasjonstall: $iterations" }
val salt = ByteArray(SALT_BYTES).also(buf::get)
val iv = ByteArray(IV_BYTES).also(buf::get)
val ct = ByteArray(buf.remaining()).also(buf::get)
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.DECRYPT_MODE, deriveKey(passphrase, salt, iterations), GCMParameterSpec(TAG_BITS, iv))
return cipher.doFinal(ct) // AEADBadTagException = wrong passphrase or tampering
}
private fun deriveKey(passphrase: CharArray, salt: ByteArray, iterations: Int): SecretKeySpec {
val spec = PBEKeySpec(passphrase, salt, iterations, KEY_BITS)
val key = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec)
try {
return SecretKeySpec(key.encoded, "AES")
} finally {
spec.clearPassword()
}
}
}

View file

@ -0,0 +1,72 @@
package no.naiv.meddetsamme.backup
import android.content.Context
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import no.naiv.meddetsamme.data.MedDatabase
import no.naiv.meddetsamme.settings.SettingsStore
/**
* The one entry point for export, import and auto-backup all three share
* [BackupSerializer] and [BackupCrypto] by construction (brief decision #6).
*/
class BackupManager(private val context: Context) {
private val db get() = MedDatabase.get(context)
/** Plaintext JSON export (caller decides whether/how to encrypt). */
suspend fun exportJson(): String {
val dao = db.backupDao()
return BackupSerializer.export(dao.allMedications(), dao.allDoseTimes(), dao.allDoseLogs())
}
/** Export encrypted with a *typed* passphrase (never stored — manual path). */
suspend fun exportEncrypted(passphrase: CharArray): ByteArray =
BackupCrypto.all().first().encrypt(exportJson().toByteArray(Charsets.UTF_8), passphrase)
/**
* Import from file content: encrypted (format auto-detected) or plain JSON.
* Replace-all; the caller MUST re-arm alarms afterwards.
*/
suspend fun import(data: ByteArray, passphrase: CharArray?): Int {
val crypto = BackupCrypto.detect(data)
val json = when {
crypto != null -> {
requireNotNull(passphrase) { "Denne filen er kryptert — passordfrase kreves" }
crypto.decrypt(data, passphrase).toString(Charsets.UTF_8)
}
else -> data.toString(Charsets.UTF_8)
}
val file = BackupSerializer.parse(json)
BackupSerializer.restore(db, file)
return file.medications.size
}
/**
* Unattended backup S3. Encrypts with the STORED passphrase if one is
* configured (protects against bucket compromise only see SettingsStore),
* otherwise uploads plaintext JSON by explicit user choice.
* Distinct timestamped objects; retention is the bucket's lifecycle rule's
* problem, not ours.
*/
suspend fun runAutoBackup(): String {
val settings = SettingsStore(context)
val s3 = checkNotNull(settings.s3Client()) { "Backup er ikke konfigurert" }
val json = exportJson().toByteArray(Charsets.UTF_8)
val passphrase = settings.autoBackupPassphrase
val (body, suffix) = if (passphrase.isNullOrEmpty()) {
json to "json"
} else {
val crypto = BackupCrypto.all().first()
crypto.encrypt(json, passphrase.toCharArray()) to "json.${crypto.formatId}"
}
val stamp = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'")
.withZone(ZoneOffset.UTC).format(Instant.now())
val key = "meddetsamme/backup-$stamp.$suffix"
s3.put(key, body)
return key
}
}

View file

@ -0,0 +1,134 @@
package no.naiv.meddetsamme.backup
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import no.naiv.meddetsamme.data.DoseLog
import no.naiv.meddetsamme.data.DoseStatus
import no.naiv.meddetsamme.data.DoseTime
import no.naiv.meddetsamme.data.MedDatabase
import no.naiv.meddetsamme.data.MedForm
import no.naiv.meddetsamme.data.Medication
/**
* The ONE serializer for export, import and auto-backup (brief decision #6).
*
* Explicit DTOs, not the Room entities: the backup format is a contract with
* future versions of this app (and with `age -d` + jq on a laptop), so a Room
* schema change must be a *deliberate* format change bump [BackupFile.VERSION]
* and keep reading old versions never an accident.
*/
object BackupSerializer {
private val json = Json {
prettyPrint = true // human-readable on the laptop is part of the point
ignoreUnknownKeys = true // older app reading newer minor additions
}
fun export(meds: List<Medication>, doseTimes: List<DoseTime>, logs: List<DoseLog>): String =
json.encodeToString(
BackupFile(
version = BackupFile.VERSION,
exportedAtMillis = System.currentTimeMillis(),
medications = meds.map { it.toDto() },
doseTimes = doseTimes.map { it.toDto() },
doseLogs = logs.map { it.toDto() },
),
)
/** @throws IllegalArgumentException on unknown version or malformed JSON. */
fun parse(text: String): BackupFile {
val file = try {
json.decodeFromString<BackupFile>(text)
} catch (e: Exception) {
throw IllegalArgumentException("Ikke en gyldig sikkerhetskopi: ${e.message}", e)
}
require(file.version in 1..BackupFile.VERSION) { "Ukjent backupversjon ${file.version}" }
return file
}
/**
* Replace-all restore in one transaction: a half-imported medication list is
* worse than a failed import. Caller re-arms alarms afterwards.
*/
suspend fun restore(db: MedDatabase, file: BackupFile) {
db.restoreAll(
file.medications.map { it.toEntity() },
file.doseTimes.map { it.toEntity() },
file.doseLogs.map { it.toEntity() },
)
}
}
@Serializable
data class BackupFile(
val version: Int,
val exportedAtMillis: Long,
val medications: List<MedicationDto>,
val doseTimes: List<DoseTimeDto>,
val doseLogs: List<DoseLogDto>,
) {
companion object {
const val VERSION = 1
}
}
@Serializable
data class MedicationDto(
val id: Long,
val name: String,
val strength: String,
val unit: String,
val form: String,
val withFood: Boolean,
val notes: String,
val inventoryUnits: Double,
val packageSize: Double? = null,
val lowStockLeadDays: Int,
val rxExpiryEpochDay: Long? = null,
val refillsRemaining: Int? = null,
val atcCode: String? = null,
val active: Boolean,
)
@Serializable
data class DoseTimeDto(
val id: Long,
val medId: Long,
val minuteOfDay: Int,
val amount: Double,
val daysOfWeekMask: Int,
val intervalDays: Int,
val anchorEpochDay: Long? = null,
)
@Serializable
data class DoseLogDto(
val id: Long,
val medId: Long,
val doseTimeId: Long? = null,
val scheduledAtMillis: Long,
val amount: Double,
val status: String,
val actionedAtMillis: Long? = null,
val nagCount: Int = 0,
)
// Enum round-trip via name: stable as long as enum constants are never renamed
// (they are part of the backup contract too).
private fun Medication.toDto() = MedicationDto(
id, name, strength, unit, form.name, withFood, notes, inventoryUnits,
packageSize, lowStockLeadDays, rxExpiryEpochDay, refillsRemaining, atcCode, active,
)
private fun MedicationDto.toEntity() = Medication(
id, name, strength, unit, MedForm.valueOf(form), withFood, notes, inventoryUnits,
packageSize, lowStockLeadDays, rxExpiryEpochDay, refillsRemaining, atcCode, active,
)
private fun DoseTime.toDto() = DoseTimeDto(id, medId, minuteOfDay, amount, daysOfWeekMask, intervalDays, anchorEpochDay)
private fun DoseTimeDto.toEntity() = DoseTime(id, medId, minuteOfDay, amount, daysOfWeekMask, intervalDays, anchorEpochDay)
private fun DoseLog.toDto() = DoseLogDto(id, medId, doseTimeId, scheduledAtMillis, amount, status.name, actionedAtMillis, nagCount)
private fun DoseLogDto.toEntity() = DoseLog(id, medId, doseTimeId, scheduledAtMillis, amount, DoseStatus.valueOf(status), actionedAtMillis, nagCount)

View file

@ -0,0 +1,48 @@
package no.naiv.meddetsamme.backup
import android.content.Context
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import java.time.Duration
import no.naiv.meddetsamme.settings.SettingsStore
/**
* Daily auto-backup. Periodic + inexact is exactly right here: a late backup
* is harmless, unlike a late dose (which is why doses use AlarmManager and
* this doesn't).
*/
class BackupWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
if (!SettingsStore(applicationContext).isBackupConfigured) return Result.success()
return try {
BackupManager(applicationContext).runAutoBackup()
Result.success()
} catch (e: Exception) {
// Transient (network, clock skew): WorkManager retries with backoff.
if (runAttemptCount < 3) Result.retry() else Result.failure()
}
}
companion object {
private const val WORK_NAME = "auto-backup"
/** Idempotent; call on every app start. UPDATE keeps an existing cadence. */
fun schedule(context: Context) {
val request = PeriodicWorkRequestBuilder<BackupWorker>(Duration.ofDays(1))
.setConstraints(
Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build(),
)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, Duration.ofMinutes(10))
.build()
WorkManager.getInstance(context)
.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, request)
}
}
}

View file

@ -0,0 +1,88 @@
package no.naiv.meddetsamme.backup
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
/**
* Slim S3 PUT/GET for a self-hosted Garage bucket. Path-style on purpose:
* `https://endpoint/bucket/key` — Garage behind one Caddy hostname, no
* wildcard-DNS virtual-host games. HTTPS is enforced (health data leaves the
* phone here; Caddy terminates TLS on the tailnet host).
*/
class S3Client(
private val endpoint: String, // e.g. "https://s3.example.ts.net"
private val region: String, // Garage default is often "garage"
private val bucket: String,
private val accessKey: String,
private val secretKey: String,
private val client: OkHttpClient = defaultClient,
) {
companion object {
private val defaultClient by lazy { OkHttpClient() }
private val AMZ_DATE = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'").withZone(ZoneOffset.UTC)
}
class S3Exception(val code: Int, body: String) :
RuntimeException("S3 HTTP $code: ${body.take(300)}")
/** @throws S3Exception / IOException — caller (WorkManager) handles retry. */
fun put(key: String, body: ByteArray, contentType: String = "application/octet-stream") {
val request = signedRequestBuilder("PUT", key, body)
.put(body.toRequestBody(contentType.toMediaType()))
.build()
client.newCall(request).execute().use { resp ->
if (!resp.isSuccessful) throw S3Exception(resp.code, resp.body?.string().orEmpty())
}
}
fun get(key: String): ByteArray {
val request = signedRequestBuilder("GET", key, ByteArray(0)).get().build()
client.newCall(request).execute().use { resp ->
if (!resp.isSuccessful) throw S3Exception(resp.code, resp.body?.string().orEmpty())
return resp.body?.bytes() ?: ByteArray(0)
}
}
private fun signedRequestBuilder(method: String, key: String, body: ByteArray): Request.Builder {
require(endpoint.startsWith("https://")) { "Endepunkt må være https://" }
val url = "$endpoint/$bucket/$key".toHttpUrl()
val payloadHash = SigV4.hexSha256(body)
val amzDate = AMZ_DATE.format(Instant.now())
// Sign exactly what S3 requires: host + content hash + date.
val headers = mapOf(
"host" to hostHeader(url.host, url.port),
"x-amz-content-sha256" to payloadHash,
"x-amz-date" to amzDate,
)
val auth = SigV4.sign(
method = method,
canonicalUri = url.encodedPath,
canonicalQuery = "",
headers = headers,
payloadHash = payloadHash,
amzDate = amzDate,
region = region,
service = "s3",
accessKey = accessKey,
secretKey = secretKey,
)
return Request.Builder()
.url(url)
.header("x-amz-content-sha256", payloadHash)
.header("x-amz-date", amzDate)
.header("Authorization", auth.authorizationHeader)
}
/** Port only when non-default — must match what the server sees, or the signature breaks. */
private fun hostHeader(host: String, port: Int): String =
if (port == 443) host else "$host:$port"
}

View file

@ -0,0 +1,82 @@
package no.naiv.meddetsamme.backup
import java.security.MessageDigest
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
/**
* Hand-rolled AWS Signature V4 (brief decision #6: no AWS SDK for one PUT).
* Pure function of its inputs unit-tested against AWS's published example
* signature. If a Garage PUT fails, suspect clock skew or a non-path-style
* URL before suspecting these hashes (CLAUDE.md, load-bearing).
*
* Reference: docs.aws.amazon.com "Signature Version 4 signing process".
*/
object SigV4 {
data class SigningResult(val authorizationHeader: String)
/**
* @param canonicalUri path with each segment URI-encoded, e.g. "/bucket/key"
* @param canonicalQuery already-encoded query string sorted by key ("" if none)
* @param headers headers to sign; MUST include host (and for S3,
* x-amz-date + x-amz-content-sha256). Names any case.
* @param payloadHash lowercase hex SHA-256 of the body
* @param amzDate ISO8601 basic "yyyyMMdd'T'HHmmss'Z'"
*/
fun sign(
method: String,
canonicalUri: String,
canonicalQuery: String,
headers: Map<String, String>,
payloadHash: String,
amzDate: String,
region: String,
service: String,
accessKey: String,
secretKey: String,
): SigningResult {
val dateStamp = amzDate.substring(0, 8)
// Canonical headers: lowercase names, trimmed values, sorted by name.
val canonical = headers.entries
.map { (k, v) -> k.lowercase() to v.trim() }
.sortedBy { it.first }
val canonicalHeaders = canonical.joinToString("") { (k, v) -> "$k:$v\n" }
val signedHeaders = canonical.joinToString(";") { it.first }
val canonicalRequest = listOf(
method, canonicalUri, canonicalQuery, canonicalHeaders, signedHeaders, payloadHash,
).joinToString("\n")
val scope = "$dateStamp/$region/$service/aws4_request"
val stringToSign = listOf(
"AWS4-HMAC-SHA256", amzDate, scope, hexSha256(canonicalRequest.toByteArray()),
).joinToString("\n")
// The HMAC key-derivation chain — the part people get wrong by hashing
// strings instead of chaining raw MACs.
val kDate = hmac("AWS4$secretKey".toByteArray(), dateStamp)
val kRegion = hmac(kDate, region)
val kService = hmac(kRegion, service)
val kSigning = hmac(kService, "aws4_request")
val signature = hex(hmac(kSigning, stringToSign))
return SigningResult(
"AWS4-HMAC-SHA256 Credential=$accessKey/$scope, " +
"SignedHeaders=$signedHeaders, Signature=$signature",
)
}
fun hexSha256(data: ByteArray): String =
hex(MessageDigest.getInstance("SHA-256").digest(data))
private fun hmac(key: ByteArray, data: String): ByteArray =
Mac.getInstance("HmacSHA256").run {
init(SecretKeySpec(key, "HmacSHA256"))
doFinal(data.toByteArray())
}
private fun hex(bytes: ByteArray): String =
bytes.joinToString("") { "%02x".format(it) }
}

View file

@ -0,0 +1,51 @@
package no.naiv.meddetsamme.data
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Transaction
/**
* Bulk read/replace for backup. Inserts preserve original ids (autoGenerate
* only kicks in for id = 0), which keeps FK references in the file intact.
*/
@Dao
interface BackupDao {
@Query("SELECT * FROM medication")
suspend fun allMedications(): List<Medication>
@Query("SELECT * FROM dose_time")
suspend fun allDoseTimes(): List<DoseTime>
@Query("SELECT * FROM dose_log")
suspend fun allDoseLogs(): List<DoseLog>
@Query("DELETE FROM medication")
suspend fun clearMedications()
@Insert
suspend fun insertMedications(meds: List<Medication>)
@Insert
suspend fun insertDoseTimes(doseTimes: List<DoseTime>)
@Insert
suspend fun insertDoseLogs(logs: List<DoseLog>)
/**
* Replace-all restore. Deleting medication CASCADEs to dose_time and
* dose_log, so one delete clears everything; insertion order respects FKs.
*/
@Transaction
suspend fun replaceAll(
meds: List<Medication>,
doseTimes: List<DoseTime>,
logs: List<DoseLog>,
) {
clearMedications()
insertMedications(meds)
insertDoseTimes(doseTimes)
insertDoseLogs(logs)
}
}

View file

@ -22,6 +22,13 @@ abstract class MedDatabase : RoomDatabase() {
abstract fun medicationDao(): MedicationDao
abstract fun doseTimeDao(): DoseTimeDao
abstract fun doseLogDao(): DoseLogDao
abstract fun backupDao(): BackupDao
suspend fun restoreAll(
meds: List<Medication>,
doseTimes: List<DoseTime>,
logs: List<DoseLog>,
) = backupDao().replaceAll(meds, doseTimes, logs)
companion object {
@Volatile private var instance: MedDatabase? = null

View file

@ -0,0 +1,108 @@
package no.naiv.meddetsamme.settings
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
/**
* Backup credentials (S3 endpoint/keys, optional auto-backup passphrase) in
* SharedPreferences with values AES-GCM-encrypted under a non-exportable
* AndroidKeyStore key. Replaces the deprecated androidx security-crypto
* (EncryptedSharedPreferences) with the same construction, ~70 lines, zero deps.
*
* Honest threat model (brief insists): the auto-backup PASSPHRASE IS STORED so
* the nightly job can run unattended. Keystore encryption protects these values
* against off-device leaks of the prefs file (adb backup-style extraction),
* NOT against code running on an unlocked, compromised device. The stored
* passphrase therefore only protects backups against *bucket* compromise.
* Manual export prompts for a typed passphrase and never persists it.
*/
class SettingsStore(context: Context) {
private val prefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
var s3Endpoint: String? by encrypted("s3_endpoint")
var s3Region: String? by encrypted("s3_region")
var s3Bucket: String? by encrypted("s3_bucket")
var s3AccessKey: String? by encrypted("s3_access_key")
var s3SecretKey: String? by encrypted("s3_secret_key")
var autoBackupPassphrase: String? by encrypted("auto_backup_passphrase")
/** Non-secret flags can live in plaintext prefs. */
var batteryPromptShown: Boolean
get() = prefs.getBoolean("battery_prompt_shown", false)
set(v) = prefs.edit().putBoolean("battery_prompt_shown", v).apply()
val isBackupConfigured: Boolean
get() = !s3Endpoint.isNullOrBlank() && !s3Bucket.isNullOrBlank() &&
!s3AccessKey.isNullOrBlank() && !s3SecretKey.isNullOrBlank()
fun s3Client(): no.naiv.meddetsamme.backup.S3Client? {
if (!isBackupConfigured) return null
return no.naiv.meddetsamme.backup.S3Client(
endpoint = s3Endpoint!!.trimEnd('/'),
region = s3Region?.takeIf { it.isNotBlank() } ?: "garage",
bucket = s3Bucket!!,
accessKey = s3AccessKey!!,
secretKey = s3SecretKey!!,
)
}
// ---- crypto plumbing ---------------------------------------------------
private fun encrypted(prefKey: String) = object : kotlin.properties.ReadWriteProperty<Any?, String?> {
override fun getValue(thisRef: Any?, property: kotlin.reflect.KProperty<*>): String? =
prefs.getString(prefKey, null)?.let { decrypt(it) }
override fun setValue(thisRef: Any?, property: kotlin.reflect.KProperty<*>, value: String?) {
prefs.edit().apply {
if (value == null) remove(prefKey) else putString(prefKey, encrypt(value))
}.apply()
}
}
private fun key(): SecretKey {
val ks = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
(ks.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it }
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
generator.init(
KeyGenParameterSpec.Builder(KEY_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.build(),
)
return generator.generateKey()
}
private fun encrypt(value: String): String {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, key()) // Keystore picks a fresh random IV
val ct = cipher.doFinal(value.toByteArray(Charsets.UTF_8))
return Base64.encodeToString(cipher.iv, Base64.NO_WRAP) + ":" +
Base64.encodeToString(ct, Base64.NO_WRAP)
}
private fun decrypt(stored: String): String? = try {
val (ivB64, ctB64) = stored.split(":", limit = 2)
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(
Cipher.DECRYPT_MODE,
key(),
GCMParameterSpec(128, Base64.decode(ivB64, Base64.NO_WRAP)),
)
String(cipher.doFinal(Base64.decode(ctB64, Base64.NO_WRAP)), Charsets.UTF_8)
} catch (_: Exception) {
null // key rotated/wiped (e.g. restore to new device) → treat as unset
}
companion object {
private const val KEY_ALIAS = "settings-store"
}
}

View file

@ -0,0 +1,77 @@
package no.naiv.meddetsamme.backup
import no.naiv.meddetsamme.data.DoseLog
import no.naiv.meddetsamme.data.DoseStatus
import no.naiv.meddetsamme.data.DoseTime
import no.naiv.meddetsamme.data.MedForm
import no.naiv.meddetsamme.data.Medication
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
class BackupSerializerTest {
private val med = Medication(
id = 7,
name = "Levaxin",
strength = "50 µg",
unit = "tablett",
form = MedForm.TABLET,
withFood = false,
notes = "tas fastende",
inventoryUnits = 42.5,
packageSize = 100.0,
lowStockLeadDays = 10,
rxExpiryEpochDay = 20800,
refillsRemaining = 2,
atcCode = "H03AA01",
active = true,
)
private val doseTime = DoseTime(
id = 3, medId = 7, minuteOfDay = 7 * 60 + 30, amount = 1.5,
daysOfWeekMask = 0x3E, intervalDays = 0, anchorEpochDay = null,
)
private val log = DoseLog(
id = 11, medId = 7, doseTimeId = 3, scheduledAtMillis = 1_780_000_000_000,
amount = 1.5, status = DoseStatus.TAKEN, actionedAtMillis = 1_780_000_123_000, nagCount = 2,
)
@Test
fun `export-parse round trip preserves every field`() {
val json = BackupSerializer.export(listOf(med), listOf(doseTime), listOf(log))
val parsed = BackupSerializer.parse(json)
assertEquals(BackupFile.VERSION, parsed.version)
assertEquals(1, parsed.medications.size)
// DTO → entity → compare against original: catches silently dropped fields.
val medBack = parsed.medications.first()
assertEquals(med.name, medBack.name)
assertEquals(med.strength, medBack.strength)
assertEquals(med.form.name, medBack.form)
assertEquals(med.rxExpiryEpochDay, medBack.rxExpiryEpochDay)
assertEquals(med.refillsRemaining, medBack.refillsRemaining)
assertEquals(med.inventoryUnits, medBack.inventoryUnits, 0.0)
assertEquals(doseTime.daysOfWeekMask, parsed.doseTimes.first().daysOfWeekMask)
assertEquals(log.status.name, parsed.doseLogs.first().status)
assertEquals(log.nagCount, parsed.doseLogs.first().nagCount)
}
@Test
fun `parse rejects garbage and future versions`() {
assertThrows(IllegalArgumentException::class.java) {
BackupSerializer.parse("not json at all")
}
assertThrows(IllegalArgumentException::class.java) {
BackupSerializer.parse(
"""{"version":99,"exportedAtMillis":0,"medications":[],"doseTimes":[],"doseLogs":[]}""",
)
}
}
@Test
fun `export is human readable json`() {
val json = BackupSerializer.export(listOf(med), emptyList(), emptyList())
assertTrue(json.contains("\"name\": \"Levaxin\"")) // pretty-printed
}
}

View file

@ -0,0 +1,54 @@
package no.naiv.meddetsamme.backup
import javax.crypto.AEADBadTagException
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 JceBackupCryptoTest {
private val plaintext = """{"version":1,"medications":[]}""".toByteArray()
private val passphrase = "korrekt hest batteri stift".toCharArray()
@Test
fun `round trip with correct passphrase`() {
val ct = JceBackupCrypto.encrypt(plaintext, passphrase)
assertArrayEquals(plaintext, JceBackupCrypto.decrypt(ct, passphrase))
}
@Test
fun `wrong passphrase fails authentication, not garbage output`() {
val ct = JceBackupCrypto.encrypt(plaintext, passphrase)
assertThrows(AEADBadTagException::class.java) {
JceBackupCrypto.decrypt(ct, "feil passord".toCharArray())
}
}
@Test
fun `tampered ciphertext fails authentication`() {
val ct = JceBackupCrypto.encrypt(plaintext, passphrase)
ct[ct.size - 5] = (ct[ct.size - 5].toInt() xor 1).toByte()
assertThrows(AEADBadTagException::class.java) {
JceBackupCrypto.decrypt(ct, passphrase)
}
}
@Test
fun `format detection by magic`() {
val ct = JceBackupCrypto.encrypt(plaintext, passphrase)
assertTrue(JceBackupCrypto.matches(ct))
assertFalse(JceBackupCrypto.matches(plaintext))
assertEquals(JceBackupCrypto, BackupCrypto.detect(ct))
assertEquals(null, BackupCrypto.detect(plaintext))
}
@Test
fun `fresh salt and IV every time`() {
val a = JceBackupCrypto.encrypt(plaintext, passphrase)
val b = JceBackupCrypto.encrypt(plaintext, passphrase)
assertFalse(a.contentEquals(b)) // deterministic encryption would leak equality
}
}

View file

@ -0,0 +1,61 @@
package no.naiv.meddetsamme.backup
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class SigV4Test {
/**
* AWS's own worked example from the Signature Version 4 documentation
* ("Example: GET request to IAM"): known inputs published signature.
* If this passes, the canonicalization and HMAC chain are right.
*/
@Test
fun `matches the published AWS documentation example signature`() {
val emptyBodyHash = SigV4.hexSha256(ByteArray(0))
assertEquals(
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
emptyBodyHash,
)
val result = SigV4.sign(
method = "GET",
canonicalUri = "/",
canonicalQuery = "Action=ListUsers&Version=2010-05-08",
headers = mapOf(
"content-type" to "application/x-www-form-urlencoded; charset=utf-8",
"host" to "iam.amazonaws.com",
"x-amz-date" to "20150830T123600Z",
),
payloadHash = emptyBodyHash,
amzDate = "20150830T123600Z",
region = "us-east-1",
service = "iam",
accessKey = "AKIDEXAMPLE",
secretKey = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
)
assertTrue(
"got: ${result.authorizationHeader}",
result.authorizationHeader.endsWith(
"Signature=5d672d79c15b13162d9279b0855cfba6789a8edb4c82c400e06b5924a6f2b5d7",
),
)
assertTrue(
result.authorizationHeader.startsWith(
"AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/iam/aws4_request",
),
)
assertTrue(result.authorizationHeader.contains("SignedHeaders=content-type;host;x-amz-date"))
}
@Test
fun `payload hash of known body`() {
// SHA-256("hello") — standard test value.
assertEquals(
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
SigV4.hexSha256("hello".toByteArray()),
)
}
}