From 8f5b18cf68af82c29d105cc68535aa888f718343 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Wed, 10 Jun 2026 13:54:13 +0200 Subject: [PATCH] Backup: versioned JSON serializer, SigV4 + S3 client, JCE crypto, daily worker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/build.gradle.kts | 5 + .../no/naiv/meddetsamme/MedDetSammeApp.kt | 2 + .../naiv/meddetsamme/backup/BackupCrypto.kt | 103 ++++++++++++++ .../naiv/meddetsamme/backup/BackupManager.kt | 72 ++++++++++ .../meddetsamme/backup/BackupSerializer.kt | 134 ++++++++++++++++++ .../naiv/meddetsamme/backup/BackupWorker.kt | 48 +++++++ .../no/naiv/meddetsamme/backup/S3Client.kt | 88 ++++++++++++ .../java/no/naiv/meddetsamme/backup/SigV4.kt | 82 +++++++++++ .../no/naiv/meddetsamme/data/BackupDao.kt | 51 +++++++ .../no/naiv/meddetsamme/data/MedDatabase.kt | 7 + .../meddetsamme/settings/SettingsStore.kt | 108 ++++++++++++++ .../backup/BackupSerializerTest.kt | 77 ++++++++++ .../meddetsamme/backup/JceBackupCryptoTest.kt | 54 +++++++ .../no/naiv/meddetsamme/backup/SigV4Test.kt | 61 ++++++++ 14 files changed, 892 insertions(+) create mode 100644 app/src/main/java/no/naiv/meddetsamme/backup/BackupCrypto.kt create mode 100644 app/src/main/java/no/naiv/meddetsamme/backup/BackupManager.kt create mode 100644 app/src/main/java/no/naiv/meddetsamme/backup/BackupSerializer.kt create mode 100644 app/src/main/java/no/naiv/meddetsamme/backup/BackupWorker.kt create mode 100644 app/src/main/java/no/naiv/meddetsamme/backup/S3Client.kt create mode 100644 app/src/main/java/no/naiv/meddetsamme/backup/SigV4.kt create mode 100644 app/src/main/java/no/naiv/meddetsamme/data/BackupDao.kt create mode 100644 app/src/main/java/no/naiv/meddetsamme/settings/SettingsStore.kt create mode 100644 app/src/test/java/no/naiv/meddetsamme/backup/BackupSerializerTest.kt create mode 100644 app/src/test/java/no/naiv/meddetsamme/backup/JceBackupCryptoTest.kt create mode 100644 app/src/test/java/no/naiv/meddetsamme/backup/SigV4Test.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7714191..5582296 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) } diff --git a/app/src/main/java/no/naiv/meddetsamme/MedDetSammeApp.kt b/app/src/main/java/no/naiv/meddetsamme/MedDetSammeApp.kt index 26c8a77..e8ac349 100644 --- a/app/src/main/java/no/naiv/meddetsamme/MedDetSammeApp.kt +++ b/app/src/main/java/no/naiv/meddetsamme/MedDetSammeApp.kt @@ -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) } } diff --git a/app/src/main/java/no/naiv/meddetsamme/backup/BackupCrypto.kt b/app/src/main/java/no/naiv/meddetsamme/backup/BackupCrypto.kt new file mode 100644 index 0000000..01c50bb --- /dev/null +++ b/app/src/main/java/no/naiv/meddetsamme/backup/BackupCrypto.kt @@ -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 = 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() + } + } +} diff --git a/app/src/main/java/no/naiv/meddetsamme/backup/BackupManager.kt b/app/src/main/java/no/naiv/meddetsamme/backup/BackupManager.kt new file mode 100644 index 0000000..0c15ba5 --- /dev/null +++ b/app/src/main/java/no/naiv/meddetsamme/backup/BackupManager.kt @@ -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 + } +} diff --git a/app/src/main/java/no/naiv/meddetsamme/backup/BackupSerializer.kt b/app/src/main/java/no/naiv/meddetsamme/backup/BackupSerializer.kt new file mode 100644 index 0000000..2ffe34b --- /dev/null +++ b/app/src/main/java/no/naiv/meddetsamme/backup/BackupSerializer.kt @@ -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, doseTimes: List, logs: List): 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(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, + val doseTimes: List, + val doseLogs: List, +) { + 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) diff --git a/app/src/main/java/no/naiv/meddetsamme/backup/BackupWorker.kt b/app/src/main/java/no/naiv/meddetsamme/backup/BackupWorker.kt new file mode 100644 index 0000000..c0fe6e1 --- /dev/null +++ b/app/src/main/java/no/naiv/meddetsamme/backup/BackupWorker.kt @@ -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(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) + } + } +} diff --git a/app/src/main/java/no/naiv/meddetsamme/backup/S3Client.kt b/app/src/main/java/no/naiv/meddetsamme/backup/S3Client.kt new file mode 100644 index 0000000..809f4ba --- /dev/null +++ b/app/src/main/java/no/naiv/meddetsamme/backup/S3Client.kt @@ -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" +} diff --git a/app/src/main/java/no/naiv/meddetsamme/backup/SigV4.kt b/app/src/main/java/no/naiv/meddetsamme/backup/SigV4.kt new file mode 100644 index 0000000..1e03e7c --- /dev/null +++ b/app/src/main/java/no/naiv/meddetsamme/backup/SigV4.kt @@ -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, + 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) } +} diff --git a/app/src/main/java/no/naiv/meddetsamme/data/BackupDao.kt b/app/src/main/java/no/naiv/meddetsamme/data/BackupDao.kt new file mode 100644 index 0000000..fb74e64 --- /dev/null +++ b/app/src/main/java/no/naiv/meddetsamme/data/BackupDao.kt @@ -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 + + @Query("SELECT * FROM dose_time") + suspend fun allDoseTimes(): List + + @Query("SELECT * FROM dose_log") + suspend fun allDoseLogs(): List + + @Query("DELETE FROM medication") + suspend fun clearMedications() + + @Insert + suspend fun insertMedications(meds: List) + + @Insert + suspend fun insertDoseTimes(doseTimes: List) + + @Insert + suspend fun insertDoseLogs(logs: List) + + /** + * 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, + doseTimes: List, + logs: List, + ) { + clearMedications() + insertMedications(meds) + insertDoseTimes(doseTimes) + insertDoseLogs(logs) + } +} diff --git a/app/src/main/java/no/naiv/meddetsamme/data/MedDatabase.kt b/app/src/main/java/no/naiv/meddetsamme/data/MedDatabase.kt index ce8f377..adcff52 100644 --- a/app/src/main/java/no/naiv/meddetsamme/data/MedDatabase.kt +++ b/app/src/main/java/no/naiv/meddetsamme/data/MedDatabase.kt @@ -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, + doseTimes: List, + logs: List, + ) = backupDao().replaceAll(meds, doseTimes, logs) companion object { @Volatile private var instance: MedDatabase? = null diff --git a/app/src/main/java/no/naiv/meddetsamme/settings/SettingsStore.kt b/app/src/main/java/no/naiv/meddetsamme/settings/SettingsStore.kt new file mode 100644 index 0000000..4fa6a15 --- /dev/null +++ b/app/src/main/java/no/naiv/meddetsamme/settings/SettingsStore.kt @@ -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 { + 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" + } +} diff --git a/app/src/test/java/no/naiv/meddetsamme/backup/BackupSerializerTest.kt b/app/src/test/java/no/naiv/meddetsamme/backup/BackupSerializerTest.kt new file mode 100644 index 0000000..6dec495 --- /dev/null +++ b/app/src/test/java/no/naiv/meddetsamme/backup/BackupSerializerTest.kt @@ -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 + } +} diff --git a/app/src/test/java/no/naiv/meddetsamme/backup/JceBackupCryptoTest.kt b/app/src/test/java/no/naiv/meddetsamme/backup/JceBackupCryptoTest.kt new file mode 100644 index 0000000..53e5030 --- /dev/null +++ b/app/src/test/java/no/naiv/meddetsamme/backup/JceBackupCryptoTest.kt @@ -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 + } +} diff --git a/app/src/test/java/no/naiv/meddetsamme/backup/SigV4Test.kt b/app/src/test/java/no/naiv/meddetsamme/backup/SigV4Test.kt new file mode 100644 index 0000000..6bb83b9 --- /dev/null +++ b/app/src/test/java/no/naiv/meddetsamme/backup/SigV4Test.kt @@ -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()), + ) + } +}