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

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