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"