Google Auto Backup of the age-encrypted blob, passphrase-gated restore

Per request: re-enable allowBackup but scope it to ONE file via
include-rules — files/gbackup/latest.json.age, an age-encrypted export
written only while a backup passphrase is set (never plaintext to
Google; DB/prefs stay excluded). On a new device the OS restores the
blob, the Today screen detects an empty DB + present blob and offers
'Gjenopprett' — which demands the passphrase typed, since the Keystore
key never migrates. Clearing the passphrase deletes the blob.

scrypt work factor dropped 18→15: age's desktop default needs a 256 MiB
working set and OOM-crashed on-device (found the hard way); 15 = 32 MiB,
still real brute-force cost, and largeHeap covers decrypting foreign
files made at 18. Verified by instrumented round-trip tests (write →
empty DB → restore; wrong passphrase keeps DB empty; cleared passphrase
removes blob) — UI automation couldn't drive the multi-field dialog
reliably, so the proof lives in the test instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-06-11 19:20:48 +02:00
commit c9ee76387f
11 changed files with 264 additions and 24 deletions

View file

@ -31,7 +31,10 @@ The name says the brief: nag me to take the dose *now*.
3. Re-arm every alarm on `BOOT_COMPLETED` + `MY_PACKAGE_REPLACED` (state is wiped then).
4. Surface a one-time battery-optimisation exemption prompt — biggest cause of dropped
reminders on aggressive OEMs.
5. No Google Auto Backup (`allowBackup="false"`, excluded from cloud-backup + transfer).
5. Google Auto Backup carries ONLY the app's own age-encrypted blob (`files/gbackup/`,
include-rules in dataExtractionRules/fullBackupContent exclude everything else).
Blob exists only while a passphrase is set; restore on a new device requires typing
it (Keystore never migrates). Amended 2026-06 from a full opt-out, on user request.
6. Own backup: versioned JSON → encrypted → S3-compatible PUT to self-hosted **Garage**,
path-style, hand-rolled SigV4. One serializer for export, import, and auto-backup.
7. Crypto target is **age** (passphrase/scrypt) so backups are `age -d`-decryptable and

View file

@ -77,9 +77,10 @@ ScheduleEngine — pure: next occurrence, daily consumption,
Room with exported schemas committed under `app/schemas/`; migrations are
proven by instrumented `MigrationTestHelper` tests before they touch real
data. Google Auto Backup is disabled (`allowBackup=false` + full
`dataExtractionRules` opt-out) — health data leaves the device only through
the app's own encrypted backup.
data. Google Auto Backup carries exactly one file: the app's own
age-encrypted export (written only while a backup passphrase is set) —
include-rules keep the database and settings out, and restoring on a new
device requires typing the passphrase, since Keystore keys never migrate.
## Backup format

View file

