Skip to content

feat: add WebAuthn PRF extension support#1435

Closed
billiegoose wants to merge 3 commits into
AChep:masterfrom
billiegoose:claude/webauthn-prf-extension-4UZsC
Closed

feat: add WebAuthn PRF extension support#1435
billiegoose wants to merge 3 commits into
AChep:masterfrom
billiegoose:claude/webauthn-prf-extension-4UZsC

Conversation

@billiegoose
Copy link
Copy Markdown

@billiegoose billiegoose commented May 25, 2026

Hi, huge Keyguard fan here. I hope this contribution finds you well. I wanted to use passkeys to derive an AES key in my website. I learned that the feature is supported in Google Password and Apple's Password manager but not (yet) for Keyguard. Normally I wouldn't allow Claude to make a PR for me, but because WebAuthn is a well-defined specification, I am more confident that it is probably implemented correctly. I did manually test it as well, using a debug build APK and Android/Firefox/Keyguard and it worked.


The PRF extension lets relying parties derive deterministic symmetric keys from a passkey — enabling phishing-resistant end-to-end encryption. See Corbado's writeup for use cases.

Changes (all in :common):

  • Registration: return clientExtensionResults.prf.enabled = true when extensions.prf is requested
  • Authentication: compute and return clientExtensionResults.prf.results from extensions.prf.eval / evalByCredential
  • CTAP 2.2+: return PRF results immediately at creation time when extensions.prf.eval is included
  • Unit tests for the PRF computation

Manually Tested against https://webauthn-passkeys-prf-demo.explore.corbado.com/ on Mobile Firefox 151 / Android 16:

Screenshot_2026-05-25-01-10-12-72_3aea4af51f236e4932235fdada7d1643

claude added 2 commits May 25, 2026 04:41
During registration, respond with clientExtensionResults.prf.enabled = true
when the relying party requests the PRF extension via extensions.prf.

During authentication, resolve PRF inputs from extensions.prf.eval (or
evalByCredential for the specific credential), compute deterministic PRF
outputs using HMAC-SHA-256 over a per-credential key derived from the
private key, and return them in clientExtensionResults.prf.results.

PRF computation follows the WebAuthn Level 3 spec:
  prfSalt   = SHA-256("WebAuthn PRF\x00" || prfInput)
  hmacKey   = HMAC-SHA-256(privateKeyBytes, "prf")
  prfOutput = HMAC-SHA-256(hmacKey, prfSalt)

Modules changed: :common (androidMain, commonMain, androidUnitTest)

https://claude.ai/code/session_01FB6FapLddy7iUzQiXXNJcr
When the relying party includes extensions.prf.eval in the create()
call, compute and return the PRF results immediately in
clientExtensionResults.prf.results alongside enabled:true, saving
the RP a separate authentication round-trip.

https://claude.ai/code/session_01FB6FapLddy7iUzQiXXNJcr
@github-actions
Copy link
Copy Markdown
Contributor


Thank you for your submission, we really appreciate it. Like many open-source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution. You can sign the CLA by just posting a Pull Request Comment same as the below format.


I have read the CLA Document and I hereby sign the CLA


You can retrigger this bot by commenting recheck in this Pull Request. Posted by the CLA Assistant Lite bot.

@billiegoose
Copy link
Copy Markdown
Author

I have read the CLA Document and I hereby sign the CLA

@billiegoose
Copy link
Copy Markdown
Author

recheck

@billiegoose billiegoose changed the title Claude/webauthn prf extension 4 u zs c feat: add WebAuthn PRF extension support May 25, 2026
@AChep
Copy link
Copy Markdown
Owner

AChep commented May 25, 2026

Thanks! It looks very reviewable to me and on the first glance it all makes sense. I'll do a proper review later on and hopefully will merge that in!

AChep added a commit that referenced this pull request May 25, 2026
@AChep
Copy link
Copy Markdown
Owner

AChep commented May 25, 2026

internal fun computeWebAuthnPrf(
    cryptoService: CryptoGenerator,
    privateKeyBytes: ByteArray,
    prfInput: ByteArray,
): ByteArray {
    val prfSalt = cryptoService.hashSha256(PasskeyUtils.PRF_LABEL + prfInput)
    val hmacKey = cryptoService.hmacSha256(
        key = privateKeyBytes,
        data = "prf".toByteArray(Charsets.UTF_8),
    )
    return cryptoService.hmacSha256(
        key = hmacKey,
        data = prfSalt,
    )
}

The hmacKey computation doesn't really follow the spec. In the spec we should create and store a separate key specifically for the extension. While the current approach seems secure it will very likely break the compatibility with future Bitwarden clients.

This implementation is basically https://community.bitwarden.com/t/support-for-storing-prf-capable-passkeys-in-bitwarden-vault/82239/61 of the alternative Keyguard world.

- Store a separate 32-byte random `prfSecret` per credential (the
  "credRandom" equivalent) instead of deriving from the signing key.
  The PRF algorithm is now: SHA-256("WebAuthn PRF\x00" || input) →
  HMAC-SHA-256(prfSecret, prfSalt), matching the W3C spec exactly.
- Propagate `prfSecret` through the full data stack: BitwardenCipher,
  DSecret, LoginFido2CredentialsEntity, LoginFido2CredentialsRequest,
  CipherDecoder, CipherMapping, CipherCrypto, AddCredentialCipher.
- Enforce user verification before returning PRF results: if the user
  was not verified, the response contains `prf: {}` instead of results.
- Always include `prf: {}` in clientExtensionResults when PRF was
  requested, even when no results are available (per spec).
- Improve tests: use the new `prfSecretBytes` parameter, add an
  independent reference implementation as expected value, and add a
  regression test confirming the output differs from the old
  private-key-derived computation.

https://claude.ai/code/session_01FB6FapLddy7iUzQiXXNJcr
@AChep
Copy link
Copy Markdown
Owner

AChep commented May 25, 2026

We can't create a new property either, because we will not be able to sync it back to Bitwarden servers. This is a stalemate until Bitwarden decides to implement it themselves.

@billiegoose
Copy link
Copy Markdown
Author

Ah... I see. Oh well!

@AChep AChep closed this May 26, 2026
@github-actions github-actions Bot locked and limited conversation to collaborators May 26, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants