Skip to content

fix(passkeys): look up cached passphrases by primary key ID - #218

Open
jacksonbaxter wants to merge 1 commit into
pando85:mainfrom
jacksonbaxter:jb-fix-passkey-passphrase-key-id
Open

jacksonbaxter wants to merge 1 commit into
pando85:mainfrom
jacksonbaxter:jb-fix-passkey-passphrase-key-id

Conversation

@jacksonbaxter

@jacksonbaxter jacksonbaxter commented Oct 26, 2026 •

Copy link
Copy Markdown

📢 Type of change

  • Bugfix
  • New feature
  • Enhancement
  • Refactoring
  • Dependency updates

📜 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:

  • AppPasskeyProviderActivity puts the passphrase it unlocks from the Fast Unlock store into PasskeyPassphraseCache under the identifier it was persisted with, i.e. the .gpg-id identifier that decrypted a password (e.g. 08edf7567183ce27 or john.doe@example.com).
  • PgpainlessPasskeyDecryptor calls PgpUnlockContext.unlockKey() with the message's recipient key ID as a decimal Long (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:

  • PgpainlessPasskeyDecryptor asks for the passphrase by the matching key's primary key ID (one-line change).
  • AppPasskeyProviderActivity maps each persisted identifier to the primary key ID of the key it names with the existing CryptoRepository.getLongKeyIdFromKeyId() before caching the passphrase. This also covers .gpg-id entries that name a key by email or by subkey ID.
  • The expected format is documented on 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?

  • New 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 on main with IncorrectPassphrase(keyId=-3479754033431166429) and passes with this change.
  • Locally (JDK 21, macOS): spotlessCheck, lint and assembleDebug pass. :crypto:pgpainless:test :passkeys:core:test :passkeys:provider:testDebugUnitTest :app:testDebugUnitTest pass except for 10 tests that fail the same way on unmodified main on macOS only (AtomicWriterDurabilityTest, AtomicCredentialWriterTest, PassSecretsMapStoreTest; the macOS temp directory sits behind the /var → /private/var symlink). They pass in CI on Linux.

📝 Checklist

  • I formatted the code with ./gradlew spotlessApply
  • I reviewed the submitted code
  • I added a CHANGELOG entry if applicable

🔮 Next steps

Related gaps I noticed while tracing this, left out to keep the PR focused:

  • The passkey flow doesn't apply Fast Unlock's validity period (BIOMETRICS_AND_PIN_TIMEOUT / BIOMETRICS_AND_PIN_LAST_USE) the way BasePGPActivity.getPersistentAndDecrypt() does, and nothing calls PasskeyPassphraseCache.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.
  • Fast Unlock with PIN can't provide the passphrase to passkeys: PIN mode persists passphrases encrypted with KeyType.PERSISTENT, while the passkey flow decrypts them with KeyType.PERSISTENT_WITH_AUTHENTICATION.

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
jacksonbaxter force-pushed the jb-fix-passkey-passphrase-key-id branch from bccec85 to 83e41b6 Compare October 26, 2026 20:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant