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.Result
import com.github.michaelbull.result.getAll import com.github.michaelbull.result.getAll
import com.github.michaelbull.result.mapBoth import com.github.michaelbull.result.mapBoth
import com.github.michaelbull.result.unwrap
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import javax.inject.Inject import javax.inject.Inject
@@ -41,9 +40,10 @@ constructor(
suspend fun decrypt( suspend fun decrypt(
password: String, password: String,
identities: List<GpgIdentifier>,
message: ByteArrayInputStream, message: ByteArrayInputStream,
out: ByteArrayOutputStream, out: ByteArrayOutputStream,
) = withContext(dispatcherProvider.io()) { decryptPgp(password, message, out) } ) = withContext(dispatcherProvider.io()) { decryptPgp(password, identities, message, out) }
suspend fun encrypt( suspend fun encrypt(
identities: List<GpgIdentifier>, identities: List<GpgIdentifier>,
@@ -53,11 +53,12 @@ constructor(
private suspend fun decryptPgp( private suspend fun decryptPgp(
password: String, password: String,
identities: List<GpgIdentifier>,
message: ByteArrayInputStream, message: ByteArrayInputStream,
out: ByteArrayOutputStream, out: ByteArrayOutputStream,
): Result<Unit, CryptoHandlerException> { ): Result<Unit, CryptoHandlerException> {
val keys = identities.map { id -> pgpKeyManager.getKeyById(id) }.getAll()
val decryptionOptions = PGPDecryptOptions.Builder().build() val decryptionOptions = PGPDecryptOptions.Builder().build()
val keys = pgpKeyManager.getAllKeys().unwrap()
return pgpCryptoHandler.decrypt(keys, password, message, out, decryptionOptions) return pgpCryptoHandler.decrypt(keys, password, message, out, decryptionOptions)
} }

View File

@@ -13,13 +13,17 @@ import android.os.Bundle
import android.view.autofill.AutofillManager import android.view.autofill.AutofillManager
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import app.passwordstore.data.crypto.GPGPassphraseCache
import app.passwordstore.data.passfile.PasswordEntry import app.passwordstore.data.passfile.PasswordEntry
import app.passwordstore.ui.crypto.BasePgpActivity import app.passwordstore.ui.crypto.BasePgpActivity
import app.passwordstore.ui.crypto.PasswordDialog import app.passwordstore.ui.crypto.PasswordDialog
import app.passwordstore.util.auth.BiometricAuthenticator
import app.passwordstore.util.autofill.AutofillPreferences import app.passwordstore.util.autofill.AutofillPreferences
import app.passwordstore.util.autofill.AutofillResponseBuilder import app.passwordstore.util.autofill.AutofillResponseBuilder
import app.passwordstore.util.autofill.DirectoryStructure import app.passwordstore.util.autofill.DirectoryStructure
import app.passwordstore.util.extensions.asLog 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.AutofillAction
import com.github.androidpasswordstore.autofillparser.Credentials import com.github.androidpasswordstore.autofillparser.Credentials
import com.github.michaelbull.result.getOrElse import com.github.michaelbull.result.getOrElse
@@ -77,6 +81,8 @@ class AutofillDecryptActivity : BasePgpActivity() {
} }
@Inject lateinit var passwordEntryFactory: PasswordEntry.Factory @Inject lateinit var passwordEntryFactory: PasswordEntry.Factory
@Inject lateinit var features: Features
@Inject lateinit var passphraseCache: GPGPassphraseCache
private lateinit var directoryStructure: DirectoryStructure private lateinit var directoryStructure: DirectoryStructure
@@ -101,6 +107,35 @@ class AutofillDecryptActivity : BasePgpActivity() {
directoryStructure = AutofillPreferences.directoryStructure(this) directoryStructure = AutofillPreferences.directoryStructure(this)
logcat { action.toString() } logcat { action.toString() }
requireKeysExist { requireKeysExist {
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() val dialog = PasswordDialog()
lifecycleScope.launch { lifecycleScope.launch {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
@@ -113,7 +148,6 @@ class AutofillDecryptActivity : BasePgpActivity() {
} }
dialog.show(supportFragmentManager, "PASSWORD_DIALOG") dialog.show(supportFragmentManager, "PASSWORD_DIALOG")
} }
}
private suspend fun decrypt( private suspend fun decrypt(
filePath: File, filePath: File,
@@ -143,6 +177,7 @@ class AutofillDecryptActivity : BasePgpActivity() {
} }
private suspend fun decryptCredential(file: File, password: String): Credentials? { private suspend fun decryptCredential(file: File, password: String): Credentials? {
val gpgIdentifiers = getGpgIdentifiers("") ?: return null
runCatching { file.readBytes().inputStream() } runCatching { file.readBytes().inputStream() }
.onFailure { e -> .onFailure { e ->
logcat(ERROR) { e.asLog("File to decrypt not found") } logcat(ERROR) { e.asLog("File to decrypt not found") }
@@ -154,6 +189,7 @@ class AutofillDecryptActivity : BasePgpActivity() {
val outputStream = ByteArrayOutputStream() val outputStream = ByteArrayOutputStream()
repository.decrypt( repository.decrypt(
password, password,
gpgIdentifiers,
encryptedInput, encryptedInput,
outputStream, outputStream,
) )

View File

@@ -11,13 +11,18 @@ import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import app.passwordstore.R 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.passfile.PasswordEntry
import app.passwordstore.data.password.FieldItem import app.passwordstore.data.password.FieldItem
import app.passwordstore.databinding.DecryptLayoutBinding import app.passwordstore.databinding.DecryptLayoutBinding
import app.passwordstore.ui.adapters.FieldItemAdapter import app.passwordstore.ui.adapters.FieldItemAdapter
import app.passwordstore.util.auth.BiometricAuthenticator
import app.passwordstore.util.extensions.getString import app.passwordstore.util.extensions.getString
import app.passwordstore.util.extensions.unsafeLazy import app.passwordstore.util.extensions.unsafeLazy
import app.passwordstore.util.extensions.viewBinding 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.Constants
import app.passwordstore.util.settings.PreferenceKeys import app.passwordstore.util.settings.PreferenceKeys
import com.github.michaelbull.result.Err import com.github.michaelbull.result.Err
@@ -28,7 +33,6 @@ import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
import kotlin.time.ExperimentalTime
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@@ -39,14 +43,14 @@ import kotlinx.coroutines.withContext
import logcat.LogPriority.ERROR import logcat.LogPriority.ERROR
import logcat.logcat import logcat.logcat
@OptIn(ExperimentalTime::class)
@AndroidEntryPoint @AndroidEntryPoint
class DecryptActivity : BasePgpActivity() { class DecryptActivity : BasePgpActivity() {
private val binding by viewBinding(DecryptLayoutBinding::inflate) private val binding by viewBinding(DecryptLayoutBinding::inflate)
private val relativeParentPath by unsafeLazy { getParentPath(fullPath, repoPath) } private val relativeParentPath by unsafeLazy { getParentPath(fullPath, repoPath) }
@Inject lateinit var passwordEntryFactory: PasswordEntry.Factory @Inject lateinit var passwordEntryFactory: PasswordEntry.Factory
@Inject lateinit var passphraseCache: GPGPassphraseCache
@Inject lateinit var features: Features
private var passwordEntry: PasswordEntry? = null private var passwordEntry: PasswordEntry? = null
private var retries = 0 private var retries = 0
@@ -63,7 +67,16 @@ class DecryptActivity : BasePgpActivity() {
true 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 { 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) { if (retries < MAX_RETRIES) {
retries += 1 retries += 1
} else { } else {
@@ -147,16 +181,19 @@ class DecryptActivity : BasePgpActivity() {
lifecycleScope.launch(dispatcherProvider.main()) { lifecycleScope.launch(dispatcherProvider.main()) {
dialog.password.collectLatest { value -> dialog.password.collectLatest { value ->
if (value != null) { if (value != null) {
when (val result = decryptWithPassphrase(value)) { when (val result = decryptWithPassphrase(value, gpgIdentifiers)) {
is Ok -> { is Ok -> {
val entry = passwordEntryFactory.create(result.value.toByteArray()) val entry = passwordEntryFactory.create(result.value.toByteArray())
passwordEntry = entry passwordEntry = entry
createPasswordUI(entry) createPasswordUI(entry)
startAutoDismissTimer() startAutoDismissTimer()
if (authResult is BiometricAuthenticator.Result.Success) {
passphraseCache.cachePassphrase(this@DecryptActivity, gpgIdentifiers.first(), value)
}
} }
is Err -> { is Err -> {
logcat(ERROR) { result.error.stackTraceToString() } 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") 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 message = withContext(dispatcherProvider.io()) { File(fullPath).readBytes().inputStream() }
val outputStream = ByteArrayOutputStream() val outputStream = ByteArrayOutputStream()
val result = val result =
repository.decrypt( repository.decrypt(
password, password,
gpgIdentifiers,
message, message,
outputStream, outputStream,
) )

View File

@@ -9,6 +9,7 @@ import androidx.fragment.app.FragmentActivity
import app.passwordstore.R import app.passwordstore.R
import app.passwordstore.ui.pgp.PGPKeyListActivity import app.passwordstore.ui.pgp.PGPKeyListActivity
import app.passwordstore.util.extensions.launchActivity import app.passwordstore.util.extensions.launchActivity
import app.passwordstore.util.features.Feature
import app.passwordstore.util.settings.PreferenceKeys import app.passwordstore.util.settings.PreferenceKeys
import de.Maxr1998.modernpreferences.PreferenceScreen import de.Maxr1998.modernpreferences.PreferenceScreen
import de.Maxr1998.modernpreferences.helpers.onClick 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 titleRes = R.string.pref_pgp_ascii_armor_title
persistent = true 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. */ /** Opt into the new SSH layer implemented as a freestanding module. */
EnableNewSSHLayer(false, "enable_new_ssh"), EnableNewSSHLayer(false, "enable_new_ssh"),
/** Opt into a cache layer for GPG passphrases. */
EnableGPGPassphraseCache(false, "enable_gpg_passphrase_cache"),
; ;
companion object { 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="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_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="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> </resources>