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