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(),
)
}
viewModel { RecoveryCodeViewModel(this@App, get(), get(), get(), get(), get(), get()) }
viewModel {
RecoveryCodeViewModel(this@App, get(), get(), get(), get(), get(), get(), get())
}
viewModel {
BackupStorageViewModel(
app = this@App,

View file

@ -190,17 +190,23 @@ internal class CryptoImpl(
secureRandom.nextBytes(this)
}
override val repoId: String
get() { // TODO maybe cache this, but what if main key changes during run-time?
@SuppressLint("HardwareIds")
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))
}
return hmacHasher.doFinal(androidId.toByteArrayFromHex()).toHexString()
/**
* The ID of the backup repository tied to this user/device via [ANDROID_ID]
* and the current [KeyManager.getMainKey].
*
* Attention: If the main key ever changes, we need to kill our process,
* so all lazy values that depend on that key or the [gearTableKey] get reinitialized.
*/
override val repoId: String by lazy {
@SuppressLint("HardwareIds")
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
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) {
for (i in 1..n) {
try {

View file

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

View file

@ -11,6 +11,7 @@ import android.content.Intent
import android.os.IBinder
import android.util.Log
import com.stevesoltys.seedvault.crypto.KeyManager
import com.stevesoltys.seedvault.permitDiskReads
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
@ -45,7 +46,7 @@ class ConfigurableBackupTransportService : Service(), KoinComponent {
override fun onBind(intent: Intent): IBinder? {
// 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) {
notificationManager.onNoMainKeyError()
backupManager.isBackupEnabled = false

View file

@ -26,15 +26,15 @@ class RecoveryCodeActivity : BackupActivity() {
setContentView(R.layout.activity_recovery_code)
viewModel.isRestore = isRestore()
viewModel.confirmButtonClicked.observeEvent(this, { clicked ->
viewModel.confirmButtonClicked.observeEvent(this) { clicked ->
if (clicked) showInput(true)
})
viewModel.recoveryCodeSaved.observeEvent(this, { saved ->
}
viewModel.recoveryCodeSaved.observeEvent(this) { saved ->
if (saved) {
setResult(RESULT_OK)
finishAfterTransition()
}
})
}
if (savedInstanceState == null) {
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.crypto.Crypto
import com.stevesoltys.seedvault.crypto.KeyManager
import com.stevesoltys.seedvault.repo.AppBackupManager
import com.stevesoltys.seedvault.transport.backup.BackupInitializer
import com.stevesoltys.seedvault.ui.LiveEvent
import com.stevesoltys.seedvault.ui.MutableLiveEvent
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.calyxos.backup.storage.api.StorageBackup
import java.io.IOException
import kotlin.system.exitProcess
internal const val WORD_NUM = 12
@ -35,6 +38,7 @@ internal class RecoveryCodeViewModel(
private val crypto: Crypto,
private val keyManager: KeyManager,
private val backupManager: IBackupManager,
private val appBackupManager: AppBackupManager,
private val backupInitializer: BackupInitializer,
private val notificationManager: BackupNotificationManager,
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.
* We can't delete other backups safely, because we can't be sure
* 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() {
Log.d(TAG, "Re-initializing backup location...")
// TODO this code is almost identical to BackupStorageViewModel#onLocationSet(), unify?
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
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 {
// initialize the new location
if (backupManager.isBackupEnabled) backupInitializer.initialize({ }) {
// no-op
}
if (backupManager.isBackupEnabled) backupInitializer.initialize(exitApp, exitApp)
} catch (e: IOException) {
Log.e(TAG, "Error starting new RestoreSet", e)
exitApp()
}
}
}

View file

@ -5,7 +5,6 @@
package com.stevesoltys.seedvault.worker
import com.stevesoltys.seedvault.repo.AppBackupManager
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
@ -27,7 +26,6 @@ val workerModule = module {
appBackupManager = get(),
)
}
single { AppBackupManager(get(), get(), get(), get(), get(), get()) }
single {
ApkBackup(
pm = androidContext().packageManager,
@ -45,7 +43,7 @@ val workerModule = module {
packageService = get(),
apkBackup = 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_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_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_recreated">New recovery code has been created successfully</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. Exiting…</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>

View file

@ -123,6 +123,20 @@ internal class AppBackupManagerTest : TransportTest() {
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() {
every { backendManager.backend } returns backend
every { crypto.repoId } returns repoId