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) 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 @@ - - - - - - - - - - - - - - - - - + + + + + + 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..dffa220cb6 100644 --- a/app/src/main/java/app/passwordstore/data/crypto/CryptoRepository.kt +++ b/app/src/main/java/app/passwordstore/data/crypto/CryptoRepository.kt @@ -46,6 +46,7 @@ constructor( private val pgpKeyManager: PGPKeyManager, private val pgpCryptoHandler: PGPainlessCryptoHandler, private val dispatcherProvider: DispatcherProvider, + private val openPgpProviderRepository: OpenPgpProviderRepository, @SettingsPreferences private val settings: SharedPreferences, ) { @@ -90,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) } @@ -116,8 +122,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 +139,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 +177,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/OpenPgpApiBackend.kt b/app/src/main/java/app/passwordstore/data/crypto/OpenPgpApiBackend.kt new file mode 100644 index 0000000000..4d518998ae --- /dev/null +++ b/app/src/main/java/app/passwordstore/data/crypto/OpenPgpApiBackend.kt @@ -0,0 +1,359 @@ +/* + * 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.io.IOException +import java.util.concurrent.CancellationException +import javax.inject.Inject +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +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, + maxOutputBytes = MAX_METADATA_OUTPUT_BYTES, + interactionHandler = interactionHandler, + ) {} + + suspend fun decrypt( + 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 + } + + 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, + maxOutputBytes = MAX_PUBLIC_KEY_OUTPUT_BYTES, + 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, + maxOutputBytes = MAX_METADATA_OUTPUT_BYTES, + interactionHandler = interactionHandler, + ) { call -> + call.result.getLongArrayExtra(OpenPgpApi.RESULT_KEY_IDS) ?: longArrayOf() + } + + private suspend fun executeWithInteraction( + providerPackage: String, + initialRequest: Intent, + input: ByteArray?, + maxOutputBytes: Long, + interactionHandler: InteractionHandler?, + onSuccess: (OpenPgpApiCall) -> T, + ): OperationResult { + var request = initialRequest + + repeat(MAX_INTERACTION_ROUNDS) { + val call = + try { + executor.execute(providerPackage, request, input, maxOutputBytes) + } 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 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) + ?: 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) + 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 + // pure permission grant, in which case retrying the original request is safe. + request = interaction.data ?: request + } + InteractionResult.Cancelled -> return OperationResult.Cancelled + } + } + else -> { + call.output.fill(0) + @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") + ) + } + + 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) : + IOException("OpenPGP provider output exceeded $maxBytes bytes") + +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?, + maxOutputBytes: Long, + ): OpenPgpApiCall +} + +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" } + } + + 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 throwIfLimitExceeded() { + if (limitExceeded) throw OpenPgpOutputLimitExceededException(maxBytes) + } + + fun wipe() { + buf.fill(0) + reset() + } + + private fun ensureCapacityFor(additionalBytes: Int) { + if (count.toLong() + additionalBytes > maxBytes) { + limitExceeded = true + throw OpenPgpOutputLimitExceededException(maxBytes) + } + } +} + +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?, + maxOutputBytes: Long, + ): OpenPgpApiCall = + withService(providerPackage) { service -> + val output = BoundedByteArrayOutputStream(maxOutputBytes) + try { + 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() + } + } + + 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() + val boundService = + withTimeoutOrNull(SERVICE_BIND_TIMEOUT_MILLIS) { service.await() } + ?: throw OpenPgpProviderException( + "Timed out binding to OpenPGP provider $providerPackage" + ) + operation(boundService) + } finally { + // 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) {} + } + } + + 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) + } + + private companion object { + const val SERVICE_BIND_TIMEOUT_MILLIS = 15_000L + } +} 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..e9d01c63d8 --- /dev/null +++ b/app/src/main/java/app/passwordstore/data/crypto/OpenPgpInteraction.kt @@ -0,0 +1,81 @@ +/* + * 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 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 kotlin.coroutines.resumeWithException +import kotlinx.coroutines.asContextElement +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext + +/** + * 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< + kotlinx.coroutines.CancellableContinuation? + >( + 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) } + try { + launcher.launch(IntentSenderRequest.Builder(pendingIntent.intentSender).build()) + } catch (error: Throwable) { + if (waiting.compareAndSet(continuation, null) && continuation.isActive) { + continuation.resumeWithException(error) + } + } + } +} 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..a8ab7d717c --- /dev/null +++ b/app/src/main/java/app/passwordstore/data/crypto/OpenPgpProviderRepository.kt @@ -0,0 +1,268 @@ +/* + * 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.injection.prefs.SettingsPreferences +import app.passwordstore.util.settings.PreferenceKeys +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton + +/** Coordinates APS-local provider selection with provider-owned OpenPGP key material. */ +@Singleton +class OpenPgpProviderRepository +@Inject +constructor( + private val backend: OpenPgpApiBackend, + @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? = + 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") + ) + if (!backend.isProviderInstalled(provider)) return providerUnavailable(provider) + return backend.checkPermission(provider, interactionHandler) + } + + suspend fun decrypt( + ciphertext: ByteArray, + interactionHandler: OpenPgpApiBackend.InteractionHandler? = null, + maxOutputBytes: Long = OpenPgpApiBackend.DEFAULT_MAX_DECRYPT_OUTPUT_BYTES, + ): OpenPgpApiBackend.OperationResult { + val provider = + selectedProviderPackage() + ?: return OpenPgpApiBackend.OperationResult.Failure( + IllegalStateException("No external OpenPGP provider is selected") + ) + if (!backend.isProviderInstalled(provider)) return providerUnavailable(provider) + return backend.decrypt(provider, ciphertext, interactionHandler, maxOutputBytes) + } + + /** + * Resolves [identifiers] against the selected provider and returns fresh public certificates. + * + * 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 -> + OpenPgpApiBackend.OperationResult.UserInteractionRequired(resolved.pendingIntent) + OpenPgpApiBackend.OperationResult.Cancelled -> OpenPgpApiBackend.OperationResult.Cancelled + is OpenPgpApiBackend.OperationResult.Failure -> + OpenPgpApiBackend.OperationResult.Failure(resolved.error) + } + } + + /** + * 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, + interactionHandler: OpenPgpApiBackend.InteractionHandler? = null, + ): OpenPgpApiBackend.OperationResult { + val provider = + selectedProviderPackage() + ?: return OpenPgpApiBackend.OperationResult.Failure( + IllegalStateException("No external OpenPGP provider is selected") + ) + 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 -> + OpenPgpApiBackend.OperationResult.UserInteractionRequired(resolved.pendingIntent) + OpenPgpApiBackend.OperationResult.Cancelled -> OpenPgpApiBackend.OperationResult.Cancelled + is OpenPgpApiBackend.OperationResult.Failure -> + OpenPgpApiBackend.OperationResult.Failure(resolved.error) + } + } + + /** 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) { + 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) + is PGPIdentifier.UserId -> { + when ( + val resolved = + backend.resolveKeyIds( + providerPackage = provider, + userIds = arrayOf(identifier.email), + interactionHandler = interactionHandler, + ) + ) { + 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) + } + } + } + + if (keyIds.isEmpty()) { + return OpenPgpApiBackend.OperationResult.Failure( + IllegalStateException("OpenPGP provider could not resolve $identifier") + ) + } + if (identifier is PGPIdentifier.UserId && keyIds.size > 1) { + return OpenPgpApiBackend.OperationResult.Failure( + OpenPgpAmbiguousRecipientException(identifier.email, keyIds.toList()) + ) + } + + val keys = mutableListOf() + for (keyId in keyIds) { + when ( + val fetched = + backend.getPublicKey( + providerPackage = provider, + keyId = keyId, + interactionHandler = interactionHandler, + ) + ) { + is OpenPgpApiBackend.OperationResult.Success -> { + 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 (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) + } + } + 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 + } + + 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(provider: String): OpenPgpApiBackend.OperationResult = + OpenPgpApiBackend.OperationResult.Failure( + OpenPgpProviderException("Selected OpenPGP provider $provider is not installed") + ) +} + +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/injection/passkeys/PasskeysModule.kt b/app/src/main/java/app/passwordstore/injection/passkeys/PasskeysModule.kt index 1e8de4381c..4994fbb2ec 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,21 @@ 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.OpenPgpPassRecipientResolver +import app.passwordstore.passkeys.OpenPgpPasskeyDecryptor import app.passwordstore.passkeys.PasskeyMetadataIndex import app.passwordstore.passkeys.PasskeyPassphraseCache import app.passwordstore.passkeys.crypto.ES256CryptoHandler @@ -70,7 +76,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 @@ -84,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..ee71bcec5a --- /dev/null +++ b/app/src/main/java/app/passwordstore/passkeys/OpenPgpPassRecipientResolver.kt @@ -0,0 +1,66 @@ +/* + * 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.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 +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 + +/** Resolves pass recipients from the selected provider without weakening `.gpg-id` policy. */ +class OpenPgpPassRecipientResolver( + private val delegate: DefaultPassRecipientResolver, + private val providerRepository: OpenPgpProviderRepository, + private val interactionCoordinator: OpenPgpInteractionCoordinator, +) : PassRecipientResolver { + + override suspend fun resolveFor(target: File): Result, RecipientPolicyError> { + if (!providerRepository.hasSelectedProvider()) return delegate.resolveFor(target) + + val identifiers = + delegate + .resolveIdentifiersFor(target) + .fold( + success = { it }, + failure = { + return Err(it) + }, + ) + + return when ( + val resolved = + providerRepository.resolvePublicKeys( + identifiers, + OpenPgpApiBackend.InteractionHandler { pendingIntent -> + interactionCoordinator.interact(pendingIntent) + }, + ) + ) { + 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/main/java/app/passwordstore/passkeys/OpenPgpPasskeyDecryptor.kt b/app/src/main/java/app/passwordstore/passkeys/OpenPgpPasskeyDecryptor.kt new file mode 100644 index 0000000000..77e7fcb11f --- /dev/null +++ b/app/src/main/java/app/passwordstore/passkeys/OpenPgpPasskeyDecryptor.kt @@ -0,0 +1,150 @@ +/* + * 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.OpenPgpOutputLimitExceededException +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) + }, + maxOutputBytes = limits.maxPlaintextBytes, + ) + ) { + 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 -> + if (result.error is OpenPgpOutputLimitExceededException) { + Err(PasskeyDecryptionError.PlaintextTooLarge(limits.maxPlaintextBytes)) + } else { + Err( + PasskeyDecryptionError.UnsupportedFormat( + result.error.message ?: "External OpenPGP provider decryption failed" + ) + ) + } + } + } + + private fun usesExternalProvider(): Boolean = + !settings.getString(PreferenceKeys.OPENPGP_PROVIDER_PACKAGE, null).isNullOrBlank() +} 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..1d736a3d35 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,41 @@ 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 { + when ( + val result = openPgpProviderRepository.ensurePublicKeys(ids, 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), + ) + ) } - } else { - onKeysExist(ids) } + 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 +288,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 +301,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 +636,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 +836,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 +860,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..64b57b51ee 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,90 @@ 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 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 = + 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 -> + 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 + } + + dialog.dismiss() + val provider = providers[which - providerOffset] + 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 38b4b99a35..51b9ae51b7 100644 --- a/app/src/main/java/app/passwordstore/util/settings/PreferenceKeys.kt +++ b/app/src/main/java/app/passwordstore/util/settings/PreferenceKeys.kt @@ -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" 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..3845d6b4ad --- /dev/null +++ b/app/src/test/java/app/passwordstore/data/crypto/OpenPgpApiBackendTest.kt @@ -0,0 +1,141 @@ +/* + * 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.assertFailsWith +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`(): Unit = 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 `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 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() + } + + @Test + fun `provider continuation intent is used after user interaction`(): Unit = 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`(): Unit = 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?, Long) -> OpenPgpApiCall + ) : OpenPgpApiExecutor { + override fun providers(): List = emptyList() + + override suspend fun execute( + providerPackage: String, + request: Intent, + input: ByteArray?, + maxOutputBytes: Long, + ): OpenPgpApiCall = executeBlock(providerPackage, request, input, maxOutputBytes) + } +} 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..dcec062c5e --- /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`(): Unit = 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/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..f370350dc5 --- /dev/null +++ b/app/src/test/java/app/passwordstore/data/crypto/OpenPgpProviderRepositoryTest.kt @@ -0,0 +1,195 @@ +/* + * 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 kotlin.test.assertTrue +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 `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`(): Unit = 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 + 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?, Long) -> OpenPgpApiCall + ) : OpenPgpApiExecutor { + override fun providers(): List = + listOf(OpenPgpApiBackend.Provider(PROVIDER, "Provider")) + + override suspend fun execute( + providerPackage: String, + request: Intent, + input: ByteArray?, + maxOutputBytes: Long, + ): OpenPgpApiCall = executeBlock(providerPackage, request, input, maxOutputBytes) + } + + 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..6f6fb9a2c9 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,20 @@ 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 +132,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 +142,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 +166,6 @@ public class DefaultPassRecipientResolver( } val identifiers = mutableListOf() - for ((index, rawLine) in lines.withIndex()) { val commentMatch = COMMENT_PATTERN.find(rawLine) val line = @@ -180,9 +193,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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7052cdff81..499893cb6a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } } 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") } + } } }