@ -0,0 +1,100 @@
package no.naiv.meddetsamme.backup
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.runBlocking
import no.naiv.meddetsamme.data.InventoryItem
import no.naiv.meddetsamme.data.MedDatabase
import no.naiv.meddetsamme.data.MedForm
import no.naiv.meddetsamme.data.Medication
import no.naiv.meddetsamme.settings.SettingsStore
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
/**
* The Google Auto Backup path end to end, without the OS backup transport
* (which UI/automation can't drive deterministically): writeCloudBlob encrypts
* with the stored passphrase, pendingCloudRestore fires only on an empty DB,
* and import() with the right passphrase brings the data back. This is the
* "new phone" recovery, and it must keep working Keystore won't migrate, so
* the typed passphrase is the only key.
*/
@RunWith(AndroidJUnit4::class)
class CloudBackupRoundTripTest {
private val context = ApplicationProvider.getApplicationContext<android.content.Context>()
private val db get() = MedDatabase.get(context)
private val manager = BackupManager(context)
@Before fun clear() {
runBlocking { db.restoreAll(emptyList(), emptyList(), emptyList(), emptyList()) }
BackupManager.cloudBlobFile(context).delete()
}
@After fun cleanup() {
runBlocking { db.restoreAll(emptyList(), emptyList(), emptyList(), emptyList()) }
BackupManager.cloudBlobFile(context).delete()
SettingsStore(context).autoBackupPassphrase = null
}
@Test fun writeThenRestoreOnEmptyDb() = runBlocking {
SettingsStore(context).autoBackupPassphrase = "korrekt hest"
val itemId = db.inventoryDao().insert(
InventoryItem(name = "Levaxin", strength = "50 µg", unit = "tablett", form = MedForm.TABLET, stockUnits = 42.0),
)
db.medicationDao().insert(Medication(itemId = itemId, notes = "fastende"))
manager.writeCloudBlob()
val blob = BackupManager.cloudBlobFile(context)
assertTrue("blob written", blob.exists())
assertTrue("blob is an age v1 file", blob.readBytes().decodeToString(0, 21) == "age-encryption.org/v1")
// Non-empty DB → no restore offered.
assertNull(manager.pendingCloudRestore())
// Simulate the new device: empty DB, blob restored by the OS.
db.restoreAll(emptyList(), emptyList(), emptyList(), emptyList())
val pending = manager.pendingCloudRestore()
assertNotNull("restore offered on empty DB", pending)
val count = manager.import(pending!!.readBytes(), "korrekt hest".toCharArray())
assertEquals(1, count)
assertEquals("Levaxin", db.inventoryDao().getAll().single().name)
assertEquals("fastende", db.medicationDao().getAllWithItems().single().med.notes)
}
@Test fun wrongPassphraseFailsAndKeepsDbEmpty() = runBlocking {
SettingsStore(context).autoBackupPassphrase = "riktig"
val itemId = db.inventoryDao().insert(
InventoryItem(name = "Levaxin", strength = "50 µg", unit = "tablett", form = MedForm.TABLET, stockUnits = 42.0),
)
db.medicationDao().insert(Medication(itemId = itemId))
manager.writeCloudBlob()
db.restoreAll(emptyList(), emptyList(), emptyList(), emptyList())
val blob = manager.pendingCloudRestore()!!
try {
manager.import(blob.readBytes(), "feil".toCharArray())
org.junit.Assert.fail("wrong passphrase must throw")
} catch (_: Exception) { /* expected */ }
assertTrue("DB still empty after failed restore", db.inventoryDao().getAll().isEmpty())
}
@Test fun noPassphraseRemovesBlob() = runBlocking {
SettingsStore(context).autoBackupPassphrase = "x"
db.inventoryDao().insert(InventoryItem(name = "A", strength = "", unit = "stk", form = MedForm.OTHER))
manager.writeCloudBlob()
assertTrue(BackupManager.cloudBlobFile(context).exists())
// Clearing the passphrase must stop offering data to Google.
SettingsStore(context).autoBackupPassphrase = null
manager.writeCloudBlob()
assertTrue("blob removed when passphrase cleared", !BackupManager.cloudBlobFile(context).exists())
}
}

View file

@ -21,16 +21,21 @@
<!-- Backups to self-hosted S3 (Garage) and FEST dataset refresh only. -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- No Google Auto Backup: this app runs its own encrypted backup. allowBackup
covers pre-12; dataExtractionRules opts out of cloud backup AND device-to-
device transfer on 12+. -->
<!-- largeHeap: scrypt for age files made elsewhere (CLI default 2^18)
needs a 256 MiB working set on decrypt; our own files use 2^15. -->
<!-- Google Auto Backup carries EXACTLY ONE thing: the app's own
age-encrypted export in files/gbackup/ (see data_extraction_rules /
full_backup_content — includes beat excludes, so everything else is
out). The blob only exists when a passphrase is set, and restoring on
a new device requires typing it: Keystore keys never migrate. -->
<application
android:name=".MedDetSammeApp"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:allowBackup="false"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="false"
android:fullBackupContent="@xml/full_backup_content"
android:largeHeap="true"
android:supportsRtl="true"
android:theme="@style/Theme.MedDetSamme">

