Delete repo and exit process when key changes

There is no easy way to re-initialize all data based on the old key, so to prevent usage of the old key we need to exit our process. When the app is started again, only the new key will be used.
This commit is contained in:
Torsten Grote 2024-09-18 11:52:43 -03:00
parent 62991ed38b
commit 20ea0b332d
No known key found for this signature in database
GPG key ID: 3E5F77D92CF891FF
10 changed files with 75 additions and 25 deletions

View file

@ -94,7 +94,9 @@ open class App : Application() {
backupStateManager = get(), backupStateManager = get(),
) )
} }
viewModel { RecoveryCodeViewModel(this@App, get(), get(), get(), get(), get(), get()) } viewModel {
RecoveryCodeViewModel(this@App, get(), get(), get(), get(), get(), get(), get())
}
viewModel { viewModel {
BackupStorageViewModel( BackupStorageViewModel(
app = this@App, app = this@App,

View file

@ -190,17 +190,23 @@ internal class CryptoImpl(
secureRandom.nextBytes(this) secureRandom.nextBytes(this)
} }
override val repoId: String /**
get() { // TODO maybe cache this, but what if main key changes during run-time? * The ID of the backup repository tied to this user/device via [ANDROID_ID]
@SuppressLint("HardwareIds") * and the current [KeyManager.getMainKey].
val androidId = Settings.Secure.getString(context.contentResolver, ANDROID_ID) *
val repoIdKey = * Attention: If the main key ever changes, we need to kill our process,
deriveKey(keyManager.getMainKey(), "app backup repoId key".toByteArray()) * so all lazy values that depend on that key or the [gearTableKey] get reinitialized.
val hmacHasher: Mac = Mac.getInstance(ALGORITHM_HMAC).apply { */
init(SecretKeySpec(repoIdKey, ALGORITHM_HMAC)) override val repoId: String by lazy {
} @SuppressLint("HardwareIds")
return hmacHasher.doFinal(androidId.toByteArrayFromHex()).toHexString() val androidId = Settings.Secure.getString(context.contentResolver, ANDROID_ID)
val repoIdKey =
deriveKey(keyManager.getMainKey(), "app backup repoId key".toByteArray())
val hmacHasher: Mac = Mac.getInstance(ALGORITHM_HMAC).apply {
init(SecretKeySpec(repoIdKey, ALGORITHM_HMAC))
} }
hmacHasher.doFinal(androidId.toByteArrayFromHex()).toHexString()
}
override val gearTableKey: ByteArray override val gearTableKey: ByteArray
get() = deriveKey(keyManager.getMainKey(), "app backup gear table key".toByteArray()) get() = deriveKey(keyManager.getMainKey(), "app backup gear table key".toByteArray())

View file

@ -112,6 +112,17 @@ internal class AppBackupManager(
} }
} }
/**
* Careful, this removes the entire backup repository from the backend
* and clears local blob cache.
*/
@WorkerThread
@Throws(IOException::class)
suspend fun removeBackupRepo() {
blobCache.clearLocalCache()
backendManager.backend.remove(TopLevelFolder(crypto.repoId))
}
private suspend fun keepTrying(n: Int = 3, block: suspend () -> Unit) { private suspend fun keepTrying(n: Int = 3, block: suspend () -> Unit) {
for (i in 1..n) { for (i in 1..n) {
try { try {

View file

@ -10,6 +10,7 @@ import org.koin.dsl.module
import java.io.File import java.io.File
val repoModule = module { val repoModule = module {
single { AppBackupManager(get(), get(), get(), get(), get(), get()) }
single { BackupReceiver(get(), get(), get()) } single { BackupReceiver(get(), get(), get()) }
single { BlobCache(androidContext()) } single { BlobCache(androidContext()) }
single { BlobCreator(get(), get()) } single { BlobCreator(get(), get()) }

View file

@ -11,6 +11,7 @@ import android.content.Intent
import android.os.IBinder import android.os.IBinder
import android.util.Log import android.util.Log
import com.stevesoltys.seedvault.crypto.KeyManager import com.stevesoltys.seedvault.crypto.KeyManager
import com.stevesoltys.seedvault.permitDiskReads
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@ -45,7 +46,7 @@ class ConfigurableBackupTransportService : Service(), KoinComponent {
override fun onBind(intent: Intent): IBinder? { override fun onBind(intent: Intent): IBinder? {
// refuse to work until we have the main key // refuse to work until we have the main key
val noMainKey = keyManager.hasBackupKey() && !keyManager.hasMainKey() val noMainKey = permitDiskReads { keyManager.hasBackupKey() && !keyManager.hasMainKey() }
if (noMainKey && backupManager.currentTransport == TRANSPORT_ID) { if (noMainKey && backupManager.currentTransport == TRANSPORT_ID) {
notificationManager.onNoMainKeyError() notificationManager.onNoMainKeyError()
backupManager.isBackupEnabled = false backupManager.isBackupEnabled = false

View file

@ -26,15 +26,15 @@ class RecoveryCodeActivity : BackupActivity() {
setContentView(R.layout.activity_recovery_code) setContentView(R.layout.activity_recovery_code)
viewModel.isRestore = isRestore() viewModel.isRestore = isRestore()
viewModel.confirmButtonClicked.observeEvent(this, { clicked -> viewModel.confirmButtonClicked.observeEvent(this) { clicked ->
if (clicked) showInput(true) if (clicked) showInput(true)
}) }
viewModel.recoveryCodeSaved.observeEvent(this, { saved -> viewModel.recoveryCodeSaved.observeEvent(this) { saved ->
if (saved) { if (saved) {
setResult(RESULT_OK) setResult(RESULT_OK)
finishAfterTransition() finishAfterTransition()
} }
}) }
if (savedInstanceState == null) { if (savedInstanceState == null) {
if (viewModel.isRestore) showInput(false) if (viewModel.isRestore) showInput(false)

View file

@ -16,15 +16,18 @@ import cash.z.ecc.android.bip39.toSeed
import com.stevesoltys.seedvault.App import com.stevesoltys.seedvault.App
import com.stevesoltys.seedvault.crypto.Crypto import com.stevesoltys.seedvault.crypto.Crypto
import com.stevesoltys.seedvault.crypto.KeyManager import com.stevesoltys.seedvault.crypto.KeyManager
import com.stevesoltys.seedvault.repo.AppBackupManager
import com.stevesoltys.seedvault.transport.backup.BackupInitializer import com.stevesoltys.seedvault.transport.backup.BackupInitializer
import com.stevesoltys.seedvault.ui.LiveEvent import com.stevesoltys.seedvault.ui.LiveEvent
import com.stevesoltys.seedvault.ui.MutableLiveEvent import com.stevesoltys.seedvault.ui.MutableLiveEvent
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.calyxos.backup.storage.api.StorageBackup import org.calyxos.backup.storage.api.StorageBackup
import java.io.IOException import java.io.IOException
import kotlin.system.exitProcess
internal const val WORD_NUM = 12 internal const val WORD_NUM = 12
@ -35,6 +38,7 @@ internal class RecoveryCodeViewModel(
private val crypto: Crypto, private val crypto: Crypto,
private val keyManager: KeyManager, private val keyManager: KeyManager,
private val backupManager: IBackupManager, private val backupManager: IBackupManager,
private val appBackupManager: AppBackupManager,
private val backupInitializer: BackupInitializer, private val backupInitializer: BackupInitializer,
private val notificationManager: BackupNotificationManager, private val notificationManager: BackupNotificationManager,
private val storageBackup: StorageBackup, private val storageBackup: StorageBackup,
@ -104,20 +108,33 @@ internal class RecoveryCodeViewModel(
* The reason is that old backups won't be readable anymore with the new key. * The reason is that old backups won't be readable anymore with the new key.
* We can't delete other backups safely, because we can't be sure * We can't delete other backups safely, because we can't be sure
* that they don't belong to a different device or user. * that they don't belong to a different device or user.
*
* Our process will be terminated at the end to ensure the old key isn't used anymore.
*/ */
@OptIn(DelicateCoroutinesApi::class)
fun reinitializeBackupLocation() { fun reinitializeBackupLocation() {
Log.d(TAG, "Re-initializing backup location...") Log.d(TAG, "Re-initializing backup location...")
// TODO this code is almost identical to BackupStorageViewModel#onLocationSet(), unify?
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
// remove old backup repository and clear local blob cache
try {
appBackupManager.removeBackupRepo()
} catch (e: IOException) {
Log.e(TAG, "Error removing backup repo: ", e)
}
// remove old storage snapshots and clear cache // remove old storage snapshots and clear cache
storageBackup.init() storageBackup.init()
// we'll need to kill our process to not have references to the old key around
// trying to re-set all those references is complicated, so exiting the app is easier.
val exitApp = {
Log.w(TAG, "Shutting down app...")
exitProcess(0)
}
try { try {
// initialize the new location // initialize the new location
if (backupManager.isBackupEnabled) backupInitializer.initialize({ }) { if (backupManager.isBackupEnabled) backupInitializer.initialize(exitApp, exitApp)
// no-op
}
} catch (e: IOException) { } catch (e: IOException) {
Log.e(TAG, "Error starting new RestoreSet", e) Log.e(TAG, "Error starting new RestoreSet", e)
exitApp()
} }
} }
} }

View file

@ -5,7 +5,6 @@
package com.stevesoltys.seedvault.worker package com.stevesoltys.seedvault.worker
import com.stevesoltys.seedvault.repo.AppBackupManager
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module import org.koin.dsl.module
@ -27,7 +26,6 @@ val workerModule = module {
appBackupManager = get(), appBackupManager = get(),
) )
} }
single { AppBackupManager(get(), get(), get(), get(), get(), get()) }
single { single {
ApkBackup( ApkBackup(
pm = androidContext().packageManager, pm = androidContext().packageManager,
@ -45,7 +43,7 @@ val workerModule = module {
packageService = get(), packageService = get(),
apkBackup = get(), apkBackup = get(),
iconManager = get(), iconManager = get(),
nm = get() nm = get(),
) )
} }
} }

View file

@ -139,8 +139,8 @@
<string name="recovery_code_verification_try_again">Try again</string> <string name="recovery_code_verification_try_again">Try again</string>
<string name="recovery_code_verification_generate_new">Generate new code</string> <string name="recovery_code_verification_generate_new">Generate new code</string>
<string name="recovery_code_verification_new_dialog_title">Wait one second…</string> <string name="recovery_code_verification_new_dialog_title">Wait one second…</string>
<string name="recovery_code_verification_new_dialog_message">Generating a new code will make your existing backups inaccessible. We\'ll try to delete them if possible.\n\nAre you sure you want to do this?</string> <string name="recovery_code_verification_new_dialog_message">Generating a new code will make your existing backups inaccessible. We\'ll try to delete them if possible. This app will close when done.\n\nAre you sure you want to do this?</string>
<string name="recovery_code_recreated">New recovery code has been created successfully</string> <string name="recovery_code_recreated">New recovery code has been created successfully. Exiting…</string>
<string name="recovery_code_auth_title">Re-enter your screen lock</string> <string name="recovery_code_auth_title">Re-enter your screen lock</string>
<string name="recovery_code_auth_description">Enter your device credentials to continue</string> <string name="recovery_code_auth_description">Enter your device credentials to continue</string>

View file

@ -123,6 +123,20 @@ internal class AppBackupManagerTest : TransportTest() {
assertEquals(snapshot, appBackupManager.afterBackupFinished(true)) assertEquals(snapshot, appBackupManager.afterBackupFinished(true))
} }
@Test
fun `removeBackupRepo deletes repo and local cache`() = runBlocking {
every { blobCache.clearLocalCache() } just Runs
every { crypto.repoId } returns repoId
coEvery { backendManager.backend.remove(TopLevelFolder(repoId)) } just Runs
appBackupManager.removeBackupRepo()
coVerify {
blobCache.clearLocalCache()
backendManager.backend.remove(TopLevelFolder(repoId))
}
}
private suspend fun minimalBeforeBackup() { private suspend fun minimalBeforeBackup() {
every { backendManager.backend } returns backend every { backendManager.backend } returns backend
every { crypto.repoId } returns repoId every { crypto.repoId } returns repoId