Skip to content

Commit 51caa1b

Browse files
NiKrauseclaude
andauthored
fix: record what the authenticator agreed to, not just what the browser 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>
1 parent 3ac9241 commit 51caa1b

8 files changed

Lines changed: 319 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,41 @@
44

55
### Fixed
66

7+
- Actually write the secret key into `largeBlob`. The keystore record carried
8+
`secretKey: sk, // Will be moved to largeBlob` — and nothing ever moved it.
9+
That was the only occurrence of the field in `src/`, and
10+
`storeEncryptedKeystore()` serialises a fixed whitelist that does not include
11+
it, so the key was dropped at persist time. Choosing largeBlob therefore
12+
produced a keystore that worked for the session that created it and could
13+
never be unlocked again: on the next load `retrieveSKFromLargeBlob()` read a
14+
blob nobody had written and failed with "No largeBlob data found in
15+
credential".
16+
17+
The key never reached disk in the clear — the whitelist saw to that — but the
18+
feature was inert. `writeSKToLargeBlob()` now performs the assertion that
19+
stores it (a blob can only be written during an assertion, never at
20+
registration, so this costs one extra prompt) and **throws unless the
21+
authenticator reports `written: true`**. Failing there beats persisting a
22+
record that cannot be opened.
23+
24+
- Carry the authenticator's own answer on the credential. `createCredential()`
25+
now records `extensionSupport` — what the authenticator agreed to during the
26+
ceremony — because the raw `PublicKeyCredential` does not survive past that
27+
function, so the answer is read there or lost. Client support is not
28+
authenticator support: measured on a macOS platform authenticator in Brave,
29+
the client advertises `hmacCreateSecret` while the authenticator refuses it
30+
and PRF works. Reading only the client is what left the demo offering
31+
hmac-secret and then failing at the ceremony, which is the failure originally
32+
reported in #9.
33+
34+
The encrypted-keystore demo consumes it: methods the authenticator refused
35+
read "Browser yes, this passkey no" rather than "Supported", become
36+
unselectable, and a selection it cannot honour falls back automatically. It
37+
also no longer logs `encrypted: Yes (largeBlob)` off the back of the
38+
_requested_ options — that line reported intent as though it were outcome, so
39+
a failed write looked identical to a successful one. It now names the request
40+
and the authenticator's capability separately, so a mismatch is visible.
41+
742
- Detect WebAuthn extensions by asking the browser instead of inspecting a
843
prototype. `checkExtensionSupport()` tested
944
`'largeBlob' in PublicKeyCredential.prototype` — but extensions are never

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

