Allow user to verify existing recovery code
This commit is contained in:
parent
b7f8c24538
commit
d64413a7c3
10 changed files with 119 additions and 11 deletions
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
10
app/src/main/res/drawable/ic_vpn_key.xml
Normal file
10
app/src/main/res/drawable/ic_vpn_key.xml
Normal 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>
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue