Allow user to verify existing recovery code
diff --git a/app/src/main/java/com/stevesoltys/seedvault/App.kt b/app/src/main/java/com/stevesoltys/seedvault/App.kt
index ce1f047..e91e7c8 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 @@
         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 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 <T> 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 cf19a22..52d7ef8 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 @@
 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 @@
         }
     }
 
+    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 8b6a696..78c8e6e 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.InputStream
 import java.io.OutputStream
 import javax.crypto.Cipher
+import javax.crypto.spec.SecretKeySpec
 import kotlin.math.min
 
 /**
@@ -95,6 +96,13 @@
      */
     @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 @@
         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 e472bd3..eedbdee 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.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 @@
 
     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 acad9e5..9d6a026 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.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 @@
     ): 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 60e4c67..abd2d8f 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.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 @@
     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 @@
         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 @@
         }
         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<String> {
@@ -110,7 +127,7 @@
         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 @@
         }
     }
 
+    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 4d29364..90ff889 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 @@
 
 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 @@
 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<CharSequence> by lazy {
         val items: ArrayList<CharSequence> = ArrayList(WORD_NUM)
@@ -40,10 +45,13 @@
     private val mRecoveryCodeSaved = MutableLiveEvent<Boolean>()
     internal val recoveryCodeSaved: LiveEvent<Boolean> = mRecoveryCodeSaved
 
+    private val mExistingCodeChecked = MutableLiveEvent<Boolean>()
+    internal val existingCodeChecked: LiveEvent<Boolean> = mExistingCodeChecked
+
     internal var isRestore: Boolean = false
 
     @Throws(WordNotFoundException::class, InvalidChecksumException::class)
-    fun validateAndContinue(input: List<CharSequence>) {
+    fun validateAndContinue(input: List<CharSequence>, forVerifyingNewCode: Boolean) {
         try {
             MnemonicValidator.ofWordList(English.INSTANCE).validate(input)
         } catch (e: UnexpectedWhiteSpaceException) {
@@ -53,9 +61,12 @@
         }
         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 0000000..7b554c9
--- /dev/null
+++ b/app/src/main/res/drawable/ic_vpn_key.xml
@@ -0,0 +1,10 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:tint="?attr/colorControlNormal"
+    android:viewportWidth="24"
+    android:viewportHeight="24">
+    <path
+        android:fillColor="@android:color/white"
+        android:pathData="M12.65,10C11.83,7.67 9.61,6 7,6c-3.31,0 -6,2.69 -6,6s2.69,6 6,6c2.61,0 4.83,-1.67 5.65,-4H17v4h4v-4h2v-4H12.65zM7,14c-1.1,0 -2,-0.9 -2,-2s0.9,-2 2,-2 2,0.9 2,2 -0.9,2 -2,2z" />
+</vector>
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index fce3fdb..f3ac414 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -28,6 +28,8 @@
     <string name="settings_backup_status_summary">Last backup: %1$s</string>
     <string name="settings_backup_exclude_apps">Exclude apps</string>
     <string name="settings_backup_now">Backup now</string>
+    <string name="settings_backup_recovery_code">Recovery Code</string>
+    <string name="settings_backup_recovery_code_summary">Verify existing code or generate a new one</string>
 
     <!-- Storage -->
     <string name="storage_fragment_backup_title">Choose where to store backups</string>
@@ -53,7 +55,7 @@
     <string name="recovery_code_confirm_button">Confirm code</string>
     <string name="recovery_code_confirm_intro">Enter your 12-word recovery code to ensure that it will work when you need it.</string>
     <string name="recovery_code_input_intro">Enter your 12-word recovery code that you wrote down when setting up backups.</string>
-    <string name="recovery_code_done_button">Done</string>
+    <string name="recovery_code_done_button">Verify</string>
     <string name="recovery_code_input_hint_1">Word 1</string>
     <string name="recovery_code_input_hint_2">Word 2</string>
     <string name="recovery_code_input_hint_3">Word 3</string>
@@ -69,6 +71,11 @@
     <string name="recovery_code_error_empty_word">You forgot to enter this word.</string>
     <string name="recovery_code_error_invalid_word">Wrong word. Did you mean %1$s or %2$s?</string>
     <string name="recovery_code_error_checksum_word">Your code is invalid. Please check all words and try again!</string>
+    <string name="recovery_code_verification_ok_title">Recovery Code Verified</string>
+    <string name="recovery_code_verification_ok_message">Your code is correct and will work for restoring your backup.</string>
+    <string name="recovery_code_verification_error_title">Incorrect Recovery Code</string>
+    <string name="recovery_code_verification_error_message">You have entered an invalid recovery code. Please try again!\n\nIf you have lost your code, tap on Generate New Code below.</string>
+    <string name="recovery_code_verification_try_again">Try Again</string>
 
     <!-- Notification -->
     <string name="notification_channel_title">Backup notification</string>
diff --git a/app/src/main/res/xml/settings.xml b/app/src/main/res/xml/settings.xml
index 2665461..a9954c0 100644
--- a/app/src/main/res/xml/settings.xml
+++ b/app/src/main/res/xml/settings.xml
@@ -37,6 +37,14 @@
         app:title="@string/settings_backup_apk_title" />
 
     <androidx.preference.Preference
+        app:dependency="backup"
+        app:fragment="com.stevesoltys.seedvault.ui.recoverycode.RecoveryCodeInputFragment"
+        app:icon="@drawable/ic_vpn_key"
+        app:key="backup_recovery_code"
+        app:summary="@string/settings_backup_recovery_code_summary"
+        app:title="@string/settings_backup_recovery_code" />
+
+    <androidx.preference.Preference
         app:allowDividerAbove="true"
         app:allowDividerBelow="false"
         app:dependency="backup"