Lines changed: 77 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,11 @@
6464
hmacSecret: false,
6565
known: false,
6666
};
67+
// What the authenticator agreed to during registration, which is a different
68+
// question from what the browser can negotiate. Null until a credential
69+
// exists. A platform authenticator commonly answers yes to PRF and no to
70+
// hmac-secret on a browser that advertises both.
71+
let credentialSupport = null;
6772
let useWorkerKeystore = false;
6873
let workerAvailable = false;
6974
let workerClient = null;
@@ -148,21 +153,79 @@
148153
};
149154
}
150155
151-
await initializeWebAuthn();
156+
// Client capabilities first, then the credential. The order matters:
157+
// initializeWebAuthn() loads a stored credential and lets its recorded
158+
// answer refine the choice, and checkEncryptionSupport() picks a method
159+
// from the browser's view alone — running it second would undo that.
152160
await checkEncryptionSupport();
161+
await initializeWebAuthn();
153162
});
154163
155164
onDestroy(() => {
156165
resetWorkerClient();
157166
});
158167
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';
168+
// The method values and the support-object keys do not spell the extension
169+
// the same way; keep the translation in one place rather than at each use.
170+
const SUPPORT_KEY = {
171+
prf: 'prf',
172+
largeBlob: 'largeBlob',
173+
'hmac-secret': 'hmacSecret',
174+
};
175+
const METHOD_PREFERENCE = ['prf', 'largeBlob', 'hmac-secret'];
176+
177+
// Four states. Once a credential exists its answer overrules the browser's:
178+
// the browser saying yes only means it would pass the request along.
179+
function supportLabel(name) {
180+
if (credentialSupport) {
181+
if (credentialSupport[name]) return '✅ Supported';
182+
if (extensionSupport[name]) return '⚠️ Browser yes, this passkey no';
183+
return '❌ Not supported';
184+
}
185+
if (extensionSupport[name]) return '✅ Supported';
163186
return extensionSupport.known ? '❌ Not supported' : '❓ Unknown';
164187
}
165188
189+
// May the method be offered at all? Before a credential exists we go by the
190+
// browser and keep anything it cannot vouch for selectable. Afterwards the
191+
// authenticator decides, because it is the one that has to deliver.
192+
function methodAvailable(method) {
193+
const name = SUPPORT_KEY[method];
194+
if (credentialSupport) return credentialSupport[name] === true;
195+
return !extensionSupport.known || extensionSupport[name] === true;
196+
}
197+
198+
/**
199+
* Adopt what the authenticator agreed to, and step off a method it refused.
200+
*
201+
* Without this the UI keeps promising a method the ceremony will not honour —
202+
* which is how "hmac-secret ✅ Supported" ends in "No hmac-secret output from
203+
* credential" (issue #9).
204+
*/
205+
function applyCredentialSupport(support) {
206+
if (!support) return;
207+
credentialSupport = support;
208+
console.log('Authenticator extension support:', support);
209+
210+
if (methodAvailable(encryptionMethod)) return;
211+
212+
const fallback = METHOD_PREFERENCE.find((method) =>
213+
methodAvailable(method)
214+
);
215+
216+
if (fallback) {
217+
console.warn(
218+
`Authenticator does not support ${encryptionMethod}; falling back to ${fallback}`
219+
);
220+
encryptionMethod = fallback;
221+
} else {
222+
console.warn(
223+
'Authenticator supports none of the encryption extensions; keystore encryption disabled'
224+
);
225+
useEncryption = false;
226+
}
227+
}
228+
166229
async function checkEncryptionSupport() {
167230
try {
168231
extensionSupport = await KeystoreEncryption.checkExtensionSupport();
@@ -211,6 +274,9 @@
211274
// Load stored credential
212275
credential = loadStoredCredential();
213276
if (credential) {
277+
// Credentials registered before this was recorded carry no answer, so
278+
// the browser's view stands until the next registration.
279+
applyCredentialSupport(credential.extensionSupport);
214280
status = 'Credential found, ready to authenticate!';
215281
}
216282
} catch (error) {
@@ -376,6 +442,9 @@
376442
keystoreEncryptionMethod: encryptionMethod,
377443
});
378444
445+
// The ceremony has now answered what the browser could only guess at.
446+
applyCredentialSupport(credential.extensionSupport);
447+
379448
// Store credential for future use
380449
storeCredential(credential);
381450
@@ -935,8 +1004,7 @@
9351004
type="radio"
9361005
bind:group={encryptionMethod}
9371006
value="prf"
938-
disabled={loading ||
939-
(extensionSupport.known && !extensionSupport.prf)}
1007+
disabled={loading || !methodAvailable('prf')}
9401008
style="cursor: pointer;"
9411009
/>
9421010
<span style="color: var(--cds-text-primary);">PRF</span>
@@ -953,8 +1021,7 @@
9531021
type="radio"
9541022
bind:group={encryptionMethod}
9551023
value="largeBlob"
956-
disabled={loading ||
957-
(extensionSupport.known && !extensionSupport.largeBlob)}
1024+
disabled={loading || !methodAvailable('largeBlob')}
9581025
style="cursor: pointer;"
9591026
/>
9601027
<span style="color: var(--cds-text-primary);">largeBlob</span>
@@ -971,8 +1038,7 @@
9711038
type="radio"
9721039
bind:group={encryptionMethod}
9731040
value="hmac-secret"
974-
disabled={loading ||
975-
(extensionSupport.known && !extensionSupport.hmacSecret)}
1041+
disabled={loading || !methodAvailable('hmac-secret')}
9761042
style="cursor: pointer;"
9771043
/>
9781044
<span style="color: var(--cds-text-primary);"

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

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -206,9 +206,15 @@ export async function setupOrbitDB(credential, options = {}) {
206206
didType: options.useKeystoreDID
207207
? `${options.keystoreKeyType} (from keystore)`
208208
: 'P-256 (from WebAuthn)',
209-
encrypted: options.encryptKeystore
210-
? `Yes (${options.encryptionMethod})`
211-
: 'No',
209+
// Deliberately worded as a request, not a result. This line used to read
210+
// "Yes (largeBlob)" whether or not the authenticator had honoured
211+
// largeBlob, which made a failed write look like a success in the console.
212+
// The second field is what the authenticator actually agreed to, so a
213+
// mismatch between the two is visible rather than hidden.
214+
encryptionRequested: options.encryptKeystore
215+
? options.encryptionMethod
216+
: 'none',
217+
authenticatorSupports: credential?.extensionSupport ?? 'not recorded',
212218
});
213219

214220
// Try to verify our identity is in the identities store

src/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,7 @@ export {
277277
decryptWithAESGCM,
278278
addLargeBlobToCredentialOptions,
279279
addPRFToCredentialOptions,
280+
writeSKToLargeBlob,
280281
retrieveSKFromLargeBlob,
281282
addHmacSecretToCredentialOptions,
282283
wrapSKWithPRF,

src/keystore/encryption.js

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,67 @@ export function addPRFToCredentialOptions(
213213
};
214214
}
215215

216+
/**
217+
* Write the secret key into the credential's largeBlob.
218+
*
219+
* A blob can only be written during an assertion, never at registration — so
220+
* this costs one extra WebAuthn prompt after the credential exists. That is
221+
* why `addLargeBlobToCredentialOptions()` alone does not persist anything.
222+
*
223+
* Throws unless the authenticator reports `written: true`. Silence here would
224+
* leave a keystore whose key exists only in memory: it works for the current
225+
* session and can never be unlocked again, because
226+
* {@link retrieveSKFromLargeBlob} would read a blob nobody wrote.
227+
*
228+
* @param {Uint8Array} credentialId - WebAuthn credential ID (raw bytes).
229+
* @param {Uint8Array} sk - Secret key to store.
230+
* @param {string} rpId - Relying party ID (domain).
231+
* @returns {Promise<void>}
232+
*/
233+
export async function writeSKToLargeBlob(credentialId, sk, rpId) {
234+
log('Writing secret key to largeBlob');
235+
236+
let assertion;
237+
try {
238+
assertion = await navigator.credentials.get(
239+
buildCredentialRequestOptions({
240+
challenge: crypto.getRandomValues(new Uint8Array(32)),
241+
credentialId,
242+
rpId,
243+
userVerification: 'required',
244+
extensions: {
245+
largeBlob: {
246+
write: sk,
247+
},
248+
},
249+
})
250+
);
251+
} catch (error) {
252+
log.error('largeBlob write assertion failed: %s', error.message);
253+
throw new KeystoreEncryptionError(
254+
`Failed to write secret key to largeBlob: ${error.message}`,
255+
{ cause: error }
256+
);
257+
}
258+
259+
// The authenticator answers whether it took the blob. Accepting the
260+
// assertion without reading this is how a write that never happened passes
261+
// for a success.
262+
const written = assertion?.getClientExtensionResults?.()?.largeBlob?.written;
263+
264+
if (written !== true) {
265+
log.error(
266+
'Authenticator did not write the largeBlob (written: %o)',
267+
written
268+
);
269+
throw new KeystoreEncryptionError(
270+
'Authenticator did not store the secret key in largeBlob'
271+
);
272+
}
273+
274+
log('Secret key written to largeBlob');
275+
}
276+
216277
/**
217278
* Retrieve secret key from WebAuthn credential using largeBlob extension
218279
* @param {Uint8Array} credentialId - WebAuthn credential ID
@@ -734,6 +795,7 @@ export default {
734795
encryptWithAESGCM,
735796
decryptWithAESGCM,
736797
addLargeBlobToCredentialOptions,
798+
writeSKToLargeBlob,
737799
retrieveSKFromLargeBlob,
738800
addHmacSecretToCredentialOptions,
739801
wrapSKWithHmacSecret,

src/keystore/provider.js

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -408,15 +408,24 @@ export class OrbitDBWebAuthnIdentityProvider {
408408
} else if (
409409
this.keystoreEncryptionMethod === KEYSTORE_ENCRYPTION_METHODS.LARGE_BLOB
410410
) {
411-
// For largeBlob, we need to store SK during next authentication
412-
// Store it temporarily for wrapping
411+
// Write the key into the authenticator before recording anything.
412+
// This used to be `secretKey: sk, // Will be moved to largeBlob` — and
413+
// nothing ever moved it: the field was dropped at serialization, so
414+
// the key survived only in memory and the keystore could never be
415+
// unlocked again. Throws if the authenticator refuses, which is better
416+
// than persisting a record that cannot be opened.
417+
await KeystoreEncryption.writeSKToLargeBlob(
418+
this.credential.rawCredentialId,
419+
sk,
420+
window.location.hostname
421+
);
422+
413423
encryptedData = {
414424
ciphertext,
415425
iv,
416426
credentialId: this.credential.credentialId,
417427
publicKey: publicKeyBytes,
418428
keyType,
419-
secretKey: sk, // Will be moved to largeBlob
420429
encryptionMethod: KEYSTORE_ENCRYPTION_METHODS.LARGE_BLOB,
421430
};
422431
} else if (

src/webauthn/provider.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,17 @@ export class WebAuthnDIDProvider {
228228
hasY: !!publicKey.y,
229229
});
230230

231+
// What the authenticator actually agreed to, as opposed to what the
232+
// browser said it could negotiate. Only the ceremony settles this, and
233+
// the raw PublicKeyCredential does not survive past this function — so
234+
// read it here or lose the answer. Callers that offer a choice of
235+
// keystore encryption method need it: a client can support largeBlob
236+
// while the key in front of it does not.
237+
const extensionSupport =
238+
KeystoreEncryption.extensionSupportFromCredential(credential);
239+
240+
webauthnLog('Authenticator extension support: %o', extensionSupport);
241+
231242
const result = {
232243
credentialId: WebAuthnDIDProvider.arrayBufferToBase64url(
233244
credential.rawId
@@ -240,6 +251,7 @@ export class WebAuthnDIDProvider {
240251
credential.response.attestationObject
241252
),
242253
prfInput: prfInput || undefined,
254+
extensionSupport,
243255
};
244256

245257
webauthnLog('Credential creation completed successfully');

0 commit comments

Comments
 (0)