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

@ -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())
}
}