From 13c5ac4f4402322a0114b283f8434e56b67b4adb Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Mon, 21 Sep 2026 13:34:51 +0200 Subject: [PATCH 01/43] feat(pgp): add OpenPGP provider backend --- .../data/crypto/OpenPgpApiBackend.kt | 254 ++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100644 app/src/main/java/app/passwordstore/data/crypto/OpenPgpApiBackend.kt diff --git a/app/src/main/java/app/passwordstore/data/crypto/OpenPgpApiBackend.kt b/app/src/main/java/app/passwordstore/data/crypto/OpenPgpApiBackend.kt new file mode 100644 index 0000000000..afd567d218 --- /dev/null +++ b/app/src/main/java/app/passwordstore/data/crypto/OpenPgpApiBackend.kt @@ -0,0 +1,254 @@ +/* + * Copyright © 2014-2026 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.data.crypto + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.ResolveInfo +import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.util.concurrent.CancellationException +import javax.inject.Inject +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.openintents.openpgp.IOpenPgpService2 +import org.openintents.openpgp.OpenPgpError +import org.openintents.openpgp.util.OpenPgpApi +import org.openintents.openpgp.util.OpenPgpServiceConnection + +/** + * Operation-oriented adapter for apps implementing the OpenPGP API. + * + * Secret keys never cross this boundary. Private-key operations are delegated to the selected + * provider, while public certificates can be retrieved for APS' local PGPainless encryption path. + */ +class OpenPgpApiBackend internal constructor(private val executor: OpenPgpApiExecutor) { + + @Inject + constructor(@ApplicationContext context: Context) : this(BinderOpenPgpApiExecutor(context)) + + data class Provider(val packageName: String, val label: String) + + sealed interface OperationResult { + data class Success(val value: T) : OperationResult + + data class UserInteractionRequired(val pendingIntent: PendingIntent) : OperationResult + + data object Cancelled : OperationResult + + data class Failure(val error: Throwable) : OperationResult + } + + fun interface InteractionHandler { + suspend fun interact(pendingIntent: PendingIntent): InteractionResult + } + + sealed interface InteractionResult { + data class Completed(val data: Intent?) : InteractionResult + + data object Cancelled : InteractionResult + } + + fun providers(): List = executor.providers() + + fun isProviderInstalled(packageName: String): Boolean = + providers().any { it.packageName == packageName } + + suspend fun checkPermission( + providerPackage: String, + interactionHandler: InteractionHandler? = null, + ): OperationResult = + executeWithInteraction( + providerPackage = providerPackage, + initialRequest = Intent(OpenPgpApi.ACTION_CHECK_PERMISSION), + input = null, + interactionHandler = interactionHandler, + ) { Unit } + + suspend fun decrypt( + providerPackage: String, + ciphertext: ByteArray, + interactionHandler: InteractionHandler? = null, + ): OperationResult = + executeWithInteraction( + providerPackage = providerPackage, + initialRequest = Intent(OpenPgpApi.ACTION_DECRYPT_VERIFY), + input = ciphertext, + interactionHandler = interactionHandler, + ) { call -> call.output } + + suspend fun getPublicKey( + providerPackage: String, + keyId: Long, + asciiArmor: Boolean = false, + interactionHandler: InteractionHandler? = null, + ): OperationResult = + executeWithInteraction( + providerPackage = providerPackage, + initialRequest = + Intent(OpenPgpApi.ACTION_GET_KEY).apply { + putExtra(OpenPgpApi.EXTRA_KEY_ID, keyId) + putExtra(OpenPgpApi.EXTRA_REQUEST_ASCII_ARMOR, asciiArmor) + }, + input = null, + interactionHandler = interactionHandler, + ) { call -> call.output } + + suspend fun resolveKeyIds( + providerPackage: String, + userIds: Array, + interactionHandler: InteractionHandler? = null, + ): OperationResult = + executeWithInteraction( + providerPackage = providerPackage, + initialRequest = + Intent(OpenPgpApi.ACTION_GET_KEY_IDS).apply { + putExtra(OpenPgpApi.EXTRA_USER_IDS, userIds) + }, + input = null, + interactionHandler = interactionHandler, + ) { call -> call.result.getLongArrayExtra(OpenPgpApi.RESULT_KEY_IDS) ?: longArrayOf() } + + private suspend fun executeWithInteraction( + providerPackage: String, + initialRequest: Intent, + input: ByteArray?, + interactionHandler: InteractionHandler?, + onSuccess: (OpenPgpApiCall) -> T, + ): OperationResult { + var request = initialRequest + + repeat(MAX_INTERACTION_ROUNDS) { + val call = + try { + executor.execute(providerPackage, request, input) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + return OperationResult.Failure(error) + } + + when (call.result.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) { + OpenPgpApi.RESULT_CODE_SUCCESS -> return OperationResult.Success(onSuccess(call)) + OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> { + @Suppress("DEPRECATION") + val pendingIntent = + call.result.getParcelableExtra(OpenPgpApi.RESULT_INTENT) + ?: return OperationResult.Failure( + IllegalStateException( + "OpenPGP provider requested user interaction without a PendingIntent" + ) + ) + val handler = + interactionHandler ?: return OperationResult.UserInteractionRequired(pendingIntent) + when (val interaction = handler.interact(pendingIntent)) { + is InteractionResult.Completed -> { + // The OpenPGP API specifies that the result Intent contains the original operation + // plus the provider's newly granted state. Some providers return no data after a + // pure permission grant, in which case retrying the original request is safe. + request = interaction.data ?: request + } + InteractionResult.Cancelled -> return OperationResult.Cancelled + } + } + else -> { + @Suppress("DEPRECATION") + val error = call.result.getParcelableExtra(OpenPgpApi.RESULT_ERROR) + return OperationResult.Failure( + OpenPgpProviderException(error?.message ?: "OpenPGP provider operation failed") + ) + } + } + } + + return OperationResult.Failure( + IllegalStateException("OpenPGP provider requested too many interaction rounds") + ) + } + + private companion object { + const val MAX_INTERACTION_ROUNDS = 4 + } +} + +class OpenPgpProviderException(message: String) : Exception(message) + +internal data class OpenPgpApiCall(val result: Intent, val output: ByteArray) + +internal interface OpenPgpApiExecutor { + fun providers(): List + + suspend fun execute( + providerPackage: String, + request: Intent, + input: ByteArray?, + ): OpenPgpApiCall +} + +internal class BinderOpenPgpApiExecutor(private val context: Context) : OpenPgpApiExecutor { + + @Suppress("DEPRECATION") + override fun providers(): List { + return context.packageManager + .queryIntentServices(Intent(OpenPgpApi.SERVICE_INTENT_2), 0) + .mapNotNull(::providerFromResolveInfo) + .distinctBy { it.packageName } + .sortedBy { it.label.lowercase() } + } + + override suspend fun execute( + providerPackage: String, + request: Intent, + input: ByteArray?, + ): OpenPgpApiCall = + withService(providerPackage) { service -> + val output = ByteArrayOutputStream() + val result = + OpenPgpApi(context, service) + .executeApi(request, input?.let(::ByteArrayInputStream), output) + OpenPgpApiCall(result, output.toByteArray()) + } + + private suspend fun withService( + providerPackage: String, + operation: (IOpenPgpService2) -> T, + ): T = + withContext(Dispatchers.IO) { + val service = CompletableDeferred() + val connection = + OpenPgpServiceConnection( + context, + providerPackage, + object : OpenPgpServiceConnection.OnBound { + override fun onBound(boundService: IOpenPgpService2) { + service.complete(boundService) + } + + override fun onError(error: Exception) { + service.completeExceptionally(error) + } + }, + ) + + try { + connection.bindToService() + operation(service.await()) + } finally { + if (connection.isBound) runCatching { connection.unbindFromService() } + } + } + + private fun providerFromResolveInfo(info: ResolveInfo): OpenPgpApiBackend.Provider? { + val serviceInfo = info.serviceInfo ?: return null + val packageName = serviceInfo.packageName ?: return null + val label = + info.loadLabel(context.packageManager)?.toString()?.ifBlank { packageName } ?: packageName + return OpenPgpApiBackend.Provider(packageName, label) + } +} From a33e5f50d4d7156077ceb6657a49c4bdbeb436ff Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Mon, 21 Sep 2026 13:35:06 +0200 Subject: [PATCH 02/43] feat(pgp): add scoped provider interaction handling --- .../data/crypto/OpenPgpInteraction.kt | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 app/src/main/java/app/passwordstore/data/crypto/OpenPgpInteraction.kt diff --git a/app/src/main/java/app/passwordstore/data/crypto/OpenPgpInteraction.kt b/app/src/main/java/app/passwordstore/data/crypto/OpenPgpInteraction.kt new file mode 100644 index 0000000000..d15b73438f --- /dev/null +++ b/app/src/main/java/app/passwordstore/data/crypto/OpenPgpInteraction.kt @@ -0,0 +1,73 @@ +/* + * Copyright © 2014-2026 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.data.crypto + +import android.app.Activity +import android.app.PendingIntent +import android.content.Intent +import androidx.activity.ComponentActivity +import androidx.activity.result.IntentSenderRequest +import androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult +import java.util.concurrent.atomic.AtomicReference +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.asContextElement +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlin.coroutines.resume + +/** + * Makes a foreground OpenPGP interaction handler available only to the coroutine tree that owns it. + * + * This is deliberately coroutine-scoped rather than process-global. Credential-provider metadata + * scans can run concurrently with a foreground assertion and must never consume that assertion's + * provider interaction. + */ +@Singleton +class OpenPgpInteractionCoordinator @Inject constructor() { + + private val currentHandler = ThreadLocal() + + suspend fun withHandler( + handler: OpenPgpApiBackend.InteractionHandler, + block: suspend () -> T, + ): T = withContext(currentHandler.asContextElement(handler)) { block() } + + suspend fun interact(pendingIntent: PendingIntent): OpenPgpApiBackend.InteractionResult { + val handler = currentHandler.get() + ?: return OpenPgpApiBackend.InteractionResult.Cancelled + return handler.interact(pendingIntent) + } +} + +/** Activity Result API bridge used by password and Credential Provider activities. */ +class OpenPgpActivityInteractionHandler(activity: ComponentActivity) : + OpenPgpApiBackend.InteractionHandler { + + private val waiting = AtomicReference?>(null) + + private val launcher = + activity.registerForActivityResult(StartIntentSenderForResult()) { result -> + val continuation = waiting.getAndSet(null) ?: return@registerForActivityResult + if (!continuation.isActive) return@registerForActivityResult + if (result.resultCode == Activity.RESULT_OK) { + continuation.resume(OpenPgpApiBackend.InteractionResult.Completed(result.data)) + } else { + continuation.resume(OpenPgpApiBackend.InteractionResult.Cancelled) + } + } + + override suspend fun interact( + pendingIntent: PendingIntent + ): OpenPgpApiBackend.InteractionResult = + suspendCancellableCoroutine { continuation -> + check(waiting.compareAndSet(null, continuation)) { + "Another OpenPGP provider interaction is already active" + } + continuation.invokeOnCancellation { waiting.compareAndSet(continuation, null) } + launcher.launch(IntentSenderRequest.Builder(pendingIntent.intentSender).build()) + } +} From 499c2fa6c2ad5b20935a9869595ec79c1756f8b8 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Mon, 21 Sep 2026 13:35:47 +0200 Subject: [PATCH 03/43] feat(pgp): add provider selection and public-key retrieval --- .../data/crypto/OpenPgpProviderRepository.kt | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 app/src/main/java/app/passwordstore/data/crypto/OpenPgpProviderRepository.kt diff --git a/app/src/main/java/app/passwordstore/data/crypto/OpenPgpProviderRepository.kt b/app/src/main/java/app/passwordstore/data/crypto/OpenPgpProviderRepository.kt new file mode 100644 index 0000000000..436b94ea9b --- /dev/null +++ b/app/src/main/java/app/passwordstore/data/crypto/OpenPgpProviderRepository.kt @@ -0,0 +1,162 @@ +/* + * Copyright © 2014-2026 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.data.crypto + +import android.content.SharedPreferences +import app.passwordstore.crypto.KeyUtils +import app.passwordstore.crypto.PGPIdentifier +import app.passwordstore.crypto.PGPKey +import app.passwordstore.crypto.PGPKeyManager +import app.passwordstore.injection.prefs.SettingsPreferences +import app.passwordstore.util.settings.PreferenceKeys +import javax.inject.Inject +import javax.inject.Singleton + +/** Coordinates APS-local provider selection with local public-certificate storage. */ +@Singleton +class OpenPgpProviderRepository @Inject constructor( + private val backend: OpenPgpApiBackend, + private val keyManager: PGPKeyManager, + @SettingsPreferences private val settings: SharedPreferences, +) { + + fun providers(): List = backend.providers() + + fun selectedProviderPackage(): String? = + settings.getString(PreferenceKeys.OPENPGP_PROVIDER_PACKAGE, null)?.takeIf { it.isNotBlank() } + + fun hasSelectedProvider(): Boolean = selectedProviderPackage() != null + + fun isSelectedProviderInstalled(): Boolean = + selectedProviderPackage()?.let(backend::isProviderInstalled) == true + + suspend fun checkPermission( + interactionHandler: OpenPgpApiBackend.InteractionHandler? = null, + ): OpenPgpApiBackend.OperationResult { + val provider = + selectedProviderPackage() + ?: return OpenPgpApiBackend.OperationResult.Failure( + IllegalStateException("No external OpenPGP provider is selected") + ) + return backend.checkPermission(provider, interactionHandler) + } + + suspend fun decrypt( + ciphertext: ByteArray, + interactionHandler: OpenPgpApiBackend.InteractionHandler? = null, + ): OpenPgpApiBackend.OperationResult { + val provider = + selectedProviderPackage() + ?: return OpenPgpApiBackend.OperationResult.Failure( + IllegalStateException("No external OpenPGP provider is selected") + ) + return backend.decrypt(provider, ciphertext, interactionHandler) + } + + /** + * Makes the public certificates required by [identifiers] available to PGPainless. + * + * The provider remains the sole owner of private key material. Retrieved certificates are + * checked against the provider-returned key ID before they are accepted by the local key manager. + */ + suspend fun ensurePublicKeys( + identifiers: List, + interactionHandler: OpenPgpApiBackend.InteractionHandler? = null, + ): OpenPgpApiBackend.OperationResult { + val provider = + selectedProviderPackage() + ?: return OpenPgpApiBackend.OperationResult.Failure( + IllegalStateException("No external OpenPGP provider is selected") + ) + + for (identifier in identifiers) { + if (hasLocalKey(identifier)) continue + + val keyIds = + when (identifier) { + is PGPIdentifier.KeyId -> longArrayOf(identifier.id) + is PGPIdentifier.UserId -> { + when ( + val resolved = + backend.resolveKeyIds( + providerPackage = provider, + userIds = arrayOf(identifier.email), + interactionHandler = interactionHandler, + ) + ) { + is OpenPgpApiBackend.OperationResult.Success -> resolved.value + is OpenPgpApiBackend.OperationResult.UserInteractionRequired -> return resolved + OpenPgpApiBackend.OperationResult.Cancelled -> return resolved + is OpenPgpApiBackend.OperationResult.Failure -> return resolved + } + } + } + + if (keyIds.isEmpty()) { + return OpenPgpApiBackend.OperationResult.Failure( + IllegalStateException("OpenPGP provider could not resolve $identifier") + ) + } + + for (keyId in keyIds.distinct()) { + when ( + val fetched = + backend.getPublicKey( + providerPackage = provider, + keyId = keyId, + interactionHandler = interactionHandler, + ) + ) { + is OpenPgpApiBackend.OperationResult.Success -> { + val candidate = PGPKey(fetched.value) + val certificate = + KeyUtils.tryParseCertificateOrKey(candidate) + ?: return OpenPgpApiBackend.OperationResult.Failure( + IllegalArgumentException("Provider returned an invalid OpenPGP certificate") + ) + if (KeyUtils.isSecretKey(certificate)) { + return OpenPgpApiBackend.OperationResult.Failure( + SecurityException("OpenPGP provider unexpectedly returned secret key material") + ) + } + if (certificate.getAllKeyIdentifiers().none { it.getKeyId() == keyId }) { + return OpenPgpApiBackend.OperationResult.Failure( + SecurityException("Provider certificate does not match requested key ID") + ) + } + + var importFailure: Throwable? = null + keyManager.addKey(candidate, replace = false).fold( + success = {}, + failure = { error -> + // A concurrent import or an existing public certificate is harmless if the + // requested identity can now be resolved locally. + if (!hasLocalKey(identifier)) importFailure = error + }, + ) + if (importFailure != null) { + return OpenPgpApiBackend.OperationResult.Failure(importFailure!!) + } + } + is OpenPgpApiBackend.OperationResult.UserInteractionRequired -> return fetched + OpenPgpApiBackend.OperationResult.Cancelled -> return fetched + is OpenPgpApiBackend.OperationResult.Failure -> return fetched + } + } + + if (!hasLocalKey(identifier)) { + return OpenPgpApiBackend.OperationResult.Failure( + IllegalStateException("Retrieved certificates do not satisfy $identifier") + ) + } + } + + return OpenPgpApiBackend.OperationResult.Success(Unit) + } + + private fun hasLocalKey(identifier: PGPIdentifier): Boolean = + keyManager.getKeyById(identifier).fold(success = { true }, failure = { false }) +} From 86492d7448b4a834ea5bbded28d4e1a4432dc42a Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Mon, 21 Sep 2026 13:36:27 +0200 Subject: [PATCH 04/43] feat(passkeys): delegate PGP decryption to selected provider --- .../passkeys/OpenPgpPasskeyDecryptor.kt | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 app/src/main/java/app/passwordstore/passkeys/OpenPgpPasskeyDecryptor.kt diff --git a/app/src/main/java/app/passwordstore/passkeys/OpenPgpPasskeyDecryptor.kt b/app/src/main/java/app/passwordstore/passkeys/OpenPgpPasskeyDecryptor.kt new file mode 100644 index 0000000000..bd885fb7ae --- /dev/null +++ b/app/src/main/java/app/passwordstore/passkeys/OpenPgpPasskeyDecryptor.kt @@ -0,0 +1,151 @@ +/* + * Copyright © 2014-2026 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.passkeys + +import android.content.SharedPreferences +import app.passwordstore.data.crypto.OpenPgpApiBackend +import app.passwordstore.data.crypto.OpenPgpInteractionCoordinator +import app.passwordstore.data.crypto.OpenPgpProviderRepository +import app.passwordstore.injection.prefs.SettingsPreferences +import app.passwordstore.passkeys.crypto.PasskeyDecryptionError +import app.passwordstore.passkeys.crypto.PasskeyPgpDecryptor +import app.passwordstore.passkeys.crypto.PgpUnlockContext +import app.passwordstore.passkeys.security.BoundedInputStream +import app.passwordstore.passkeys.security.PasskeyInputLimits +import app.passwordstore.passkeys.security.SensitiveBytes +import app.passwordstore.util.settings.PreferenceKeys +import com.github.michaelbull.result.Err +import com.github.michaelbull.result.Ok +import com.github.michaelbull.result.Result +import java.io.File +import java.io.InputStream +import javax.inject.Inject + +/** Selects PGPainless or the configured OpenPGP provider for passkey decryption. */ +class OpenPgpPasskeyDecryptor @Inject constructor( + private val localDecryptor: PasskeyPgpDecryptor, + private val providerRepository: OpenPgpProviderRepository, + private val interactionCoordinator: OpenPgpInteractionCoordinator, + @SettingsPreferences private val settings: SharedPreferences, +) : PasskeyPgpDecryptor { + + override suspend fun decrypt( + file: File, + unlockContext: PgpUnlockContext, + limits: PasskeyInputLimits, + ): Result { + if (!usesExternalProvider()) return localDecryptor.decrypt(file, unlockContext, limits) + + val length = file.length() + if (length == 0L) return Err(PasskeyDecryptionError.MalformedCiphertext) + if (length > limits.maxCiphertextBytes) { + return Err(PasskeyDecryptionError.CiphertextTooLarge(length, limits.maxCiphertextBytes)) + } + + val ciphertext = + file.inputStream().use { stream -> + BoundedInputStream(stream, limits.maxCiphertextBytes).readBoundedBytes(length.toInt()) + } + return try { + decryptExternal(ciphertext, limits) + } finally { + ciphertext.fill(0) + } + } + + override suspend fun decryptFromBytes( + ciphertext: ByteArray, + unlockContext: PgpUnlockContext, + limits: PasskeyInputLimits, + ): Result { + if (!usesExternalProvider()) { + return localDecryptor.decryptFromBytes(ciphertext, unlockContext, limits) + } + if (ciphertext.isEmpty()) return Err(PasskeyDecryptionError.MalformedCiphertext) + if (ciphertext.size.toLong() > limits.maxCiphertextBytes) { + return Err( + PasskeyDecryptionError.CiphertextTooLarge( + ciphertext.size.toLong(), + limits.maxCiphertextBytes, + ) + ) + } + return decryptExternal(ciphertext, limits) + } + + override suspend fun decryptFromStream( + ciphertextStream: InputStream, + ciphertextLength: Long, + unlockContext: PgpUnlockContext, + limits: PasskeyInputLimits, + ): Result { + if (!usesExternalProvider()) { + return localDecryptor.decryptFromStream( + ciphertextStream, + ciphertextLength, + unlockContext, + limits, + ) + } + if (ciphertextLength == 0L) return Err(PasskeyDecryptionError.MalformedCiphertext) + if (ciphertextLength > limits.maxCiphertextBytes) { + return Err( + PasskeyDecryptionError.CiphertextTooLarge(ciphertextLength, limits.maxCiphertextBytes) + ) + } + + val ciphertext = + BoundedInputStream(ciphertextStream, limits.maxCiphertextBytes) + .readBoundedBytes(ciphertextLength.toInt()) + return try { + decryptExternal(ciphertext, limits) + } finally { + ciphertext.fill(0) + } + } + + private suspend fun decryptExternal( + ciphertext: ByteArray, + limits: PasskeyInputLimits, + ): Result { + val provider = + settings.getString(PreferenceKeys.OPENPGP_PROVIDER_PACKAGE, null) + ?: return Err(PasskeyDecryptionError.MissingSecretKey(emptySet())) + + return when ( + val result = + providerRepository.decrypt( + ciphertext, + OpenPgpApiBackend.InteractionHandler { pendingIntent -> + interactionCoordinator.interact(pendingIntent) + }, + ) + ) { + is OpenPgpApiBackend.OperationResult.Success -> { + val plaintext = result.value + if (plaintext.size.toLong() > limits.maxPlaintextBytes) { + plaintext.fill(0) + Err(PasskeyDecryptionError.PlaintextTooLarge(limits.maxPlaintextBytes)) + } else { + Ok(SensitiveBytes(plaintext)) + } + } + is OpenPgpApiBackend.OperationResult.UserInteractionRequired -> + Err(PasskeyDecryptionError.KeyLocked(provider)) + OpenPgpApiBackend.OperationResult.Cancelled -> + Err(PasskeyDecryptionError.KeyLocked(provider)) + is OpenPgpApiBackend.OperationResult.Failure -> + Err( + PasskeyDecryptionError.UnsupportedFormat( + result.error.message ?: "External OpenPGP provider decryption failed" + ) + ) + } + } + + private fun usesExternalProvider(): Boolean = + !settings.getString(PreferenceKeys.OPENPGP_PROVIDER_PACKAGE, null).isNullOrBlank() +} From d935e6ece21a5aba6093eb15540bdcad19e177e6 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Mon, 21 Sep 2026 13:36:54 +0200 Subject: [PATCH 05/43] build: add scoped OpenPGP API repository --- settings.gradle.kts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/settings.gradle.kts b/settings.gradle.kts index 6ab28dacf7..d1efd73023 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -58,6 +58,9 @@ dependencyResolutionManagement { } } mavenCentral { mavenContent { releasesOnly() } } + maven("https://jitpack.io") { + content { includeGroup("com.github.open-keychain.open-keychain") } + } } } From 6b6fa04a776a7c39d2981fa6c2f5cbeb571b2f42 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Mon, 21 Sep 2026 13:37:32 +0200 Subject: [PATCH 06/43] build: add OpenPGP API dependency --- gradle/libs.versions.toml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7052cdff81..34ad76778c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -39,7 +39,7 @@ androidx-security = "androidx.security:security-crypto-ktx:1.1.0" androidx-swiperefreshlayout = "androidx.swiperefreshlayout:swiperefreshlayout:1.2.0" aps-sublimeFuzzy = "com.github.android-password-store:sublime-fuzzy:2.3.4" aps-zxingAndroidEmbedded = "com.github.android-password-store:zxing-android-embedded:4.2.1" -build-agp = { module = "com.android.tools.build:gradle", version.ref = "agp" } +build-agp = { module = "com.android.tools:gradle", version.ref = "agp" } build-kotlin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } build-okhttp = "com.squareup.okhttp3:okhttp:5.5.0" build-r8 = "com.android.tools:r8:9.4.24" @@ -82,6 +82,7 @@ thirdparty-kotlinResult = { module = "com.michael-bull.kotlin-result:kotlin-resu thirdparty-kotlinResult-coroutines = { module = "com.michael-bull.kotlin-result:kotlin-result-coroutines", version.ref = "kotlinResult" } thirdparty-logcat = "com.squareup.logcat:logcat:0.4" thirdparty-modernAndroidPrefs = "de.maxr1998:modernandroidpreferences:2.4.0-beta2" +thirdparty-openpgp-api = "com.github.open-keychain.open-keychain:openpgp-api:v5.7.1" thirdparty-pgpainless = "org.pgpainless:pgpainless-core:2.0.4" thirdparty-slack-lints = "com.slack.lint:slack-lint-checks:0.11.1" thirdparty-slf4j-api = { module = "org.slf4j:slf4j-api", version = { strictly = "[1.7, 1.8[", prefer = "1.7.36" } } @@ -91,8 +92,8 @@ thirdparty-uri = "com.eygraber:uri-kmp:0.0.21" # build-diffutils = "io.github.java-diff-utils:java-diff-utils:4.17" # build-download = "de.undercouch:gradle-download-task:5.7.0" # build-javapoet = "com.squareup:javapoet:2.13.0" -# build-moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" } -# build-moshi-kotlin = { module = "com.squareup.moshi:moshi-kotlin", version.ref = "moshi" } +# build-moshi = { module = "com.squareup.moshi:moshi:1.15.2" } +# build-moshi-kotlin = { module = "com.squareup.moshi:moshi-kotlin:1.15.2" } # thirdparty-leakcanary-plumber = "com.squareup.leakcanary:plumber-android-startup:2.14" [bundles] From f5654c4c274dd5288dd6e21139a40f7ecaa48c2d Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Mon, 21 Sep 2026 13:38:20 +0200 Subject: [PATCH 07/43] build: depend on OpenPGP API client --- app/build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index eea298c437..d21fb2bf6e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -91,6 +91,7 @@ dependencies { implementation(libs.thirdparty.kotlinResult) implementation(libs.thirdparty.logcat) implementation(libs.thirdparty.modernAndroidPrefs) + implementation(libs.thirdparty.openpgp.api) implementation(libs.thirdparty.sshj) implementation(libs.thirdparty.bouncycastle.bcprov) implementation(libs.thirdparty.bouncycastle.bcutil) From 76d0f24c320df1d65d168479cad5d97ec8977422 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Mon, 21 Sep 2026 13:38:54 +0200 Subject: [PATCH 08/43] feat(pgp): persist selected external provider --- .../java/app/passwordstore/util/settings/PreferenceKeys.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/app/passwordstore/util/settings/PreferenceKeys.kt b/app/src/main/java/app/passwordstore/util/settings/PreferenceKeys.kt index 38b4b99a35..bdab1f23ce 100644 --- a/app/src/main/java/app/passwordstore/util/settings/PreferenceKeys.kt +++ b/app/src/main/java/app/passwordstore/util/settings/PreferenceKeys.kt @@ -58,7 +58,7 @@ object PreferenceKeys { const val OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES = "oreo_autofill_custom_public_suffixes" const val OREO_AUTOFILL_DEFAULT_USERNAME = "oreo_autofill_default_username" const val DIRECTORY_STRUCTURE = "oreo_autofill_directory_structure" - const val AUTOFILL_SAVE_DIRECTORY = "oreo_autofill_save_directory" + const val AUTOFILL_SAVE_DIRECTORY = "autofill_save_directory" const val STRICT_DOMAIN_SEARCH = "oreo_autofill_strict_domain_search" const val PREF_KEY_PWGEN_TYPE = "pref_key_pwgen_type" const val REPOSITORY_INITIALIZED = "repository_initialized" @@ -108,6 +108,7 @@ object PreferenceKeys { const val DICEWARE_LENGTH = "diceware_length" const val DISABLE_SYNC_ACTION = "disable_sync_action" const val ASCII_ARMOR = "pgpainless_ascii_armor" + const val OPENPGP_PROVIDER_PACKAGE = "openpgp_provider_package" @Deprecated( message = "We refactored persistent caching of the PGP passphrase and this is no longer used" From 94c1d70c35f93b079c2fe26cac48c928c9d55940 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Mon, 21 Sep 2026 13:39:31 +0200 Subject: [PATCH 09/43] feat(passkeys): select external OpenPGP decryptor --- .../injection/passkeys/PasskeysModule.kt | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/app/passwordstore/injection/passkeys/PasskeysModule.kt b/app/src/main/java/app/passwordstore/injection/passkeys/PasskeysModule.kt index 1e8de4381c..67605170e0 100644 --- a/app/src/main/java/app/passwordstore/injection/passkeys/PasskeysModule.kt +++ b/app/src/main/java/app/passwordstore/injection/passkeys/PasskeysModule.kt @@ -8,15 +8,20 @@ package app.passwordstore.injection.passkeys import android.content.Context +import android.content.SharedPreferences import app.passwordstore.crypto.DefaultPassRecipientResolver import app.passwordstore.crypto.PGPKey import app.passwordstore.crypto.PGPKeyManager import app.passwordstore.crypto.PGPainlessCryptoHandler import app.passwordstore.crypto.PgpainlessPasskeyDecryptor +import app.passwordstore.data.crypto.OpenPgpInteractionCoordinator +import app.passwordstore.data.crypto.OpenPgpProviderRepository +import app.passwordstore.injection.prefs.SettingsPreferences import app.passwordstore.passkeys.BiometricPasskeyAuthenticator import app.passwordstore.passkeys.DefaultRepositoryGenerationProvider import app.passwordstore.passkeys.DefaultWebAuthnCallerVerifier import app.passwordstore.passkeys.KeystorePgpUnlockContext +import app.passwordstore.passkeys.OpenPgpPasskeyDecryptor import app.passwordstore.passkeys.PasskeyMetadataIndex import app.passwordstore.passkeys.PasskeyPassphraseCache import app.passwordstore.passkeys.crypto.ES256CryptoHandler @@ -70,7 +75,16 @@ object PasskeysModule { fun providePasskeyPgpDecryptor( cryptoHandler: PGPainlessCryptoHandler, keyManager: PGPKeyManager, - ): PasskeyPgpDecryptor = PgpainlessPasskeyDecryptor(cryptoHandler, keyManager) + providerRepository: OpenPgpProviderRepository, + interactionCoordinator: OpenPgpInteractionCoordinator, + @SettingsPreferences settings: SharedPreferences, + ): PasskeyPgpDecryptor = + OpenPgpPasskeyDecryptor( + localDecryptor = PgpainlessPasskeyDecryptor(cryptoHandler, keyManager), + providerRepository = providerRepository, + interactionCoordinator = interactionCoordinator, + settings = settings, + ) @Provides @Singleton From 0ab075ca5257f3c389598876b1e8ddc5068f2d17 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Mon, 21 Sep 2026 13:54:19 +0200 Subject: [PATCH 10/43] chore: stage OpenPGP provider integration finish --- .github/apply-openpgp-finish.py | 805 ++++++++++++++++++++++++++++++++ 1 file changed, 805 insertions(+) create mode 100644 .github/apply-openpgp-finish.py diff --git a/.github/apply-openpgp-finish.py b/.github/apply-openpgp-finish.py new file mode 100644 index 0000000000..e02f9c3b16 --- /dev/null +++ b/.github/apply-openpgp-finish.py @@ -0,0 +1,805 @@ +from pathlib import Path + + +def read(path: str) -> str: + return Path(path).read_text() + + +def write(path: str, text: str) -> None: + Path(path).parent.mkdir(parents=True, exist_ok=True) + Path(path).write_text(text) + + +def replace_once(path: str, old: str, new: str) -> None: + text = read(path) + if text.count(old) != 1: + raise RuntimeError(f"{path}: expected exactly one marker, got {text.count(old)}: {old[:80]!r}") + write(path, text.replace(old, new, 1)) + + +def replace_between(path: str, start: str, end: str, replacement: str) -> None: + text = read(path) + i = text.find(start) + if i < 0: + raise RuntimeError(f"{path}: start marker not found: {start!r}") + j = text.find(end, i) + if j < 0: + raise RuntimeError(f"{path}: end marker not found: {end!r}") + write(path, text[:i] + replacement + text[j:]) + + +# Repair accidental unrelated edits from the bootstrap commits. +replace_once( + "app/src/main/java/app/passwordstore/util/settings/PreferenceKeys.kt", + 'const val AUTOFILL_SAVE_DIRECTORY = "autofill_save_directory"', + 'const val AUTOFILL_SAVE_DIRECTORY = "oreo_autofill_save_directory"', +) +replace_once( + "gradle/libs.versions.toml", + 'build-agp = { module = "com.android.tools:gradle", version.ref = "agp" }', + 'build-agp = { module = "com.android.tools.build:gradle", version.ref = "agp" }', +) + +# Make OpenPGP providers discoverable without relying on QUERY_ALL_PACKAGES. +replace_once( + "app/src/main/AndroidManifest.xml", + ' android:installLocation="auto">\n\n \n\n \n \n \n \n \n\n Unit) {\n onKeyListCallback = onKeysExist\n lifecycleScope.launch {\n", + " protected fun requireKeysExist(onKeysExist: () -> Unit) {\n" + " onKeyListCallback = onKeysExist\n" + " if (openPgpProviderRepository.hasSelectedProvider()) {\n" + " onKeysExist()\n" + " return\n" + " }\n" + " lifecycleScope.launch {\n", +) + +new_require_encrypt = ''' protected fun requireEncryptionKeysExist( + subDir: String, + onKeysExist: (List) -> Unit, + ) { + val ids = getPGPIdentifiers(subDir) + if (ids.isNullOrEmpty()) { + val (title, message) = + if (ids == null) { + resources.getString(R.string.missing_gpg_id_dialog_title) to + resources.getString(R.string.missing_gpg_id_dialog_message) + } else { + resources.getString(R.string.invalid_gpg_id_dialog_title) to + resources.getString(R.string.invalid_gpg_id_dialog_message) + } + openKeyManagerDialog(title, message) { + val intent = PGPKeyListActivity.newIntent(this@BasePGPActivity, keySelection = true) + intent.putExtra("SUB_PATH", subDir) + keySelectAction.launch(intent) + } + return + } + + if (openPgpProviderRepository.hasSelectedProvider()) { + lifecycleScope.launch { + val missing = ids.filterNot(repository::hasKey) + if (missing.isEmpty()) { + onKeysExist(ids) + return@launch + } + when ( + val result = + openPgpProviderRepository.ensurePublicKeys(missing, openPgpInteractionHandler) + ) { + is OpenPgpApiBackend.OperationResult.Success -> onKeysExist(ids) + OpenPgpApiBackend.OperationResult.Cancelled -> Unit + is OpenPgpApiBackend.OperationResult.UserInteractionRequired -> + snackbar(message = getString(R.string.openpgp_provider_interaction_failed)) + is OpenPgpApiBackend.OperationResult.Failure -> + snackbar( + message = + getString( + R.string.openpgp_provider_operation_failed, + result.error.message ?: getString(R.string.error), + ) + ) + } + } + return + } + + val idsWithKey = ids.filter { repository.hasKey(it) } + if (idsWithKey.isEmpty()) { + val title = resources.getString(R.string.no_pgp_keys_dialog_title) + val missingKeysForIds = ids.joinToString(", ") + val message = resources.getString(R.string.no_pgp_keys_dialog_message) + missingKeysForIds + openKeyManagerDialog(title, message) { + keyImportAction.launch(PGPKeyListActivity.newIntent(this@BasePGPActivity)) + } + } else { + onKeysExist(ids) + } + } + +''' +replace_between( + base, + " protected fun requireEncryptionKeysExist(\n", + " protected fun requireDecryptionKeysExist(\n", + new_require_encrypt, +) + +new_require_decrypt = ''' protected fun requireDecryptionKeysExist( + subDir: String, + onKeysExist: (List) -> Unit, + ) { + val ids = getPGPIdentifiers(subDir) + if (ids.isNullOrEmpty()) { + val (title, message) = + if (ids == null) { + resources.getString(R.string.missing_gpg_id_dialog_title) to + resources.getString(R.string.missing_gpg_id_dialog_message) + } else { + resources.getString(R.string.invalid_gpg_id_dialog_title) to + resources.getString(R.string.invalid_gpg_id_dialog_message) + } + openKeyManagerDialog(title, message) { + val intent = PGPKeyListActivity.newIntent(this@BasePGPActivity, keySelection = true) + intent.putExtra("SUB_PATH", subDir) + keySelectAction.launch(intent) + } + return + } + + if (openPgpProviderRepository.hasSelectedProvider()) { + onKeysExist(ids) + return + } + + val idsWithKey = ids.filter { repository.hasKey(it) } + val idsWithDecryptionKey = idsWithKey.filter { repository.hasDecKey(it) } + + if (idsWithDecryptionKey.isEmpty()) { + val title = resources.getString(R.string.no_decryption_keys_dialog_title) + val missingDecKeysForIds = + if (idsWithKey.isNotEmpty()) { + ids + .map { id -> + if (id in idsWithKey) "\\n${id}: ${getString(R.string.pgp_public_only)}" + else "\\n${id}: ${getString(R.string.pgp_unknown)}" + } + .joinToString() + } else { + ids.joinToString(", ") + } + val message = + resources.getString(R.string.no_decryption_keys_dialog_message) + missingDecKeysForIds + openKeyManagerDialog(title, message) { + keyImportAction.launch(PGPKeyListActivity.newIntent(this@BasePGPActivity)) + } + } else { + onKeysExist(ids) + } + } + +''' +replace_between( + base, + " protected fun requireDecryptionKeysExist(\n", + " /**\n * Copies a provided [password]", + new_require_decrypt, +) +replace_once( + base, + " protected fun getPersistentAndDecrypt(identifiers: List, action: String? = null) {\n // Detect AES key invalidation", + " protected fun getPersistentAndDecrypt(identifiers: List, action: String? = null) {\n" + " if (openPgpProviderRepository.hasSelectedProvider()) {\n" + " decrypt(identifiers)\n" + " return\n" + " }\n\n" + " // Detect AES key invalidation", +) +replace_once( + base, + " protected fun decrypt(identifiers: List, isError: Boolean = false) {\n val passphrases = cachedPassphrases.filterKeys {", + " protected fun decrypt(identifiers: List, isError: Boolean = false) {\n" + " if (openPgpProviderRepository.hasSelectedProvider()) {\n" + " lifecycleScope.launch(dispatcherProvider.main()) { decryptWithOpenPgpProvider() }\n" + " return\n" + " }\n" + " val passphrases = cachedPassphrases.filterKeys {", +) +replace_once( + base, + " /** Subclass-specific implementations */\n open suspend fun decryptWithPassphrase(\n", + " protected suspend fun decryptUsingOpenPgpProvider(\n" + " ciphertext: ByteArray\n" + " ): OpenPgpApiBackend.OperationResult =\n" + " openPgpProviderRepository.decrypt(ciphertext, openPgpInteractionHandler)\n\n" + " protected open suspend fun decryptWithOpenPgpProvider() {}\n\n" + " /** Subclass-specific implementations */\n" + " open suspend fun decryptWithPassphrase(\n", +) + +# Password viewer: consume provider plaintext using the existing secure byte/char conversion path. +decrypt_activity = "app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt" +replace_once( + decrypt_activity, + "import app.passwordstore.crypto.errors.NoDecryptionKeyAvailableException\n", + "import app.passwordstore.crypto.errors.NoDecryptionKeyAvailableException\n" + "import app.passwordstore.data.crypto.OpenPgpApiBackend\n", +) +replace_once( + decrypt_activity, + " override suspend fun decryptWithPassphrase(\n", + ''' override suspend fun decryptWithOpenPgpProvider() { + val ciphertext = withContext(dispatcherProvider.io()) { File(fullPath).readBytes() } + try { + when (val result = decryptUsingOpenPgpProvider(ciphertext)) { + is OpenPgpApiBackend.OperationResult.Success -> { + val plaintextBytes = result.value + try { + val plaintextChars = plaintextBytes.toCharArray() + try { + val entry = passwordEntryFactory.create(plaintextChars) + encryptedEntryChars = AESEncryption.encrypt(plaintextChars) + entry.clearExtraChars() + createPasswordUI(entry) + } finally { + plaintextChars.wipe() + } + } finally { + plaintextBytes.wipe() + } + } + OpenPgpApiBackend.OperationResult.Cancelled -> finish() + is OpenPgpApiBackend.OperationResult.UserInteractionRequired -> + snackbar(message = getString(R.string.openpgp_provider_interaction_failed)) + is OpenPgpApiBackend.OperationResult.Failure -> + snackbar( + message = + getString( + R.string.openpgp_provider_operation_failed, + result.error.message ?: getString(R.string.error), + ) + ) + } + } finally { + ciphertext.wipe() + } + } + + override suspend fun decryptWithPassphrase( +''', +) + +# Autofill decryption uses the same provider backend and still returns an Autofill dataset. +autofill = "app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt" +replace_once( + autofill, + "import app.passwordstore.crypto.errors.NoDecryptionKeyAvailableException\n", + "import app.passwordstore.crypto.errors.NoDecryptionKeyAvailableException\n" + "import app.passwordstore.data.crypto.OpenPgpApiBackend\n", +) +replace_once( + autofill, + " override suspend fun decryptWithPassphrase(\n", + ''' override suspend fun decryptWithOpenPgpProvider() { + val encryptedFile = File(filePath) + val ciphertext = withContext(dispatcherProvider.io()) { encryptedFile.readBytes() } + try { + when (val result = decryptUsingOpenPgpProvider(ciphertext)) { + is OpenPgpApiBackend.OperationResult.Success -> { + val plaintextBytes = result.value + try { + val plaintextChars = plaintextBytes.toCharArray() + val entry = + try { + passwordEntryFactory.create(plaintextChars) + } finally { + plaintextChars.wipe() + } + entry.clearExtra() + val directoryStructure = AutofillPreferences.directoryStructure(this) + val credentials = + AutofillPreferences.credentialsFromStoreEntry( + this, + encryptedFile, + entry, + directoryStructure, + ) + val fillInDataset = + AutofillResponseBuilder.makeFillInDataset( + this@AutofillDecryptActivity, + credentials, + clientState, + action, + ) + withContext(dispatcherProvider.main()) { + setResult( + RESULT_OK, + Intent().apply { + putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset) + }, + ) + if (entry.hasTotp()) { + val otp = entry.currentOtp + val remainingTime = otp.remainingTime.inWholeSeconds + copyTextToClipboard(otp.value.toCharArray(), isSensitive = false) + otpTimer?.shutdownNow() + val otpTimerNew = Executors.newSingleThreadScheduledExecutor() + otpTimer = otpTimerNew + otpTimerNew.schedule( + { copyTextToClipboard(entry.currentOtp.value.toCharArray(), isSensitive = false) }, + remainingTime, + TimeUnit.SECONDS, + ) + } + entry.clear() + finish() + } + } finally { + plaintextBytes.wipe() + } + } + OpenPgpApiBackend.OperationResult.Cancelled -> finish() + is OpenPgpApiBackend.OperationResult.UserInteractionRequired -> { + snackbar(message = getString(R.string.openpgp_provider_interaction_failed)) + finish() + } + is OpenPgpApiBackend.OperationResult.Failure -> { + snackbar( + message = + getString( + R.string.openpgp_provider_operation_failed, + result.error.message ?: getString(R.string.error), + ) + ) + finish() + } + } + } finally { + ciphertext.wipe() + } + } + + override suspend fun decryptWithPassphrase( +''', +) + +# Scope OpenPGP UI permission/decryption requests to the full Credential Provider transaction. +passkey_activity = "app/src/main/java/app/passwordstore/passkeys/AppPasskeyProviderActivity.kt" +replace_once( + passkey_activity, + "import app.passwordstore.data.repo.PasswordRepository\n", + "import app.passwordstore.data.crypto.OpenPgpActivityInteractionHandler\n" + "import app.passwordstore.data.crypto.OpenPgpInteractionCoordinator\n" + "import app.passwordstore.data.repo.PasswordRepository\n", +) +replace_once( + passkey_activity, + " @Inject lateinit var signatureCounterTransaction: SignatureCounterTransaction\n", + " @Inject lateinit var signatureCounterTransaction: SignatureCounterTransaction\n" + " @Inject lateinit var openPgpInteractionCoordinator: OpenPgpInteractionCoordinator\n", +) +replace_once( + passkey_activity, + " @Inject\n @app.passwordstore.injection.prefs.PGPPassphrases\n", + " private val openPgpInteractionHandler = OpenPgpActivityInteractionHandler(this)\n\n" + " @Inject\n @app.passwordstore.injection.prefs.PGPPassphrases\n", +) +replace_once( + passkey_activity, + " PendingIntentHandler.retrieveProviderGetCredentialRequest(intent)?.let {\n handleGetCredential(it)\n return\n }\n\n PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)?.let {\n handleCreateCredential(it)\n return\n }", + " PendingIntentHandler.retrieveProviderGetCredentialRequest(intent)?.let {\n" + " openPgpInteractionCoordinator.withHandler(openPgpInteractionHandler) {\n" + " handleGetCredential(it)\n" + " }\n" + " return\n" + " }\n\n" + " PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)?.let {\n" + " openPgpInteractionCoordinator.withHandler(openPgpInteractionHandler) {\n" + " handleCreateCredential(it)\n" + " }\n" + " return\n" + " }", +) + +# Provider-backed recipient resolver: import missing public certs, then retry strict .gpg-id resolution. +write( + "app/src/main/java/app/passwordstore/passkeys/OpenPgpPassRecipientResolver.kt", + '''/* + * Copyright © 2014-2026 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.passkeys + +import app.passwordstore.crypto.PGPIdentifier +import app.passwordstore.crypto.PGPKey +import app.passwordstore.data.crypto.OpenPgpApiBackend +import app.passwordstore.data.crypto.OpenPgpInteractionCoordinator +import app.passwordstore.data.crypto.OpenPgpProviderRepository +import app.passwordstore.passkeys.storage.PassRecipientResolver +import app.passwordstore.passkeys.storage.RecipientPolicyError +import com.github.michaelbull.result.Err +import com.github.michaelbull.result.Ok +import com.github.michaelbull.result.Result +import com.github.michaelbull.result.fold +import java.io.File + +/** Adds provider public-certificate retrieval without weakening hierarchical `.gpg-id` policy. */ +class OpenPgpPassRecipientResolver( + private val delegate: PassRecipientResolver, + private val providerRepository: OpenPgpProviderRepository, + private val interactionCoordinator: OpenPgpInteractionCoordinator, +) : PassRecipientResolver { + + override suspend fun resolveFor(target: File): Result, RecipientPolicyError> { + return delegate.resolveFor(target).fold( + success = { Ok(it) }, + failure = { error -> + if ( + error !is RecipientPolicyError.RecipientNotFound || + !providerRepository.hasSelectedProvider() + ) { + return@fold Err(error) + } + + val identifier = PGPIdentifier.fromString(error.identifier) ?: return@fold Err(error) + when ( + providerRepository.ensurePublicKeys( + listOf(identifier), + OpenPgpApiBackend.InteractionHandler { pendingIntent -> + interactionCoordinator.interact(pendingIntent) + }, + ) + ) { + is OpenPgpApiBackend.OperationResult.Success -> delegate.resolveFor(target) + else -> Err(error) + } + }, + ) + } +} +''', +) + +passkeys_module = "app/src/main/java/app/passwordstore/injection/passkeys/PasskeysModule.kt" +replace_once( + passkeys_module, + "import app.passwordstore.passkeys.OpenPgpPasskeyDecryptor\n", + "import app.passwordstore.passkeys.OpenPgpPassRecipientResolver\n" + "import app.passwordstore.passkeys.OpenPgpPasskeyDecryptor\n", +) +replace_once( + passkeys_module, + " fun providePassRecipientResolver(\n @ApplicationContext context: Context,\n keyManager: PGPKeyManager,\n ): PassRecipientResolver {\n val repositoryRoot = File(context.filesDir, \"store\")\n return DefaultPassRecipientResolver(repositoryRoot, keyManager)\n }", + " fun providePassRecipientResolver(\n" + " @ApplicationContext context: Context,\n" + " keyManager: PGPKeyManager,\n" + " providerRepository: OpenPgpProviderRepository,\n" + " interactionCoordinator: OpenPgpInteractionCoordinator,\n" + " ): PassRecipientResolver {\n" + " val repositoryRoot = File(context.filesDir, \"store\")\n" + " val localResolver = DefaultPassRecipientResolver(repositoryRoot, keyManager)\n" + " return OpenPgpPassRecipientResolver(\n" + " localResolver,\n" + " providerRepository,\n" + " interactionCoordinator,\n" + " )\n" + " }", +) + +# Provider preference and explicit permission grant. +write( + "app/src/main/java/app/passwordstore/ui/settings/PGPSettings.kt", + '''/* + * Copyright © 2014-2026 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.settings + +import android.content.Intent +import androidx.core.content.edit +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.lifecycleScope +import app.passwordstore.R +import app.passwordstore.data.crypto.OpenPgpActivityInteractionHandler +import app.passwordstore.data.crypto.OpenPgpApiBackend +import app.passwordstore.ui.pgp.PGPKeyListActivity +import app.passwordstore.util.extensions.sharedPrefs +import app.passwordstore.util.settings.PreferenceKeys +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import de.Maxr1998.modernpreferences.PreferenceScreen +import de.Maxr1998.modernpreferences.helpers.onClick +import de.Maxr1998.modernpreferences.helpers.pref +import de.Maxr1998.modernpreferences.helpers.switch +import kotlinx.coroutines.launch + +class PGPSettings(private val activity: FragmentActivity) : SettingsProvider { + + private val backend = OpenPgpApiBackend(activity.applicationContext) + private val interactionHandler = OpenPgpActivityInteractionHandler(activity) + + override fun provideSettings(builder: PreferenceScreen.Builder) { + builder.apply { + pref("_") { + titleRes = R.string.pref_pgp_key_manager_title + persistent = false + onClick { + (activity as SettingsActivity) + .repositorySettings + .sshKeyAction + .launch(Intent(activity, PGPKeyListActivity::class.java)) + false + } + } + pref("_openpgp_provider") { + titleRes = R.string.pref_openpgp_provider_title + summaryRes = R.string.pref_openpgp_provider_summary + persistent = false + onClick { + showOpenPgpProviderDialog() + false + } + } + switch(PreferenceKeys.ASCII_ARMOR) { + titleRes = R.string.pref_pgp_ascii_armor_title + persistent = true + } + } + } + + private fun showOpenPgpProviderDialog() { + val providers = backend.providers() + val current = activity.sharedPrefs.getString(PreferenceKeys.OPENPGP_PROVIDER_PACKAGE, null) + val labels = + listOf(activity.getString(R.string.pref_openpgp_provider_internal)) + + providers.map { "${it.label} (${it.packageName})" } + val checked = + providers.indexOfFirst { it.packageName == current }.let { if (it < 0) 0 else it + 1 } + + MaterialAlertDialogBuilder(activity) + .setTitle(R.string.pref_openpgp_provider_title) + .setSingleChoiceItems(labels.toTypedArray(), checked) { dialog, which -> + dialog.dismiss() + if (which == 0) { + activity.sharedPrefs.edit { remove(PreferenceKeys.OPENPGP_PROVIDER_PACKAGE) } + return@setSingleChoiceItems + } + + val provider = providers[which - 1] + activity.lifecycleScope.launch { + when (val result = backend.checkPermission(provider.packageName, interactionHandler)) { + is OpenPgpApiBackend.OperationResult.Success -> + activity.sharedPrefs.edit { + putString(PreferenceKeys.OPENPGP_PROVIDER_PACKAGE, provider.packageName) + } + OpenPgpApiBackend.OperationResult.Cancelled -> Unit + is OpenPgpApiBackend.OperationResult.UserInteractionRequired -> + showProviderError(activity.getString(R.string.openpgp_provider_interaction_failed)) + is OpenPgpApiBackend.OperationResult.Failure -> + showProviderError( + activity.getString( + R.string.openpgp_provider_operation_failed, + result.error.message ?: activity.getString(R.string.error), + ) + ) + } + } + } + .setNegativeButton(R.string.dialog_cancel, null) + .show() + } + + private fun showProviderError(message: String) { + MaterialAlertDialogBuilder(activity) + .setTitle(R.string.pref_openpgp_provider_title) + .setMessage(message) + .setPositiveButton(android.R.string.ok, null) + .show() + } +} +''', +) + +# Strings used by provider selection and operation failures. +replace_once( + "app/src/main/res/values/strings.xml", + ' Key manager\n', + ' Key manager\n' + ' OpenPGP backend\n' + ' Use APS keys or delegate private-key operations to a compatible OpenPGP provider.\n' + ' Internal key manager\n' + ' The OpenPGP provider interaction could not be completed.\n' + ' External OpenPGP provider operation failed: %1$s\n', +) + +# Focused tests for the interaction continuation contract and coroutine scoping. +write( + "app/src/test/java/app/passwordstore/data/crypto/OpenPgpApiBackendTest.kt", + '''/* + * Copyright © 2014-2026 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.data.crypto + +import android.app.PendingIntent +import android.content.Intent +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlinx.coroutines.runBlocking +import org.openintents.openpgp.util.OpenPgpApi +import org.robolectric.RuntimeEnvironment +import org.robolectric.RobolectricTestRunner +import org.junit.runner.RunWith + +@RunWith(RobolectricTestRunner::class) +class OpenPgpApiBackendTest { + + @Test + fun `decrypt returns provider output on success`() = runBlocking { + val executor = FakeExecutor { _, _, _ -> + OpenPgpApiCall(result(OpenPgpApi.RESULT_CODE_SUCCESS), byteArrayOf(1, 2, 3)) + } + val backend = OpenPgpApiBackend(executor) + + val result = backend.decrypt("provider", byteArrayOf(9)) + + assertContentEquals(byteArrayOf(1, 2, 3), assertIs>(result).value) + } + + @Test + fun `provider continuation intent is used after user interaction`() = runBlocking { + val pendingIntent = + PendingIntent.getActivity( + RuntimeEnvironment.getApplication(), + 7, + Intent("interaction"), + PendingIntent.FLAG_IMMUTABLE, + ) + val seenActions = mutableListOf() + val executor = FakeExecutor { _, request, _ -> + seenActions += request.action + if (seenActions.size == 1) { + OpenPgpApiCall( + result(OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED).apply { + putExtra(OpenPgpApi.RESULT_INTENT, pendingIntent) + }, + byteArrayOf(), + ) + } else { + OpenPgpApiCall(result(OpenPgpApi.RESULT_CODE_SUCCESS), byteArrayOf(4)) + } + } + val backend = OpenPgpApiBackend(executor) + + val operation = + backend.decrypt( + "provider", + byteArrayOf(9), + OpenPgpApiBackend.InteractionHandler { + OpenPgpApiBackend.InteractionResult.Completed(Intent("continued")) + }, + ) + + assertIs>(operation) + assertEquals(listOf(OpenPgpApi.ACTION_DECRYPT_VERIFY, "continued"), seenActions) + } + + @Test + fun `interaction is surfaced when no foreground handler exists`() = runBlocking { + val pendingIntent = + PendingIntent.getActivity( + RuntimeEnvironment.getApplication(), + 8, + Intent("interaction"), + PendingIntent.FLAG_IMMUTABLE, + ) + val executor = FakeExecutor { _, _, _ -> + OpenPgpApiCall( + result(OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED).apply { + putExtra(OpenPgpApi.RESULT_INTENT, pendingIntent) + }, + byteArrayOf(), + ) + } + + val operation = OpenPgpApiBackend(executor).decrypt("provider", byteArrayOf(9)) + + assertIs(operation) + } + + private fun result(code: Int): Intent = + Intent().apply { putExtra(OpenPgpApi.RESULT_CODE, code) } + + private class FakeExecutor( + private val executeBlock: suspend (String, Intent, ByteArray?) -> OpenPgpApiCall + ) : OpenPgpApiExecutor { + override fun providers(): List = emptyList() + + override suspend fun execute( + providerPackage: String, + request: Intent, + input: ByteArray?, + ): OpenPgpApiCall = executeBlock(providerPackage, request, input) + } +} +''', +) +write( + "app/src/test/java/app/passwordstore/data/crypto/OpenPgpInteractionCoordinatorTest.kt", + '''/* + * Copyright © 2014-2026 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.data.crypto + +import android.app.PendingIntent +import android.content.Intent +import kotlin.test.Test +import kotlin.test.assertIs +import kotlinx.coroutines.runBlocking +import org.junit.runner.RunWith +import org.robolectric.RuntimeEnvironment +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class OpenPgpInteractionCoordinatorTest { + + @Test + fun `handler is visible only inside its coroutine scope`() = runBlocking { + val coordinator = OpenPgpInteractionCoordinator() + val pendingIntent = + PendingIntent.getActivity( + RuntimeEnvironment.getApplication(), + 9, + Intent("interaction"), + PendingIntent.FLAG_IMMUTABLE, + ) + + assertIs(coordinator.interact(pendingIntent)) + + coordinator.withHandler( + OpenPgpApiBackend.InteractionHandler { + OpenPgpApiBackend.InteractionResult.Completed(Intent("completed")) + } + ) { + assertIs(coordinator.interact(pendingIntent)) + } + + assertIs(coordinator.interact(pendingIntent)) + } +} +''', +) From 7d4e839772b3dfe662ad8b01db141ae75b6d0f6d Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Mon, 21 Sep 2026 13:54:52 +0200 Subject: [PATCH 11/43] chore: apply and validate OpenPGP provider integration --- .github/workflows/apply-openpgp-finish.yml | 36 ++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 .github/workflows/apply-openpgp-finish.yml diff --git a/.github/workflows/apply-openpgp-finish.yml b/.github/workflows/apply-openpgp-finish.yml new file mode 100644 index 0000000000..1bb9d7af56 --- /dev/null +++ b/.github/workflows/apply-openpgp-finish.yml @@ -0,0 +1,36 @@ +name: Finish OpenPGP provider integration + +on: + push: + branches: + - feature/openpgp-provider + +permissions: + contents: write + +jobs: + apply-and-test: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: feature/openpgp-provider + - name: Setup build environment + uses: ./.github/reusable-workflows/setup-gradle + with: + java-version: 21 + - name: Apply integration + run: python .github/apply-openpgp-finish.py + - name: Format + run: ./gradlew spotlessApply + - name: Commit implementation + run: | + rm .github/apply-openpgp-finish.py .github/workflows/apply-openpgp-finish.yml + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "feat(pgp): integrate external OpenPGP providers" + git push origin HEAD:feature/openpgp-provider + - name: Run focused tests + run: ./gradlew :app:testDebugUnitTest :passkeys:core:test :passkeys:provider:testDebugUnitTest :crypto:pgpainless:test From ac582cf285509bdeb6dbce0a22050cf4b4a3cb6a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 11:56:12 +0000 Subject: [PATCH 12/43] feat(pgp): integrate external OpenPGP providers --- .github/apply-openpgp-finish.py | 805 ------------------ .github/workflows/apply-openpgp-finish.yml | 36 - app/src/main/AndroidManifest.xml | 6 + .../data/crypto/OpenPgpApiBackend.kt | 19 +- .../data/crypto/OpenPgpInteraction.kt | 17 +- .../data/crypto/OpenPgpProviderRepository.kt | 28 +- .../injection/passkeys/PasskeysModule.kt | 10 +- .../passkeys/AppPasskeyProviderActivity.kt | 13 +- .../passkeys/OpenPgpPassRecipientResolver.kt | 56 ++ .../passkeys/OpenPgpPasskeyDecryptor.kt | 7 +- .../ui/autofill/AutofillDecryptActivity.kt | 82 ++ .../ui/crypto/BasePGPActivity.kt | 146 ++-- .../ui/crypto/DecryptActivity.kt | 38 + .../passwordstore/ui/settings/PGPSettings.kt | 69 ++ .../util/settings/PreferenceKeys.kt | 2 +- app/src/main/res/values/strings.xml | 5 + .../data/crypto/OpenPgpApiBackendTest.kt | 112 +++ .../OpenPgpInteractionCoordinatorTest.kt | 43 + gradle/libs.versions.toml | 2 +- 19 files changed, 569 insertions(+), 927 deletions(-) delete mode 100644 .github/apply-openpgp-finish.py delete mode 100644 .github/workflows/apply-openpgp-finish.yml create mode 100644 app/src/main/java/app/passwordstore/passkeys/OpenPgpPassRecipientResolver.kt create mode 100644 app/src/test/java/app/passwordstore/data/crypto/OpenPgpApiBackendTest.kt create mode 100644 app/src/test/java/app/passwordstore/data/crypto/OpenPgpInteractionCoordinatorTest.kt diff --git a/.github/apply-openpgp-finish.py b/.github/apply-openpgp-finish.py deleted file mode 100644 index e02f9c3b16..0000000000 --- a/.github/apply-openpgp-finish.py +++ /dev/null @@ -1,805 +0,0 @@ -from pathlib import Path - - -def read(path: str) -> str: - return Path(path).read_text() - - -def write(path: str, text: str) -> None: - Path(path).parent.mkdir(parents=True, exist_ok=True) - Path(path).write_text(text) - - -def replace_once(path: str, old: str, new: str) -> None: - text = read(path) - if text.count(old) != 1: - raise RuntimeError(f"{path}: expected exactly one marker, got {text.count(old)}: {old[:80]!r}") - write(path, text.replace(old, new, 1)) - - -def replace_between(path: str, start: str, end: str, replacement: str) -> None: - text = read(path) - i = text.find(start) - if i < 0: - raise RuntimeError(f"{path}: start marker not found: {start!r}") - j = text.find(end, i) - if j < 0: - raise RuntimeError(f"{path}: end marker not found: {end!r}") - write(path, text[:i] + replacement + text[j:]) - - -# Repair accidental unrelated edits from the bootstrap commits. -replace_once( - "app/src/main/java/app/passwordstore/util/settings/PreferenceKeys.kt", - 'const val AUTOFILL_SAVE_DIRECTORY = "autofill_save_directory"', - 'const val AUTOFILL_SAVE_DIRECTORY = "oreo_autofill_save_directory"', -) -replace_once( - "gradle/libs.versions.toml", - 'build-agp = { module = "com.android.tools:gradle", version.ref = "agp" }', - 'build-agp = { module = "com.android.tools.build:gradle", version.ref = "agp" }', -) - -# Make OpenPGP providers discoverable without relying on QUERY_ALL_PACKAGES. -replace_once( - "app/src/main/AndroidManifest.xml", - ' android:installLocation="auto">\n\n \n\n \n \n \n \n \n\n Unit) {\n onKeyListCallback = onKeysExist\n lifecycleScope.launch {\n", - " protected fun requireKeysExist(onKeysExist: () -> Unit) {\n" - " onKeyListCallback = onKeysExist\n" - " if (openPgpProviderRepository.hasSelectedProvider()) {\n" - " onKeysExist()\n" - " return\n" - " }\n" - " lifecycleScope.launch {\n", -) - -new_require_encrypt = ''' protected fun requireEncryptionKeysExist( - subDir: String, - onKeysExist: (List) -> Unit, - ) { - val ids = getPGPIdentifiers(subDir) - if (ids.isNullOrEmpty()) { - val (title, message) = - if (ids == null) { - resources.getString(R.string.missing_gpg_id_dialog_title) to - resources.getString(R.string.missing_gpg_id_dialog_message) - } else { - resources.getString(R.string.invalid_gpg_id_dialog_title) to - resources.getString(R.string.invalid_gpg_id_dialog_message) - } - openKeyManagerDialog(title, message) { - val intent = PGPKeyListActivity.newIntent(this@BasePGPActivity, keySelection = true) - intent.putExtra("SUB_PATH", subDir) - keySelectAction.launch(intent) - } - return - } - - if (openPgpProviderRepository.hasSelectedProvider()) { - lifecycleScope.launch { - val missing = ids.filterNot(repository::hasKey) - if (missing.isEmpty()) { - onKeysExist(ids) - return@launch - } - when ( - val result = - openPgpProviderRepository.ensurePublicKeys(missing, openPgpInteractionHandler) - ) { - is OpenPgpApiBackend.OperationResult.Success -> onKeysExist(ids) - OpenPgpApiBackend.OperationResult.Cancelled -> Unit - is OpenPgpApiBackend.OperationResult.UserInteractionRequired -> - snackbar(message = getString(R.string.openpgp_provider_interaction_failed)) - is OpenPgpApiBackend.OperationResult.Failure -> - snackbar( - message = - getString( - R.string.openpgp_provider_operation_failed, - result.error.message ?: getString(R.string.error), - ) - ) - } - } - return - } - - val idsWithKey = ids.filter { repository.hasKey(it) } - if (idsWithKey.isEmpty()) { - val title = resources.getString(R.string.no_pgp_keys_dialog_title) - val missingKeysForIds = ids.joinToString(", ") - val message = resources.getString(R.string.no_pgp_keys_dialog_message) + missingKeysForIds - openKeyManagerDialog(title, message) { - keyImportAction.launch(PGPKeyListActivity.newIntent(this@BasePGPActivity)) - } - } else { - onKeysExist(ids) - } - } - -''' -replace_between( - base, - " protected fun requireEncryptionKeysExist(\n", - " protected fun requireDecryptionKeysExist(\n", - new_require_encrypt, -) - -new_require_decrypt = ''' protected fun requireDecryptionKeysExist( - subDir: String, - onKeysExist: (List) -> Unit, - ) { - val ids = getPGPIdentifiers(subDir) - if (ids.isNullOrEmpty()) { - val (title, message) = - if (ids == null) { - resources.getString(R.string.missing_gpg_id_dialog_title) to - resources.getString(R.string.missing_gpg_id_dialog_message) - } else { - resources.getString(R.string.invalid_gpg_id_dialog_title) to - resources.getString(R.string.invalid_gpg_id_dialog_message) - } - openKeyManagerDialog(title, message) { - val intent = PGPKeyListActivity.newIntent(this@BasePGPActivity, keySelection = true) - intent.putExtra("SUB_PATH", subDir) - keySelectAction.launch(intent) - } - return - } - - if (openPgpProviderRepository.hasSelectedProvider()) { - onKeysExist(ids) - return - } - - val idsWithKey = ids.filter { repository.hasKey(it) } - val idsWithDecryptionKey = idsWithKey.filter { repository.hasDecKey(it) } - - if (idsWithDecryptionKey.isEmpty()) { - val title = resources.getString(R.string.no_decryption_keys_dialog_title) - val missingDecKeysForIds = - if (idsWithKey.isNotEmpty()) { - ids - .map { id -> - if (id in idsWithKey) "\\n${id}: ${getString(R.string.pgp_public_only)}" - else "\\n${id}: ${getString(R.string.pgp_unknown)}" - } - .joinToString() - } else { - ids.joinToString(", ") - } - val message = - resources.getString(R.string.no_decryption_keys_dialog_message) + missingDecKeysForIds - openKeyManagerDialog(title, message) { - keyImportAction.launch(PGPKeyListActivity.newIntent(this@BasePGPActivity)) - } - } else { - onKeysExist(ids) - } - } - -''' -replace_between( - base, - " protected fun requireDecryptionKeysExist(\n", - " /**\n * Copies a provided [password]", - new_require_decrypt, -) -replace_once( - base, - " protected fun getPersistentAndDecrypt(identifiers: List, action: String? = null) {\n // Detect AES key invalidation", - " protected fun getPersistentAndDecrypt(identifiers: List, action: String? = null) {\n" - " if (openPgpProviderRepository.hasSelectedProvider()) {\n" - " decrypt(identifiers)\n" - " return\n" - " }\n\n" - " // Detect AES key invalidation", -) -replace_once( - base, - " protected fun decrypt(identifiers: List, isError: Boolean = false) {\n val passphrases = cachedPassphrases.filterKeys {", - " protected fun decrypt(identifiers: List, isError: Boolean = false) {\n" - " if (openPgpProviderRepository.hasSelectedProvider()) {\n" - " lifecycleScope.launch(dispatcherProvider.main()) { decryptWithOpenPgpProvider() }\n" - " return\n" - " }\n" - " val passphrases = cachedPassphrases.filterKeys {", -) -replace_once( - base, - " /** Subclass-specific implementations */\n open suspend fun decryptWithPassphrase(\n", - " protected suspend fun decryptUsingOpenPgpProvider(\n" - " ciphertext: ByteArray\n" - " ): OpenPgpApiBackend.OperationResult =\n" - " openPgpProviderRepository.decrypt(ciphertext, openPgpInteractionHandler)\n\n" - " protected open suspend fun decryptWithOpenPgpProvider() {}\n\n" - " /** Subclass-specific implementations */\n" - " open suspend fun decryptWithPassphrase(\n", -) - -# Password viewer: consume provider plaintext using the existing secure byte/char conversion path. -decrypt_activity = "app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt" -replace_once( - decrypt_activity, - "import app.passwordstore.crypto.errors.NoDecryptionKeyAvailableException\n", - "import app.passwordstore.crypto.errors.NoDecryptionKeyAvailableException\n" - "import app.passwordstore.data.crypto.OpenPgpApiBackend\n", -) -replace_once( - decrypt_activity, - " override suspend fun decryptWithPassphrase(\n", - ''' override suspend fun decryptWithOpenPgpProvider() { - val ciphertext = withContext(dispatcherProvider.io()) { File(fullPath).readBytes() } - try { - when (val result = decryptUsingOpenPgpProvider(ciphertext)) { - is OpenPgpApiBackend.OperationResult.Success -> { - val plaintextBytes = result.value - try { - val plaintextChars = plaintextBytes.toCharArray() - try { - val entry = passwordEntryFactory.create(plaintextChars) - encryptedEntryChars = AESEncryption.encrypt(plaintextChars) - entry.clearExtraChars() - createPasswordUI(entry) - } finally { - plaintextChars.wipe() - } - } finally { - plaintextBytes.wipe() - } - } - OpenPgpApiBackend.OperationResult.Cancelled -> finish() - is OpenPgpApiBackend.OperationResult.UserInteractionRequired -> - snackbar(message = getString(R.string.openpgp_provider_interaction_failed)) - is OpenPgpApiBackend.OperationResult.Failure -> - snackbar( - message = - getString( - R.string.openpgp_provider_operation_failed, - result.error.message ?: getString(R.string.error), - ) - ) - } - } finally { - ciphertext.wipe() - } - } - - override suspend fun decryptWithPassphrase( -''', -) - -# Autofill decryption uses the same provider backend and still returns an Autofill dataset. -autofill = "app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt" -replace_once( - autofill, - "import app.passwordstore.crypto.errors.NoDecryptionKeyAvailableException\n", - "import app.passwordstore.crypto.errors.NoDecryptionKeyAvailableException\n" - "import app.passwordstore.data.crypto.OpenPgpApiBackend\n", -) -replace_once( - autofill, - " override suspend fun decryptWithPassphrase(\n", - ''' override suspend fun decryptWithOpenPgpProvider() { - val encryptedFile = File(filePath) - val ciphertext = withContext(dispatcherProvider.io()) { encryptedFile.readBytes() } - try { - when (val result = decryptUsingOpenPgpProvider(ciphertext)) { - is OpenPgpApiBackend.OperationResult.Success -> { - val plaintextBytes = result.value - try { - val plaintextChars = plaintextBytes.toCharArray() - val entry = - try { - passwordEntryFactory.create(plaintextChars) - } finally { - plaintextChars.wipe() - } - entry.clearExtra() - val directoryStructure = AutofillPreferences.directoryStructure(this) - val credentials = - AutofillPreferences.credentialsFromStoreEntry( - this, - encryptedFile, - entry, - directoryStructure, - ) - val fillInDataset = - AutofillResponseBuilder.makeFillInDataset( - this@AutofillDecryptActivity, - credentials, - clientState, - action, - ) - withContext(dispatcherProvider.main()) { - setResult( - RESULT_OK, - Intent().apply { - putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset) - }, - ) - if (entry.hasTotp()) { - val otp = entry.currentOtp - val remainingTime = otp.remainingTime.inWholeSeconds - copyTextToClipboard(otp.value.toCharArray(), isSensitive = false) - otpTimer?.shutdownNow() - val otpTimerNew = Executors.newSingleThreadScheduledExecutor() - otpTimer = otpTimerNew - otpTimerNew.schedule( - { copyTextToClipboard(entry.currentOtp.value.toCharArray(), isSensitive = false) }, - remainingTime, - TimeUnit.SECONDS, - ) - } - entry.clear() - finish() - } - } finally { - plaintextBytes.wipe() - } - } - OpenPgpApiBackend.OperationResult.Cancelled -> finish() - is OpenPgpApiBackend.OperationResult.UserInteractionRequired -> { - snackbar(message = getString(R.string.openpgp_provider_interaction_failed)) - finish() - } - is OpenPgpApiBackend.OperationResult.Failure -> { - snackbar( - message = - getString( - R.string.openpgp_provider_operation_failed, - result.error.message ?: getString(R.string.error), - ) - ) - finish() - } - } - } finally { - ciphertext.wipe() - } - } - - override suspend fun decryptWithPassphrase( -''', -) - -# Scope OpenPGP UI permission/decryption requests to the full Credential Provider transaction. -passkey_activity = "app/src/main/java/app/passwordstore/passkeys/AppPasskeyProviderActivity.kt" -replace_once( - passkey_activity, - "import app.passwordstore.data.repo.PasswordRepository\n", - "import app.passwordstore.data.crypto.OpenPgpActivityInteractionHandler\n" - "import app.passwordstore.data.crypto.OpenPgpInteractionCoordinator\n" - "import app.passwordstore.data.repo.PasswordRepository\n", -) -replace_once( - passkey_activity, - " @Inject lateinit var signatureCounterTransaction: SignatureCounterTransaction\n", - " @Inject lateinit var signatureCounterTransaction: SignatureCounterTransaction\n" - " @Inject lateinit var openPgpInteractionCoordinator: OpenPgpInteractionCoordinator\n", -) -replace_once( - passkey_activity, - " @Inject\n @app.passwordstore.injection.prefs.PGPPassphrases\n", - " private val openPgpInteractionHandler = OpenPgpActivityInteractionHandler(this)\n\n" - " @Inject\n @app.passwordstore.injection.prefs.PGPPassphrases\n", -) -replace_once( - passkey_activity, - " PendingIntentHandler.retrieveProviderGetCredentialRequest(intent)?.let {\n handleGetCredential(it)\n return\n }\n\n PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)?.let {\n handleCreateCredential(it)\n return\n }", - " PendingIntentHandler.retrieveProviderGetCredentialRequest(intent)?.let {\n" - " openPgpInteractionCoordinator.withHandler(openPgpInteractionHandler) {\n" - " handleGetCredential(it)\n" - " }\n" - " return\n" - " }\n\n" - " PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)?.let {\n" - " openPgpInteractionCoordinator.withHandler(openPgpInteractionHandler) {\n" - " handleCreateCredential(it)\n" - " }\n" - " return\n" - " }", -) - -# Provider-backed recipient resolver: import missing public certs, then retry strict .gpg-id resolution. -write( - "app/src/main/java/app/passwordstore/passkeys/OpenPgpPassRecipientResolver.kt", - '''/* - * Copyright © 2014-2026 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.passkeys - -import app.passwordstore.crypto.PGPIdentifier -import app.passwordstore.crypto.PGPKey -import app.passwordstore.data.crypto.OpenPgpApiBackend -import app.passwordstore.data.crypto.OpenPgpInteractionCoordinator -import app.passwordstore.data.crypto.OpenPgpProviderRepository -import app.passwordstore.passkeys.storage.PassRecipientResolver -import app.passwordstore.passkeys.storage.RecipientPolicyError -import com.github.michaelbull.result.Err -import com.github.michaelbull.result.Ok -import com.github.michaelbull.result.Result -import com.github.michaelbull.result.fold -import java.io.File - -/** Adds provider public-certificate retrieval without weakening hierarchical `.gpg-id` policy. */ -class OpenPgpPassRecipientResolver( - private val delegate: PassRecipientResolver, - private val providerRepository: OpenPgpProviderRepository, - private val interactionCoordinator: OpenPgpInteractionCoordinator, -) : PassRecipientResolver { - - override suspend fun resolveFor(target: File): Result, RecipientPolicyError> { - return delegate.resolveFor(target).fold( - success = { Ok(it) }, - failure = { error -> - if ( - error !is RecipientPolicyError.RecipientNotFound || - !providerRepository.hasSelectedProvider() - ) { - return@fold Err(error) - } - - val identifier = PGPIdentifier.fromString(error.identifier) ?: return@fold Err(error) - when ( - providerRepository.ensurePublicKeys( - listOf(identifier), - OpenPgpApiBackend.InteractionHandler { pendingIntent -> - interactionCoordinator.interact(pendingIntent) - }, - ) - ) { - is OpenPgpApiBackend.OperationResult.Success -> delegate.resolveFor(target) - else -> Err(error) - } - }, - ) - } -} -''', -) - -passkeys_module = "app/src/main/java/app/passwordstore/injection/passkeys/PasskeysModule.kt" -replace_once( - passkeys_module, - "import app.passwordstore.passkeys.OpenPgpPasskeyDecryptor\n", - "import app.passwordstore.passkeys.OpenPgpPassRecipientResolver\n" - "import app.passwordstore.passkeys.OpenPgpPasskeyDecryptor\n", -) -replace_once( - passkeys_module, - " fun providePassRecipientResolver(\n @ApplicationContext context: Context,\n keyManager: PGPKeyManager,\n ): PassRecipientResolver {\n val repositoryRoot = File(context.filesDir, \"store\")\n return DefaultPassRecipientResolver(repositoryRoot, keyManager)\n }", - " fun providePassRecipientResolver(\n" - " @ApplicationContext context: Context,\n" - " keyManager: PGPKeyManager,\n" - " providerRepository: OpenPgpProviderRepository,\n" - " interactionCoordinator: OpenPgpInteractionCoordinator,\n" - " ): PassRecipientResolver {\n" - " val repositoryRoot = File(context.filesDir, \"store\")\n" - " val localResolver = DefaultPassRecipientResolver(repositoryRoot, keyManager)\n" - " return OpenPgpPassRecipientResolver(\n" - " localResolver,\n" - " providerRepository,\n" - " interactionCoordinator,\n" - " )\n" - " }", -) - -# Provider preference and explicit permission grant. -write( - "app/src/main/java/app/passwordstore/ui/settings/PGPSettings.kt", - '''/* - * Copyright © 2014-2026 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.settings - -import android.content.Intent -import androidx.core.content.edit -import androidx.fragment.app.FragmentActivity -import androidx.lifecycle.lifecycleScope -import app.passwordstore.R -import app.passwordstore.data.crypto.OpenPgpActivityInteractionHandler -import app.passwordstore.data.crypto.OpenPgpApiBackend -import app.passwordstore.ui.pgp.PGPKeyListActivity -import app.passwordstore.util.extensions.sharedPrefs -import app.passwordstore.util.settings.PreferenceKeys -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import de.Maxr1998.modernpreferences.PreferenceScreen -import de.Maxr1998.modernpreferences.helpers.onClick -import de.Maxr1998.modernpreferences.helpers.pref -import de.Maxr1998.modernpreferences.helpers.switch -import kotlinx.coroutines.launch - -class PGPSettings(private val activity: FragmentActivity) : SettingsProvider { - - private val backend = OpenPgpApiBackend(activity.applicationContext) - private val interactionHandler = OpenPgpActivityInteractionHandler(activity) - - override fun provideSettings(builder: PreferenceScreen.Builder) { - builder.apply { - pref("_") { - titleRes = R.string.pref_pgp_key_manager_title - persistent = false - onClick { - (activity as SettingsActivity) - .repositorySettings - .sshKeyAction - .launch(Intent(activity, PGPKeyListActivity::class.java)) - false - } - } - pref("_openpgp_provider") { - titleRes = R.string.pref_openpgp_provider_title - summaryRes = R.string.pref_openpgp_provider_summary - persistent = false - onClick { - showOpenPgpProviderDialog() - false - } - } - switch(PreferenceKeys.ASCII_ARMOR) { - titleRes = R.string.pref_pgp_ascii_armor_title - persistent = true - } - } - } - - private fun showOpenPgpProviderDialog() { - val providers = backend.providers() - val current = activity.sharedPrefs.getString(PreferenceKeys.OPENPGP_PROVIDER_PACKAGE, null) - val labels = - listOf(activity.getString(R.string.pref_openpgp_provider_internal)) + - providers.map { "${it.label} (${it.packageName})" } - val checked = - providers.indexOfFirst { it.packageName == current }.let { if (it < 0) 0 else it + 1 } - - MaterialAlertDialogBuilder(activity) - .setTitle(R.string.pref_openpgp_provider_title) - .setSingleChoiceItems(labels.toTypedArray(), checked) { dialog, which -> - dialog.dismiss() - if (which == 0) { - activity.sharedPrefs.edit { remove(PreferenceKeys.OPENPGP_PROVIDER_PACKAGE) } - return@setSingleChoiceItems - } - - val provider = providers[which - 1] - activity.lifecycleScope.launch { - when (val result = backend.checkPermission(provider.packageName, interactionHandler)) { - is OpenPgpApiBackend.OperationResult.Success -> - activity.sharedPrefs.edit { - putString(PreferenceKeys.OPENPGP_PROVIDER_PACKAGE, provider.packageName) - } - OpenPgpApiBackend.OperationResult.Cancelled -> Unit - is OpenPgpApiBackend.OperationResult.UserInteractionRequired -> - showProviderError(activity.getString(R.string.openpgp_provider_interaction_failed)) - is OpenPgpApiBackend.OperationResult.Failure -> - showProviderError( - activity.getString( - R.string.openpgp_provider_operation_failed, - result.error.message ?: activity.getString(R.string.error), - ) - ) - } - } - } - .setNegativeButton(R.string.dialog_cancel, null) - .show() - } - - private fun showProviderError(message: String) { - MaterialAlertDialogBuilder(activity) - .setTitle(R.string.pref_openpgp_provider_title) - .setMessage(message) - .setPositiveButton(android.R.string.ok, null) - .show() - } -} -''', -) - -# Strings used by provider selection and operation failures. -replace_once( - "app/src/main/res/values/strings.xml", - ' Key manager\n', - ' Key manager\n' - ' OpenPGP backend\n' - ' Use APS keys or delegate private-key operations to a compatible OpenPGP provider.\n' - ' Internal key manager\n' - ' The OpenPGP provider interaction could not be completed.\n' - ' External OpenPGP provider operation failed: %1$s\n', -) - -# Focused tests for the interaction continuation contract and coroutine scoping. -write( - "app/src/test/java/app/passwordstore/data/crypto/OpenPgpApiBackendTest.kt", - '''/* - * Copyright © 2014-2026 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.data.crypto - -import android.app.PendingIntent -import android.content.Intent -import kotlin.test.Test -import kotlin.test.assertContentEquals -import kotlin.test.assertEquals -import kotlin.test.assertIs -import kotlinx.coroutines.runBlocking -import org.openintents.openpgp.util.OpenPgpApi -import org.robolectric.RuntimeEnvironment -import org.robolectric.RobolectricTestRunner -import org.junit.runner.RunWith - -@RunWith(RobolectricTestRunner::class) -class OpenPgpApiBackendTest { - - @Test - fun `decrypt returns provider output on success`() = runBlocking { - val executor = FakeExecutor { _, _, _ -> - OpenPgpApiCall(result(OpenPgpApi.RESULT_CODE_SUCCESS), byteArrayOf(1, 2, 3)) - } - val backend = OpenPgpApiBackend(executor) - - val result = backend.decrypt("provider", byteArrayOf(9)) - - assertContentEquals(byteArrayOf(1, 2, 3), assertIs>(result).value) - } - - @Test - fun `provider continuation intent is used after user interaction`() = runBlocking { - val pendingIntent = - PendingIntent.getActivity( - RuntimeEnvironment.getApplication(), - 7, - Intent("interaction"), - PendingIntent.FLAG_IMMUTABLE, - ) - val seenActions = mutableListOf() - val executor = FakeExecutor { _, request, _ -> - seenActions += request.action - if (seenActions.size == 1) { - OpenPgpApiCall( - result(OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED).apply { - putExtra(OpenPgpApi.RESULT_INTENT, pendingIntent) - }, - byteArrayOf(), - ) - } else { - OpenPgpApiCall(result(OpenPgpApi.RESULT_CODE_SUCCESS), byteArrayOf(4)) - } - } - val backend = OpenPgpApiBackend(executor) - - val operation = - backend.decrypt( - "provider", - byteArrayOf(9), - OpenPgpApiBackend.InteractionHandler { - OpenPgpApiBackend.InteractionResult.Completed(Intent("continued")) - }, - ) - - assertIs>(operation) - assertEquals(listOf(OpenPgpApi.ACTION_DECRYPT_VERIFY, "continued"), seenActions) - } - - @Test - fun `interaction is surfaced when no foreground handler exists`() = runBlocking { - val pendingIntent = - PendingIntent.getActivity( - RuntimeEnvironment.getApplication(), - 8, - Intent("interaction"), - PendingIntent.FLAG_IMMUTABLE, - ) - val executor = FakeExecutor { _, _, _ -> - OpenPgpApiCall( - result(OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED).apply { - putExtra(OpenPgpApi.RESULT_INTENT, pendingIntent) - }, - byteArrayOf(), - ) - } - - val operation = OpenPgpApiBackend(executor).decrypt("provider", byteArrayOf(9)) - - assertIs(operation) - } - - private fun result(code: Int): Intent = - Intent().apply { putExtra(OpenPgpApi.RESULT_CODE, code) } - - private class FakeExecutor( - private val executeBlock: suspend (String, Intent, ByteArray?) -> OpenPgpApiCall - ) : OpenPgpApiExecutor { - override fun providers(): List = emptyList() - - override suspend fun execute( - providerPackage: String, - request: Intent, - input: ByteArray?, - ): OpenPgpApiCall = executeBlock(providerPackage, request, input) - } -} -''', -) -write( - "app/src/test/java/app/passwordstore/data/crypto/OpenPgpInteractionCoordinatorTest.kt", - '''/* - * Copyright © 2014-2026 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.data.crypto - -import android.app.PendingIntent -import android.content.Intent -import kotlin.test.Test -import kotlin.test.assertIs -import kotlinx.coroutines.runBlocking -import org.junit.runner.RunWith -import org.robolectric.RuntimeEnvironment -import org.robolectric.RobolectricTestRunner - -@RunWith(RobolectricTestRunner::class) -class OpenPgpInteractionCoordinatorTest { - - @Test - fun `handler is visible only inside its coroutine scope`() = runBlocking { - val coordinator = OpenPgpInteractionCoordinator() - val pendingIntent = - PendingIntent.getActivity( - RuntimeEnvironment.getApplication(), - 9, - Intent("interaction"), - PendingIntent.FLAG_IMMUTABLE, - ) - - assertIs(coordinator.interact(pendingIntent)) - - coordinator.withHandler( - OpenPgpApiBackend.InteractionHandler { - OpenPgpApiBackend.InteractionResult.Completed(Intent("completed")) - } - ) { - assertIs(coordinator.interact(pendingIntent)) - } - - assertIs(coordinator.interact(pendingIntent)) - } -} -''', -) diff --git a/.github/workflows/apply-openpgp-finish.yml b/.github/workflows/apply-openpgp-finish.yml deleted file mode 100644 index 1bb9d7af56..0000000000 --- a/.github/workflows/apply-openpgp-finish.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Finish OpenPGP provider integration - -on: - push: - branches: - - feature/openpgp-provider - -permissions: - contents: write - -jobs: - apply-and-test: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: feature/openpgp-provider - - name: Setup build environment - uses: ./.github/reusable-workflows/setup-gradle - with: - java-version: 21 - - name: Apply integration - run: python .github/apply-openpgp-finish.py - - name: Format - run: ./gradlew spotlessApply - - name: Commit implementation - run: | - rm .github/apply-openpgp-finish.py .github/workflows/apply-openpgp-finish.yml - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "feat(pgp): integrate external OpenPGP providers" - git push origin HEAD:feature/openpgp-provider - - name: Run focused tests - run: ./gradlew :app:testDebugUnitTest :passkeys:core:test :passkeys:provider:testDebugUnitTest :crypto:pgpainless:test diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2221e32fc6..d101b883b0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -7,6 +7,12 @@ xmlns:tools="http://schemas.android.com/tools" android:installLocation="auto"> + + + + + + diff --git a/app/src/main/java/app/passwordstore/data/crypto/OpenPgpApiBackend.kt b/app/src/main/java/app/passwordstore/data/crypto/OpenPgpApiBackend.kt index afd567d218..74ef98f3c6 100644 --- a/app/src/main/java/app/passwordstore/data/crypto/OpenPgpApiBackend.kt +++ b/app/src/main/java/app/passwordstore/data/crypto/OpenPgpApiBackend.kt @@ -69,7 +69,9 @@ class OpenPgpApiBackend internal constructor(private val executor: OpenPgpApiExe initialRequest = Intent(OpenPgpApi.ACTION_CHECK_PERMISSION), input = null, interactionHandler = interactionHandler, - ) { Unit } + ) { + Unit + } suspend fun decrypt( providerPackage: String, @@ -81,7 +83,9 @@ class OpenPgpApiBackend internal constructor(private val executor: OpenPgpApiExe initialRequest = Intent(OpenPgpApi.ACTION_DECRYPT_VERIFY), input = ciphertext, interactionHandler = interactionHandler, - ) { call -> call.output } + ) { call -> + call.output + } suspend fun getPublicKey( providerPackage: String, @@ -98,7 +102,9 @@ class OpenPgpApiBackend internal constructor(private val executor: OpenPgpApiExe }, input = null, interactionHandler = interactionHandler, - ) { call -> call.output } + ) { call -> + call.output + } suspend fun resolveKeyIds( providerPackage: String, @@ -113,7 +119,9 @@ class OpenPgpApiBackend internal constructor(private val executor: OpenPgpApiExe }, input = null, interactionHandler = interactionHandler, - ) { call -> call.result.getLongArrayExtra(OpenPgpApi.RESULT_KEY_IDS) ?: longArrayOf() } + ) { call -> + call.result.getLongArrayExtra(OpenPgpApi.RESULT_KEY_IDS) ?: longArrayOf() + } private suspend fun executeWithInteraction( providerPackage: String, @@ -210,8 +218,7 @@ internal class BinderOpenPgpApiExecutor(private val context: Context) : OpenPgpA withService(providerPackage) { service -> val output = ByteArrayOutputStream() val result = - OpenPgpApi(context, service) - .executeApi(request, input?.let(::ByteArrayInputStream), output) + OpenPgpApi(context, service).executeApi(request, input?.let(::ByteArrayInputStream), output) OpenPgpApiCall(result, output.toByteArray()) } diff --git a/app/src/main/java/app/passwordstore/data/crypto/OpenPgpInteraction.kt b/app/src/main/java/app/passwordstore/data/crypto/OpenPgpInteraction.kt index d15b73438f..6baf33012d 100644 --- a/app/src/main/java/app/passwordstore/data/crypto/OpenPgpInteraction.kt +++ b/app/src/main/java/app/passwordstore/data/crypto/OpenPgpInteraction.kt @@ -7,17 +7,16 @@ package app.passwordstore.data.crypto import android.app.Activity import android.app.PendingIntent -import android.content.Intent import androidx.activity.ComponentActivity import androidx.activity.result.IntentSenderRequest import androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult import java.util.concurrent.atomic.AtomicReference import javax.inject.Inject import javax.inject.Singleton +import kotlin.coroutines.resume import kotlinx.coroutines.asContextElement import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext -import kotlin.coroutines.resume /** * Makes a foreground OpenPGP interaction handler available only to the coroutine tree that owns it. @@ -37,8 +36,7 @@ class OpenPgpInteractionCoordinator @Inject constructor() { ): T = withContext(currentHandler.asContextElement(handler)) { block() } suspend fun interact(pendingIntent: PendingIntent): OpenPgpApiBackend.InteractionResult { - val handler = currentHandler.get() - ?: return OpenPgpApiBackend.InteractionResult.Cancelled + val handler = currentHandler.get() ?: return OpenPgpApiBackend.InteractionResult.Cancelled return handler.interact(pendingIntent) } } @@ -47,7 +45,12 @@ class OpenPgpInteractionCoordinator @Inject constructor() { class OpenPgpActivityInteractionHandler(activity: ComponentActivity) : OpenPgpApiBackend.InteractionHandler { - private val waiting = AtomicReference?>(null) + private val waiting = + AtomicReference< + kotlinx.coroutines.CancellableContinuation? + >( + null + ) private val launcher = activity.registerForActivityResult(StartIntentSenderForResult()) { result -> @@ -60,9 +63,7 @@ class OpenPgpActivityInteractionHandler(activity: ComponentActivity) : } } - override suspend fun interact( - pendingIntent: PendingIntent - ): OpenPgpApiBackend.InteractionResult = + override suspend fun interact(pendingIntent: PendingIntent): OpenPgpApiBackend.InteractionResult = suspendCancellableCoroutine { continuation -> check(waiting.compareAndSet(null, continuation)) { "Another OpenPGP provider interaction is already active" diff --git a/app/src/main/java/app/passwordstore/data/crypto/OpenPgpProviderRepository.kt b/app/src/main/java/app/passwordstore/data/crypto/OpenPgpProviderRepository.kt index 436b94ea9b..7ca72aec04 100644 --- a/app/src/main/java/app/passwordstore/data/crypto/OpenPgpProviderRepository.kt +++ b/app/src/main/java/app/passwordstore/data/crypto/OpenPgpProviderRepository.kt @@ -17,7 +17,9 @@ import javax.inject.Singleton /** Coordinates APS-local provider selection with local public-certificate storage. */ @Singleton -class OpenPgpProviderRepository @Inject constructor( +class OpenPgpProviderRepository +@Inject +constructor( private val backend: OpenPgpApiBackend, private val keyManager: PGPKeyManager, @SettingsPreferences private val settings: SharedPreferences, @@ -34,7 +36,7 @@ class OpenPgpProviderRepository @Inject constructor( selectedProviderPackage()?.let(backend::isProviderInstalled) == true suspend fun checkPermission( - interactionHandler: OpenPgpApiBackend.InteractionHandler? = null, + interactionHandler: OpenPgpApiBackend.InteractionHandler? = null ): OpenPgpApiBackend.OperationResult { val provider = selectedProviderPackage() @@ -59,8 +61,8 @@ class OpenPgpProviderRepository @Inject constructor( /** * Makes the public certificates required by [identifiers] available to PGPainless. * - * The provider remains the sole owner of private key material. Retrieved certificates are - * checked against the provider-returned key ID before they are accepted by the local key manager. + * The provider remains the sole owner of private key material. Retrieved certificates are checked + * against the provider-returned key ID before they are accepted by the local key manager. */ suspend fun ensurePublicKeys( identifiers: List, @@ -129,14 +131,16 @@ class OpenPgpProviderRepository @Inject constructor( } var importFailure: Throwable? = null - keyManager.addKey(candidate, replace = false).fold( - success = {}, - failure = { error -> - // A concurrent import or an existing public certificate is harmless if the - // requested identity can now be resolved locally. - if (!hasLocalKey(identifier)) importFailure = error - }, - ) + keyManager + .addKey(candidate, replace = false) + .fold( + success = {}, + failure = { error -> + // A concurrent import or an existing public certificate is harmless if the + // requested identity can now be resolved locally. + if (!hasLocalKey(identifier)) importFailure = error + }, + ) if (importFailure != null) { return OpenPgpApiBackend.OperationResult.Failure(importFailure!!) } diff --git a/app/src/main/java/app/passwordstore/injection/passkeys/PasskeysModule.kt b/app/src/main/java/app/passwordstore/injection/passkeys/PasskeysModule.kt index 67605170e0..4994fbb2ec 100644 --- a/app/src/main/java/app/passwordstore/injection/passkeys/PasskeysModule.kt +++ b/app/src/main/java/app/passwordstore/injection/passkeys/PasskeysModule.kt @@ -21,6 +21,7 @@ import app.passwordstore.passkeys.BiometricPasskeyAuthenticator import app.passwordstore.passkeys.DefaultRepositoryGenerationProvider import app.passwordstore.passkeys.DefaultWebAuthnCallerVerifier import app.passwordstore.passkeys.KeystorePgpUnlockContext +import app.passwordstore.passkeys.OpenPgpPassRecipientResolver import app.passwordstore.passkeys.OpenPgpPasskeyDecryptor import app.passwordstore.passkeys.PasskeyMetadataIndex import app.passwordstore.passkeys.PasskeyPassphraseCache @@ -98,9 +99,16 @@ object PasskeysModule { fun providePassRecipientResolver( @ApplicationContext context: Context, keyManager: PGPKeyManager, + providerRepository: OpenPgpProviderRepository, + interactionCoordinator: OpenPgpInteractionCoordinator, ): PassRecipientResolver { val repositoryRoot = File(context.filesDir, "store") - return DefaultPassRecipientResolver(repositoryRoot, keyManager) + val localResolver = DefaultPassRecipientResolver(repositoryRoot, keyManager) + return OpenPgpPassRecipientResolver( + localResolver, + providerRepository, + interactionCoordinator, + ) } @Provides diff --git a/app/src/main/java/app/passwordstore/passkeys/AppPasskeyProviderActivity.kt b/app/src/main/java/app/passwordstore/passkeys/AppPasskeyProviderActivity.kt index 06b5c97d00..f6f9ee568e 100644 --- a/app/src/main/java/app/passwordstore/passkeys/AppPasskeyProviderActivity.kt +++ b/app/src/main/java/app/passwordstore/passkeys/AppPasskeyProviderActivity.kt @@ -23,6 +23,8 @@ import androidx.credentials.exceptions.GetCredentialUnknownException import androidx.credentials.provider.PendingIntentHandler import androidx.lifecycle.lifecycleScope import app.passwordstore.R as AppR +import app.passwordstore.data.crypto.OpenPgpActivityInteractionHandler +import app.passwordstore.data.crypto.OpenPgpInteractionCoordinator import app.passwordstore.data.repo.PasswordRepository import app.passwordstore.passkeys.crypto.CallerType import app.passwordstore.passkeys.crypto.ClientDataBinding @@ -68,8 +70,11 @@ class AppPasskeyProviderActivity : BaseGitActivity() { @Inject lateinit var generationProvider: RepositoryGenerationProvider @Inject lateinit var highWaterMark: SignatureCounterHighWaterMark @Inject lateinit var signatureCounterTransaction: SignatureCounterTransaction + @Inject lateinit var openPgpInteractionCoordinator: OpenPgpInteractionCoordinator @Inject lateinit var passphraseCache: PasskeyPassphraseCache @Inject lateinit var metadataIndex: PasskeyMetadataIndex + private val openPgpInteractionHandler = OpenPgpActivityInteractionHandler(this) + @Inject @app.passwordstore.injection.prefs.PGPPassphrases lateinit var persistentPassphrases: android.content.SharedPreferences @@ -92,12 +97,16 @@ class AppPasskeyProviderActivity : BaseGitActivity() { @RequiresApi(34) private suspend fun handleProviderRequest() { PendingIntentHandler.retrieveProviderGetCredentialRequest(intent)?.let { - handleGetCredential(it) + openPgpInteractionCoordinator.withHandler(openPgpInteractionHandler) { + handleGetCredential(it) + } return } PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)?.let { - handleCreateCredential(it) + openPgpInteractionCoordinator.withHandler(openPgpInteractionHandler) { + handleCreateCredential(it) + } return } diff --git a/app/src/main/java/app/passwordstore/passkeys/OpenPgpPassRecipientResolver.kt b/app/src/main/java/app/passwordstore/passkeys/OpenPgpPassRecipientResolver.kt new file mode 100644 index 0000000000..caff02cbae --- /dev/null +++ b/app/src/main/java/app/passwordstore/passkeys/OpenPgpPassRecipientResolver.kt @@ -0,0 +1,56 @@ +/* + * Copyright © 2014-2026 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.passkeys + +import app.passwordstore.crypto.PGPIdentifier +import app.passwordstore.crypto.PGPKey +import app.passwordstore.data.crypto.OpenPgpApiBackend +import app.passwordstore.data.crypto.OpenPgpInteractionCoordinator +import app.passwordstore.data.crypto.OpenPgpProviderRepository +import app.passwordstore.passkeys.storage.PassRecipientResolver +import app.passwordstore.passkeys.storage.RecipientPolicyError +import com.github.michaelbull.result.Err +import com.github.michaelbull.result.Ok +import com.github.michaelbull.result.Result +import com.github.michaelbull.result.fold +import java.io.File + +/** Adds provider public-certificate retrieval without weakening hierarchical `.gpg-id` policy. */ +class OpenPgpPassRecipientResolver( + private val delegate: PassRecipientResolver, + private val providerRepository: OpenPgpProviderRepository, + private val interactionCoordinator: OpenPgpInteractionCoordinator, +) : PassRecipientResolver { + + override suspend fun resolveFor(target: File): Result, RecipientPolicyError> { + return delegate + .resolveFor(target) + .fold( + success = { Ok(it) }, + failure = { error -> + if ( + error !is RecipientPolicyError.RecipientNotFound || + !providerRepository.hasSelectedProvider() + ) { + return@fold Err(error) + } + + val identifier = PGPIdentifier.fromString(error.identifier) ?: return@fold Err(error) + when ( + providerRepository.ensurePublicKeys( + listOf(identifier), + OpenPgpApiBackend.InteractionHandler { pendingIntent -> + interactionCoordinator.interact(pendingIntent) + }, + ) + ) { + is OpenPgpApiBackend.OperationResult.Success -> delegate.resolveFor(target) + else -> Err(error) + } + }, + ) + } +} diff --git a/app/src/main/java/app/passwordstore/passkeys/OpenPgpPasskeyDecryptor.kt b/app/src/main/java/app/passwordstore/passkeys/OpenPgpPasskeyDecryptor.kt index bd885fb7ae..e628ba54e0 100644 --- a/app/src/main/java/app/passwordstore/passkeys/OpenPgpPasskeyDecryptor.kt +++ b/app/src/main/java/app/passwordstore/passkeys/OpenPgpPasskeyDecryptor.kt @@ -25,7 +25,9 @@ import java.io.InputStream import javax.inject.Inject /** Selects PGPainless or the configured OpenPGP provider for passkey decryption. */ -class OpenPgpPasskeyDecryptor @Inject constructor( +class OpenPgpPasskeyDecryptor +@Inject +constructor( private val localDecryptor: PasskeyPgpDecryptor, private val providerRepository: OpenPgpProviderRepository, private val interactionCoordinator: OpenPgpInteractionCoordinator, @@ -135,8 +137,7 @@ class OpenPgpPasskeyDecryptor @Inject constructor( } is OpenPgpApiBackend.OperationResult.UserInteractionRequired -> Err(PasskeyDecryptionError.KeyLocked(provider)) - OpenPgpApiBackend.OperationResult.Cancelled -> - Err(PasskeyDecryptionError.KeyLocked(provider)) + OpenPgpApiBackend.OperationResult.Cancelled -> Err(PasskeyDecryptionError.KeyLocked(provider)) is OpenPgpApiBackend.OperationResult.Failure -> Err( PasskeyDecryptionError.UnsupportedFormat( 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 6e6b13f389..e867381071 100644 --- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt @@ -16,6 +16,7 @@ import app.passwordstore.R import app.passwordstore.crypto.PGPIdentifier import app.passwordstore.crypto.errors.IncorrectPassphraseException import app.passwordstore.crypto.errors.NoDecryptionKeyAvailableException +import app.passwordstore.data.crypto.OpenPgpApiBackend import app.passwordstore.data.passfile.PasswordEntry import app.passwordstore.data.repo.PasswordRepository import app.passwordstore.ui.crypto.BasePGPActivity @@ -79,6 +80,87 @@ class AutofillDecryptActivity : BasePGPActivity() { } } + override suspend fun decryptWithOpenPgpProvider() { + val encryptedFile = File(filePath) + val ciphertext = withContext(dispatcherProvider.io()) { encryptedFile.readBytes() } + try { + when (val result = decryptUsingOpenPgpProvider(ciphertext)) { + is OpenPgpApiBackend.OperationResult.Success -> { + val plaintextBytes = result.value + try { + val plaintextChars = plaintextBytes.toCharArray() + val entry = + try { + passwordEntryFactory.create(plaintextChars) + } finally { + plaintextChars.wipe() + } + entry.clearExtra() + val directoryStructure = AutofillPreferences.directoryStructure(this) + val credentials = + AutofillPreferences.credentialsFromStoreEntry( + this, + encryptedFile, + entry, + directoryStructure, + ) + val fillInDataset = + AutofillResponseBuilder.makeFillInDataset( + this@AutofillDecryptActivity, + credentials, + clientState, + action, + ) + withContext(dispatcherProvider.main()) { + setResult( + RESULT_OK, + Intent().apply { + putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset) + }, + ) + if (entry.hasTotp()) { + val otp = entry.currentOtp + val remainingTime = otp.remainingTime.inWholeSeconds + copyTextToClipboard(otp.value.toCharArray(), isSensitive = false) + otpTimer?.shutdownNow() + val otpTimerNew = Executors.newSingleThreadScheduledExecutor() + otpTimer = otpTimerNew + otpTimerNew.schedule( + { + copyTextToClipboard(entry.currentOtp.value.toCharArray(), isSensitive = false) + }, + remainingTime, + TimeUnit.SECONDS, + ) + } + entry.clear() + finish() + } + } finally { + plaintextBytes.wipe() + } + } + OpenPgpApiBackend.OperationResult.Cancelled -> finish() + is OpenPgpApiBackend.OperationResult.UserInteractionRequired -> { + snackbar(message = getString(R.string.openpgp_provider_interaction_failed)) + finish() + } + is OpenPgpApiBackend.OperationResult.Failure -> { + snackbar( + message = + getString( + R.string.openpgp_provider_operation_failed, + result.error.message ?: getString(R.string.error), + ) + ) + finish() + } + } + } finally { + ciphertext.wipe() + } + } + override suspend fun decryptWithPassphrase( passphrases: Map, identifiers: List, diff --git a/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt b/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt index ec2977401a..4d51c5e38b 100644 --- a/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt @@ -22,6 +22,9 @@ import androidx.lifecycle.lifecycleScope import app.passwordstore.R import app.passwordstore.crypto.PGPIdentifier import app.passwordstore.data.crypto.CryptoRepository +import app.passwordstore.data.crypto.OpenPgpActivityInteractionHandler +import app.passwordstore.data.crypto.OpenPgpApiBackend +import app.passwordstore.data.crypto.OpenPgpProviderRepository import app.passwordstore.data.repo.PasswordRepository import app.passwordstore.injection.prefs.PGPPassphrases import app.passwordstore.injection.prefs.SettingsPreferences @@ -90,6 +93,8 @@ open class BasePGPActivity : AppCompatActivity() { /* Counter for the user's decryption (with passphrase) attempts */ private var retries = 0 + private val openPgpInteractionHandler = OpenPgpActivityInteractionHandler(this) + private var secondsOnPause = 0L // seconds since Epoch upon pause private var timeout = 0L @@ -151,6 +156,7 @@ open class BasePGPActivity : AppCompatActivity() { @UnlockPins @Inject lateinit var unlockPins: SharedPreferences @Inject lateinit var repository: CryptoRepository + @Inject lateinit var openPgpProviderRepository: OpenPgpProviderRepository @Inject lateinit var dispatcherProvider: DispatcherProvider /** @@ -198,6 +204,10 @@ open class BasePGPActivity : AppCompatActivity() { */ protected fun requireKeysExist(onKeysExist: () -> Unit) { onKeyListCallback = onKeysExist + if (openPgpProviderRepository.hasSelectedProvider()) { + onKeysExist() + return + } lifecycleScope.launch { val hasKeys = repository.hasKeys() if (!hasKeys) { @@ -221,15 +231,11 @@ open class BasePGPActivity : AppCompatActivity() { ) { val ids = getPGPIdentifiers(subDir) if (ids.isNullOrEmpty()) { - /* Store not initialised properly; open Key Manager in selection mode and - * let user choose one or multiple keys */ val (title, message) = if (ids == null) { - // .gpg-id is missing resources.getString(R.string.missing_gpg_id_dialog_title) to resources.getString(R.string.missing_gpg_id_dialog_message) } else { - // .gpg-id contains no or malformed PGP IDs resources.getString(R.string.invalid_gpg_id_dialog_title) to resources.getString(R.string.invalid_gpg_id_dialog_message) } @@ -238,23 +244,47 @@ open class BasePGPActivity : AppCompatActivity() { intent.putExtra("SUB_PATH", subDir) keySelectAction.launch(intent) } - } else { - val idsWithKey = ids.filter { repository.hasKey(it) } - - if (idsWithKey.isEmpty()) { // No keys at all - /** - * The app does not provide keys with the requested key IDs; open Key Manager in key - * creation/import mode and let the user _import_ the needed PGP keys - */ - val title = resources.getString(R.string.no_pgp_keys_dialog_title) - val missingKeysForIds = ids.joinToString(", ") - val message = resources.getString(R.string.no_pgp_keys_dialog_message) + missingKeysForIds - openKeyManagerDialog(title, message) { - keyImportAction.launch(PGPKeyListActivity.newIntent(this@BasePGPActivity)) + return + } + + if (openPgpProviderRepository.hasSelectedProvider()) { + lifecycleScope.launch { + val missing = ids.filterNot(repository::hasKey) + if (missing.isEmpty()) { + onKeysExist(ids) + return@launch } - } else { - onKeysExist(ids) + when ( + val result = + openPgpProviderRepository.ensurePublicKeys(missing, openPgpInteractionHandler) + ) { + is OpenPgpApiBackend.OperationResult.Success -> onKeysExist(ids) + OpenPgpApiBackend.OperationResult.Cancelled -> Unit + is OpenPgpApiBackend.OperationResult.UserInteractionRequired -> + snackbar(message = getString(R.string.openpgp_provider_interaction_failed)) + is OpenPgpApiBackend.OperationResult.Failure -> + snackbar( + message = + getString( + R.string.openpgp_provider_operation_failed, + result.error.message ?: getString(R.string.error), + ) + ) + } + } + return + } + + val idsWithKey = ids.filter { repository.hasKey(it) } + if (idsWithKey.isEmpty()) { + val title = resources.getString(R.string.no_pgp_keys_dialog_title) + val missingKeysForIds = ids.joinToString(", ") + val message = resources.getString(R.string.no_pgp_keys_dialog_message) + missingKeysForIds + openKeyManagerDialog(title, message) { + keyImportAction.launch(PGPKeyListActivity.newIntent(this@BasePGPActivity)) } + } else { + onKeysExist(ids) } } @@ -264,15 +294,11 @@ open class BasePGPActivity : AppCompatActivity() { ) { val ids = getPGPIdentifiers(subDir) if (ids.isNullOrEmpty()) { - /* Store not initialised properly; open Key Manager in selection mode and - * let user choose one or multiple keys */ val (title, message) = if (ids == null) { - // .gpg-id is missing resources.getString(R.string.missing_gpg_id_dialog_title) to resources.getString(R.string.missing_gpg_id_dialog_message) } else { - // .gpg-id contains no or malformed PGP IDs resources.getString(R.string.invalid_gpg_id_dialog_title) to resources.getString(R.string.invalid_gpg_id_dialog_message) } @@ -281,37 +307,37 @@ open class BasePGPActivity : AppCompatActivity() { intent.putExtra("SUB_PATH", subDir) keySelectAction.launch(intent) } - } else { - val idsWithKey = ids.filter { repository.hasKey(it) } - val idsWithDecryptionKey = idsWithKey.filter { repository.hasDecKey(it) } - - if (idsWithDecryptionKey.isEmpty()) { - /** - * The app does not provide secret decryption keys with the requested key IDs; open Key - * Manager in key creation/import mode and let the user _import_ the needed PGP keys - */ - val title = resources.getString(R.string.no_decryption_keys_dialog_title) - val missingDecKeysForIds = - if (idsWithKey.isNotEmpty()) { - // Some keys keys are available, but they are all public - ids - .map { id -> - if (id in idsWithKey) "\n${id}: ${getString(R.string.pgp_public_only)}" - else "\n${id}: ${getString(R.string.pgp_unknown)}" - } - .joinToString() - } else { - // No keys at all - ids.joinToString(", ") - } - val message = - resources.getString(R.string.no_decryption_keys_dialog_message) + missingDecKeysForIds - openKeyManagerDialog(title, message) { - keyImportAction.launch(PGPKeyListActivity.newIntent(this@BasePGPActivity)) + return + } + + if (openPgpProviderRepository.hasSelectedProvider()) { + onKeysExist(ids) + return + } + + val idsWithKey = ids.filter { repository.hasKey(it) } + val idsWithDecryptionKey = idsWithKey.filter { repository.hasDecKey(it) } + + if (idsWithDecryptionKey.isEmpty()) { + val title = resources.getString(R.string.no_decryption_keys_dialog_title) + val missingDecKeysForIds = + if (idsWithKey.isNotEmpty()) { + ids + .map { id -> + if (id in idsWithKey) "\n${id}: ${getString(R.string.pgp_public_only)}" + else "\n${id}: ${getString(R.string.pgp_unknown)}" + } + .joinToString() + } else { + ids.joinToString(", ") } - } else { - onKeysExist(ids) + val message = + resources.getString(R.string.no_decryption_keys_dialog_message) + missingDecKeysForIds + openKeyManagerDialog(title, message) { + keyImportAction.launch(PGPKeyListActivity.newIntent(this@BasePGPActivity)) } + } else { + onKeysExist(ids) } } @@ -616,6 +642,11 @@ open class BasePGPActivity : AppCompatActivity() { /* Find persistent PGP passphrases with matching key ID, unlock the first one * with biometrics or after PIN verification */ protected fun getPersistentAndDecrypt(identifiers: List, action: String? = null) { + if (openPgpProviderRepository.hasSelectedProvider()) { + decrypt(identifiers) + return + } + // Detect AES key invalidation due to enrollment of a new fingerprint and emit warning if ( BiometricAuthenticator.canAuthenticate(this@BasePGPActivity) && @@ -811,6 +842,10 @@ open class BasePGPActivity : AppCompatActivity() { } protected fun decrypt(identifiers: List, isError: Boolean = false) { + if (openPgpProviderRepository.hasSelectedProvider()) { + lifecycleScope.launch(dispatcherProvider.main()) { decryptWithOpenPgpProvider() } + return + } val passphrases = cachedPassphrases.filterKeys { identifiers.map { it.toString() }.contains(it) } @@ -831,6 +866,13 @@ open class BasePGPActivity : AppCompatActivity() { } } + protected suspend fun decryptUsingOpenPgpProvider( + ciphertext: ByteArray + ): OpenPgpApiBackend.OperationResult = + openPgpProviderRepository.decrypt(ciphertext, openPgpInteractionHandler) + + protected open suspend fun decryptWithOpenPgpProvider() {} + /** Subclass-specific implementations */ open suspend fun decryptWithPassphrase( passphrases: Map, 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 9970f227ff..5dcde1c022 100644 --- a/app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt @@ -16,6 +16,7 @@ import app.passwordstore.R import app.passwordstore.crypto.PGPIdentifier import app.passwordstore.crypto.errors.IncorrectPassphraseException import app.passwordstore.crypto.errors.NoDecryptionKeyAvailableException +import app.passwordstore.data.crypto.OpenPgpApiBackend import app.passwordstore.data.passfile.PasswordEntry import app.passwordstore.data.password.FieldItem import app.passwordstore.databinding.DecryptLayoutBinding @@ -85,6 +86,43 @@ class DecryptActivity : BasePGPActivity() { super.onDestroy() } + override suspend fun decryptWithOpenPgpProvider() { + val ciphertext = withContext(dispatcherProvider.io()) { File(fullPath).readBytes() } + try { + when (val result = decryptUsingOpenPgpProvider(ciphertext)) { + is OpenPgpApiBackend.OperationResult.Success -> { + val plaintextBytes = result.value + try { + val plaintextChars = plaintextBytes.toCharArray() + try { + val entry = passwordEntryFactory.create(plaintextChars) + encryptedEntryChars = AESEncryption.encrypt(plaintextChars) + entry.clearExtraChars() + createPasswordUI(entry) + } finally { + plaintextChars.wipe() + } + } finally { + plaintextBytes.wipe() + } + } + OpenPgpApiBackend.OperationResult.Cancelled -> finish() + is OpenPgpApiBackend.OperationResult.UserInteractionRequired -> + snackbar(message = getString(R.string.openpgp_provider_interaction_failed)) + is OpenPgpApiBackend.OperationResult.Failure -> + snackbar( + message = + getString( + R.string.openpgp_provider_operation_failed, + result.error.message ?: getString(R.string.error), + ) + ) + } + } finally { + ciphertext.wipe() + } + } + override suspend fun decryptWithPassphrase( passphrases: Map, identifiers: List, 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 540ba5554c..28f2686dfd 100644 --- a/app/src/main/java/app/passwordstore/ui/settings/PGPSettings.kt +++ b/app/src/main/java/app/passwordstore/ui/settings/PGPSettings.kt @@ -6,17 +6,27 @@ package app.passwordstore.ui.settings import android.content.Intent +import androidx.core.content.edit import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.lifecycleScope import app.passwordstore.R +import app.passwordstore.data.crypto.OpenPgpActivityInteractionHandler +import app.passwordstore.data.crypto.OpenPgpApiBackend import app.passwordstore.ui.pgp.PGPKeyListActivity +import app.passwordstore.util.extensions.sharedPrefs import app.passwordstore.util.settings.PreferenceKeys +import com.google.android.material.dialog.MaterialAlertDialogBuilder import de.Maxr1998.modernpreferences.PreferenceScreen import de.Maxr1998.modernpreferences.helpers.onClick import de.Maxr1998.modernpreferences.helpers.pref import de.Maxr1998.modernpreferences.helpers.switch +import kotlinx.coroutines.launch class PGPSettings(private val activity: FragmentActivity) : SettingsProvider { + private val backend = OpenPgpApiBackend(activity.applicationContext) + private val interactionHandler = OpenPgpActivityInteractionHandler(activity) + override fun provideSettings(builder: PreferenceScreen.Builder) { builder.apply { pref("_") { @@ -30,10 +40,69 @@ class PGPSettings(private val activity: FragmentActivity) : SettingsProvider { false } } + pref("_openpgp_provider") { + titleRes = R.string.pref_openpgp_provider_title + summaryRes = R.string.pref_openpgp_provider_summary + persistent = false + onClick { + showOpenPgpProviderDialog() + false + } + } switch(PreferenceKeys.ASCII_ARMOR) { titleRes = R.string.pref_pgp_ascii_armor_title persistent = true } } } + + private fun showOpenPgpProviderDialog() { + val providers = backend.providers() + val current = activity.sharedPrefs.getString(PreferenceKeys.OPENPGP_PROVIDER_PACKAGE, null) + val labels = + listOf(activity.getString(R.string.pref_openpgp_provider_internal)) + + providers.map { "${it.label} (${it.packageName})" } + val checked = + providers.indexOfFirst { it.packageName == current }.let { if (it < 0) 0 else it + 1 } + + MaterialAlertDialogBuilder(activity) + .setTitle(R.string.pref_openpgp_provider_title) + .setSingleChoiceItems(labels.toTypedArray(), checked) { dialog, which -> + dialog.dismiss() + if (which == 0) { + activity.sharedPrefs.edit { remove(PreferenceKeys.OPENPGP_PROVIDER_PACKAGE) } + return@setSingleChoiceItems + } + + val provider = providers[which - 1] + activity.lifecycleScope.launch { + when (val result = backend.checkPermission(provider.packageName, interactionHandler)) { + is OpenPgpApiBackend.OperationResult.Success -> + activity.sharedPrefs.edit { + putString(PreferenceKeys.OPENPGP_PROVIDER_PACKAGE, provider.packageName) + } + OpenPgpApiBackend.OperationResult.Cancelled -> Unit + is OpenPgpApiBackend.OperationResult.UserInteractionRequired -> + showProviderError(activity.getString(R.string.openpgp_provider_interaction_failed)) + is OpenPgpApiBackend.OperationResult.Failure -> + showProviderError( + activity.getString( + R.string.openpgp_provider_operation_failed, + result.error.message ?: activity.getString(R.string.error), + ) + ) + } + } + } + .setNegativeButton(R.string.dialog_cancel, null) + .show() + } + + private fun showProviderError(message: String) { + MaterialAlertDialogBuilder(activity) + .setTitle(R.string.pref_openpgp_provider_title) + .setMessage(message) + .setPositiveButton(android.R.string.ok, null) + .show() + } } diff --git a/app/src/main/java/app/passwordstore/util/settings/PreferenceKeys.kt b/app/src/main/java/app/passwordstore/util/settings/PreferenceKeys.kt index bdab1f23ce..51b9ae51b7 100644 --- a/app/src/main/java/app/passwordstore/util/settings/PreferenceKeys.kt +++ b/app/src/main/java/app/passwordstore/util/settings/PreferenceKeys.kt @@ -58,7 +58,7 @@ object PreferenceKeys { const val OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES = "oreo_autofill_custom_public_suffixes" const val OREO_AUTOFILL_DEFAULT_USERNAME = "oreo_autofill_default_username" const val DIRECTORY_STRUCTURE = "oreo_autofill_directory_structure" - const val AUTOFILL_SAVE_DIRECTORY = "autofill_save_directory" + const val AUTOFILL_SAVE_DIRECTORY = "oreo_autofill_save_directory" const val STRICT_DOMAIN_SEARCH = "oreo_autofill_strict_domain_search" const val PREF_KEY_PWGEN_TYPE = "pref_key_pwgen_type" const val REPOSITORY_INITIALIZED = "repository_initialized" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d616321c7f..8658484d2f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -157,6 +157,11 @@ Prevent swiping down on the password list from triggering a Git sync. Import PGP key Key manager + OpenPGP backend + Use APS keys or delegate private-key operations to a compatible OpenPGP provider. + Internal key manager + The OpenPGP provider interaction could not be completed. + External OpenPGP provider operation failed: %1$s Encrypt in ASCII armor mode Backup/Export Export public key diff --git a/app/src/test/java/app/passwordstore/data/crypto/OpenPgpApiBackendTest.kt b/app/src/test/java/app/passwordstore/data/crypto/OpenPgpApiBackendTest.kt new file mode 100644 index 0000000000..50c2bfd3cc --- /dev/null +++ b/app/src/test/java/app/passwordstore/data/crypto/OpenPgpApiBackendTest.kt @@ -0,0 +1,112 @@ +/* + * Copyright © 2014-2026 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.data.crypto + +import android.app.PendingIntent +import android.content.Intent +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlinx.coroutines.runBlocking +import org.junit.runner.RunWith +import org.openintents.openpgp.util.OpenPgpApi +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class OpenPgpApiBackendTest { + + @Test + fun `decrypt returns provider output on success`() = runBlocking { + val executor = FakeExecutor { _, _, _ -> + OpenPgpApiCall(result(OpenPgpApi.RESULT_CODE_SUCCESS), byteArrayOf(1, 2, 3)) + } + val backend = OpenPgpApiBackend(executor) + + val result = backend.decrypt("provider", byteArrayOf(9)) + + assertContentEquals( + byteArrayOf(1, 2, 3), + assertIs>(result).value, + ) + } + + @Test + fun `provider continuation intent is used after user interaction`() = runBlocking { + val pendingIntent = + PendingIntent.getActivity( + RuntimeEnvironment.getApplication(), + 7, + Intent("interaction"), + PendingIntent.FLAG_IMMUTABLE, + ) + val seenActions = mutableListOf() + val executor = FakeExecutor { _, request, _ -> + seenActions += request.action + if (seenActions.size == 1) { + OpenPgpApiCall( + result(OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED).apply { + putExtra(OpenPgpApi.RESULT_INTENT, pendingIntent) + }, + byteArrayOf(), + ) + } else { + OpenPgpApiCall(result(OpenPgpApi.RESULT_CODE_SUCCESS), byteArrayOf(4)) + } + } + val backend = OpenPgpApiBackend(executor) + + val operation = + backend.decrypt( + "provider", + byteArrayOf(9), + OpenPgpApiBackend.InteractionHandler { + OpenPgpApiBackend.InteractionResult.Completed(Intent("continued")) + }, + ) + + assertIs>(operation) + assertEquals(listOf(OpenPgpApi.ACTION_DECRYPT_VERIFY, "continued"), seenActions) + } + + @Test + fun `interaction is surfaced when no foreground handler exists`() = runBlocking { + val pendingIntent = + PendingIntent.getActivity( + RuntimeEnvironment.getApplication(), + 8, + Intent("interaction"), + PendingIntent.FLAG_IMMUTABLE, + ) + val executor = FakeExecutor { _, _, _ -> + OpenPgpApiCall( + result(OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED).apply { + putExtra(OpenPgpApi.RESULT_INTENT, pendingIntent) + }, + byteArrayOf(), + ) + } + + val operation = OpenPgpApiBackend(executor).decrypt("provider", byteArrayOf(9)) + + assertIs(operation) + } + + private fun result(code: Int): Intent = Intent().apply { putExtra(OpenPgpApi.RESULT_CODE, code) } + + private class FakeExecutor( + private val executeBlock: suspend (String, Intent, ByteArray?) -> OpenPgpApiCall + ) : OpenPgpApiExecutor { + override fun providers(): List = emptyList() + + override suspend fun execute( + providerPackage: String, + request: Intent, + input: ByteArray?, + ): OpenPgpApiCall = executeBlock(providerPackage, request, input) + } +} diff --git a/app/src/test/java/app/passwordstore/data/crypto/OpenPgpInteractionCoordinatorTest.kt b/app/src/test/java/app/passwordstore/data/crypto/OpenPgpInteractionCoordinatorTest.kt new file mode 100644 index 0000000000..9666ccb49b --- /dev/null +++ b/app/src/test/java/app/passwordstore/data/crypto/OpenPgpInteractionCoordinatorTest.kt @@ -0,0 +1,43 @@ +/* + * Copyright © 2014-2026 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.data.crypto + +import android.app.PendingIntent +import android.content.Intent +import kotlin.test.Test +import kotlin.test.assertIs +import kotlinx.coroutines.runBlocking +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class OpenPgpInteractionCoordinatorTest { + + @Test + fun `handler is visible only inside its coroutine scope`() = runBlocking { + val coordinator = OpenPgpInteractionCoordinator() + val pendingIntent = + PendingIntent.getActivity( + RuntimeEnvironment.getApplication(), + 9, + Intent("interaction"), + PendingIntent.FLAG_IMMUTABLE, + ) + + assertIs(coordinator.interact(pendingIntent)) + + coordinator.withHandler( + OpenPgpApiBackend.InteractionHandler { + OpenPgpApiBackend.InteractionResult.Completed(Intent("completed")) + } + ) { + assertIs(coordinator.interact(pendingIntent)) + } + + assertIs(coordinator.interact(pendingIntent)) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 34ad76778c..a1ee56e742 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -39,7 +39,7 @@ androidx-security = "androidx.security:security-crypto-ktx:1.1.0" androidx-swiperefreshlayout = "androidx.swiperefreshlayout:swiperefreshlayout:1.2.0" aps-sublimeFuzzy = "com.github.android-password-store:sublime-fuzzy:2.3.4" aps-zxingAndroidEmbedded = "com.github.android-password-store:zxing-android-embedded:4.2.1" -build-agp = { module = "com.android.tools:gradle", version.ref = "agp" } +build-agp = { module = "com.android.tools.build:gradle", version.ref = "agp" } build-kotlin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } build-okhttp = "com.squareup.okhttp3:okhttp:5.5.0" build-r8 = "com.android.tools:r8:9.4.24" From c9274fbf59d7718931f3bb185e34e4ce60a6e497 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Mon, 21 Sep 2026 13:59:24 +0200 Subject: [PATCH 13/43] fix(pgp): map provider operation result types --- .../data/crypto/OpenPgpProviderRepository.kt | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/app/passwordstore/data/crypto/OpenPgpProviderRepository.kt b/app/src/main/java/app/passwordstore/data/crypto/OpenPgpProviderRepository.kt index 7ca72aec04..2472475311 100644 --- a/app/src/main/java/app/passwordstore/data/crypto/OpenPgpProviderRepository.kt +++ b/app/src/main/java/app/passwordstore/data/crypto/OpenPgpProviderRepository.kt @@ -12,6 +12,7 @@ import app.passwordstore.crypto.PGPKey import app.passwordstore.crypto.PGPKeyManager import app.passwordstore.injection.prefs.SettingsPreferences import app.passwordstore.util.settings.PreferenceKeys +import com.github.michaelbull.result.fold import javax.inject.Inject import javax.inject.Singleton @@ -90,9 +91,14 @@ constructor( ) ) { is OpenPgpApiBackend.OperationResult.Success -> resolved.value - is OpenPgpApiBackend.OperationResult.UserInteractionRequired -> return resolved - OpenPgpApiBackend.OperationResult.Cancelled -> return resolved - is OpenPgpApiBackend.OperationResult.Failure -> return resolved + is OpenPgpApiBackend.OperationResult.UserInteractionRequired -> + return OpenPgpApiBackend.OperationResult.UserInteractionRequired( + resolved.pendingIntent + ) + OpenPgpApiBackend.OperationResult.Cancelled -> + return OpenPgpApiBackend.OperationResult.Cancelled + is OpenPgpApiBackend.OperationResult.Failure -> + return OpenPgpApiBackend.OperationResult.Failure(resolved.error) } } } @@ -145,9 +151,12 @@ constructor( return OpenPgpApiBackend.OperationResult.Failure(importFailure!!) } } - is OpenPgpApiBackend.OperationResult.UserInteractionRequired -> return fetched - OpenPgpApiBackend.OperationResult.Cancelled -> return fetched - is OpenPgpApiBackend.OperationResult.Failure -> return fetched + is OpenPgpApiBackend.OperationResult.UserInteractionRequired -> + return OpenPgpApiBackend.OperationResult.UserInteractionRequired(fetched.pendingIntent) + OpenPgpApiBackend.OperationResult.Cancelled -> + return OpenPgpApiBackend.OperationResult.Cancelled + is OpenPgpApiBackend.OperationResult.Failure -> + return OpenPgpApiBackend.OperationResult.Failure(fetched.error) } } From 95345edf06cfd5804df8bb388622d63865d5fa72 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Mon, 21 Sep 2026 14:00:45 +0200 Subject: [PATCH 14/43] chore: keep catalog changes scoped to OpenPGP --- gradle/libs.versions.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a1ee56e742..3460cc568a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,7 +29,7 @@ androidx-fragment-ktx = "androidx.fragment:fragment-ktx:1.9.0" androidx-work-runtime-ktx = "androidx.work:work-runtime-ktx:2.11.2" androidx-lifecycle-common = { module = "androidx.lifecycle:lifecycle-common", version.ref = "lifecycle" } androidx-lifecycle-livedataKtx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycle" } -androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } +androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose" } androidx-lifecycle-viewmodelKtx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" } androidx-material = "com.google.android.material:material:1.14.0" androidx-preference = "androidx.preference:preference:1.2.1" @@ -92,8 +92,8 @@ thirdparty-uri = "com.eygraber:uri-kmp:0.0.21" # build-diffutils = "io.github.java-diff-utils:java-diff-utils:4.17" # build-download = "de.undercouch:gradle-download-task:5.7.0" # build-javapoet = "com.squareup:javapoet:2.13.0" -# build-moshi = { module = "com.squareup.moshi:moshi:1.15.2" } -# build-moshi-kotlin = { module = "com.squareup.moshi:moshi-kotlin:1.15.2" } +# build-moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" } +# build-moshi-kotlin = { module = "com.squareup.moshi:moshi-kotlin", version.ref = "moshi" } # thirdparty-leakcanary-plumber = "com.squareup.leakcanary:plumber-android-startup:2.14" [bundles] From e7d89dbd7b93d8d588ad5502f2e8ba65672c464b Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Mon, 21 Sep 2026 14:01:38 +0200 Subject: [PATCH 15/43] fix: restore lifecycle catalog version reference --- gradle/libs.versions.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3460cc568a..8e6316b46c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,7 +29,7 @@ androidx-fragment-ktx = "androidx.fragment:fragment-ktx:1.9.0" androidx-work-runtime-ktx = "androidx.work:work-runtime-ktx:2.11.2" androidx-lifecycle-common = { module = "androidx.lifecycle:lifecycle-common", version.ref = "lifecycle" } androidx-lifecycle-livedataKtx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycle" } -androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose" } +androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } androidx-lifecycle-viewmodelKtx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" } androidx-material = "com.google.android.material:material:1.14.0" androidx-preference = "androidx.preference:preference:1.2.1" @@ -60,7 +60,7 @@ kotlinx-collections-immutable = "org.jetbrains.kotlinx:kotlinx-collections-immut kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } -kotlinx-datetime = "org.jetbrains.kotlinx:kotlinx-datetime:0.8.0-0.6.x-compat" +kotlinx-datetime = "org.jetbrains.kotlinx-datetime:0.8.0-0.6.x-compat" kotlinx-serialization-json = "org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0" testing-junit = "junit:junit:4.13.2" testing-kotlintest-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } From 109e6f5c88b86174db98aa99eb64947f4ad4434f Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Mon, 21 Sep 2026 14:02:26 +0200 Subject: [PATCH 16/43] fix: keep version catalog identical except OpenPGP dependency --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8e6316b46c..499893cb6a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -60,7 +60,7 @@ kotlinx-collections-immutable = "org.jetbrains.kotlinx:kotlinx-collections-immut kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } -kotlinx-datetime = "org.jetbrains.kotlinx-datetime:0.8.0-0.6.x-compat" +kotlinx-datetime = "org.jetbrains.kotlinx:kotlinx-datetime:0.8.0-0.6.x-compat" kotlinx-serialization-json = "org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0" testing-junit = "junit:junit:4.13.2" testing-kotlintest-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } From 0959eedfa35174c27213530ce69e39fa6fa193ea Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Mon, 21 Sep 2026 14:02:45 +0200 Subject: [PATCH 17/43] chore: add temporary OpenPGP verification workflow --- .github/workflows/verify-openpgp-provider.yml | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .github/workflows/verify-openpgp-provider.yml diff --git a/.github/workflows/verify-openpgp-provider.yml b/.github/workflows/verify-openpgp-provider.yml new file mode 100644 index 0000000000..a07c0fe8ee --- /dev/null +++ b/.github/workflows/verify-openpgp-provider.yml @@ -0,0 +1,22 @@ +name: Verify OpenPGP provider branch + +on: + push: + branches: + - feature/openpgp-provider + +permissions: + contents: read + +jobs: + verify: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Setup build environment + uses: ./.github/reusable-workflows/setup-gradle + with: + java-version: 21 + - name: Check style and focused tests + run: ./gradlew spotlessCheck :app:testDebugUnitTest :passkeys:core:test :passkeys:provider:testDebugUnitTest :crypto:pgpainless:test From f59b46da14da7e613f99c9ec57011ae5ff776aa4 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Mon, 21 Sep 2026 14:05:34 +0200 Subject: [PATCH 18/43] fix(pgp): fail closed around provider interaction --- .../data/crypto/OpenPgpApiBackend.kt | 42 +++++++++++++++---- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/app/passwordstore/data/crypto/OpenPgpApiBackend.kt b/app/src/main/java/app/passwordstore/data/crypto/OpenPgpApiBackend.kt index 74ef98f3c6..b1e1788f67 100644 --- a/app/src/main/java/app/passwordstore/data/crypto/OpenPgpApiBackend.kt +++ b/app/src/main/java/app/passwordstore/data/crypto/OpenPgpApiBackend.kt @@ -17,6 +17,7 @@ import javax.inject.Inject import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout import org.openintents.openpgp.IOpenPgpService2 import org.openintents.openpgp.OpenPgpError import org.openintents.openpgp.util.OpenPgpApi @@ -143,19 +144,41 @@ class OpenPgpApiBackend internal constructor(private val executor: OpenPgpApiExe } when (call.result.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) { - OpenPgpApi.RESULT_CODE_SUCCESS -> return OperationResult.Success(onSuccess(call)) + OpenPgpApi.RESULT_CODE_SUCCESS -> { + return try { + OperationResult.Success(onSuccess(call)) + } catch (error: CancellationException) { + call.output.fill(0) + throw error + } catch (error: Throwable) { + call.output.fill(0) + OperationResult.Failure(error) + } + } OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> { @Suppress("DEPRECATION") val pendingIntent = call.result.getParcelableExtra(OpenPgpApi.RESULT_INTENT) - ?: return OperationResult.Failure( - IllegalStateException( - "OpenPGP provider requested user interaction without a PendingIntent" + ?: run { + call.output.fill(0) + return OperationResult.Failure( + IllegalStateException( + "OpenPGP provider requested user interaction without a PendingIntent" + ) ) - ) + } + call.output.fill(0) val handler = interactionHandler ?: return OperationResult.UserInteractionRequired(pendingIntent) - when (val interaction = handler.interact(pendingIntent)) { + val interaction = + try { + handler.interact(pendingIntent) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + return OperationResult.Failure(error) + } + when (interaction) { is InteractionResult.Completed -> { // The OpenPGP API specifies that the result Intent contains the original operation // plus the provider's newly granted state. Some providers return no data after a @@ -166,6 +189,7 @@ class OpenPgpApiBackend internal constructor(private val executor: OpenPgpApiExe } } else -> { + call.output.fill(0) @Suppress("DEPRECATION") val error = call.result.getParcelableExtra(OpenPgpApi.RESULT_ERROR) return OperationResult.Failure( @@ -245,7 +269,7 @@ internal class BinderOpenPgpApiExecutor(private val context: Context) : OpenPgpA try { connection.bindToService() - operation(service.await()) + operation(withTimeout(SERVICE_BIND_TIMEOUT_MILLIS) { service.await() }) } finally { if (connection.isBound) runCatching { connection.unbindFromService() } } @@ -258,4 +282,8 @@ internal class BinderOpenPgpApiExecutor(private val context: Context) : OpenPgpA info.loadLabel(context.packageManager)?.toString()?.ifBlank { packageName } ?: packageName return OpenPgpApiBackend.Provider(packageName, label) } + + private companion object { + const val SERVICE_BIND_TIMEOUT_MILLIS = 15_000L + } } From 263f69664e07c760ec1186c552a2e94e5900e144 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Mon, 21 Sep 2026 14:06:51 +0200 Subject: [PATCH 19/43] fix(pgp): avoid redundant Unit expression --- .../java/app/passwordstore/data/crypto/OpenPgpApiBackend.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/src/main/java/app/passwordstore/data/crypto/OpenPgpApiBackend.kt b/app/src/main/java/app/passwordstore/data/crypto/OpenPgpApiBackend.kt index b1e1788f67..b3ab2d010d 100644 --- a/app/src/main/java/app/passwordstore/data/crypto/OpenPgpApiBackend.kt +++ b/app/src/main/java/app/passwordstore/data/crypto/OpenPgpApiBackend.kt @@ -70,9 +70,7 @@ class OpenPgpApiBackend internal constructor(private val executor: OpenPgpApiExe initialRequest = Intent(OpenPgpApi.ACTION_CHECK_PERMISSION), input = null, interactionHandler = interactionHandler, - ) { - Unit - } + ) {} suspend fun decrypt( providerPackage: String, From 609273657baac1f47a404ba786459950cad9164e Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Mon, 21 Sep 2026 14:07:35 +0200 Subject: [PATCH 20/43] fix(passkeys): resolve all missing provider recipients --- .../passkeys/OpenPgpPassRecipientResolver.kt | 59 +++++++++++-------- 1 file changed, 34 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/app/passwordstore/passkeys/OpenPgpPassRecipientResolver.kt b/app/src/main/java/app/passwordstore/passkeys/OpenPgpPassRecipientResolver.kt index caff02cbae..5ce28c2031 100644 --- a/app/src/main/java/app/passwordstore/passkeys/OpenPgpPassRecipientResolver.kt +++ b/app/src/main/java/app/passwordstore/passkeys/OpenPgpPassRecipientResolver.kt @@ -26,31 +26,40 @@ class OpenPgpPassRecipientResolver( ) : PassRecipientResolver { override suspend fun resolveFor(target: File): Result, RecipientPolicyError> { - return delegate - .resolveFor(target) - .fold( - success = { Ok(it) }, - failure = { error -> - if ( - error !is RecipientPolicyError.RecipientNotFound || - !providerRepository.hasSelectedProvider() - ) { - return@fold Err(error) - } + val attemptedIdentifiers = mutableSetOf() - val identifier = PGPIdentifier.fromString(error.identifier) ?: return@fold Err(error) - when ( - providerRepository.ensurePublicKeys( - listOf(identifier), - OpenPgpApiBackend.InteractionHandler { pendingIntent -> - interactionCoordinator.interact(pendingIntent) - }, - ) - ) { - is OpenPgpApiBackend.OperationResult.Success -> delegate.resolveFor(target) - else -> Err(error) - } - }, - ) + while (true) { + var resolvedKeys: List? = null + var resolutionError: RecipientPolicyError? = null + delegate + .resolveFor(target) + .fold( + success = { resolvedKeys = it }, + failure = { resolutionError = it }, + ) + + resolvedKeys?.let { return Ok(it) } + val error = resolutionError ?: return Err(RecipientPolicyError.EmptyRecipientSet) + if ( + error !is RecipientPolicyError.RecipientNotFound || + !providerRepository.hasSelectedProvider() || + !attemptedIdentifiers.add(error.identifier) + ) { + return Err(error) + } + + val identifier = PGPIdentifier.fromString(error.identifier) ?: return Err(error) + when ( + providerRepository.ensurePublicKeys( + listOf(identifier), + OpenPgpApiBackend.InteractionHandler { pendingIntent -> + interactionCoordinator.interact(pendingIntent) + }, + ) + ) { + is OpenPgpApiBackend.OperationResult.Success -> Unit + else -> return Err(error) + } + } } } From df4a465da6be90df3ecbd6d04612c7ec148e3eb3 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Mon, 21 Sep 2026 19:54:12 +0200 Subject: [PATCH 21/43] style(passkeys): apply Spotless formatting --- .../passwordstore/passkeys/OpenPgpPassRecipientResolver.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/app/passwordstore/passkeys/OpenPgpPassRecipientResolver.kt b/app/src/main/java/app/passwordstore/passkeys/OpenPgpPassRecipientResolver.kt index 5ce28c2031..a68fd4eff6 100644 --- a/app/src/main/java/app/passwordstore/passkeys/OpenPgpPassRecipientResolver.kt +++ b/app/src/main/java/app/passwordstore/passkeys/OpenPgpPassRecipientResolver.kt @@ -38,7 +38,9 @@ class OpenPgpPassRecipientResolver( failure = { resolutionError = it }, ) - resolvedKeys?.let { return Ok(it) } + resolvedKeys?.let { + return Ok(it) + } val error = resolutionError ?: return Err(RecipientPolicyError.EmptyRecipientSet) if ( error !is RecipientPolicyError.RecipientNotFound || From eedb4f87e662de895e0cdc0572c216587b9d243d Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Mon, 21 Sep 2026 19:57:56 +0200 Subject: [PATCH 22/43] fix(pgp): surface provider bind timeouts as failures --- .../app/passwordstore/data/crypto/OpenPgpApiBackend.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/app/passwordstore/data/crypto/OpenPgpApiBackend.kt b/app/src/main/java/app/passwordstore/data/crypto/OpenPgpApiBackend.kt index b3ab2d010d..47762bde4a 100644 --- a/app/src/main/java/app/passwordstore/data/crypto/OpenPgpApiBackend.kt +++ b/app/src/main/java/app/passwordstore/data/crypto/OpenPgpApiBackend.kt @@ -17,7 +17,7 @@ import javax.inject.Inject import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull import org.openintents.openpgp.IOpenPgpService2 import org.openintents.openpgp.OpenPgpError import org.openintents.openpgp.util.OpenPgpApi @@ -267,7 +267,12 @@ internal class BinderOpenPgpApiExecutor(private val context: Context) : OpenPgpA try { connection.bindToService() - operation(withTimeout(SERVICE_BIND_TIMEOUT_MILLIS) { service.await() }) + val boundService = + withTimeoutOrNull(SERVICE_BIND_TIMEOUT_MILLIS) { service.await() } + ?: throw OpenPgpProviderException( + "Timed out binding to OpenPGP provider $providerPackage" + ) + operation(boundService) } finally { if (connection.isBound) runCatching { connection.unbindFromService() } } From 90529125288063387c89367ffc0bb4b8b0fa17a7 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Mon, 21 Sep 2026 19:58:12 +0200 Subject: [PATCH 23/43] fix(pgp): release interaction slot on launch failure --- .../app/passwordstore/data/crypto/OpenPgpInteraction.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/app/passwordstore/data/crypto/OpenPgpInteraction.kt b/app/src/main/java/app/passwordstore/data/crypto/OpenPgpInteraction.kt index 6baf33012d..e9d01c63d8 100644 --- a/app/src/main/java/app/passwordstore/data/crypto/OpenPgpInteraction.kt +++ b/app/src/main/java/app/passwordstore/data/crypto/OpenPgpInteraction.kt @@ -14,6 +14,7 @@ import java.util.concurrent.atomic.AtomicReference import javax.inject.Inject import javax.inject.Singleton import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException import kotlinx.coroutines.asContextElement import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext @@ -69,6 +70,12 @@ class OpenPgpActivityInteractionHandler(activity: ComponentActivity) : "Another OpenPGP provider interaction is already active" } continuation.invokeOnCancellation { waiting.compareAndSet(continuation, null) } - launcher.launch(IntentSenderRequest.Builder(pendingIntent.intentSender).build()) + try { + launcher.launch(IntentSenderRequest.Builder(pendingIntent.intentSender).build()) + } catch (error: Throwable) { + if (waiting.compareAndSet(continuation, null) && continuation.isActive) { + continuation.resumeWithException(error) + } + } } } From 741dfa3dbbe846aeea024268e8f301a7bbd0eae8 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Mon, 21 Sep 2026 20:03:48 +0200 Subject: [PATCH 24/43] test(pgp): make continuation action type explicit --- .../app/passwordstore/data/crypto/OpenPgpApiBackendTest.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/test/java/app/passwordstore/data/crypto/OpenPgpApiBackendTest.kt b/app/src/test/java/app/passwordstore/data/crypto/OpenPgpApiBackendTest.kt index 50c2bfd3cc..462728d2b8 100644 --- a/app/src/test/java/app/passwordstore/data/crypto/OpenPgpApiBackendTest.kt +++ b/app/src/test/java/app/passwordstore/data/crypto/OpenPgpApiBackendTest.kt @@ -70,7 +70,10 @@ class OpenPgpApiBackendTest { ) assertIs>(operation) - assertEquals(listOf(OpenPgpApi.ACTION_DECRYPT_VERIFY, "continued"), seenActions) + assertEquals( + listOf(OpenPgpApi.ACTION_DECRYPT_VERIFY, "continued"), + seenActions, + ) } @Test From 04f7089fcb081db5ca260212c8325fc2ea1a0301 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Mon, 21 Sep 2026 20:07:26 +0200 Subject: [PATCH 25/43] chore: remove temporary OpenPGP verification workflow --- .github/workflows/verify-openpgp-provider.yml | 22 ------------------- 1 file changed, 22 deletions(-) delete mode 100644 .github/workflows/verify-openpgp-provider.yml diff --git a/.github/workflows/verify-openpgp-provider.yml b/.github/workflows/verify-openpgp-provider.yml deleted file mode 100644 index a07c0fe8ee..0000000000 --- a/.github/workflows/verify-openpgp-provider.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Verify OpenPGP provider branch - -on: - push: - branches: - - feature/openpgp-provider - -permissions: - contents: read - -jobs: - verify: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Setup build environment - uses: ./.github/reusable-workflows/setup-gradle - with: - java-version: 21 - - name: Check style and focused tests - run: ./gradlew spotlessCheck :app:testDebugUnitTest :passkeys:core:test :passkeys:provider:testDebugUnitTest :crypto:pgpainless:test From 857ab3ce98f81dc2fe63c0d42c45fd2ac8299a84 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Mon, 21 Sep 2026 20:09:07 +0200 Subject: [PATCH 26/43] test(pgp): use JUnit-compatible Unit signatures --- .../app/passwordstore/data/crypto/OpenPgpApiBackendTest.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/test/java/app/passwordstore/data/crypto/OpenPgpApiBackendTest.kt b/app/src/test/java/app/passwordstore/data/crypto/OpenPgpApiBackendTest.kt index 462728d2b8..a6fdcdd901 100644 --- a/app/src/test/java/app/passwordstore/data/crypto/OpenPgpApiBackendTest.kt +++ b/app/src/test/java/app/passwordstore/data/crypto/OpenPgpApiBackendTest.kt @@ -21,7 +21,7 @@ import org.robolectric.RuntimeEnvironment class OpenPgpApiBackendTest { @Test - fun `decrypt returns provider output on success`() = runBlocking { + fun `decrypt returns provider output on success`(): Unit = runBlocking { val executor = FakeExecutor { _, _, _ -> OpenPgpApiCall(result(OpenPgpApi.RESULT_CODE_SUCCESS), byteArrayOf(1, 2, 3)) } @@ -36,7 +36,7 @@ class OpenPgpApiBackendTest { } @Test - fun `provider continuation intent is used after user interaction`() = runBlocking { + fun `provider continuation intent is used after user interaction`(): Unit = runBlocking { val pendingIntent = PendingIntent.getActivity( RuntimeEnvironment.getApplication(), @@ -77,7 +77,7 @@ class OpenPgpApiBackendTest { } @Test - fun `interaction is surfaced when no foreground handler exists`() = runBlocking { + fun `interaction is surfaced when no foreground handler exists`(): Unit = runBlocking { val pendingIntent = PendingIntent.getActivity( RuntimeEnvironment.getApplication(), From d66a70b5417e2655d886c70e383a5debc001f568 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Mon, 21 Sep 2026 20:09:21 +0200 Subject: [PATCH 27/43] test(pgp): use JUnit-compatible Unit signature --- .../data/crypto/OpenPgpInteractionCoordinatorTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/test/java/app/passwordstore/data/crypto/OpenPgpInteractionCoordinatorTest.kt b/app/src/test/java/app/passwordstore/data/crypto/OpenPgpInteractionCoordinatorTest.kt index 9666ccb49b..dcec062c5e 100644 --- a/app/src/test/java/app/passwordstore/data/crypto/OpenPgpInteractionCoordinatorTest.kt +++ b/app/src/test/java/app/passwordstore/data/crypto/OpenPgpInteractionCoordinatorTest.kt @@ -18,7 +18,7 @@ import org.robolectric.RuntimeEnvironment class OpenPgpInteractionCoordinatorTest { @Test - fun `handler is visible only inside its coroutine scope`() = runBlocking { + fun `handler is visible only inside its coroutine scope`(): Unit = runBlocking { val coordinator = OpenPgpInteractionCoordinator() val pendingIntent = PendingIntent.getActivity( From 9963d00d9b334318139f2a4500ea430e21620e50 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Mon, 21 Sep 2026 20:15:58 +0200 Subject: [PATCH 28/43] fix(pgp): avoid non-null assertion in provider import --- .../passwordstore/data/crypto/OpenPgpProviderRepository.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/app/passwordstore/data/crypto/OpenPgpProviderRepository.kt b/app/src/main/java/app/passwordstore/data/crypto/OpenPgpProviderRepository.kt index 2472475311..546803f526 100644 --- a/app/src/main/java/app/passwordstore/data/crypto/OpenPgpProviderRepository.kt +++ b/app/src/main/java/app/passwordstore/data/crypto/OpenPgpProviderRepository.kt @@ -147,8 +147,8 @@ constructor( if (!hasLocalKey(identifier)) importFailure = error }, ) - if (importFailure != null) { - return OpenPgpApiBackend.OperationResult.Failure(importFailure!!) + importFailure?.let { error -> + return OpenPgpApiBackend.OperationResult.Failure(error) } } is OpenPgpApiBackend.OperationResult.UserInteractionRequired -> From 3aa8e64e9c4bbc8d606e5af564d927c0b33c124a Mon Sep 17 00:00:00 2001 From: "forkline-dev[bot]" Date: Mon, 21 Sep 2026 18:27:45 +0000 Subject: [PATCH 29/43] Fix lint: replace runCatching with try-catch and remove stale InvalidPackage baseline entries --- app/lint-baseline.xml | 30 +------------------ .../data/crypto/OpenPgpApiBackend.kt | 6 +++- 2 files changed, 6 insertions(+), 30 deletions(-) diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml index 18ac88951c..ca7686e717 100644 --- a/app/lint-baseline.xml +++ b/app/lint-baseline.xml @@ -1,35 +1,7 @@ - - - - - - - - - - - - - - - - - Date: Tue, 22 Sep 2026 07:18:06 +0200 Subject: [PATCH 30/43] fix(pgp): fail closed for unavailable providers --- .../data/crypto/OpenPgpProviderRepository.kt | 10 ++++++ .../passwordstore/ui/settings/PGPSettings.kt | 32 +++++++++++++++---- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/app/passwordstore/data/crypto/OpenPgpProviderRepository.kt b/app/src/main/java/app/passwordstore/data/crypto/OpenPgpProviderRepository.kt index 546803f526..7e74bdf534 100644 --- a/app/src/main/java/app/passwordstore/data/crypto/OpenPgpProviderRepository.kt +++ b/app/src/main/java/app/passwordstore/data/crypto/OpenPgpProviderRepository.kt @@ -44,6 +44,7 @@ constructor( ?: return OpenPgpApiBackend.OperationResult.Failure( IllegalStateException("No external OpenPGP provider is selected") ) + if (!backend.isProviderInstalled(provider)) return providerUnavailable(provider) return backend.checkPermission(provider, interactionHandler) } @@ -56,6 +57,7 @@ constructor( ?: return OpenPgpApiBackend.OperationResult.Failure( IllegalStateException("No external OpenPGP provider is selected") ) + if (!backend.isProviderInstalled(provider)) return providerUnavailable(provider) return backend.decrypt(provider, ciphertext, interactionHandler) } @@ -74,6 +76,7 @@ constructor( ?: return OpenPgpApiBackend.OperationResult.Failure( IllegalStateException("No external OpenPGP provider is selected") ) + if (!backend.isProviderInstalled(provider)) return providerUnavailable(provider) for (identifier in identifiers) { if (hasLocalKey(identifier)) continue @@ -170,6 +173,13 @@ constructor( return OpenPgpApiBackend.OperationResult.Success(Unit) } + private fun providerUnavailable( + provider: String + ): OpenPgpApiBackend.OperationResult = + OpenPgpApiBackend.OperationResult.Failure( + OpenPgpProviderException("Selected OpenPGP provider $provider is not installed") + ) + private fun hasLocalKey(identifier: PGPIdentifier): Boolean = keyManager.getKeyById(identifier).fold(success = { true }, failure = { false }) } 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 28f2686dfd..591a7796db 100644 --- a/app/src/main/java/app/passwordstore/ui/settings/PGPSettings.kt +++ b/app/src/main/java/app/passwordstore/ui/settings/PGPSettings.kt @@ -59,22 +59,42 @@ class PGPSettings(private val activity: FragmentActivity) : SettingsProvider { private fun showOpenPgpProviderDialog() { val providers = backend.providers() val current = activity.sharedPrefs.getString(PreferenceKeys.OPENPGP_PROVIDER_PACKAGE, null) - val labels = - listOf(activity.getString(R.string.pref_openpgp_provider_internal)) + - providers.map { "${it.label} (${it.packageName})" } + val unavailableCurrent = + current?.takeIf { selected -> providers.none { it.packageName == selected } } + val labels = buildList { + add(activity.getString(R.string.pref_openpgp_provider_internal)) + unavailableCurrent?.let { add("$it (${activity.getString(R.string.error)})") } + addAll(providers.map { "${it.label} (${it.packageName})" }) + } val checked = - providers.indexOfFirst { it.packageName == current }.let { if (it < 0) 0 else it + 1 } + when { + current == null -> 0 + unavailableCurrent != null -> 1 + else -> providers.indexOfFirst { it.packageName == current } + 1 + } + val providerOffset = if (unavailableCurrent != null) 2 else 1 MaterialAlertDialogBuilder(activity) .setTitle(R.string.pref_openpgp_provider_title) .setSingleChoiceItems(labels.toTypedArray(), checked) { dialog, which -> - dialog.dismiss() if (which == 0) { + dialog.dismiss() activity.sharedPrefs.edit { remove(PreferenceKeys.OPENPGP_PROVIDER_PACKAGE) } return@setSingleChoiceItems } + if (unavailableCurrent != null && which == 1) { + dialog.dismiss() + showProviderError( + activity.getString( + R.string.openpgp_provider_operation_failed, + "$unavailableCurrent: ${activity.getString(R.string.error)}", + ) + ) + return@setSingleChoiceItems + } - val provider = providers[which - 1] + dialog.dismiss() + val provider = providers[which - providerOffset] activity.lifecycleScope.launch { when (val result = backend.checkPermission(provider.packageName, interactionHandler)) { is OpenPgpApiBackend.OperationResult.Success -> From 7746d029072aeb52f20e68061b8ed74d50f3911b Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Tue, 22 Sep 2026 07:23:26 +0200 Subject: [PATCH 31/43] fix(pgp): resolve encryption recipients from provider --- .../data/crypto/CryptoRepository.kt | 35 ++-- .../data/crypto/OpenPgpProviderRepository.kt | 160 +++++++++++++----- .../passkeys/OpenPgpPassRecipientResolver.kt | 56 +++--- .../crypto/OpenPgpProviderRepositoryTest.kt | 151 +++++++++++++++++ .../crypto/DefaultPassRecipientResolver.kt | 31 ++-- 5 files changed, 333 insertions(+), 100 deletions(-) create mode 100644 app/src/test/java/app/passwordstore/data/crypto/OpenPgpProviderRepositoryTest.kt 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 7b710f7f63..3b96c8e99c 100644 --- a/app/src/main/java/app/passwordstore/data/crypto/CryptoRepository.kt +++ b/app/src/main/java/app/passwordstore/data/crypto/CryptoRepository.kt @@ -46,13 +46,15 @@ constructor( private val pgpKeyManager: PGPKeyManager, private val pgpCryptoHandler: PGPainlessCryptoHandler, private val dispatcherProvider: DispatcherProvider, + private val openPgpProviderRepository: OpenPgpProviderRepository, @SettingsPreferences private val settings: SharedPreferences, ) { fun hasKeys(): Boolean = pgpKeyManager.getAllKeys().mapBoth(success = { it.isNotEmpty() }, failure = { false }) - fun hasKey(id: PGPIdentifier): Boolean = pgpKeyManager.getKeyById(id).isOk + fun hasKey(id: PGPIdentifier): Boolean = + if (openPgpProviderRepository.hasSelectedProvider()) false else pgpKeyManager.getKeyById(id).isOk fun isSecretKey(id: PGPIdentifier): Boolean { val key = pgpKeyManager.getKeyById(id).get() @@ -116,8 +118,7 @@ constructor( encryptedMessage: ByteArrayInputStream, message: ByteArrayOutputStream, ) = run { - if (passphrases.keys.first() == "") { // New passphrase from user input - // Test it against the PGP identities of current entry + if (passphrases.keys.first() == "") { identities.mapUntil({ it.second.isOk }) { id -> encryptedMessage.reset() message.reset() @@ -134,7 +135,7 @@ constructor( result.getError()?.let { logcat { it.asLog() } } Pair(id.toString(), result.map { message }) } - } else { // Get the first working cached passphrase + } else { passphrases.keys.toList().mapUntil({ it.second.isOk }) { id -> encryptedMessage.reset() message.reset() @@ -172,13 +173,25 @@ constructor( message: ByteArrayInputStream, encryptedMessage: ByteArrayOutputStream, ) = run { - // get primary key IDs in order to identify and avoid duplicate keys - val primaryKeyIds = - identities - .mapNotNull { getLongKeyIdFromKeyId(it) } - .distinct() - .mapNotNull { PGPIdentifier.fromString(it) } - val keys = primaryKeyIds.map { id -> pgpKeyManager.getKeyById(id) }.filterOk() + val keys = + if (openPgpProviderRepository.hasSelectedProvider()) { + openPgpProviderRepository.resolvedPublicKeysFor(identities).orEmpty() + } else { + val primaryKeyIds = + identities + .mapNotNull { getLongKeyIdFromKeyId(it) } + .distinct() + .mapNotNull { PGPIdentifier.fromString(it) } + primaryKeyIds.map { id -> pgpKeyManager.getKeyById(id) }.filterOk() + } + encryptWithKeys(keys, message, encryptedMessage) + } + + private fun encryptWithKeys( + keys: List, + message: ByteArrayInputStream, + encryptedMessage: ByteArrayOutputStream, + ) = run { val encryptionOptions = PGPEncryptOptions.Builder() .withAsciiArmor(settings.getBoolean(PreferenceKeys.ASCII_ARMOR, false)) diff --git a/app/src/main/java/app/passwordstore/data/crypto/OpenPgpProviderRepository.kt b/app/src/main/java/app/passwordstore/data/crypto/OpenPgpProviderRepository.kt index 7e74bdf534..5a48a00b3a 100644 --- a/app/src/main/java/app/passwordstore/data/crypto/OpenPgpProviderRepository.kt +++ b/app/src/main/java/app/passwordstore/data/crypto/OpenPgpProviderRepository.kt @@ -9,23 +9,28 @@ import android.content.SharedPreferences import app.passwordstore.crypto.KeyUtils import app.passwordstore.crypto.PGPIdentifier import app.passwordstore.crypto.PGPKey -import app.passwordstore.crypto.PGPKeyManager import app.passwordstore.injection.prefs.SettingsPreferences import app.passwordstore.util.settings.PreferenceKeys -import com.github.michaelbull.result.fold +import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Singleton -/** Coordinates APS-local provider selection with local public-certificate storage. */ +/** Coordinates APS-local provider selection with provider-owned OpenPGP key material. */ @Singleton class OpenPgpProviderRepository @Inject constructor( private val backend: OpenPgpApiBackend, - private val keyManager: PGPKeyManager, @SettingsPreferences private val settings: SharedPreferences, ) { + private data class PublicKeyCacheKey( + val providerPackage: String, + val identifier: PGPIdentifier, + ) + + private val resolvedPublicKeys = ConcurrentHashMap>() + fun providers(): List = backend.providers() fun selectedProviderPackage(): String? = @@ -62,10 +67,32 @@ constructor( } /** - * Makes the public certificates required by [identifiers] available to PGPainless. + * Resolves [identifiers] against the selected provider and returns fresh public certificates. * - * The provider remains the sole owner of private key material. Retrieved certificates are checked - * against the provider-returned key ID before they are accepted by the local key manager. + * The provider is authoritative for recipient resolution whenever it is selected. The local APS + * key manager is deliberately not consulted here: otherwise a previously imported certificate or + * an older key carrying the same user ID could silently override provider-side rotation or + * revocation state. + */ + suspend fun resolvePublicKeys( + identifiers: List, + interactionHandler: OpenPgpApiBackend.InteractionHandler? = null, + ): OpenPgpApiBackend.OperationResult> { + return when (val resolved = resolvePublicKeysByIdentifier(identifiers, interactionHandler)) { + is OpenPgpApiBackend.OperationResult.Success -> + OpenPgpApiBackend.OperationResult.Success(deduplicate(resolved.value.values.flatten())) + is OpenPgpApiBackend.OperationResult.UserInteractionRequired -> resolved + OpenPgpApiBackend.OperationResult.Cancelled -> resolved + is OpenPgpApiBackend.OperationResult.Failure -> resolved + } + } + + /** + * Resolves fresh provider certificates and makes that exact resolution available to the immediate + * APS password-encryption path. + * + * This cache is only a hand-off between recipient validation and encryption; it is never used to + * avoid a provider lookup. Every call refreshes the provider state first. */ suspend fun ensurePublicKeys( identifiers: List, @@ -78,9 +105,43 @@ constructor( ) if (!backend.isProviderInstalled(provider)) return providerUnavailable(provider) + return when (val resolved = resolvePublicKeysByIdentifier(identifiers, interactionHandler)) { + is OpenPgpApiBackend.OperationResult.Success -> { + for ((identifier, keys) in resolved.value) { + resolvedPublicKeys[PublicKeyCacheKey(provider, identifier)] = keys + } + OpenPgpApiBackend.OperationResult.Success(Unit) + } + is OpenPgpApiBackend.OperationResult.UserInteractionRequired -> resolved + OpenPgpApiBackend.OperationResult.Cancelled -> resolved + is OpenPgpApiBackend.OperationResult.Failure -> resolved + } + } + + /** Returns only a complete provider resolution previously produced by [ensurePublicKeys]. */ + fun resolvedPublicKeysFor(identifiers: List): List? { + val provider = selectedProviderPackage() ?: return null + val keys = mutableListOf() for (identifier in identifiers) { - if (hasLocalKey(identifier)) continue + val resolved = resolvedPublicKeys[PublicKeyCacheKey(provider, identifier)] ?: return null + keys += resolved + } + return deduplicate(keys) + } + + private suspend fun resolvePublicKeysByIdentifier( + identifiers: List, + interactionHandler: OpenPgpApiBackend.InteractionHandler?, + ): OpenPgpApiBackend.OperationResult>> { + val provider = + selectedProviderPackage() + ?: return OpenPgpApiBackend.OperationResult.Failure( + IllegalStateException("No external OpenPGP provider is selected") + ) + if (!backend.isProviderInstalled(provider)) return providerUnavailable(provider) + val result = linkedMapOf>() + for (identifier in identifiers.distinct()) { val keyIds = when (identifier) { is PGPIdentifier.KeyId -> longArrayOf(identifier.id) @@ -93,15 +154,10 @@ constructor( interactionHandler = interactionHandler, ) ) { - is OpenPgpApiBackend.OperationResult.Success -> resolved.value - is OpenPgpApiBackend.OperationResult.UserInteractionRequired -> - return OpenPgpApiBackend.OperationResult.UserInteractionRequired( - resolved.pendingIntent - ) - OpenPgpApiBackend.OperationResult.Cancelled -> - return OpenPgpApiBackend.OperationResult.Cancelled - is OpenPgpApiBackend.OperationResult.Failure -> - return OpenPgpApiBackend.OperationResult.Failure(resolved.error) + is OpenPgpApiBackend.OperationResult.Success -> resolved.value.distinct().toLongArray() + is OpenPgpApiBackend.OperationResult.UserInteractionRequired -> return resolved + OpenPgpApiBackend.OperationResult.Cancelled -> return resolved + is OpenPgpApiBackend.OperationResult.Failure -> return resolved } } } @@ -111,8 +167,14 @@ constructor( IllegalStateException("OpenPGP provider could not resolve $identifier") ) } + if (identifier is PGPIdentifier.UserId && keyIds.size > 1) { + return OpenPgpApiBackend.OperationResult.Failure( + OpenPgpAmbiguousRecipientException(identifier.email, keyIds.toList()) + ) + } - for (keyId in keyIds.distinct()) { + val keys = mutableListOf() + for (keyId in keyIds) { when ( val fetched = backend.getPublicKey( @@ -138,39 +200,41 @@ constructor( SecurityException("Provider certificate does not match requested key ID") ) } - - var importFailure: Throwable? = null - keyManager - .addKey(candidate, replace = false) - .fold( - success = {}, - failure = { error -> - // A concurrent import or an existing public certificate is harmless if the - // requested identity can now be resolved locally. - if (!hasLocalKey(identifier)) importFailure = error - }, + if (!KeyUtils.isKeyUsable(certificate)) { + return OpenPgpApiBackend.OperationResult.Failure( + IllegalArgumentException("Provider returned an unusable OpenPGP certificate") + ) + } + if ( + identifier is PGPIdentifier.UserId && + certificate.getAllUserIds().none { + identifier.email == it.getUserId() || + identifier.email == PGPIdentifier.splitUserId(it.getUserId()) + } + ) { + return OpenPgpApiBackend.OperationResult.Failure( + SecurityException("Provider certificate does not match requested user ID") ) - importFailure?.let { error -> - return OpenPgpApiBackend.OperationResult.Failure(error) } + keys += PGPKey(certificate.getEncoded()) } - is OpenPgpApiBackend.OperationResult.UserInteractionRequired -> - return OpenPgpApiBackend.OperationResult.UserInteractionRequired(fetched.pendingIntent) - OpenPgpApiBackend.OperationResult.Cancelled -> - return OpenPgpApiBackend.OperationResult.Cancelled - is OpenPgpApiBackend.OperationResult.Failure -> - return OpenPgpApiBackend.OperationResult.Failure(fetched.error) + is OpenPgpApiBackend.OperationResult.UserInteractionRequired -> return fetched + OpenPgpApiBackend.OperationResult.Cancelled -> return fetched + is OpenPgpApiBackend.OperationResult.Failure -> return fetched } } - - if (!hasLocalKey(identifier)) { - return OpenPgpApiBackend.OperationResult.Failure( - IllegalStateException("Retrieved certificates do not satisfy $identifier") - ) - } + result[identifier] = keys } - return OpenPgpApiBackend.OperationResult.Success(Unit) + return OpenPgpApiBackend.OperationResult.Success(result) + } + + private fun deduplicate(keys: List): List { + val seenPrimaryKeyIds = mutableSetOf() + return keys.filter { key -> + val certificate = KeyUtils.tryParseCertificateOrKey(key) ?: return@filter false + seenPrimaryKeyIds.add(KeyUtils.tryGetKeyId(certificate).id) + } } private fun providerUnavailable( @@ -179,7 +243,9 @@ constructor( OpenPgpApiBackend.OperationResult.Failure( OpenPgpProviderException("Selected OpenPGP provider $provider is not installed") ) - - private fun hasLocalKey(identifier: PGPIdentifier): Boolean = - keyManager.getKeyById(identifier).fold(success = { true }, failure = { false }) } + +class OpenPgpAmbiguousRecipientException( + val identifier: String, + val keyIds: List, +) : Exception("OpenPGP provider resolved $identifier to multiple keys") diff --git a/app/src/main/java/app/passwordstore/passkeys/OpenPgpPassRecipientResolver.kt b/app/src/main/java/app/passwordstore/passkeys/OpenPgpPassRecipientResolver.kt index a68fd4eff6..dd3892b576 100644 --- a/app/src/main/java/app/passwordstore/passkeys/OpenPgpPassRecipientResolver.kt +++ b/app/src/main/java/app/passwordstore/passkeys/OpenPgpPassRecipientResolver.kt @@ -5,8 +5,9 @@ package app.passwordstore.passkeys -import app.passwordstore.crypto.PGPIdentifier +import app.passwordstore.crypto.DefaultPassRecipientResolver import app.passwordstore.crypto.PGPKey +import app.passwordstore.data.crypto.OpenPgpAmbiguousRecipientException import app.passwordstore.data.crypto.OpenPgpApiBackend import app.passwordstore.data.crypto.OpenPgpInteractionCoordinator import app.passwordstore.data.crypto.OpenPgpProviderRepository @@ -18,50 +19,43 @@ import com.github.michaelbull.result.Result import com.github.michaelbull.result.fold import java.io.File -/** Adds provider public-certificate retrieval without weakening hierarchical `.gpg-id` policy. */ +/** Resolves pass recipients from the selected provider without weakening `.gpg-id` policy. */ class OpenPgpPassRecipientResolver( - private val delegate: PassRecipientResolver, + private val delegate: DefaultPassRecipientResolver, private val providerRepository: OpenPgpProviderRepository, private val interactionCoordinator: OpenPgpInteractionCoordinator, ) : PassRecipientResolver { override suspend fun resolveFor(target: File): Result, RecipientPolicyError> { - val attemptedIdentifiers = mutableSetOf() + if (!providerRepository.hasSelectedProvider()) return delegate.resolveFor(target) - while (true) { - var resolvedKeys: List? = null - var resolutionError: RecipientPolicyError? = null + val identifiers = delegate - .resolveFor(target) - .fold( - success = { resolvedKeys = it }, - failure = { resolutionError = it }, - ) - - resolvedKeys?.let { - return Ok(it) - } - val error = resolutionError ?: return Err(RecipientPolicyError.EmptyRecipientSet) - if ( - error !is RecipientPolicyError.RecipientNotFound || - !providerRepository.hasSelectedProvider() || - !attemptedIdentifiers.add(error.identifier) - ) { - return Err(error) - } + .resolveIdentifiersFor(target) + .fold(success = { it }, failure = { return Err(it) }) - val identifier = PGPIdentifier.fromString(error.identifier) ?: return Err(error) - when ( - providerRepository.ensurePublicKeys( - listOf(identifier), + return when ( + val resolved = + providerRepository.resolvePublicKeys( + identifiers, OpenPgpApiBackend.InteractionHandler { pendingIntent -> interactionCoordinator.interact(pendingIntent) }, ) - ) { - is OpenPgpApiBackend.OperationResult.Success -> Unit - else -> return Err(error) + ) { + is OpenPgpApiBackend.OperationResult.Success -> Ok(resolved.value) + is OpenPgpApiBackend.OperationResult.Failure -> { + val error = resolved.error + if (error is OpenPgpAmbiguousRecipientException) { + Err(RecipientPolicyError.AmbiguousRecipient(error.identifier)) + } else { + Err(RecipientPolicyError.RecipientNotFound(identifiers.joinToString(", "))) + } } + is OpenPgpApiBackend.OperationResult.UserInteractionRequired -> + Err(RecipientPolicyError.RecipientNotFound(identifiers.joinToString(", "))) + OpenPgpApiBackend.OperationResult.Cancelled -> + Err(RecipientPolicyError.RecipientNotFound(identifiers.joinToString(", "))) } } } diff --git a/app/src/test/java/app/passwordstore/data/crypto/OpenPgpProviderRepositoryTest.kt b/app/src/test/java/app/passwordstore/data/crypto/OpenPgpProviderRepositoryTest.kt new file mode 100644 index 0000000000..e1f42aa099 --- /dev/null +++ b/app/src/test/java/app/passwordstore/data/crypto/OpenPgpProviderRepositoryTest.kt @@ -0,0 +1,151 @@ +/* + * Copyright © 2014-2026 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.data.crypto + +import android.content.Context +import android.content.Intent +import app.passwordstore.crypto.KeyUtils +import app.passwordstore.crypto.PGPIdentifier +import app.passwordstore.crypto.PGPKey +import app.passwordstore.crypto.PGPKeyManager +import app.passwordstore.util.settings.PreferenceKeys +import com.github.michaelbull.result.unwrap +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlinx.coroutines.runBlocking +import org.bouncycastle.openpgp.api.OpenPGPKey +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.openintents.openpgp.util.OpenPgpApi +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class OpenPgpProviderRepositoryTest { + + @get:Rule val temporaryFolder = TemporaryFolder() + + @Test + fun `provider is consulted on every public key resolution`() = runBlocking { + val certificate = certificate() + val keyId = KeyUtils.tryGetKeyId(certificate).id + var getKeyCalls = 0 + val backend = + OpenPgpApiBackend( + FakeExecutor { _, request, _ -> + when (request.action) { + OpenPgpApi.ACTION_GET_KEY -> { + getKeyCalls++ + OpenPgpApiCall(success(), certificate.getEncoded()) + } + else -> error("Unexpected action ${request.action}") + } + } + ) + val repository = repository(backend) + + assertIs>>( + repository.resolvePublicKeys(listOf(PGPIdentifier.KeyId(keyId))) + ) + assertIs>>( + repository.resolvePublicKeys(listOf(PGPIdentifier.KeyId(keyId))) + ) + + assertEquals(2, getKeyCalls) + } + + @Test + fun `ambiguous user id resolution fails closed`() = runBlocking { + var getKeyCalls = 0 + val backend = + OpenPgpApiBackend( + FakeExecutor { _, request, _ -> + when (request.action) { + OpenPgpApi.ACTION_GET_KEY_IDS -> + OpenPgpApiCall( + success().apply { putExtra(OpenPgpApi.RESULT_KEY_IDS, longArrayOf(1L, 2L)) }, + byteArrayOf(), + ) + OpenPgpApi.ACTION_GET_KEY -> { + getKeyCalls++ + error("Ambiguous IDs must not fetch a certificate") + } + else -> error("Unexpected action ${request.action}") + } + } + ) + val repository = repository(backend) + + val result = repository.resolvePublicKeys(listOf(PGPIdentifier.UserId("alice@example.com"))) + + val failure = assertIs(result) + assertIs(failure.error) + assertEquals(0, getKeyCalls) + } + + @Test + fun `ensure public keys only exposes a complete fresh resolution`() = runBlocking { + val certificate = certificate() + val keyId = KeyUtils.tryGetKeyId(certificate).id + val identifier = PGPIdentifier.KeyId(keyId) + val backend = + OpenPgpApiBackend( + FakeExecutor { _, request, _ -> + when (request.action) { + OpenPgpApi.ACTION_GET_KEY -> OpenPgpApiCall(success(), certificate.getEncoded()) + else -> error("Unexpected action ${request.action}") + } + } + ) + val repository = repository(backend) + + assertNull(repository.resolvedPublicKeysFor(listOf(identifier))) + assertIs>( + repository.ensurePublicKeys(listOf(identifier)) + ) + assertEquals(1, repository.resolvedPublicKeysFor(listOf(identifier))?.size) + } + + private fun certificate() = + PGPKeyManager(temporaryFolder.root.absolutePath) + .generateKey("Alice ", null) + .unwrap() + .let(KeyUtils::tryParseCertificateOrKey) + .let { parsed -> + require(parsed is OpenPGPKey) + parsed.toCertificate() + } + + private fun repository(backend: OpenPgpApiBackend): OpenPgpProviderRepository { + val context = RuntimeEnvironment.getApplication() + val preferences = context.getSharedPreferences("openpgp-provider-test", Context.MODE_PRIVATE) + preferences.edit().clear().putString(PreferenceKeys.OPENPGP_PROVIDER_PACKAGE, PROVIDER).commit() + return OpenPgpProviderRepository(backend, preferences) + } + + private fun success(): Intent = + Intent().apply { putExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_SUCCESS) } + + private class FakeExecutor( + private val executeBlock: suspend (String, Intent, ByteArray?) -> OpenPgpApiCall + ) : OpenPgpApiExecutor { + override fun providers(): List = + listOf(OpenPgpApiBackend.Provider(PROVIDER, "Provider")) + + override suspend fun execute( + providerPackage: String, + request: Intent, + input: ByteArray?, + ): OpenPgpApiCall = executeBlock(providerPackage, request, input) + } + + private companion object { + const val PROVIDER = "org.example.openpgp" + } +} diff --git a/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/DefaultPassRecipientResolver.kt b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/DefaultPassRecipientResolver.kt index 902b9b29c5..712b0ec57b 100644 --- a/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/DefaultPassRecipientResolver.kt +++ b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/DefaultPassRecipientResolver.kt @@ -27,7 +27,10 @@ public class DefaultPassRecipientResolver( private val gpgIdFileName: String = GPG_ID_FILE_NAME, ) : PassRecipientResolver { - override suspend fun resolveFor(target: File): Result, RecipientPolicyError> = + /** Resolves only the hierarchical recipient policy, without consulting a key store. */ + public suspend fun resolveIdentifiersFor( + target: File + ): Result, RecipientPolicyError> = withContext(Dispatchers.IO) { val canonicalRoot = repositoryRoot.canonicalFile val canonicalTarget = @@ -91,6 +94,18 @@ public class DefaultPassRecipientResolver( return@withContext com.github.michaelbull.result.Err(RecipientPolicyError.EmptyRecipientSet) } + com.github.michaelbull.result.Ok(parsedIdentifiers) + } + + override suspend fun resolveFor(target: File): Result, RecipientPolicyError> { + val parsedIdentifiers = + resolveIdentifiersFor(target) + .fold( + success = { it }, + failure = { error -> return com.github.michaelbull.result.Err(error) }, + ) + + return withContext(Dispatchers.IO) { val resolvedKeys = mutableListOf() val seenFingerprints = mutableSetOf() @@ -115,9 +130,7 @@ public class DefaultPassRecipientResolver( } val primaryKeyId = KeyUtils.tryGetKeyId(cert).id - if (!seenFingerprints.add(primaryKeyId)) { - continue - } + if (!seenFingerprints.add(primaryKeyId)) continue resolvedKeys.add(key) } @@ -127,14 +140,13 @@ public class DefaultPassRecipientResolver( com.github.michaelbull.result.Ok(resolvedKeys) } + } private fun findGpgIdFile(startDir: File, root: File): File? { var current: File? = startDir while (current != null) { val candidate = File(current, gpgIdFileName) - if (candidate.exists() && candidate.isFile) { - return candidate - } + if (candidate.exists() && candidate.isFile) return candidate if (current.canonicalPath == root.canonicalPath) break current = current.parentFile ?: break } @@ -152,7 +164,6 @@ public class DefaultPassRecipientResolver( } val identifiers = mutableListOf() - for ((index, rawLine) in lines.withIndex()) { val commentMatch = COMMENT_PATTERN.find(rawLine) val line = @@ -180,9 +191,7 @@ public class DefaultPassRecipientResolver( val canonicalRoot = root.canonicalFile var current = target while (true) { - if (java.nio.file.Files.isSymbolicLink(current.toPath())) { - return true - } + if (java.nio.file.Files.isSymbolicLink(current.toPath())) return true if (current.canonicalPath == canonicalRoot.path) break val parent = current.parentFile ?: break if (parent.canonicalPath == canonicalRoot.path) break From de0aa06fd9e0958e50afcb119a7dfa08d7a7aa43 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Tue, 22 Sep 2026 07:31:52 +0200 Subject: [PATCH 32/43] fix(pgp): preserve result types during recipient resolution --- .../data/crypto/OpenPgpProviderRepository.kt | 43 ++++++++++++------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/app/passwordstore/data/crypto/OpenPgpProviderRepository.kt b/app/src/main/java/app/passwordstore/data/crypto/OpenPgpProviderRepository.kt index 5a48a00b3a..989fe0f0e5 100644 --- a/app/src/main/java/app/passwordstore/data/crypto/OpenPgpProviderRepository.kt +++ b/app/src/main/java/app/passwordstore/data/crypto/OpenPgpProviderRepository.kt @@ -81,9 +81,11 @@ constructor( return when (val resolved = resolvePublicKeysByIdentifier(identifiers, interactionHandler)) { is OpenPgpApiBackend.OperationResult.Success -> OpenPgpApiBackend.OperationResult.Success(deduplicate(resolved.value.values.flatten())) - is OpenPgpApiBackend.OperationResult.UserInteractionRequired -> resolved - OpenPgpApiBackend.OperationResult.Cancelled -> resolved - is OpenPgpApiBackend.OperationResult.Failure -> resolved + is OpenPgpApiBackend.OperationResult.UserInteractionRequired -> + OpenPgpApiBackend.OperationResult.UserInteractionRequired(resolved.pendingIntent) + OpenPgpApiBackend.OperationResult.Cancelled -> OpenPgpApiBackend.OperationResult.Cancelled + is OpenPgpApiBackend.OperationResult.Failure -> + OpenPgpApiBackend.OperationResult.Failure(resolved.error) } } @@ -112,9 +114,11 @@ constructor( } OpenPgpApiBackend.OperationResult.Success(Unit) } - is OpenPgpApiBackend.OperationResult.UserInteractionRequired -> resolved - OpenPgpApiBackend.OperationResult.Cancelled -> resolved - is OpenPgpApiBackend.OperationResult.Failure -> resolved + is OpenPgpApiBackend.OperationResult.UserInteractionRequired -> + OpenPgpApiBackend.OperationResult.UserInteractionRequired(resolved.pendingIntent) + OpenPgpApiBackend.OperationResult.Cancelled -> OpenPgpApiBackend.OperationResult.Cancelled + is OpenPgpApiBackend.OperationResult.Failure -> + OpenPgpApiBackend.OperationResult.Failure(resolved.error) } } @@ -154,10 +158,16 @@ constructor( interactionHandler = interactionHandler, ) ) { - is OpenPgpApiBackend.OperationResult.Success -> resolved.value.distinct().toLongArray() - is OpenPgpApiBackend.OperationResult.UserInteractionRequired -> return resolved - OpenPgpApiBackend.OperationResult.Cancelled -> return resolved - is OpenPgpApiBackend.OperationResult.Failure -> return resolved + is OpenPgpApiBackend.OperationResult.Success -> + resolved.value.distinct().toLongArray() + is OpenPgpApiBackend.OperationResult.UserInteractionRequired -> + return OpenPgpApiBackend.OperationResult.UserInteractionRequired( + resolved.pendingIntent + ) + OpenPgpApiBackend.OperationResult.Cancelled -> + return OpenPgpApiBackend.OperationResult.Cancelled + is OpenPgpApiBackend.OperationResult.Failure -> + return OpenPgpApiBackend.OperationResult.Failure(resolved.error) } } } @@ -218,9 +228,12 @@ constructor( } keys += PGPKey(certificate.getEncoded()) } - is OpenPgpApiBackend.OperationResult.UserInteractionRequired -> return fetched - OpenPgpApiBackend.OperationResult.Cancelled -> return fetched - is OpenPgpApiBackend.OperationResult.Failure -> return fetched + is OpenPgpApiBackend.OperationResult.UserInteractionRequired -> + return OpenPgpApiBackend.OperationResult.UserInteractionRequired(fetched.pendingIntent) + OpenPgpApiBackend.OperationResult.Cancelled -> + return OpenPgpApiBackend.OperationResult.Cancelled + is OpenPgpApiBackend.OperationResult.Failure -> + return OpenPgpApiBackend.OperationResult.Failure(fetched.error) } } result[identifier] = keys @@ -237,9 +250,7 @@ constructor( } } - private fun providerUnavailable( - provider: String - ): OpenPgpApiBackend.OperationResult = + private fun providerUnavailable(provider: String): OpenPgpApiBackend.OperationResult = OpenPgpApiBackend.OperationResult.Failure( OpenPgpProviderException("Selected OpenPGP provider $provider is not installed") ) From 308e7dbec8c047879c8ee450010fa89077cfcb19 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Tue, 22 Sep 2026 07:34:42 +0200 Subject: [PATCH 33/43] style(pgp): apply repository Kotlin formatting --- .../java/app/passwordstore/data/crypto/CryptoRepository.kt | 3 ++- .../passwordstore/passkeys/OpenPgpPassRecipientResolver.kt | 7 ++++++- .../main/java/app/passwordstore/ui/settings/PGPSettings.kt | 5 +++-- .../passwordstore/crypto/DefaultPassRecipientResolver.kt | 4 +++- 4 files changed, 14 insertions(+), 5 deletions(-) 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 3b96c8e99c..37207e8b66 100644 --- a/app/src/main/java/app/passwordstore/data/crypto/CryptoRepository.kt +++ b/app/src/main/java/app/passwordstore/data/crypto/CryptoRepository.kt @@ -54,7 +54,8 @@ constructor( pgpKeyManager.getAllKeys().mapBoth(success = { it.isNotEmpty() }, failure = { false }) fun hasKey(id: PGPIdentifier): Boolean = - if (openPgpProviderRepository.hasSelectedProvider()) false else pgpKeyManager.getKeyById(id).isOk + if (openPgpProviderRepository.hasSelectedProvider()) false + else pgpKeyManager.getKeyById(id).isOk fun isSecretKey(id: PGPIdentifier): Boolean { val key = pgpKeyManager.getKeyById(id).get() diff --git a/app/src/main/java/app/passwordstore/passkeys/OpenPgpPassRecipientResolver.kt b/app/src/main/java/app/passwordstore/passkeys/OpenPgpPassRecipientResolver.kt index dd3892b576..ee71bcec5a 100644 --- a/app/src/main/java/app/passwordstore/passkeys/OpenPgpPassRecipientResolver.kt +++ b/app/src/main/java/app/passwordstore/passkeys/OpenPgpPassRecipientResolver.kt @@ -32,7 +32,12 @@ class OpenPgpPassRecipientResolver( val identifiers = delegate .resolveIdentifiersFor(target) - .fold(success = { it }, failure = { return Err(it) }) + .fold( + success = { it }, + failure = { + return Err(it) + }, + ) return when ( val resolved = 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 591a7796db..64b57b51ee 100644 --- a/app/src/main/java/app/passwordstore/ui/settings/PGPSettings.kt +++ b/app/src/main/java/app/passwordstore/ui/settings/PGPSettings.kt @@ -59,8 +59,9 @@ class PGPSettings(private val activity: FragmentActivity) : SettingsProvider { private fun showOpenPgpProviderDialog() { val providers = backend.providers() val current = activity.sharedPrefs.getString(PreferenceKeys.OPENPGP_PROVIDER_PACKAGE, null) - val unavailableCurrent = - current?.takeIf { selected -> providers.none { it.packageName == selected } } + val unavailableCurrent = current?.takeIf { selected -> + providers.none { it.packageName == selected } + } val labels = buildList { add(activity.getString(R.string.pref_openpgp_provider_internal)) unavailableCurrent?.let { add("$it (${activity.getString(R.string.error)})") } diff --git a/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/DefaultPassRecipientResolver.kt b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/DefaultPassRecipientResolver.kt index 712b0ec57b..6f6fb9a2c9 100644 --- a/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/DefaultPassRecipientResolver.kt +++ b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/DefaultPassRecipientResolver.kt @@ -102,7 +102,9 @@ public class DefaultPassRecipientResolver( resolveIdentifiersFor(target) .fold( success = { it }, - failure = { error -> return com.github.michaelbull.result.Err(error) }, + failure = { error -> + return com.github.michaelbull.result.Err(error) + }, ) return withContext(Dispatchers.IO) { From 7b5325477877fbfff1cc0a05e1a6eb6ac6cb0da5 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Tue, 22 Sep 2026 07:37:49 +0200 Subject: [PATCH 34/43] fix(pgp): bound external provider output --- .../data/crypto/OpenPgpApiBackend.kt | 64 +++++++++++++++++-- .../data/crypto/OpenPgpProviderRepository.kt | 3 +- .../passkeys/OpenPgpPasskeyDecryptor.kt | 24 ++++--- .../data/crypto/OpenPgpApiBackendTest.kt | 35 ++++++++-- .../crypto/OpenPgpProviderRepositoryTest.kt | 11 ++-- 5 files changed, 106 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/app/passwordstore/data/crypto/OpenPgpApiBackend.kt b/app/src/main/java/app/passwordstore/data/crypto/OpenPgpApiBackend.kt index 67e122a7fb..b837145226 100644 --- a/app/src/main/java/app/passwordstore/data/crypto/OpenPgpApiBackend.kt +++ b/app/src/main/java/app/passwordstore/data/crypto/OpenPgpApiBackend.kt @@ -69,6 +69,7 @@ class OpenPgpApiBackend internal constructor(private val executor: OpenPgpApiExe providerPackage = providerPackage, initialRequest = Intent(OpenPgpApi.ACTION_CHECK_PERMISSION), input = null, + maxOutputBytes = MAX_METADATA_OUTPUT_BYTES, interactionHandler = interactionHandler, ) {} @@ -76,11 +77,13 @@ class OpenPgpApiBackend internal constructor(private val executor: OpenPgpApiExe providerPackage: String, ciphertext: ByteArray, interactionHandler: InteractionHandler? = null, + maxOutputBytes: Long = DEFAULT_MAX_DECRYPT_OUTPUT_BYTES, ): OperationResult = executeWithInteraction( providerPackage = providerPackage, initialRequest = Intent(OpenPgpApi.ACTION_DECRYPT_VERIFY), input = ciphertext, + maxOutputBytes = maxOutputBytes, interactionHandler = interactionHandler, ) { call -> call.output @@ -100,6 +103,7 @@ class OpenPgpApiBackend internal constructor(private val executor: OpenPgpApiExe putExtra(OpenPgpApi.EXTRA_REQUEST_ASCII_ARMOR, asciiArmor) }, input = null, + maxOutputBytes = MAX_PUBLIC_KEY_OUTPUT_BYTES, interactionHandler = interactionHandler, ) { call -> call.output @@ -117,6 +121,7 @@ class OpenPgpApiBackend internal constructor(private val executor: OpenPgpApiExe putExtra(OpenPgpApi.EXTRA_USER_IDS, userIds) }, input = null, + maxOutputBytes = MAX_METADATA_OUTPUT_BYTES, interactionHandler = interactionHandler, ) { call -> call.result.getLongArrayExtra(OpenPgpApi.RESULT_KEY_IDS) ?: longArrayOf() @@ -126,6 +131,7 @@ class OpenPgpApiBackend internal constructor(private val executor: OpenPgpApiExe providerPackage: String, initialRequest: Intent, input: ByteArray?, + maxOutputBytes: Long, interactionHandler: InteractionHandler?, onSuccess: (OpenPgpApiCall) -> T, ): OperationResult { @@ -134,7 +140,7 @@ class OpenPgpApiBackend internal constructor(private val executor: OpenPgpApiExe repeat(MAX_INTERACTION_ROUNDS) { val call = try { - executor.execute(providerPackage, request, input) + executor.execute(providerPackage, request, input, maxOutputBytes) } catch (error: CancellationException) { throw error } catch (error: Throwable) { @@ -202,13 +208,19 @@ class OpenPgpApiBackend internal constructor(private val executor: OpenPgpApiExe ) } - private companion object { - const val MAX_INTERACTION_ROUNDS = 4 + companion object { + const val DEFAULT_MAX_DECRYPT_OUTPUT_BYTES = 16 * 1024 * 1024L + private const val MAX_PUBLIC_KEY_OUTPUT_BYTES = 1024 * 1024L + private const val MAX_METADATA_OUTPUT_BYTES = 64 * 1024L + private const val MAX_INTERACTION_ROUNDS = 4 } } class OpenPgpProviderException(message: String) : Exception(message) +class OpenPgpOutputLimitExceededException(val maxBytes: Long) : + Exception("OpenPGP provider output exceeded $maxBytes bytes") + internal data class OpenPgpApiCall(val result: Intent, val output: ByteArray) internal interface OpenPgpApiExecutor { @@ -218,9 +230,41 @@ internal interface OpenPgpApiExecutor { providerPackage: String, request: Intent, input: ByteArray?, + maxOutputBytes: Long, ): OpenPgpApiCall } +internal class BoundedByteArrayOutputStream(private val maxBytes: Long) : ByteArrayOutputStream() { + + init { + require(maxBytes in 1..Int.MAX_VALUE.toLong()) { "maxBytes must fit in a positive Int" } + } + + override fun write(value: Int) { + ensureCapacityFor(1) + super.write(value) + } + + override fun write(bytes: ByteArray, offset: Int, length: Int) { + if (offset < 0 || length < 0 || offset > bytes.size - length) { + throw IndexOutOfBoundsException() + } + ensureCapacityFor(length) + super.write(bytes, offset, length) + } + + fun wipe() { + buf.fill(0) + reset() + } + + private fun ensureCapacityFor(additionalBytes: Int) { + if (count.toLong() + additionalBytes > maxBytes) { + throw OpenPgpOutputLimitExceededException(maxBytes) + } + } +} + internal class BinderOpenPgpApiExecutor(private val context: Context) : OpenPgpApiExecutor { @Suppress("DEPRECATION") @@ -236,12 +280,18 @@ internal class BinderOpenPgpApiExecutor(private val context: Context) : OpenPgpA providerPackage: String, request: Intent, input: ByteArray?, + maxOutputBytes: Long, ): OpenPgpApiCall = withService(providerPackage) { service -> - val output = ByteArrayOutputStream() - val result = - OpenPgpApi(context, service).executeApi(request, input?.let(::ByteArrayInputStream), output) - OpenPgpApiCall(result, output.toByteArray()) + val output = BoundedByteArrayOutputStream(maxOutputBytes) + try { + val result = + OpenPgpApi(context, service) + .executeApi(request, input?.let(::ByteArrayInputStream), output) + OpenPgpApiCall(result, output.toByteArray()) + } finally { + output.wipe() + } } private suspend fun withService( diff --git a/app/src/main/java/app/passwordstore/data/crypto/OpenPgpProviderRepository.kt b/app/src/main/java/app/passwordstore/data/crypto/OpenPgpProviderRepository.kt index 989fe0f0e5..5741f0b86b 100644 --- a/app/src/main/java/app/passwordstore/data/crypto/OpenPgpProviderRepository.kt +++ b/app/src/main/java/app/passwordstore/data/crypto/OpenPgpProviderRepository.kt @@ -56,6 +56,7 @@ constructor( suspend fun decrypt( ciphertext: ByteArray, interactionHandler: OpenPgpApiBackend.InteractionHandler? = null, + maxOutputBytes: Long = OpenPgpApiBackend.DEFAULT_MAX_DECRYPT_OUTPUT_BYTES, ): OpenPgpApiBackend.OperationResult { val provider = selectedProviderPackage() @@ -63,7 +64,7 @@ constructor( IllegalStateException("No external OpenPGP provider is selected") ) if (!backend.isProviderInstalled(provider)) return providerUnavailable(provider) - return backend.decrypt(provider, ciphertext, interactionHandler) + return backend.decrypt(provider, ciphertext, interactionHandler, maxOutputBytes) } /** diff --git a/app/src/main/java/app/passwordstore/passkeys/OpenPgpPasskeyDecryptor.kt b/app/src/main/java/app/passwordstore/passkeys/OpenPgpPasskeyDecryptor.kt index e628ba54e0..77e7fcb11f 100644 --- a/app/src/main/java/app/passwordstore/passkeys/OpenPgpPasskeyDecryptor.kt +++ b/app/src/main/java/app/passwordstore/passkeys/OpenPgpPasskeyDecryptor.kt @@ -8,6 +8,7 @@ package app.passwordstore.passkeys import android.content.SharedPreferences import app.passwordstore.data.crypto.OpenPgpApiBackend import app.passwordstore.data.crypto.OpenPgpInteractionCoordinator +import app.passwordstore.data.crypto.OpenPgpOutputLimitExceededException import app.passwordstore.data.crypto.OpenPgpProviderRepository import app.passwordstore.injection.prefs.SettingsPreferences import app.passwordstore.passkeys.crypto.PasskeyDecryptionError @@ -124,26 +125,23 @@ constructor( OpenPgpApiBackend.InteractionHandler { pendingIntent -> interactionCoordinator.interact(pendingIntent) }, + maxOutputBytes = limits.maxPlaintextBytes, ) ) { - is OpenPgpApiBackend.OperationResult.Success -> { - val plaintext = result.value - if (plaintext.size.toLong() > limits.maxPlaintextBytes) { - plaintext.fill(0) - Err(PasskeyDecryptionError.PlaintextTooLarge(limits.maxPlaintextBytes)) - } else { - Ok(SensitiveBytes(plaintext)) - } - } + is OpenPgpApiBackend.OperationResult.Success -> Ok(SensitiveBytes(result.value)) is OpenPgpApiBackend.OperationResult.UserInteractionRequired -> Err(PasskeyDecryptionError.KeyLocked(provider)) OpenPgpApiBackend.OperationResult.Cancelled -> Err(PasskeyDecryptionError.KeyLocked(provider)) is OpenPgpApiBackend.OperationResult.Failure -> - Err( - PasskeyDecryptionError.UnsupportedFormat( - result.error.message ?: "External OpenPGP provider decryption failed" + if (result.error is OpenPgpOutputLimitExceededException) { + Err(PasskeyDecryptionError.PlaintextTooLarge(limits.maxPlaintextBytes)) + } else { + Err( + PasskeyDecryptionError.UnsupportedFormat( + result.error.message ?: "External OpenPGP provider decryption failed" + ) ) - ) + } } } diff --git a/app/src/test/java/app/passwordstore/data/crypto/OpenPgpApiBackendTest.kt b/app/src/test/java/app/passwordstore/data/crypto/OpenPgpApiBackendTest.kt index a6fdcdd901..b46a5dbd7c 100644 --- a/app/src/test/java/app/passwordstore/data/crypto/OpenPgpApiBackendTest.kt +++ b/app/src/test/java/app/passwordstore/data/crypto/OpenPgpApiBackendTest.kt @@ -10,6 +10,7 @@ import android.content.Intent import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertIs import kotlinx.coroutines.runBlocking import org.junit.runner.RunWith @@ -22,7 +23,7 @@ class OpenPgpApiBackendTest { @Test fun `decrypt returns provider output on success`(): Unit = runBlocking { - val executor = FakeExecutor { _, _, _ -> + val executor = FakeExecutor { _, _, _, _ -> OpenPgpApiCall(result(OpenPgpApi.RESULT_CODE_SUCCESS), byteArrayOf(1, 2, 3)) } val backend = OpenPgpApiBackend(executor) @@ -35,6 +36,29 @@ class OpenPgpApiBackendTest { ) } + @Test + fun `decrypt forwards the caller output limit`(): Unit = runBlocking { + var seenLimit = 0L + val executor = FakeExecutor { _, _, _, maxOutputBytes -> + seenLimit = maxOutputBytes + OpenPgpApiCall(result(OpenPgpApi.RESULT_CODE_SUCCESS), byteArrayOf(1)) + } + val backend = OpenPgpApiBackend(executor) + + backend.decrypt("provider", byteArrayOf(9), maxOutputBytes = 1234L) + + assertEquals(1234L, seenLimit) + } + + @Test + fun `bounded provider output fails before exceeding limit`() { + val output = BoundedByteArrayOutputStream(3) + output.write(byteArrayOf(1, 2, 3)) + + assertFailsWith { output.write(4) } + output.wipe() + } + @Test fun `provider continuation intent is used after user interaction`(): Unit = runBlocking { val pendingIntent = @@ -45,7 +69,7 @@ class OpenPgpApiBackendTest { PendingIntent.FLAG_IMMUTABLE, ) val seenActions = mutableListOf() - val executor = FakeExecutor { _, request, _ -> + val executor = FakeExecutor { _, request, _, _ -> seenActions += request.action if (seenActions.size == 1) { OpenPgpApiCall( @@ -85,7 +109,7 @@ class OpenPgpApiBackendTest { Intent("interaction"), PendingIntent.FLAG_IMMUTABLE, ) - val executor = FakeExecutor { _, _, _ -> + val executor = FakeExecutor { _, _, _, _ -> OpenPgpApiCall( result(OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED).apply { putExtra(OpenPgpApi.RESULT_INTENT, pendingIntent) @@ -102,7 +126,7 @@ class OpenPgpApiBackendTest { private fun result(code: Int): Intent = Intent().apply { putExtra(OpenPgpApi.RESULT_CODE, code) } private class FakeExecutor( - private val executeBlock: suspend (String, Intent, ByteArray?) -> OpenPgpApiCall + private val executeBlock: suspend (String, Intent, ByteArray?, Long) -> OpenPgpApiCall ) : OpenPgpApiExecutor { override fun providers(): List = emptyList() @@ -110,6 +134,7 @@ class OpenPgpApiBackendTest { providerPackage: String, request: Intent, input: ByteArray?, - ): OpenPgpApiCall = executeBlock(providerPackage, request, input) + maxOutputBytes: Long, + ): OpenPgpApiCall = executeBlock(providerPackage, request, input, maxOutputBytes) } } diff --git a/app/src/test/java/app/passwordstore/data/crypto/OpenPgpProviderRepositoryTest.kt b/app/src/test/java/app/passwordstore/data/crypto/OpenPgpProviderRepositoryTest.kt index e1f42aa099..dea65ec1d1 100644 --- a/app/src/test/java/app/passwordstore/data/crypto/OpenPgpProviderRepositoryTest.kt +++ b/app/src/test/java/app/passwordstore/data/crypto/OpenPgpProviderRepositoryTest.kt @@ -38,7 +38,7 @@ class OpenPgpProviderRepositoryTest { var getKeyCalls = 0 val backend = OpenPgpApiBackend( - FakeExecutor { _, request, _ -> + FakeExecutor { _, request, _, _ -> when (request.action) { OpenPgpApi.ACTION_GET_KEY -> { getKeyCalls++ @@ -65,7 +65,7 @@ class OpenPgpProviderRepositoryTest { var getKeyCalls = 0 val backend = OpenPgpApiBackend( - FakeExecutor { _, request, _ -> + FakeExecutor { _, request, _, _ -> when (request.action) { OpenPgpApi.ACTION_GET_KEY_IDS -> OpenPgpApiCall( @@ -96,7 +96,7 @@ class OpenPgpProviderRepositoryTest { val identifier = PGPIdentifier.KeyId(keyId) val backend = OpenPgpApiBackend( - FakeExecutor { _, request, _ -> + FakeExecutor { _, request, _, _ -> when (request.action) { OpenPgpApi.ACTION_GET_KEY -> OpenPgpApiCall(success(), certificate.getEncoded()) else -> error("Unexpected action ${request.action}") @@ -133,7 +133,7 @@ class OpenPgpProviderRepositoryTest { Intent().apply { putExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_SUCCESS) } private class FakeExecutor( - private val executeBlock: suspend (String, Intent, ByteArray?) -> OpenPgpApiCall + private val executeBlock: suspend (String, Intent, ByteArray?, Long) -> OpenPgpApiCall ) : OpenPgpApiExecutor { override fun providers(): List = listOf(OpenPgpApiBackend.Provider(PROVIDER, "Provider")) @@ -142,7 +142,8 @@ class OpenPgpProviderRepositoryTest { providerPackage: String, request: Intent, input: ByteArray?, - ): OpenPgpApiCall = executeBlock(providerPackage, request, input) + maxOutputBytes: Long, + ): OpenPgpApiCall = executeBlock(providerPackage, request, input, maxOutputBytes) } private companion object { From 35f0f2576550f50af2a7f079a1759eebba8f1c0d Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Tue, 22 Sep 2026 07:48:02 +0200 Subject: [PATCH 35/43] fix(pgp): refresh recipients without altering local key state --- .../java/app/passwordstore/data/crypto/CryptoRepository.kt | 4 +--- .../java/app/passwordstore/ui/crypto/BasePGPActivity.kt | 7 +------ 2 files changed, 2 insertions(+), 9 deletions(-) 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 37207e8b66..bf5037227f 100644 --- a/app/src/main/java/app/passwordstore/data/crypto/CryptoRepository.kt +++ b/app/src/main/java/app/passwordstore/data/crypto/CryptoRepository.kt @@ -53,9 +53,7 @@ constructor( fun hasKeys(): Boolean = pgpKeyManager.getAllKeys().mapBoth(success = { it.isNotEmpty() }, failure = { false }) - fun hasKey(id: PGPIdentifier): Boolean = - if (openPgpProviderRepository.hasSelectedProvider()) false - else pgpKeyManager.getKeyById(id).isOk + fun hasKey(id: PGPIdentifier): Boolean = pgpKeyManager.getKeyById(id).isOk fun isSecretKey(id: PGPIdentifier): Boolean { val key = pgpKeyManager.getKeyById(id).get() diff --git a/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt b/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt index 4d51c5e38b..245df965f6 100644 --- a/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt @@ -249,14 +249,9 @@ open class BasePGPActivity : AppCompatActivity() { if (openPgpProviderRepository.hasSelectedProvider()) { lifecycleScope.launch { - val missing = ids.filterNot(repository::hasKey) - if (missing.isEmpty()) { - onKeysExist(ids) - return@launch - } when ( val result = - openPgpProviderRepository.ensurePublicKeys(missing, openPgpInteractionHandler) + openPgpProviderRepository.ensurePublicKeys(ids, openPgpInteractionHandler) ) { is OpenPgpApiBackend.OperationResult.Success -> onKeysExist(ids) OpenPgpApiBackend.OperationResult.Cancelled -> Unit From 34a29686861abd46ca9fef019713d4c788f3b559 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Wed, 23 Sep 2026 15:11:35 +0200 Subject: [PATCH 36/43] fix(openpgp): fail closed on oversized provider output --- .../passwordstore/data/crypto/OpenPgpApiBackend.kt | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/app/passwordstore/data/crypto/OpenPgpApiBackend.kt b/app/src/main/java/app/passwordstore/data/crypto/OpenPgpApiBackend.kt index b837145226..60a7bc01c0 100644 --- a/app/src/main/java/app/passwordstore/data/crypto/OpenPgpApiBackend.kt +++ b/app/src/main/java/app/passwordstore/data/crypto/OpenPgpApiBackend.kt @@ -12,6 +12,7 @@ import android.content.pm.ResolveInfo import dagger.hilt.android.qualifiers.ApplicationContext import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream +import java.io.IOException import java.util.concurrent.CancellationException import javax.inject.Inject import kotlinx.coroutines.CompletableDeferred @@ -219,7 +220,7 @@ class OpenPgpApiBackend internal constructor(private val executor: OpenPgpApiExe class OpenPgpProviderException(message: String) : Exception(message) class OpenPgpOutputLimitExceededException(val maxBytes: Long) : - Exception("OpenPGP provider output exceeded $maxBytes bytes") + IOException("OpenPGP provider output exceeded $maxBytes bytes") internal data class OpenPgpApiCall(val result: Intent, val output: ByteArray) @@ -236,6 +237,8 @@ internal interface OpenPgpApiExecutor { internal class BoundedByteArrayOutputStream(private val maxBytes: Long) : ByteArrayOutputStream() { + @Volatile private var limitExceeded = false + init { require(maxBytes in 1..Int.MAX_VALUE.toLong()) { "maxBytes must fit in a positive Int" } } @@ -253,6 +256,10 @@ internal class BoundedByteArrayOutputStream(private val maxBytes: Long) : ByteAr super.write(bytes, offset, length) } + fun throwIfLimitExceeded() { + if (limitExceeded) throw OpenPgpOutputLimitExceededException(maxBytes) + } + fun wipe() { buf.fill(0) reset() @@ -260,6 +267,7 @@ internal class BoundedByteArrayOutputStream(private val maxBytes: Long) : ByteAr private fun ensureCapacityFor(additionalBytes: Int) { if (count.toLong() + additionalBytes > maxBytes) { + limitExceeded = true throw OpenPgpOutputLimitExceededException(maxBytes) } } @@ -288,6 +296,10 @@ internal class BinderOpenPgpApiExecutor(private val context: Context) : OpenPgpA val result = OpenPgpApi(context, service) .executeApi(request, input?.let(::ByteArrayInputStream), output) + // OpenPgpApi pumps provider output on a background thread and swallows IOExceptions from + // that thread. Keep an explicit overflow flag so a rejected write cannot be mistaken for a + // successful, truncated provider response. + output.throwIfLimitExceeded() OpenPgpApiCall(result, output.toByteArray()) } finally { output.wipe() From 0209e75c4e2415d61a20f219b56a5c9b4dc93a1d Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Wed, 23 Sep 2026 15:11:54 +0200 Subject: [PATCH 37/43] test(openpgp): cover sticky output overflow state --- .../app/passwordstore/data/crypto/OpenPgpApiBackendTest.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/test/java/app/passwordstore/data/crypto/OpenPgpApiBackendTest.kt b/app/src/test/java/app/passwordstore/data/crypto/OpenPgpApiBackendTest.kt index b46a5dbd7c..3845d6b4ad 100644 --- a/app/src/test/java/app/passwordstore/data/crypto/OpenPgpApiBackendTest.kt +++ b/app/src/test/java/app/passwordstore/data/crypto/OpenPgpApiBackendTest.kt @@ -51,11 +51,12 @@ class OpenPgpApiBackendTest { } @Test - fun `bounded provider output fails before exceeding limit`() { + fun `bounded provider output remembers overflow after the write fails`() { val output = BoundedByteArrayOutputStream(3) output.write(byteArrayOf(1, 2, 3)) assertFailsWith { output.write(4) } + assertFailsWith { output.throwIfLimitExceeded() } output.wipe() } From 73c5beba5535db6c2319a43d4b9b4433992f71db Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Wed, 23 Sep 2026 15:15:31 +0200 Subject: [PATCH 38/43] fix(openpgp): report resolved provider recipients correctly --- .../java/app/passwordstore/data/crypto/CryptoRepository.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 bf5037227f..dffa220cb6 100644 --- a/app/src/main/java/app/passwordstore/data/crypto/CryptoRepository.kt +++ b/app/src/main/java/app/passwordstore/data/crypto/CryptoRepository.kt @@ -91,7 +91,12 @@ constructor( } fun getEmailFromKeyId(identifier: PGPIdentifier): String? { - val key = pgpKeyManager.getKeyById(identifier).get() ?: return null + val key = + if (openPgpProviderRepository.hasSelectedProvider()) { + openPgpProviderRepository.resolvedPublicKeysFor(listOf(identifier))?.firstOrNull() + } else { + pgpKeyManager.getKeyById(identifier).get() + } ?: return null val userId = KeyUtils.tryGetUserId(key) ?: return null return PGPIdentifier.splitUserId(userId.email) } From 2a3830ff01f157304150bed0df480de008dde42a Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Wed, 23 Sep 2026 15:17:20 +0200 Subject: [PATCH 39/43] fix(openpgp): unbind pending provider connections --- .../passwordstore/data/crypto/OpenPgpApiBackend.kt | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/app/passwordstore/data/crypto/OpenPgpApiBackend.kt b/app/src/main/java/app/passwordstore/data/crypto/OpenPgpApiBackend.kt index 60a7bc01c0..4d518998ae 100644 --- a/app/src/main/java/app/passwordstore/data/crypto/OpenPgpApiBackend.kt +++ b/app/src/main/java/app/passwordstore/data/crypto/OpenPgpApiBackend.kt @@ -336,11 +336,12 @@ internal class BinderOpenPgpApiExecutor(private val context: Context) : OpenPgpA ) operation(boundService) } finally { - if (connection.isBound) { - try { - connection.unbindFromService() - } catch (_: Exception) {} - } + // A successful bindService() call can still be pending when this coroutine is cancelled or + // times out, so isBound is not sufficient to decide whether the ServiceConnection needs to + // be unregistered. unbindService() is safe to attempt here; failed binds are ignored. + try { + connection.unbindFromService() + } catch (_: Exception) {} } } From 0dce9c3aa8f72b4fb3fcc24586df21ffd10ae416 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Wed, 23 Sep 2026 15:20:34 +0200 Subject: [PATCH 40/43] fix(openpgp): wipe raw provider key output --- .../data/crypto/OpenPgpProviderRepository.kt | 67 ++++++++++--------- 1 file changed, 36 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/app/passwordstore/data/crypto/OpenPgpProviderRepository.kt b/app/src/main/java/app/passwordstore/data/crypto/OpenPgpProviderRepository.kt index 5741f0b86b..a8ab7d717c 100644 --- a/app/src/main/java/app/passwordstore/data/crypto/OpenPgpProviderRepository.kt +++ b/app/src/main/java/app/passwordstore/data/crypto/OpenPgpProviderRepository.kt @@ -195,39 +195,44 @@ constructor( ) ) { is OpenPgpApiBackend.OperationResult.Success -> { - val candidate = PGPKey(fetched.value) - val certificate = - KeyUtils.tryParseCertificateOrKey(candidate) - ?: return OpenPgpApiBackend.OperationResult.Failure( - IllegalArgumentException("Provider returned an invalid OpenPGP certificate") + val providerOutput = fetched.value + try { + val candidate = PGPKey(providerOutput) + val certificate = + KeyUtils.tryParseCertificateOrKey(candidate) + ?: return OpenPgpApiBackend.OperationResult.Failure( + IllegalArgumentException("Provider returned an invalid OpenPGP certificate") + ) + if (KeyUtils.isSecretKey(certificate)) { + return OpenPgpApiBackend.OperationResult.Failure( + SecurityException("OpenPGP provider unexpectedly returned secret key material") ) - if (KeyUtils.isSecretKey(certificate)) { - return OpenPgpApiBackend.OperationResult.Failure( - SecurityException("OpenPGP provider unexpectedly returned secret key material") - ) - } - if (certificate.getAllKeyIdentifiers().none { it.getKeyId() == keyId }) { - return OpenPgpApiBackend.OperationResult.Failure( - SecurityException("Provider certificate does not match requested key ID") - ) - } - if (!KeyUtils.isKeyUsable(certificate)) { - return OpenPgpApiBackend.OperationResult.Failure( - IllegalArgumentException("Provider returned an unusable OpenPGP certificate") - ) - } - if ( - identifier is PGPIdentifier.UserId && - certificate.getAllUserIds().none { - identifier.email == it.getUserId() || - identifier.email == PGPIdentifier.splitUserId(it.getUserId()) - } - ) { - return OpenPgpApiBackend.OperationResult.Failure( - SecurityException("Provider certificate does not match requested user ID") - ) + } + if (certificate.getAllKeyIdentifiers().none { it.getKeyId() == keyId }) { + return OpenPgpApiBackend.OperationResult.Failure( + SecurityException("Provider certificate does not match requested key ID") + ) + } + if (!KeyUtils.isKeyUsable(certificate)) { + return OpenPgpApiBackend.OperationResult.Failure( + IllegalArgumentException("Provider returned an unusable OpenPGP certificate") + ) + } + if ( + identifier is PGPIdentifier.UserId && + certificate.getAllUserIds().none { + identifier.email == it.getUserId() || + identifier.email == PGPIdentifier.splitUserId(it.getUserId()) + } + ) { + return OpenPgpApiBackend.OperationResult.Failure( + SecurityException("Provider certificate does not match requested user ID") + ) + } + keys += PGPKey(certificate.getEncoded()) + } finally { + providerOutput.fill(0) } - keys += PGPKey(certificate.getEncoded()) } is OpenPgpApiBackend.OperationResult.UserInteractionRequired -> return OpenPgpApiBackend.OperationResult.UserInteractionRequired(fetched.pendingIntent) From 90162ef6c702ccde76edab8e20ed6ce11612110f Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Wed, 23 Sep 2026 15:21:42 +0200 Subject: [PATCH 41/43] test(openpgp): cover provider certificate boundary --- .../crypto/OpenPgpProviderRepositoryTest.kt | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/app/src/test/java/app/passwordstore/data/crypto/OpenPgpProviderRepositoryTest.kt b/app/src/test/java/app/passwordstore/data/crypto/OpenPgpProviderRepositoryTest.kt index dea65ec1d1..c73c2bae3b 100644 --- a/app/src/test/java/app/passwordstore/data/crypto/OpenPgpProviderRepositoryTest.kt +++ b/app/src/test/java/app/passwordstore/data/crypto/OpenPgpProviderRepositoryTest.kt @@ -17,6 +17,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.assertNull +import kotlin.test.assertTrue import kotlinx.coroutines.runBlocking import org.bouncycastle.openpgp.api.OpenPGPKey import org.junit.Rule @@ -60,6 +61,49 @@ class OpenPgpProviderRepositoryTest { assertEquals(2, getKeyCalls) } + @Test + fun `provider key output is wiped after resolution`() = runBlocking { + val certificate = certificate() + val keyId = KeyUtils.tryGetKeyId(certificate).id + val providerOutput = certificate.getEncoded() + val backend = + OpenPgpApiBackend( + FakeExecutor { _, request, _, _ -> + when (request.action) { + OpenPgpApi.ACTION_GET_KEY -> OpenPgpApiCall(success(), providerOutput) + else -> error("Unexpected action ${request.action}") + } + } + ) + + assertIs>>( + repository(backend).resolvePublicKeys(listOf(PGPIdentifier.KeyId(keyId))) + ) + assertTrue(providerOutput.all { it == 0.toByte() }) + } + + @Test + fun `mismatched provider certificate fails closed`() = runBlocking { + val certificate = certificate() + val actualKeyId = KeyUtils.tryGetKeyId(certificate).id + val requestedKeyId = actualKeyId xor 1L + val backend = + OpenPgpApiBackend( + FakeExecutor { _, request, _, _ -> + when (request.action) { + OpenPgpApi.ACTION_GET_KEY -> OpenPgpApiCall(success(), certificate.getEncoded()) + else -> error("Unexpected action ${request.action}") + } + } + ) + + val result = + repository(backend).resolvePublicKeys(listOf(PGPIdentifier.KeyId(requestedKeyId))) + + val failure = assertIs(result) + assertIs(failure.error) + } + @Test fun `ambiguous user id resolution fails closed`() = runBlocking { var getKeyCalls = 0 From 7a7383029b367ecbf3fe23cbd8f928f6bb67edb9 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Wed, 23 Sep 2026 15:28:06 +0200 Subject: [PATCH 42/43] test(openpgp): fix provider boundary regression tests --- .../data/crypto/OpenPgpProviderRepositoryTest.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/src/test/java/app/passwordstore/data/crypto/OpenPgpProviderRepositoryTest.kt b/app/src/test/java/app/passwordstore/data/crypto/OpenPgpProviderRepositoryTest.kt index c73c2bae3b..f370350dc5 100644 --- a/app/src/test/java/app/passwordstore/data/crypto/OpenPgpProviderRepositoryTest.kt +++ b/app/src/test/java/app/passwordstore/data/crypto/OpenPgpProviderRepositoryTest.kt @@ -83,7 +83,7 @@ class OpenPgpProviderRepositoryTest { } @Test - fun `mismatched provider certificate fails closed`() = runBlocking { + fun `mismatched provider certificate fails closed`(): Unit = runBlocking { val certificate = certificate() val actualKeyId = KeyUtils.tryGetKeyId(certificate).id val requestedKeyId = actualKeyId xor 1L @@ -97,8 +97,7 @@ class OpenPgpProviderRepositoryTest { } ) - val result = - repository(backend).resolvePublicKeys(listOf(PGPIdentifier.KeyId(requestedKeyId))) + val result = repository(backend).resolvePublicKeys(listOf(PGPIdentifier.KeyId(requestedKeyId))) val failure = assertIs(result) assertIs(failure.error) From bfb901d07485f5f0e2726b3163b1ce2c68f98c76 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Wed, 23 Sep 2026 15:35:06 +0200 Subject: [PATCH 43/43] style(openpgp): apply spotless formatting --- .../main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt b/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt index 245df965f6..1d736a3d35 100644 --- a/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt @@ -250,8 +250,7 @@ open class BasePGPActivity : AppCompatActivity() { if (openPgpProviderRepository.hasSelectedProvider()) { lifecycleScope.launch { when ( - val result = - openPgpProviderRepository.ensurePublicKeys(ids, openPgpInteractionHandler) + val result = openPgpProviderRepository.ensurePublicKeys(ids, openPgpInteractionHandler) ) { is OpenPgpApiBackend.OperationResult.Success -> onKeysExist(ids) OpenPgpApiBackend.OperationResult.Cancelled -> Unit