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:
parent
46a5d7e98e
commit
8f5b18cf68
14 changed files with 892 additions and 0 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
103
app/src/main/java/no/naiv/meddetsamme/backup/BackupCrypto.kt
Normal file
103
app/src/main/java/no/naiv/meddetsamme/backup/BackupCrypto.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
134
app/src/main/java/no/naiv/meddetsamme/backup/BackupSerializer.kt
Normal file
134
app/src/main/java/no/naiv/meddetsamme/backup/BackupSerializer.kt
Normal 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)
|
||||
48
app/src/main/java/no/naiv/meddetsamme/backup/BackupWorker.kt
Normal file
48
app/src/main/java/no/naiv/meddetsamme/backup/BackupWorker.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
88
app/src/main/java/no/naiv/meddetsamme/backup/S3Client.kt
Normal file
88
app/src/main/java/no/naiv/meddetsamme/backup/S3Client.kt
Normal 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"
|
||||
}
|
||||
82
app/src/main/java/no/naiv/meddetsamme/backup/SigV4.kt
Normal file
82
app/src/main/java/no/naiv/meddetsamme/backup/SigV4.kt
Normal 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) }
|
||||
}
|
||||
51
app/src/main/java/no/naiv/meddetsamme/data/BackupDao.kt
Normal file
51
app/src/main/java/no/naiv/meddetsamme/data/BackupDao.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
108
app/src/main/java/no/naiv/meddetsamme/settings/SettingsStore.kt
Normal file
108
app/src/main/java/no/naiv/meddetsamme/settings/SettingsStore.kt
Normal 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
61
app/src/test/java/no/naiv/meddetsamme/backup/SigV4Test.kt
Normal file
61
app/src/test/java/no/naiv/meddetsamme/backup/SigV4Test.kt
Normal 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()),
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue