Skip to content

Commit cea648d

Browse files
NiKrauseclaude
andcommitted
fix: detect WebAuthn extensions by asking the browser (#9)
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>
1 parent 7d68161 commit cea648d

7 files changed

Lines changed: 418 additions & 39 deletions

File tree

CHANGELOG.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,47 @@
22

33
## Unreleased
44

5+
### Fixed
6+
7+
- Detect WebAuthn extensions by asking the browser instead of inspecting a
8+
prototype. `checkExtensionSupport()` tested
9+
`'largeBlob' in PublicKeyCredential.prototype` — but extensions are never
10+
properties of that interface, they arrive through
11+
`getClientExtensionResults()`. The test therefore returned `false` in every
12+
browser ever shipped, including those with complete support, and `hmacSecret`
13+
was hard-coded `false` beside it. Both together meant the encrypted-keystore
14+
demo disabled its own headline feature everywhere and could not be switched
15+
on at all.
16+
17+
Measured on Chrome 148: the prototype probe answered `false` for `largeBlob`,
18+
`prf` and `hmacCreateSecret` while `getClientCapabilities()` reported all
19+
three `true`. Every extension name behaves the same way, so no browser could
20+
ever have passed. Closes #9.
21+
22+
`checkExtensionSupport()` now reads `getClientCapabilities()` and reports
23+
`prf` alongside the other two. It also returns `known`, distinguishing "the
24+
browser says no" from "the browser cannot say" — callers must not treat the
25+
second as a refusal, or they disable a feature that may well work.
26+
27+
### Added
28+
29+
- `extensionSupportFromCredential(credential)` reads what the authenticator
30+
actually agreed to, from the registration response. This is the authoritative
31+
answer and the one worth persisting: a browser may support `largeBlob` while
32+
the security key in front of it does not, and only the ceremony settles that.
33+
`addLargeBlobToCredentialOptions()` takes an optional support level for the
34+
same reason — it stays `'required'` by default, because under `'preferred'`
35+
an unsupported authenticator yields a credential whose secret was silently
36+
never written.
37+
38+
### Changed
39+
40+
- The encrypted-keystore demo offers PRF as a keystore encryption method and
41+
prefers it, matching the library's own default since 0.4.0. It had hard-coded
42+
`largeBlob`, so it overrode that default and never exercised PRF at all.
43+
Methods the browser cannot vouch for are now labelled "Unknown" rather than
44+
"Not supported", and stay selectable.
45+
546
## 0.5.0
647

748
### Breaking

examples/ed25519-encrypted-keystore-demo/src/lib/WebAuthnTodo.svelte

Lines changed: 60 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,17 @@
5353
5454
// NEW: Encryption options
5555
let useEncryption = true; // Enable encryption by default
56-
let encryptionMethod = 'largeBlob'; // or 'hmac-secret'
56+
let encryptionMethod = 'prf'; // 'prf', 'largeBlob' or 'hmac-secret'
5757
let useKeystoreDID = true; // Use persistent DID from OrbitDB keystore (instead of WebAuthn P-256)
5858
let keystoreKeyType = 'Ed25519'; // Key type: 'secp256k1' or 'Ed25519' (default: Ed25519)
59-
let extensionSupport = { largeBlob: false, hmacSecret: false };
59+
// `known: false` means the browser could not tell us, which is not the same
60+
// as "unsupported" — see checkEncryptionSupport().
61+
let extensionSupport = {
62+
prf: false,
63+
largeBlob: false,
64+
hmacSecret: false,
65+
known: false,
66+
};
6067
let useWorkerKeystore = false;
6168
let workerAvailable = false;
6269
let workerClient = null;
@@ -149,19 +156,38 @@
149156
resetWorkerClient();
150157
});
151158
159+
// Three states, not two: an extension we know is missing reads differently
160+
// from one the browser would not tell us about.
161+
function supportLabel(flag) {
162+
if (flag) return '✅ Supported';
163+
return extensionSupport.known ? '❌ Not supported' : '❓ Unknown';
164+
}
165+
152166
async function checkEncryptionSupport() {
153167
try {
154168
extensionSupport = await KeystoreEncryption.checkExtensionSupport();
155169
console.log('Encryption extension support:', extensionSupport);
156170
157-
// Auto-select best encryption method
158-
if (extensionSupport.largeBlob) {
171+
// Auto-select best encryption method. PRF first: it is what the library
172+
// itself defaults to, and it derives the key from the authenticator
173+
// instead of storing a wrapped one.
174+
if (extensionSupport.prf) {
175+
encryptionMethod = 'prf';
176+
} else if (extensionSupport.largeBlob) {
159177
encryptionMethod = 'largeBlob';
160178
} else if (extensionSupport.hmacSecret) {
161179
encryptionMethod = 'hmac-secret';
162-
} else {
163-
useEncryption = false; // Disable if no support
180+
} else if (extensionSupport.known) {
181+
useEncryption = false; // The browser told us it supports none of them.
164182
console.warn('No encryption extensions supported');
183+
} else {
184+
// The browser has no getClientCapabilities(), so it cannot say in
185+
// advance. Attempting the ceremony is the only way to find out, and
186+
// it is better than refusing a feature that may well work.
187+
encryptionMethod = 'prf';
188+
console.warn(
189+
'Extension support could not be determined; attempting PRF anyway'
190+
);
165191
}
166192
} catch (error) {
167193
console.error('Failed to check encryption support:', error);
@@ -852,7 +878,10 @@
852878
type="checkbox"
853879
bind:checked={useEncryption}
854880
disabled={loading ||
855-
(!extensionSupport.largeBlob && !extensionSupport.hmacSecret)}
881+
(extensionSupport.known &&
882+
!extensionSupport.prf &&
883+
!extensionSupport.largeBlob &&
884+
!extensionSupport.hmacSecret)}
856885
style="cursor: pointer;"
857886
/>
858887
<span style="color: var(--cds-text-primary);"
@@ -899,23 +928,40 @@
899928
style="font-size: 0.875rem; font-weight: 500; color: var(--cds-text-primary);"
900929
>Encryption Method:</span
901930
>
931+
<label
932+
style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;"
933+
>
934+
<input
935+
type="radio"
936+
bind:group={encryptionMethod}
937+
value="prf"
938+
disabled={loading ||
939+
(extensionSupport.known && !extensionSupport.prf)}
940+
style="cursor: pointer;"
941+
/>
942+
<span style="color: var(--cds-text-primary);">PRF</span>
943+
<span
944+
style="font-size: 0.75rem; color: var(--cds-text-secondary);"
945+
>
946+
{supportLabel(extensionSupport.prf)}
947+
</span>
948+
</label>
902949
<label
903950
style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;"
904951
>
905952
<input
906953
type="radio"
907954
bind:group={encryptionMethod}
908955
value="largeBlob"
909-
disabled={loading || !extensionSupport.largeBlob}
956+
disabled={loading ||
957+
(extensionSupport.known && !extensionSupport.largeBlob)}
910958
style="cursor: pointer;"
911959
/>
912960
<span style="color: var(--cds-text-primary);">largeBlob</span>
913961
<span
914962
style="font-size: 0.75rem; color: var(--cds-text-secondary);"
915963
>
916-
{extensionSupport.largeBlob
917-
? '✅ Supported'
918-
: '❌ Not supported'}
964+
{supportLabel(extensionSupport.largeBlob)}
919965
</span>
920966
</label>
921967
<label
@@ -925,7 +971,8 @@
925971
type="radio"
926972
bind:group={encryptionMethod}
927973
value="hmac-secret"
928-
disabled={loading || !extensionSupport.hmacSecret}
974+
disabled={loading ||
975+
(extensionSupport.known && !extensionSupport.hmacSecret)}
929976
style="cursor: pointer;"
930977
/>
931978
<span style="color: var(--cds-text-primary);"
@@ -934,9 +981,7 @@
934981
<span
935982
style="font-size: 0.75rem; color: var(--cds-text-secondary);"
936983
>
937-
{extensionSupport.hmacSecret
938-
? '✅ Supported'
939-
: '❌ Not supported'}
984+
{supportLabel(extensionSupport.hmacSecret)}
940985
</span>
941986
</label>
942987
</div>

examples/ed25519-encrypted-keystore-demo/src/lib/libp2p.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ export async function createIdentitiesInstance() {
125125
* @param {boolean} options.useKeystoreDID - Use persistent DID from OrbitDB keystore (instead of WebAuthn P-256)
126126
* @param {string} options.keystoreKeyType - Key type: 'secp256k1' or 'Ed25519'
127127
* @param {boolean} options.encryptKeystore - Enable keystore encryption
128-
* @param {string} options.encryptionMethod - Encryption method ('largeBlob' or 'hmac-secret')
128+
* @param {string} options.encryptionMethod - Encryption method ('prf', 'largeBlob' or 'hmac-secret')
129129
*/
130130
export async function createWebAuthnIdentity(
131131
identities,
@@ -137,7 +137,9 @@ export async function createWebAuthnIdentity(
137137
useKeystoreDID = false,
138138
keystoreKeyType = 'secp256k1',
139139
encryptKeystore = false,
140-
encryptionMethod = 'largeBlob',
140+
// Matches the library's own default since 0.4.0. Hard-coding 'largeBlob'
141+
// here meant the demo silently overrode it and never exercised PRF.
142+
encryptionMethod = 'prf',
141143
} = options;
142144

143145
return await identities.createIdentity({

playwright.node.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export default defineConfig({
1818
testMatch: [
1919
'standalone-toolkit.test.js',
2020
'webauthn-debug-log.test.js',
21+
'webauthn-extension-support.test.js',
2122
'webauthn-varsig-challenge.test.js',
2223
'webauthn-attestation-parsing.test.js',
2324
'webauthn-two-peer-replication.test.js',

src/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,7 @@ export {
287287
loadEncryptedKeystore,
288288
clearEncryptedKeystore,
289289
checkExtensionSupport,
290+
extensionSupportFromCredential,
290291
} from './keystore/encryption.js';
291292

292293
export default {

src/keystore/encryption.js

Lines changed: 97 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -150,19 +150,37 @@ export async function decryptWithAESGCM(ciphertext, sk, iv) {
150150

151151
/**
152152
* Store secret key in WebAuthn credential using largeBlob extension
153+
*
154+
* Defaults to `support: 'required'` so an authenticator that cannot hold a
155+
* blob fails the ceremony outright. That is the safe default here: under
156+
* `'preferred'` the credential is created successfully and the secret is
157+
* simply never written, which loses the keystore silently.
158+
*
159+
* Pass `'preferred'` only when the caller reads the outcome back with
160+
* {@link extensionSupportFromCredential} and handles an unsupported
161+
* authenticator itself — probing for capability, for instance.
162+
*
153163
* @param {Object} credentialOptions - WebAuthn credential creation options
154164
* @param {Uint8Array} sk - Secret key to store
155-
* @returns {Promise<Object>} Enhanced credential options with largeBlob
165+
* @param {'required'|'preferred'} [support] - largeBlob support level
166+
* @returns {Object} Enhanced credential options with largeBlob
156167
*/
157-
export function addLargeBlobToCredentialOptions(credentialOptions, sk) {
158-
log('Adding largeBlob extension to credential options');
168+
export function addLargeBlobToCredentialOptions(
169+
credentialOptions,
170+
sk,
171+
support = 'required'
172+
) {
173+
log(
174+
'Adding largeBlob extension to credential options (support: %s)',
175+
support
176+
);
159177

160178
return {
161179
...credentialOptions,
162180
extensions: {
163181
...credentialOptions.extensions,
164182
largeBlob: {
165-
support: 'required',
183+
support,
166184
write: sk,
167185
},
168186
},
@@ -620,39 +638,95 @@ export async function clearEncryptedKeystore(credentialId) {
620638
}
621639

622640
/**
623-
* Check if browser supports WebAuthn extensions
624-
* @returns {Promise<Object>} Support status for largeBlob and hmac-secret
641+
* Which WebAuthn extensions is this *client* willing to negotiate?
642+
*
643+
* Answers one question only: would the browser pass the extension through. It
644+
* cannot tell you whether the authenticator behind it will honour the request —
645+
* that is settled during a ceremony, by
646+
* {@link extensionSupportFromCredential}, and belongs to the credential rather
647+
* than to the browser.
648+
*
649+
* `known: false` means the browser is too old to say. That is **not** the same
650+
* as unsupported: callers should attempt the ceremony and let its result
651+
* decide, rather than disabling the feature outright.
652+
*
653+
* @returns {Promise<{largeBlob: boolean, prf: boolean, hmacSecret: boolean, known: boolean}>}
625654
*/
626655
export async function checkExtensionSupport() {
627656
const support = {
628657
largeBlob: false,
658+
prf: false,
629659
hmacSecret: false,
660+
known: false,
630661
};
631662

632-
if (!window.PublicKeyCredential) {
663+
if (typeof globalThis.PublicKeyCredential !== 'function') {
633664
return support;
634665
}
635666

636-
try {
637-
// Check largeBlob support
638-
if (
639-
window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable
640-
) {
641-
const available =
642-
await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
643-
// largeBlob is available in Chrome 106+, Edge 106+
644-
support.largeBlob =
645-
available && 'largeBlob' in PublicKeyCredential.prototype;
646-
}
667+
// getClientCapabilities is the only pre-flight answer that means anything.
668+
// The previous check asked `'largeBlob' in PublicKeyCredential.prototype`,
669+
// which tests whether the extension is a property of the credential
670+
// interface. Extensions never are — they arrive through
671+
// getClientExtensionResults() — so it returned false in every browser ever
672+
// shipped, including those with complete support, and silently disabled
673+
// keystore encryption everywhere.
674+
if (typeof PublicKeyCredential.getClientCapabilities !== 'function') {
675+
log('getClientCapabilities unavailable; extension support is unknown');
676+
return support;
677+
}
647678

648-
// hmac-secret support cannot be reliably detected without a real credential.
649-
// Keep it false by default and let users opt-in explicitly.
650-
support.hmacSecret = false;
679+
try {
680+
const capabilities = await PublicKeyCredential.getClientCapabilities();
681+
return {
682+
largeBlob: capabilities['extension:largeBlob'] === true,
683+
prf: capabilities['extension:prf'] === true,
684+
hmacSecret: capabilities['extension:hmacCreateSecret'] === true,
685+
known: true,
686+
};
651687
} catch (error) {
652688
log.error('Failed to check extension support: %s', error.message);
689+
return support;
690+
}
691+
}
692+
693+
/**
694+
* What did the authenticator actually agree to, for this credential?
695+
*
696+
* The authoritative answer, and the only one worth persisting: a browser may
697+
* support largeBlob while the security key in front of it does not. Read this
698+
* from the registration response and store it next to the credential.
699+
*
700+
* Requires the ceremony to have asked with `support: 'preferred'` — see
701+
* {@link addLargeBlobToCredentialOptions}. Asking with `'required'` makes
702+
* creation fail outright instead of reporting back.
703+
*
704+
* @param {PublicKeyCredential} credential - The result of navigator.credentials.create().
705+
* @returns {{largeBlob: boolean, prf: boolean, hmacSecret: boolean}}
706+
*/
707+
export function extensionSupportFromCredential(credential) {
708+
const support = { largeBlob: false, prf: false, hmacSecret: false };
709+
710+
if (typeof credential?.getClientExtensionResults !== 'function') {
711+
return support;
653712
}
654713

655-
return support;
714+
try {
715+
const results = credential.getClientExtensionResults() ?? {};
716+
return {
717+
largeBlob: results.largeBlob?.supported === true,
718+
// PRF reports either an explicit `enabled` flag or, when the ceremony
719+
// evaluated a salt straight away, the results themselves.
720+
prf: results.prf?.enabled === true || results.prf?.results != null,
721+
hmacSecret: results.hmacCreateSecret === true,
722+
};
723+
} catch (error) {
724+
log.error(
725+
'Failed to read extension results from credential: %s',
726+
error.message
727+
);
728+
return support;
729+
}
656730
}
657731

658732
export default {
@@ -668,4 +742,5 @@ export default {
668742
loadEncryptedKeystore,
669743
clearEncryptedKeystore,
670744
checkExtensionSupport,
745+
extensionSupportFromCredential,
671746
};

0 commit comments

Comments
 (0)