From 83e41b681f26e6845cf1d9056f000d544223f6c9 Mon Sep 17 00:00:00 2001 From: Jackson Baxter Date: Mon, 26 Oct 2026 14:47:03 -0600 Subject: [PATCH] fix(passkeys): look up cached passphrases by primary key ID The passkey flow cached the Fast Unlock passphrase under the .gpg-id identifier it was persisted with, but PgpainlessPasskeyDecryptor asked PgpUnlockContext for it by the recipient key ID as a decimal Long, which is usually the encryption subkey. The lookup never matched, so passphrase-protected keys could not sign ("Selected passkey is unavailable"). Key both sides by the primary key ID in PGPIdentifier.KeyId format: the decryptor asks for the matching key's primary key ID, and AppPasskeyProviderActivity resolves each persisted identifier with CryptoRepository.getLongKeyIdFromKeyId() before caching its passphrase. --- CHANGELOG.md | 4 ++ .../passkeys/AppPasskeyProviderActivity.kt | 6 ++- .../crypto/PgpainlessPasskeyDecryptor.kt | 2 +- .../crypto/PgpainlessPasskeyDecryptorTest.kt | 53 +++++++++++++++++++ .../passkeys/crypto/PasskeyPgpDecryptor.kt | 1 + 5 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/PgpainlessPasskeyDecryptorTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index d566c90c3a..395d5ee095 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file ## [Unreleased] +### Fixed + +- Passkey sign-in no longer fails for passphrase-protected PGP keys when the passphrase is cached with biometric Fast Unlock + ## [1.25.2] - 2026-09-29 ### Fixed diff --git a/app/src/main/java/app/passwordstore/passkeys/AppPasskeyProviderActivity.kt b/app/src/main/java/app/passwordstore/passkeys/AppPasskeyProviderActivity.kt index f6f9ee568e..793cec6377 100644 --- a/app/src/main/java/app/passwordstore/passkeys/AppPasskeyProviderActivity.kt +++ b/app/src/main/java/app/passwordstore/passkeys/AppPasskeyProviderActivity.kt @@ -23,6 +23,7 @@ import androidx.credentials.exceptions.GetCredentialUnknownException import androidx.credentials.provider.PendingIntentHandler import androidx.lifecycle.lifecycleScope import app.passwordstore.R as AppR +import app.passwordstore.crypto.PGPIdentifier import app.passwordstore.data.crypto.OpenPgpActivityInteractionHandler import app.passwordstore.data.crypto.OpenPgpInteractionCoordinator import app.passwordstore.data.repo.PasswordRepository @@ -666,8 +667,9 @@ class AppPasskeyProviderActivity : BaseGitActivity() { private fun findFirstPersistentPassphrase(): Pair? { val allKeys = persistentPassphrases.all - for ((keyId, value) in allKeys) { - if (keyId == "unlock_pin" || keyId == PreferenceKeys.BIOMETRICS_AND_PIN_LAST_USE) continue + for ((id, value) in allKeys) { + if (id == "unlock_pin" || id == PreferenceKeys.BIOMETRICS_AND_PIN_LAST_USE) continue + val keyId = PGPIdentifier.fromString(id)?.let(repository::getLongKeyIdFromKeyId) ?: continue if (passphraseCache.contains(keyId)) continue val encrypted = (value as? String)?.toCharArray() ?: continue return keyId to encrypted diff --git a/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PgpainlessPasskeyDecryptor.kt b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PgpainlessPasskeyDecryptor.kt index eeecfca5a6..a0c1b0ef4f 100644 --- a/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PgpainlessPasskeyDecryptor.kt +++ b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PgpainlessPasskeyDecryptor.kt @@ -138,7 +138,7 @@ public class PgpainlessPasskeyDecryptor( var lastError: PasskeyDecryptionError? = null for ((key, keyId) in matchingKeys) { - val passphrase = unlockContext.unlockKey(keyId.toString()) + val passphrase = unlockContext.unlockKey(KeyUtils.tryGetKeyId(key).toString()) try { val plaintext = attemptBoundedDecryption(key, passphrase, ciphertext, limits) diff --git a/crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/PgpainlessPasskeyDecryptorTest.kt b/crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/PgpainlessPasskeyDecryptorTest.kt new file mode 100644 index 0000000000..eacd95c0b9 --- /dev/null +++ b/crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/PgpainlessPasskeyDecryptorTest.kt @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2014-2026 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.crypto + +import app.passwordstore.crypto.CryptoConstants.KEY_ID +import app.passwordstore.crypto.CryptoConstants.KEY_PASSPHRASE +import app.passwordstore.crypto.CryptoConstants.PLAIN_TEXT +import app.passwordstore.crypto.PGPIdentifier.KeyId +import app.passwordstore.passkeys.crypto.PgpUnlockContext +import com.github.michaelbull.result.unwrap +import java.io.ByteArrayOutputStream +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.rules.TemporaryFolder + +class PgpainlessPasskeyDecryptorTest { + + @get:Rule val temporaryFolder: TemporaryFolder = TemporaryFolder() + + @Test + fun `unlocks a key encrypted to its subkey with the passphrase cached by primary key ID`() = + runTest { + val cryptoHandler = PGPainlessCryptoHandler() + val keyManager = PGPKeyManager(temporaryFolder.root.absolutePath) + keyManager.addKey(PGPKey(TestUtils.getArmoredSecretKey())).unwrap() + val ciphertext = ByteArrayOutputStream() + cryptoHandler + .encrypt( + listOf(PGPKey(TestUtils.getArmoredPublicKey())), + null, + PLAIN_TEXT.byteInputStream(), + ciphertext, + PGPEncryptOptions.Builder().build(), + ) + .unwrap() + val unlockContext = + object : PgpUnlockContext { + override suspend fun unlockKey(keyId: String) = + KEY_PASSPHRASE.toCharArray().takeIf { keyId == KeyId(KEY_ID).toString() } + } + + val plaintext = + PgpainlessPasskeyDecryptor(cryptoHandler, keyManager) + .decryptFromBytes(ciphertext.toByteArray(), unlockContext) + .unwrap() + + assertEquals(PLAIN_TEXT, plaintext.bytes().decodeToString()) + } +} diff --git a/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/crypto/PasskeyPgpDecryptor.kt b/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/crypto/PasskeyPgpDecryptor.kt index bac1d8c96a..071e8170c5 100644 --- a/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/crypto/PasskeyPgpDecryptor.kt +++ b/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/crypto/PasskeyPgpDecryptor.kt @@ -43,5 +43,6 @@ public interface PasskeyPgpDecryptor { } public interface PgpUnlockContext { + /** [keyId] is the key's primary key ID, as formatted by `PGPIdentifier.KeyId`. */ public suspend fun unlockKey(keyId: String): CharArray? }