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
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue