Repository navigation
fix(passkeys): look up cached passphrases by primary key ID - #218
Open
jacksonbaxter wants to merge 1 commit into
Open
jacksonbaxter wants to merge 1 commit into
jacksonbaxter wants to merge 1 commit into
Conversation
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.
jacksonbaxter
force-pushed
the
jb-fix-passkey-passphrase-key-id
branch
from
October 26, 2026 20:55
bccec85 to
83e41b6
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
📢 Type of change
📜 Description
Passkey sign-in with a passphrase-protected PGP key fails even when the passphrase is cached with biometric Fast Unlock. The biometric prompt succeeds, then the provider returns "Selected passkey is unavailable" and the browser reports a failed sign-in.
The passphrase is stored and looked up under two different key IDs:
AppPasskeyProviderActivityputs the passphrase it unlocks from the Fast Unlock store intoPasskeyPassphraseCacheunder the identifier it was persisted with, i.e. the.gpg-ididentifier that decrypted a password (e.g.08edf7567183ce27orjohn.doe@example.com).PgpainlessPasskeyDecryptorcallsPgpUnlockContext.unlockKey()with the message's recipient key ID as a decimalLong(e.g.-3479754033431166429), which is normally the encryption subkey rather than the primary key.The two never match, so the secret key is never unlocked. This PR keys both sides by the primary key ID, as
KeyUtils.tryGetKeyId(key).toString()formats it:PgpainlessPasskeyDecryptorasks for the passphrase by the matching key's primary key ID (one-line change).AppPasskeyProviderActivitymaps each persisted identifier to the primary key ID of the key it names with the existingCryptoRepository.getLongKeyIdFromKeyId()before caching the passphrase. This also covers.gpg-identries that name a key by email or by subkey ID.PgpUnlockContext.unlockKey().💡 Motivation and Context
Follow-up to #88 / #90, which connected the Keystore-backed passphrase cache to the passkey flow. For keys with a separate encryption subkey (the usual layout), the cache key and the lookup key never matched.
💚 How did you test it?
PgpainlessPasskeyDecryptorTest: decrypts a message encrypted to the passphrase-protected test key (its recipient is the encryption subkey) with an unlock context that only returns the passphrase for the primary key ID. It fails onmainwithIncorrectPassphrase(keyId=-3479754033431166429)and passes with this change.spotlessCheck,lintandassembleDebugpass.:crypto:pgpainless:test :passkeys:core:test :passkeys:provider:testDebugUnitTest :app:testDebugUnitTestpass except for 10 tests that fail the same way on unmodifiedmainon macOS only (AtomicWriterDurabilityTest,AtomicCredentialWriterTest,PassSecretsMapStoreTest; the macOS temp directory sits behind the/var→/private/varsymlink). They pass in CI on Linux.📝 Checklist
./gradlew spotlessApply🔮 Next steps
Related gaps I noticed while tracing this, left out to keep the PR focused:
BIOMETRICS_AND_PIN_TIMEOUT/BIOMETRICS_AND_PIN_LAST_USE) the wayBasePGPActivity.getPersistentAndDecrypt()does, and nothing callsPasskeyPassphraseCache.clear(). Now that the passphrase is found, an expired persisted passphrase still unlocks passkeys after the biometric prompt.findFirstPersistentPassphrase()caches the first persisted passphrase, not necessarily the one for the key the selected passkey is encrypted to, so with several keys the first sign-in can still fail.KeyType.PERSISTENT, while the passkey flow decrypts them withKeyType.PERSISTENT_WITH_AUTHENTICATION.