fix: detect WebAuthn extensions by asking the browser - #42
Merged
Conversation
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
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>
This was referenced Aug 15, 2026
Merged
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>
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.
Closes #9.
The bug
checkExtensionSupport()asked'largeBlob' in PublicKeyCredential.prototype. Extensions are never properties of that interface — they arrive throughgetClientExtensionResults()— so the test answeredfalsein every browser ever shipped, including ones with complete support.hmacSecretsat beside it hard-coded tofalse.Together that permanently disabled keystore encryption: the demo's "Encrypt keystore with WebAuthn" checkbox is
disabledwhen 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:
in PublicKeyCredential.prototypegetClientCapabilities()largeBlobfalsetrueprffalsetruehmacCreateSecretfalsetrueProbing
appid,credBlobandcredPropsthe same way also returnsfalse, and the prototype carries onlyrawId,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 answeredfalseeverywhere, which took the working paths with it. The feature went quiet, not well. This restores it.Changes
checkExtensionSupport()readsgetClientCapabilities(), reportsprfalongside the other two, and returnsknown— 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 supportlargeBlobwhile 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.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
format:checkandlintcleanStill 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.