From d988bdd0dcfe5235cedcbd6b6dc5a49e6c3df571 Mon Sep 17 00:00:00 2001 From: Harsh Shandilya Date: Mon, 8 May 2023 02:51:02 +0530 Subject: feat: wire up passphrase cache Currently has horrible UX and is behind an experimental feature flag --- .../passwordstore/data/crypto/CryptoRepository.kt | 7 +- .../ui/autofill/AutofillDecryptActivity.kt | 50 ++++++++++++-- .../app/passwordstore/ui/crypto/DecryptActivity.kt | 76 +++++++++++++++++++--- .../app/passwordstore/ui/settings/PGPSettings.kt | 5 ++ .../app/passwordstore/util/features/Feature.kt | 3 + app/src/main/res/values/strings.xml | 1 + 6 files changed, 124 insertions(+), 18 deletions(-) (limited to 'app') diff --git a/app/src/main/java/app/passwordstore/data/crypto/CryptoRepository.kt b/app/src/main/java/app/passwordstore/data/crypto/CryptoRepository.kt index f3dff15e..ea1d9c76 100644 --- a/app/src/main/java/app/passwordstore/data/crypto/CryptoRepository.kt +++ b/app/src/main/java/app/passwordstore/data/crypto/CryptoRepository.kt @@ -18,7 +18,6 @@ import app.passwordstore.util.settings.PreferenceKeys import com.github.michaelbull.result.Result import com.github.michaelbull.result.getAll import com.github.michaelbull.result.mapBoth -import com.github.michaelbull.result.unwrap import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import javax.inject.Inject @@ -41,9 +40,10 @@ constructor( suspend fun decrypt( password: String, + identities: List, message: ByteArrayInputStream, out: ByteArrayOutputStream, - ) = withContext(dispatcherProvider.io()) { decryptPgp(password, message, out) } + ) = withContext(dispatcherProvider.io()) { decryptPgp(password, identities, message, out) } suspend fun encrypt( identities: List, @@ -53,11 +53,12 @@ constructor( private suspend fun decryptPgp( password: String, + identities: List, message: ByteArrayInputStream, out: ByteArrayOutputStream, ): Result { + val keys = identities.map { id -> pgpKeyManager.getKeyById(id) }.getAll() val decryptionOptions = PGPDecryptOptions.Builder().build() - val keys = pgpKeyManager.getAllKeys().unwrap() return pgpCryptoHandler.decrypt(keys, password, message, out, decryptionOptions) } diff --git a/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt b/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt index 4e8c9891..fe400238 100644 --- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt @@ -13,13 +13,17 @@ import android.os.Bundle import android.view.autofill.AutofillManager import androidx.annotation.RequiresApi import androidx.lifecycle.lifecycleScope +import app.passwordstore.data.crypto.GPGPassphraseCache import app.passwordstore.data.passfile.PasswordEntry import app.passwordstore.ui.crypto.BasePgpActivity import app.passwordstore.ui.crypto.PasswordDialog +import app.passwordstore.util.auth.BiometricAuthenticator import app.passwordstore.util.autofill.AutofillPreferences import app.passwordstore.util.autofill.AutofillResponseBuilder import app.passwordstore.util.autofill.DirectoryStructure import app.passwordstore.util.extensions.asLog +import app.passwordstore.util.features.Feature.EnableGPGPassphraseCache +import app.passwordstore.util.features.Features import com.github.androidpasswordstore.autofillparser.AutofillAction import com.github.androidpasswordstore.autofillparser.Credentials import com.github.michaelbull.result.getOrElse @@ -77,6 +81,8 @@ class AutofillDecryptActivity : BasePgpActivity() { } @Inject lateinit var passwordEntryFactory: PasswordEntry.Factory + @Inject lateinit var features: Features + @Inject lateinit var passphraseCache: GPGPassphraseCache private lateinit var directoryStructure: DirectoryStructure @@ -101,18 +107,46 @@ class AutofillDecryptActivity : BasePgpActivity() { directoryStructure = AutofillPreferences.directoryStructure(this) logcat { action.toString() } requireKeysExist { - val dialog = PasswordDialog() - lifecycleScope.launch { - withContext(Dispatchers.Main) { - dialog.password.collectLatest { value -> - if (value != null) { - decrypt(File(filePath), clientState, action, value) + val gpgIdentifiers = getGpgIdentifiers("") ?: return@requireKeysExist + if ( + BiometricAuthenticator.canAuthenticate(this) && features.isEnabled(EnableGPGPassphraseCache) + ) { + BiometricAuthenticator.authenticate(this) { authResult -> + if (authResult is BiometricAuthenticator.Result.Success) { + lifecycleScope.launch { + val cachedPassphrase = + passphraseCache.retrieveCachedPassphrase( + this@AutofillDecryptActivity, + gpgIdentifiers.first() + ) + if (cachedPassphrase != null) { + decrypt(File(filePath), clientState, action, cachedPassphrase) + } else { + askPassphrase(filePath, clientState, action) + } } + } else { + askPassphrase(filePath, clientState, action) + } + } + } else { + askPassphrase(filePath, clientState, action) + } + } + } + + private fun askPassphrase(filePath: String, clientState: Bundle, action: AutofillAction) { + val dialog = PasswordDialog() + lifecycleScope.launch { + withContext(Dispatchers.Main) { + dialog.password.collectLatest { value -> + if (value != null) { + decrypt(File(filePath), clientState, action, value) } } } - dialog.show(supportFragmentManager, "PASSWORD_DIALOG") } + dialog.show(supportFragmentManager, "PASSWORD_DIALOG") } private suspend fun decrypt( @@ -143,6 +177,7 @@ class AutofillDecryptActivity : BasePgpActivity() { } private suspend fun decryptCredential(file: File, password: String): Credentials? { + val gpgIdentifiers = getGpgIdentifiers("") ?: return null runCatching { file.readBytes().inputStream() } .onFailure { e -> logcat(ERROR) { e.asLog("File to decrypt not found") } @@ -154,6 +189,7 @@ class AutofillDecryptActivity : BasePgpActivity() { val outputStream = ByteArrayOutputStream() repository.decrypt( password, + gpgIdentifiers, encryptedInput, outputStream, ) diff --git a/app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt b/app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt index 3df2422f..05d1edeb 100644 --- a/app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt @@ -11,13 +11,18 @@ import android.view.Menu import android.view.MenuItem import androidx.lifecycle.lifecycleScope import app.passwordstore.R +import app.passwordstore.crypto.GpgIdentifier +import app.passwordstore.data.crypto.GPGPassphraseCache import app.passwordstore.data.passfile.PasswordEntry import app.passwordstore.data.password.FieldItem import app.passwordstore.databinding.DecryptLayoutBinding import app.passwordstore.ui.adapters.FieldItemAdapter +import app.passwordstore.util.auth.BiometricAuthenticator import app.passwordstore.util.extensions.getString import app.passwordstore.util.extensions.unsafeLazy import app.passwordstore.util.extensions.viewBinding +import app.passwordstore.util.features.Feature.EnableGPGPassphraseCache +import app.passwordstore.util.features.Features import app.passwordstore.util.settings.Constants import app.passwordstore.util.settings.PreferenceKeys import com.github.michaelbull.result.Err @@ -28,7 +33,6 @@ import java.io.ByteArrayOutputStream import java.io.File import javax.inject.Inject import kotlin.time.Duration.Companion.seconds -import kotlin.time.ExperimentalTime import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.first @@ -39,14 +43,14 @@ import kotlinx.coroutines.withContext import logcat.LogPriority.ERROR import logcat.logcat -@OptIn(ExperimentalTime::class) @AndroidEntryPoint class DecryptActivity : BasePgpActivity() { private val binding by viewBinding(DecryptLayoutBinding::inflate) private val relativeParentPath by unsafeLazy { getParentPath(fullPath, repoPath) } @Inject lateinit var passwordEntryFactory: PasswordEntry.Factory - + @Inject lateinit var passphraseCache: GPGPassphraseCache + @Inject lateinit var features: Features private var passwordEntry: PasswordEntry? = null private var retries = 0 @@ -63,7 +67,16 @@ class DecryptActivity : BasePgpActivity() { true } } - requireKeysExist { askPassphrase(isError = false) } + if ( + BiometricAuthenticator.canAuthenticate(this@DecryptActivity) && + features.isEnabled(EnableGPGPassphraseCache) + ) { + BiometricAuthenticator.authenticate(this@DecryptActivity) { authResult -> + requireKeysExist { decrypt(isError = false, authResult) } + } + } else { + requireKeysExist { decrypt(isError = false, BiometricAuthenticator.Result.Cancelled) } + } } override fun onCreateOptionsMenu(menu: Menu): Boolean { @@ -134,7 +147,28 @@ class DecryptActivity : BasePgpActivity() { ) } - private fun askPassphrase(isError: Boolean) { + private fun decrypt(isError: Boolean, authResult: BiometricAuthenticator.Result) { + val gpgIdentifiers = getGpgIdentifiers("") ?: return + lifecycleScope.launch(dispatcherProvider.main()) { + if (authResult is BiometricAuthenticator.Result.Success) { + val cachedPassphrase = + passphraseCache.retrieveCachedPassphrase(this@DecryptActivity, gpgIdentifiers.first()) + if (cachedPassphrase != null) { + decryptWithCachedPassphrase(cachedPassphrase, gpgIdentifiers, authResult) + } else { + askPassphrase(isError, gpgIdentifiers, authResult) + } + } else { + askPassphrase(isError, gpgIdentifiers, authResult) + } + } + } + + private fun askPassphrase( + isError: Boolean, + gpgIdentifiers: List, + authResult: BiometricAuthenticator.Result, + ) { if (retries < MAX_RETRIES) { retries += 1 } else { @@ -147,16 +181,19 @@ class DecryptActivity : BasePgpActivity() { lifecycleScope.launch(dispatcherProvider.main()) { dialog.password.collectLatest { value -> if (value != null) { - when (val result = decryptWithPassphrase(value)) { + when (val result = decryptWithPassphrase(value, gpgIdentifiers)) { is Ok -> { val entry = passwordEntryFactory.create(result.value.toByteArray()) passwordEntry = entry createPasswordUI(entry) startAutoDismissTimer() + if (authResult is BiometricAuthenticator.Result.Success) { + passphraseCache.cachePassphrase(this@DecryptActivity, gpgIdentifiers.first(), value) + } } is Err -> { logcat(ERROR) { result.error.stackTraceToString() } - askPassphrase(isError = true) + askPassphrase(isError = true, gpgIdentifiers, authResult) } } } @@ -165,12 +202,35 @@ class DecryptActivity : BasePgpActivity() { dialog.show(supportFragmentManager, "PASSWORD_DIALOG") } - private suspend fun decryptWithPassphrase(password: String) = runCatching { + private suspend fun decryptWithCachedPassphrase( + passphrase: String, + identifiers: List, + authResult: BiometricAuthenticator.Result, + ) { + when (val result = decryptWithPassphrase(passphrase, identifiers)) { + is Ok -> { + val entry = passwordEntryFactory.create(result.value.toByteArray()) + passwordEntry = entry + createPasswordUI(entry) + startAutoDismissTimer() + } + is Err -> { + logcat(ERROR) { result.error.stackTraceToString() } + decrypt(isError = true, authResult = authResult) + } + } + } + + private suspend fun decryptWithPassphrase( + password: String, + gpgIdentifiers: List, + ) = runCatching { val message = withContext(dispatcherProvider.io()) { File(fullPath).readBytes().inputStream() } val outputStream = ByteArrayOutputStream() val result = repository.decrypt( password, + gpgIdentifiers, message, outputStream, ) diff --git a/app/src/main/java/app/passwordstore/ui/settings/PGPSettings.kt b/app/src/main/java/app/passwordstore/ui/settings/PGPSettings.kt index a704b7a4..7be4a268 100644 --- a/app/src/main/java/app/passwordstore/ui/settings/PGPSettings.kt +++ b/app/src/main/java/app/passwordstore/ui/settings/PGPSettings.kt @@ -9,6 +9,7 @@ import androidx.fragment.app.FragmentActivity import app.passwordstore.R import app.passwordstore.ui.pgp.PGPKeyListActivity import app.passwordstore.util.extensions.launchActivity +import app.passwordstore.util.features.Feature import app.passwordstore.util.settings.PreferenceKeys import de.Maxr1998.modernpreferences.PreferenceScreen import de.Maxr1998.modernpreferences.helpers.onClick @@ -31,6 +32,10 @@ class PGPSettings(private val activity: FragmentActivity) : SettingsProvider { titleRes = R.string.pref_pgp_ascii_armor_title persistent = true } + switch(Feature.EnableGPGPassphraseCache.configKey) { + titleRes = R.string.pref_title_passphrase_cache + defaultValue = false + } } } } diff --git a/app/src/main/java/app/passwordstore/util/features/Feature.kt b/app/src/main/java/app/passwordstore/util/features/Feature.kt index a714587a..a0729f4d 100644 --- a/app/src/main/java/app/passwordstore/util/features/Feature.kt +++ b/app/src/main/java/app/passwordstore/util/features/Feature.kt @@ -22,6 +22,9 @@ enum class Feature( /** Opt into the new SSH layer implemented as a freestanding module. */ EnableNewSSHLayer(false, "enable_new_ssh"), + + /** Opt into a cache layer for GPG passphrases. */ + EnableGPGPassphraseCache(false, "enable_gpg_passphrase_cache"), ; companion object { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f3e2dbd0..e0a1d3b7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -371,4 +371,5 @@ Import a key using the add button below No keys imported There are no PGP keys imported in the app yet, press the button below to pick a key file + Enable passphrase caching -- cgit v1.2.3