feat: wire up passphrase cache

Currently has horrible UX and is behind an experimental feature flag
This commit is contained in:
Harsh Shandilya
2023-05-08 02:51:02 +05:30
parent 4ff0525e95
commit d988bdd0dc
6 changed files with 124 additions and 18 deletions

View File

@@ -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<GpgIdentifier>,
message: ByteArrayInputStream,
out: ByteArrayOutputStream,
) = withContext(dispatcherProvider.io()) { decryptPgp(password, message, out) }
) = withContext(dispatcherProvider.io()) { decryptPgp(password, identities, message, out) }
suspend fun encrypt(
identities: List<GpgIdentifier>,
@@ -53,11 +53,12 @@ constructor(
private suspend fun decryptPgp(
password: String,
identities: List<GpgIdentifier>,
message: ByteArrayInputStream,
out: ByteArrayOutputStream,
): Result<Unit, CryptoHandlerException> {
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)
}

View File

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

View File

@@ -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<GpgIdentifier>,
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<GpgIdentifier>,
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<GpgIdentifier>,
) = runCatching {
val message = withContext(dispatcherProvider.io()) { File(fullPath).readBytes().inputStream() }
val outputStream = ByteArrayOutputStream()
val result =
repository.decrypt(
password,
gpgIdentifiers,
message,
outputStream,
)

View File

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

View File

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

View File

@@ -371,4 +371,5 @@
<string name="pgp_key_manager_no_keys_guidance">Import a key using the add button below</string>
<string name="no_keys_imported_dialog_title">No keys imported</string>
<string name="no_keys_imported_dialog_message">There are no PGP keys imported in the app yet, press the button below to pick a key file</string>
<string name="pref_title_passphrase_cache">Enable passphrase caching</string>
</resources>