mirror of
https://github.com/android-password-store/Android-Password-Store
synced 2025-08-31 06:15:48 +00:00
feat: wire up passphrase cache
Currently has horrible UX and is behind an experimental feature flag
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
|
@@ -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,
|
||||
)
|
||||
|
@@ -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,
|
||||
)
|
||||
|
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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 {
|
||||
|
@@ -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>
|
||||
|
Reference in New Issue
Block a user