Allow user to verify existing recovery code

This commit is contained in:
Torsten Grote 2020-11-19 09:33:31 -03:00 committed by Chirayu Desai
parent b7f8c24538
commit d64413a7c3
10 changed files with 119 additions and 11 deletions

View file

@ -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 <T> permitDiskReads(func: () -> T): T {

View file

@ -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)
}
}
}

View file

@ -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)
}
}

View file

@ -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())

View file

@ -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)

View file

@ -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<String> {
@ -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

View file

@ -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<CharSequence> by lazy {
val items: ArrayList<CharSequence> = ArrayList(WORD_NUM)
@ -40,10 +45,13 @@ class RecoveryCodeViewModel(app: App, private val keyManager: KeyManager) : Andr
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 @@ class RecoveryCodeViewModel(app: App, private val keyManager: KeyManager) : Andr
}
val mnemonic = input.joinToString(" ")
val seed = SeedCalculator(JavaxPBKDF2WithHmacSHA512.INSTANCE).calculateSeed(mnemonic, "")
if (forVerifyingNewCode) {
keyManager.storeBackupKey(seed)
mRecoveryCodeSaved.setEvent(true)
} else {
mExistingCodeChecked.setEvent(crypto.verifyBackupKey(seed))
}
}
}

View file

@ -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>

View file

@ -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>

View file

@ -36,6 +36,14 @@
app:summary="@string/settings_backup_apk_summary"
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"