From d64413a7c39f3e5a4b93819dc6e36607826370d3 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Thu, 19 Nov 2020 09:33:31 -0300 Subject: [PATCH] Allow user to verify existing recovery code --- .../java/com/stevesoltys/seedvault/App.kt | 3 +- .../seedvault/crypto/CipherFactory.kt | 9 +++++ .../stevesoltys/seedvault/crypto/Crypto.kt | 23 +++++++++++ .../seedvault/crypto/KeyManager.kt | 3 +- .../seedvault/settings/SettingsActivity.kt | 5 +++ .../recoverycode/RecoveryCodeInputFragment.kt | 39 ++++++++++++++++++- .../ui/recoverycode/RecoveryCodeViewModel.kt | 21 +++++++--- app/src/main/res/drawable/ic_vpn_key.xml | 10 +++++ app/src/main/res/values/strings.xml | 9 ++++- app/src/main/res/xml/settings.xml | 8 ++++ 10 files changed, 119 insertions(+), 11 deletions(-) create mode 100644 app/src/main/res/drawable/ic_vpn_key.xml diff --git a/app/src/main/java/com/stevesoltys/seedvault/App.kt b/app/src/main/java/com/stevesoltys/seedvault/App.kt index ce1f0477..e91e7c8f 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/App.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/App.kt @@ -44,7 +44,7 @@ class App : Application() { factory { AppListRetriever(this@App, get(), get(), get()) } viewModel { SettingsViewModel(this@App, get(), get(), get(), get(), get()) } - viewModel { RecoveryCodeViewModel(this@App, get()) } + viewModel { RecoveryCodeViewModel(this@App, get(), get()) } viewModel { BackupStorageViewModel(this@App, get(), get(), get()) } viewModel { RestoreStorageViewModel(this@App, get(), get()) } viewModel { RestoreViewModel(this@App, get(), get(), get(), get(), get()) } @@ -111,6 +111,7 @@ const val MAGIC_PACKAGE_MANAGER = PACKAGE_MANAGER_SENTINEL const val ANCESTRAL_RECORD_KEY = "@ancestral_record@" const val GLOBAL_METADATA_KEY = "@meta@" +// TODO this doesn't work for LineageOS as they do public debug builds fun isDebugBuild() = Build.TYPE == "userdebug" fun permitDiskReads(func: () -> T): T { diff --git a/app/src/main/java/com/stevesoltys/seedvault/crypto/CipherFactory.kt b/app/src/main/java/com/stevesoltys/seedvault/crypto/CipherFactory.kt index cf19a226..52d7ef88 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/crypto/CipherFactory.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/crypto/CipherFactory.kt @@ -1,5 +1,6 @@ package com.stevesoltys.seedvault.crypto +import java.security.Key import javax.crypto.Cipher import javax.crypto.Cipher.DECRYPT_MODE import javax.crypto.Cipher.ENCRYPT_MODE @@ -11,6 +12,7 @@ internal const val GCM_AUTHENTICATION_TAG_LENGTH = 128 interface CipherFactory { fun createEncryptionCipher(): Cipher fun createDecryptionCipher(iv: ByteArray): Cipher + fun createEncryptionTestCipher(key: Key, iv: ByteArray): Cipher } internal class CipherFactoryImpl(private val keyManager: KeyManager) : CipherFactory { @@ -28,4 +30,11 @@ internal class CipherFactoryImpl(private val keyManager: KeyManager) : CipherFac } } + override fun createEncryptionTestCipher(key: Key, iv: ByteArray): Cipher { + return Cipher.getInstance(CIPHER_TRANSFORMATION).apply { + val params = GCMParameterSpec(GCM_AUTHENTICATION_TAG_LENGTH, iv) + init(ENCRYPT_MODE, key, params) + } + } + } diff --git a/app/src/main/java/com/stevesoltys/seedvault/crypto/Crypto.kt b/app/src/main/java/com/stevesoltys/seedvault/crypto/Crypto.kt index 8b6a6960..78c8e6e9 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/crypto/Crypto.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/crypto/Crypto.kt @@ -12,6 +12,7 @@ import java.io.IOException import java.io.InputStream import java.io.OutputStream import javax.crypto.Cipher +import javax.crypto.spec.SecretKeySpec import kotlin.math.min /** @@ -95,6 +96,13 @@ interface Crypto { */ @Throws(IOException::class, SecurityException::class) fun decryptMultipleSegments(inputStream: InputStream): ByteArray + + /** + * Verify that the stored backup key was created from the given seed. + * + * @return true if the key was created from given seed, false otherwise. + */ + fun verifyBackupKey(seed: ByteArray): Boolean } internal class CryptoImpl( @@ -204,4 +212,19 @@ internal class CryptoImpl( return cipher.doFinal(buffer) } + override fun verifyBackupKey(seed: ByteArray): Boolean { + // encrypt with stored backup key + val toEncrypt = "Recovery Code Verification".toByteArray() + val cipher = cipherFactory.createEncryptionCipher() + val encrypted = cipher.doFinal(toEncrypt) as ByteArray + + // encrypt with input key cipher + val secretKeySpec = SecretKeySpec(seed, 0, KEY_SIZE_BYTES, "AES") + val inputCipher = cipherFactory.createEncryptionTestCipher(secretKeySpec, cipher.iv) + val inputEncrypted = inputCipher.doFinal(toEncrypt) + + // keys match if encrypted result is the same + return encrypted.contentEquals(inputEncrypted) + } + } diff --git a/app/src/main/java/com/stevesoltys/seedvault/crypto/KeyManager.kt b/app/src/main/java/com/stevesoltys/seedvault/crypto/KeyManager.kt index e472bd39..eedbdeee 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/crypto/KeyManager.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/crypto/KeyManager.kt @@ -11,7 +11,7 @@ import javax.crypto.SecretKey import javax.crypto.spec.SecretKeySpec internal const val KEY_SIZE = 256 -private const val KEY_SIZE_BYTES = KEY_SIZE / 8 +internal const val KEY_SIZE_BYTES = KEY_SIZE / 8 private const val KEY_ALIAS = "com.stevesoltys.seedvault" private const val ANDROID_KEY_STORE = "AndroidKeyStore" @@ -47,7 +47,6 @@ internal class KeyManagerImpl : KeyManager { override fun storeBackupKey(seed: ByteArray) { if (seed.size < KEY_SIZE_BYTES) throw IllegalArgumentException() - // TODO check if using first 256 of 512 bits produced by PBKDF2WithHmacSHA512 is safe! val secretKeySpec = SecretKeySpec(seed, 0, KEY_SIZE_BYTES, "AES") val ksEntry = SecretKeyEntry(secretKeySpec) keyStore.setEntry(KEY_ALIAS, ksEntry, getKeyProtection()) diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsActivity.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsActivity.kt index acad9e5b..9d6a026a 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsActivity.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsActivity.kt @@ -9,10 +9,12 @@ import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.ui.RequireProvisioningActivity import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager +import com.stevesoltys.seedvault.ui.recoverycode.ARG_FOR_NEW_CODE import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel internal const val ACTION_APP_STATUS_LIST = "com.stevesoltys.seedvault.APP_STATUS_LIST" +private const val PREF_BACKUP_RECOVERY_CODE = "backup_recovery_code" class SettingsActivity : RequireProvisioningActivity(), OnPreferenceStartFragmentCallback { @@ -57,6 +59,9 @@ class SettingsActivity : RequireProvisioningActivity(), OnPreferenceStartFragmen ): Boolean { val fragment = supportFragmentManager.fragmentFactory.instantiate(classLoader, pref.fragment) + if (pref.key == PREF_BACKUP_RECOVERY_CODE) fragment.arguments = Bundle().apply { + putBoolean(ARG_FOR_NEW_CODE, false) + } supportFragmentManager.beginTransaction() .replace(R.id.fragment, fragment) .addToBackStack(null) diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeInputFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeInputFragment.kt index 60e4c673..abd2d8f2 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeInputFragment.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeInputFragment.kt @@ -12,16 +12,20 @@ import android.widget.Button import android.widget.TextView import android.widget.Toast import android.widget.Toast.LENGTH_LONG +import androidx.appcompat.app.AlertDialog import androidx.constraintlayout.widget.ConstraintLayout import androidx.fragment.app.Fragment import com.google.android.material.textfield.TextInputLayout import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.isDebugBuild +import com.stevesoltys.seedvault.ui.LiveEventHandler import io.github.novacrypto.bip39.Validation.InvalidChecksumException import io.github.novacrypto.bip39.Validation.WordNotFoundException import io.github.novacrypto.bip39.wordlists.English import org.koin.androidx.viewmodel.ext.android.sharedViewModel +internal const val ARG_FOR_NEW_CODE = "forVerifyingNewCode" + class RecoveryCodeInputFragment : Fragment() { private val viewModel: RecoveryCodeViewModel by sharedViewModel() @@ -43,6 +47,11 @@ class RecoveryCodeInputFragment : Fragment() { private lateinit var wordLayout12: TextInputLayout private lateinit var wordList: ConstraintLayout + /** + * True if this is for verifying a new recovery code, false for verifying an existing one. + */ + private var forVerifyingNewCode: Boolean = true + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -67,6 +76,10 @@ class RecoveryCodeInputFragment : Fragment() { wordLayout12 = v.findViewById(R.id.wordLayout12) wordList = v.findViewById(R.id.wordList) + arguments?.getBoolean(ARG_FOR_NEW_CODE, true)?.let { + forVerifyingNewCode = it + } + return v } @@ -91,7 +104,11 @@ class RecoveryCodeInputFragment : Fragment() { } doneButton.setOnClickListener { done() } - if (isDebugBuild() && !viewModel.isRestore) debugPreFill() + viewModel.existingCodeChecked.observeEvent(viewLifecycleOwner, + LiveEventHandler { verified -> onExistingCodeChecked(verified) } + ) + + if (forVerifyingNewCode && isDebugBuild() && !viewModel.isRestore) debugPreFill() } private fun getAdapter(): ArrayAdapter { @@ -110,7 +127,7 @@ class RecoveryCodeInputFragment : Fragment() { val input = getInput() if (!allFilledOut(input)) return try { - viewModel.validateAndContinue(input) + viewModel.validateAndContinue(input, forVerifyingNewCode) } catch (e: InvalidChecksumException) { Toast.makeText(context, R.string.recovery_code_error_checksum_word, LENGTH_LONG).show() } catch (e: WordNotFoundException) { @@ -141,6 +158,24 @@ class RecoveryCodeInputFragment : Fragment() { } } + private fun onExistingCodeChecked(verified: Boolean) { + AlertDialog.Builder(requireContext()).apply { + if (verified) { + setTitle(R.string.recovery_code_verification_ok_title) + setMessage(R.string.recovery_code_verification_ok_message) + setPositiveButton(android.R.string.ok) { dialog, _ -> dialog.dismiss() } + setOnDismissListener { parentFragmentManager.popBackStack() } + } else { + setIcon(R.drawable.ic_warning) + setTitle(R.string.recovery_code_verification_error_title) + setMessage(R.string.recovery_code_verification_error_message) + setPositiveButton(R.string.recovery_code_verification_try_again) { dialog, _ -> + dialog.dismiss() + } + } + }.show() + } + @Suppress("MagicNumber") private fun getWordLayout(i: Int) = when (i + 1) { 1 -> wordLayout1 diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeViewModel.kt index 4d293641..90ff8893 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeViewModel.kt @@ -2,6 +2,7 @@ package com.stevesoltys.seedvault.ui.recoverycode import androidx.lifecycle.AndroidViewModel import com.stevesoltys.seedvault.App +import com.stevesoltys.seedvault.crypto.Crypto import com.stevesoltys.seedvault.crypto.KeyManager import com.stevesoltys.seedvault.ui.LiveEvent import com.stevesoltys.seedvault.ui.MutableLiveEvent @@ -21,7 +22,11 @@ import java.util.ArrayList internal const val WORD_NUM = 12 internal const val WORD_LIST_SIZE = 2048 -class RecoveryCodeViewModel(app: App, private val keyManager: KeyManager) : AndroidViewModel(app) { +class RecoveryCodeViewModel( + app: App, + private val crypto: Crypto, + private val keyManager: KeyManager +) : AndroidViewModel(app) { internal val wordList: List by lazy { val items: ArrayList = ArrayList(WORD_NUM) @@ -40,10 +45,13 @@ class RecoveryCodeViewModel(app: App, private val keyManager: KeyManager) : Andr private val mRecoveryCodeSaved = MutableLiveEvent() internal val recoveryCodeSaved: LiveEvent = mRecoveryCodeSaved + private val mExistingCodeChecked = MutableLiveEvent() + internal val existingCodeChecked: LiveEvent = mExistingCodeChecked + internal var isRestore: Boolean = false @Throws(WordNotFoundException::class, InvalidChecksumException::class) - fun validateAndContinue(input: List) { + fun validateAndContinue(input: List, forVerifyingNewCode: Boolean) { try { MnemonicValidator.ofWordList(English.INSTANCE).validate(input) } catch (e: UnexpectedWhiteSpaceException) { @@ -53,9 +61,12 @@ class RecoveryCodeViewModel(app: App, private val keyManager: KeyManager) : Andr } val mnemonic = input.joinToString(" ") val seed = SeedCalculator(JavaxPBKDF2WithHmacSHA512.INSTANCE).calculateSeed(mnemonic, "") - keyManager.storeBackupKey(seed) - - mRecoveryCodeSaved.setEvent(true) + if (forVerifyingNewCode) { + keyManager.storeBackupKey(seed) + mRecoveryCodeSaved.setEvent(true) + } else { + mExistingCodeChecked.setEvent(crypto.verifyBackupKey(seed)) + } } } diff --git a/app/src/main/res/drawable/ic_vpn_key.xml b/app/src/main/res/drawable/ic_vpn_key.xml new file mode 100644 index 00000000..7b554c94 --- /dev/null +++ b/app/src/main/res/drawable/ic_vpn_key.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fce3fdb0..f3ac4148 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -28,6 +28,8 @@ Last backup: %1$s Exclude apps Backup now + Recovery Code + Verify existing code or generate a new one Choose where to store backups @@ -53,7 +55,7 @@ Confirm code Enter your 12-word recovery code to ensure that it will work when you need it. Enter your 12-word recovery code that you wrote down when setting up backups. - Done + Verify Word 1 Word 2 Word 3 @@ -69,6 +71,11 @@ You forgot to enter this word. Wrong word. Did you mean %1$s or %2$s? Your code is invalid. Please check all words and try again! + Recovery Code Verified + Your code is correct and will work for restoring your backup. + Incorrect Recovery Code + You have entered an invalid recovery code. Please try again!\n\nIf you have lost your code, tap on Generate New Code below. + Try Again Backup notification diff --git a/app/src/main/res/xml/settings.xml b/app/src/main/res/xml/settings.xml index 2665461f..a9954c06 100644 --- a/app/src/main/res/xml/settings.xml +++ b/app/src/main/res/xml/settings.xml @@ -36,6 +36,14 @@ app:summary="@string/settings_backup_apk_summary" app:title="@string/settings_backup_apk_title" /> + +