View file

@ -19,8 +19,14 @@ object AgeBackupCrypto : BackupCrypto {
private val BINARY_MAGIC = "age-encryption.org/v1".toByteArray(Charsets.US_ASCII)
private val ARMOR_MAGIC = "-----BEGIN AGE ENCRYPTED FILE-----".toByteArray(Charsets.US_ASCII)
/** age default work factor (2^18) — same cost `age -p` uses. */
private const val WORK_FACTOR = 18
/**
* 15, not age's desktop default 18: scrypt memory is 128·2^N·8 bytes, so
* 18 means 256 MiB a guaranteed OutOfMemoryError inside a standard
* Android heap (found the hard way on-device). 15 = 32 MiB, still a real
* cost against bucket-compromise brute force. Decrypt reads the factor
* from the file header, so any of our files open anywhere.
*/
private const val WORK_FACTOR = 15
override val formatId = "age"

View file

@ -1,6 +1,7 @@
package no.naiv.meddetsamme.backup
import android.content.Context
import java.io.File
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
@ -48,6 +49,48 @@ class BackupManager(private val context: Context) {
return file.medications.size
}
/**
* The one file Google Auto Backup is allowed to carry (see
* data_extraction_rules.xml): an age-encrypted export, written only while
* a passphrase is set never plaintext to Google. Restoring it on a new
* device requires typing the passphrase; Keystore keys don't migrate.
*/
suspend fun writeCloudBlob() {
val passphrase = SettingsStore(context).autoBackupPassphrase
val file = cloudBlobFile(context)
if (passphrase.isNullOrEmpty()) {
// Passphrase cleared → stop offering data to Google at all.
if (file.exists()) {
file.delete()
android.app.backup.BackupManager(context).dataChanged()
}
return
}
val crypto = BackupCrypto.all().first()
val bytes = crypto.encrypt(exportJson().toByteArray(Charsets.UTF_8), passphrase.toCharArray())
file.parentFile?.mkdirs()
file.writeBytes(bytes)
// Hint the OS scheduler that there's fresh data to pick up.
android.app.backup.BackupManager(context).dataChanged()
}
/**
* Encrypted blob restored by Google onto a device whose database is empty
* the "new phone" signal. Null otherwise.
*/
suspend fun pendingCloudRestore(): File? {
val file = cloudBlobFile(context)
if (!file.exists()) return null
val dao = db.backupDao()
val empty = dao.allInventoryItems().isEmpty() && dao.allMedications().isEmpty()
return if (empty) file else null
}
companion object {
fun cloudBlobFile(context: Context): File =
File(context.filesDir, "gbackup/latest.json.age")
}
/**
* Newest backup object in the bucket, or null when none exist. Keys embed
* a UTC timestamp, so the lexicographically last key is the newest.

View file

@ -20,9 +20,17 @@ import no.naiv.meddetsamme.settings.SettingsStore
class BackupWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val manager = BackupManager(applicationContext)
// The Google Auto Backup blob is independent of S3: it only needs the
// passphrase, and failing to write it is a local error worth surfacing.
try {
manager.writeCloudBlob()
} catch (e: Exception) {
return if (runAttemptCount < 3) Result.retry() else Result.failure()
}
if (!SettingsStore(applicationContext).isBackupConfigured) return Result.success()
return try {
BackupManager(applicationContext).runAutoBackup()
manager.runAutoBackup()
Result.success()
} catch (e: Exception) {
// Transient (network, clock skew): WorkManager retries with backoff.

View file

@ -164,12 +164,29 @@ fun BackupSettingsScreen(nav: Nav) {
settings.s3AccessKey = accessKey.trim().ifBlank { null }
settings.s3SecretKey = secretKey.trim().ifBlank { null }
settings.autoBackupPassphrase = autoPassphrase.ifBlank { null }
// Write/remove the Google Auto Backup blob right away so the
// protection state matches the setting immediately.
scope.launch(Dispatchers.IO) {
try {
BackupManager(context).writeCloudBlob()
} catch (e: Exception) {
withContext(Dispatchers.Main) { toast("Kunne ikke skrive Google-kopi: ${e.message}") }
}
}
toast("Lagret")
},
enabled = !passphraseMismatch,
modifier = Modifier.fillMaxWidth(),
) { Text("Lagre backupinnstillinger") }
Text(
"Google-sikkerhetskopi: når passordfrase er satt, legges en kryptert kopi i " +
"Googles enhetsbackup. Gjenoppretting på ny enhet krever at frasen tastes " +
"inn — den følger aldri med. Uten frase sendes ingenting til Google.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedButton(
onClick = {
scope.launch(Dispatchers.IO) {

View file

@ -167,6 +167,7 @@ fun TodayScreen(nav: Nav) {
modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item { CloudRestoreCard() }
item { BackupNudge(onSetup = { nav.push(Screen.BackupSettings) }) }
if (warnings.isNotEmpty()) {
item {
@ -313,6 +314,64 @@ private fun DoseCard(
}
/**
* "New phone" path: Google Auto Backup restored our encrypted blob onto a
* device with an empty database. The passphrase never migrates (Keystore is
* device-bound), so import demands it typed.
*/
@Composable
private fun CloudRestoreCard() {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var pending by remember { mutableStateOf<java.io.File?>(null) }
var askPassphrase by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
pending = no.naiv.meddetsamme.backup.BackupManager(context).pendingCloudRestore()
}
val file = pending ?: return
Card(modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Kryptert sikkerhetskopi funnet", style = MaterialTheme.typography.titleSmall)
Text(
"Google har gjenopprettet en kryptert kopi av medisindataene dine. " +
"Tast passordfrasen for å hente dem inn.",
style = MaterialTheme.typography.bodyMedium,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(onClick = { askPassphrase = true }) { Text("Gjenopprett") }
TextButton(onClick = {
file.delete()
pending = null
}) { Text("Forkast kopien") }
}
}
}
if (askPassphrase) {
PassphraseDialog(
title = "Gjenopprett fra Google-backup",
text = "Passordfrasen du satte for sikkerhetskopi på den forrige enheten.",
allowEmpty = false,
onDismiss = { askPassphrase = false },
) { passphrase ->
askPassphrase = false
scope.launch {
try {
val bytes = file.readBytes()
val count = no.naiv.meddetsamme.backup.BackupManager(context).import(bytes, passphrase)
no.naiv.meddetsamme.alarm.AlarmScheduler(context).armAll()
android.widget.Toast.makeText(context, "Gjenopprettet $count medisiner", android.widget.Toast.LENGTH_LONG).show()
pending = null
} catch (e: Exception) {
android.widget.Toast.makeText(context, "Gjenoppretting feilet: ${e.message}", android.widget.Toast.LENGTH_LONG).show()
}
}
}
}
}
/**
* Quiet one-time card while backup is unconfigured: health data existing only
* on this device is a real risk (proven the hard way on 2026-06-10). Goes

View file

@ -1,19 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Opt out of both Google cloud backup and device-to-device transfer (API 31+).
Health data stays on-device; our own encrypted S3 backup is the only copy. -->
<!-- API 31+. With an <include> present, ONLY included paths are backed up:
the age-encrypted export blob. DB, prefs and everything else stay on
the device — health data leaves only in encrypted form. -->
<data-extraction-rules>
<cloud-backup>
<exclude domain="root" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="file" path="." />
<exclude domain="external" path="." />
<include domain="file" path="gbackup/" />
</cloud-backup>
<device-transfer>
<exclude domain="root" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="file" path="." />
<exclude domain="external" path="." />
<include domain="file" path="gbackup/" />
</device-transfer>
</data-extraction-rules>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- API 2630 equivalent of data_extraction_rules: only the encrypted blob. -->
<full-backup-content>
<include domain="file" path="gbackup" />
</full-backup-content>