Skip to content

fix: detect WebAuthn extensions by asking the browser - #42

Merged
NiKrause merged 1 commit into
mainfrom
fix/extension-detection
Aug 15, 2026
Merged

fix: detect WebAuthn extensions by asking the browser#42
NiKrause merged 1 commit into
mainfrom
fix/extension-detection

Conversation

@NiKrause

Copy link
Copy Markdown
Contributor

Closes #9.

The bug

checkExtensionSupport() asked 'largeBlob' in PublicKeyCredential.prototype. Extensions are never properties of that interface — they arrive through getClientExtensionResults() — so the test answered false in every browser ever shipped, including ones with complete support. hmacSecret sat beside it hard-coded to false.

Together that permanently disabled keystore encryption: the demo's "Encrypt keystore with WebAuthn" checkbox is disabled when neither flag is set, so the demo could not demonstrate its own headline feature anywhere.

Measured, not assumed

On Chrome 148, on the deployed demo page:

Extension in PublicKeyCredential.prototype getClientCapabilities()
largeBlob false true
prf false true
hmacCreateSecret false true

Probing appid, credBlob and credProps the same way also returns false, and the prototype carries only rawId, response, authenticatorAttachment, getClientExtensionResults, toJSON, constructor. So this was never a vendor quirk — the question was asked of the wrong object, and no browser could have passed it.

What this says about #9

The crash #9 reported — No hmac-secret output from credential — did stop happening. But not because anything was fixed: the path became unreachable once the detection answered false everywhere, which took the working paths with it. The feature went quiet, not well. This restores it.

Changes

  • checkExtensionSupport() reads getClientCapabilities(), reports prf alongside the other two, and returns known — distinguishing the browser says no from the browser cannot say. Treating the second as a refusal is exactly how a working feature gets disabled; callers should attempt the ceremony instead.
  • extensionSupportFromCredential(credential) (new) reads what the authenticator actually agreed to, from the registration response. Client support is not authenticator support: a browser may support largeBlob while the key in front of it does not. Only the ceremony settles it, and the answer belongs to the credential, so it should be stored with it.
  • addLargeBlobToCredentialOptions() takes an optional support level. Still 'required' by default — under 'preferred' an unsupported authenticator yields a credential whose secret was never written, which loses the keystore silently.
  • The demo offers PRF and prefers it, matching the library's default since 0.4.0. It had hard-coded largeBlob, overriding that default and never exercising PRF. Methods the browser cannot vouch for now read "Unknown" rather than "Not supported", and stay selectable.

Verification

  • 57 node tests pass (45 before; 12 new), including the old prototype probe kept as an explicit regression guard
  • format:check and lint clean
  • the encrypted-keystore demo builds

Still worth a manual pass on the deployed demo once this lands: tick "Encrypt keystore with WebAuthn" and confirm it completes. That is the part no test here covers.

checkExtensionSupport() tested `'largeBlob' in PublicKeyCredential.prototype`.
Extensions are never properties of that interface — they arrive through
getClientExtensionResults() — so the test answered false in every browser ever
shipped, including ones with complete support. hmacSecret sat beside it
hard-coded to false. Together they meant the encrypted-keystore demo disabled
its own headline feature everywhere, and the checkbox could not be ticked at
all.

Measured on Chrome 148 rather than assumed: the prototype probe returns false
for largeBlob, prf and hmacCreateSecret, while getClientCapabilities() on the
same page reports all three true. Probing appid, credBlob and credProps the
same way also returns false, so this was never a vendor quirk — the question
was asked of the wrong object.

Worth naming what this means for #9. The crash it reported ("No hmac-secret
output from credential") did stop happening, but not because anything was
fixed: the path became unreachable when the detection started answering false
everywhere, which also took the working paths with it. The feature was quiet,
not well.

Now:

- checkExtensionSupport() reads getClientCapabilities(), reports prf alongside
  largeBlob and hmacSecret, and returns `known` so callers can tell "the
  browser says no" from "the browser cannot say". Treating the second as a
  refusal is how a working feature gets disabled.
- extensionSupportFromCredential() reads what the authenticator actually agreed
  to, from the registration response. Client support is not authenticator
  support; only the ceremony settles it, and the answer belongs to the
  credential.
- addLargeBlobToCredentialOptions() takes a support level, still 'required' by
  default — under 'preferred' an unsupported authenticator yields a credential
  whose secret was never written, which loses the keystore silently.
- The demo offers PRF and prefers it, matching the library default since 0.4.0.
  It had hard-coded largeBlob and so never exercised PRF.

Tests: 12 new cases, including the old prototype probe kept as a regression
guard. 57 node tests pass, format and lint clean, the demo builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@NiKrause
NiKrause merged commit 3ac9241 into main Aug 15, 2026
6 checks passed
NiKrause added a commit that referenced this pull request Aug 15, 2026
…er offers (#43)

* fix: record what the authenticator agreed to, not just what the browser offers

#42 fixed the client-side detection. Testing it on real hardware showed the
other half was still missing: on a macOS platform authenticator in Brave,
getClientCapabilities() advertises hmacCreateSecret, the authenticator refuses
it, and PRF works. The demo therefore labelled hmac-secret "✅ Supported",
offered it, and the ceremony failed — which is the failure #9 reported in the
first place.

Client support is not authenticator support. Only the ceremony settles it, and
the raw PublicKeyCredential does not survive past createCredential(), so the
answer is read there or lost. It now travels on the credential as
`extensionSupport` and is persisted with it.

The demo consumes it: a method the authenticator refused reads "Browser yes,
this passkey no" instead of "Supported", stops being selectable, and a current
selection it cannot honour falls back automatically — PRF, then largeBlob, then
hmac-secret. Client capabilities are resolved before the stored credential is
loaded now; the previous order let the browser's view overwrite the
authenticator's.

Also fixes a log line that the console output from that hardware run exposed:

    encrypted: options.encryptKeystore ? `Yes (${options.encryptionMethod})` : 'No'

That reports the *request*, so "Yes (largeBlob)" appeared whether or not
largeBlob had been honoured, and a failed write was indistinguishable from a
successful one. It now names the request and the authenticator's capability
separately.

Tests: 58 node tests pass, including a new case pinning the disagreement
between the two questions. Format and lint clean, demo builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: actually write the secret key into largeBlob

The keystore record carried:

    secretKey: sk, // Will be moved to largeBlob

Nothing ever moved it. That was the only occurrence of the field in all of
src/, and storeEncryptedKeystore() serialises a fixed whitelist which does not
include it — so the key was dropped at persist time. Choosing largeBlob
produced a keystore that worked for exactly the session that created it and
could never be unlocked afterwards: the next load called
retrieveSKFromLargeBlob(), read a blob nobody had written, and failed with "No
largeBlob data found in credential".

Worth stating plainly, because the first suspicion was worse: the key never
reached disk in the clear. The whitelist that dropped it also kept it out of
localStorage. The feature was inert, not leaky.

writeSKToLargeBlob() now performs the assertion that stores it — a blob can
only be written during an assertion, never at registration, which is why
addLargeBlobToCredentialOptions() alone persists nothing and why this costs one
extra prompt. It throws unless the authenticator reports `written: true`.
Failing there beats persisting a record that cannot be opened, and it is the
same lesson as the rest of this branch: read what happened, do not trust what
was requested.

Tests: 5 new cases covering confirmed write, declined write, absent result, a
truthy-but-not-true value, and a refused assertion. 63 node tests pass.

One wrinkle worth recording: Node defines globalThis.navigator as a getter-only
accessor, so the obvious `globalThis.navigator = stub` throws
TypeError: Cannot set property navigator. The stub swaps the property
descriptor and restores the original afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
NiKrause added a commit that referenced this pull request Aug 16, 2026
A patch release: nothing here breaks. Credentials registered under 0.5.0 keep
working, the derived did:key is unchanged, and no peer has to move in step
with another.

The headline is the WebAuthn user handle (#45/#46). It was the UTF-8 of
whatever the caller passed as `userId`, so an authenticator — which keeps one
credential per (rp.id, user.id) and replaces it when both match — silently
destroyed the first passkey when a second person on the same device used the
same name, and the DID with it. It is 64 random bytes now; the typed value
stays as `user.name`, the label the picker shows.

Consumers get one behavioural difference worth naming in their own UI:
re-registering under a name used before adds a passkey instead of replacing
one. That is the point, but it means a picker can now show several, so the
labels want to be distinguishable.

Also in this release: the largeBlob keystore actually persists its secret key
(#43), the credential carries the authenticator's own answer on extension
support rather than the browser's guess (#43), and extension detection asks
`getClientCapabilities()` instead of probing a prototype that never had the
properties (#9/#42) — that last one had the encrypted-keystore demo disabling
its headline feature in every browser ever shipped.

67 node tests pass against the bumped version.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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.

WebAuthn demo fails: hmac-secret output missing + largeBlob unavailable when WebAuthnInterceptor popup closes

1 participant