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

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