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:
parent
96ac8ea108
commit
c9ee76387f
11 changed files with 264 additions and 24 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
5
app/src/main/res/xml/full_backup_content.xml
Normal file
5
app/src/main/res/xml/full_backup_content.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- API 26–30 equivalent of data_extraction_rules: only the encrypted blob. -->
|
||||
<full-backup-content>
|
||||
<include domain="file" path="gbackup" />
|
||||
</full-backup-content>
|
||||
Loading…
Add table
Add a link
Reference in a new issue