mirror of
https://github.com/android-password-store/Android-Password-Store
synced 2025-09-02 23:35:08 +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.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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -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,
|
||||||
)
|
)
|
||||||
|
@@ -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,
|
||||||
)
|
)
|
||||||
|
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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 {
|
||||||
|
@@ -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>
|
||||||
|
Reference in New Issue
Block a user