@le-space/orbitdb-identity-provider-webauthn-did 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,115 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.5.1
6
+
7
+ ### Fixed
8
+
9
+ - The WebAuthn user handle is 64 random bytes instead of the UTF-8 of whatever
10
+ the caller passes as `userId`. An authenticator keeps one discoverable
11
+ credential per `(rp.id, user.id)` and **replaces** the previous one when both
12
+ match — silently, with no prompt and nothing to undo. Deriving the handle from
13
+ a typed name therefore meant two people registering as "anna" on a shared
14
+ device destroyed each other's passkey, and with it the DID and every entry
15
+ signed under it. The same happened to one person re-entering their usual name
16
+ after clearing storage. WebAuthn L2 §5.4.3 also forbids putting personally
17
+ identifying information in the handle, which a name or e-mail plainly is, and
18
+ the handle is stored in the authenticator indefinitely.
19
+
20
+ `userId` keeps its rightful place as `user.name`, the label the credential
21
+ picker shows. It is a label only: it identifies nothing, two credentials may
22
+ carry the same one, and nothing in this package looks a credential up by it.
23
+ Closes #45.
24
+
25
+ Nothing needs migrating. Recovery goes through discoverable credentials
26
+ (`readLargeBlobMetadata` calls `get()` with no `allowCredentials`), or through
27
+ an explicit credential ID where those are switched off — no path resolves a
28
+ credential by handle, so credentials registered under the old scheme keep
29
+ working untouched. The derived `did:key` comes from the credential's public
30
+ key and does not change.
31
+
32
+ Consumers should expect a behavioural difference: re-registering under a name
33
+ that was used before now **adds** a passkey instead of replacing one. That is
34
+ the point — a replaced passkey is data loss, a second entry is a choice — but
35
+ it means the picker can show several, so `user.name` and `displayName` should
36
+ be distinguishing enough to pick from. The new random handle is returned as
37
+ `credential.userHandle` (base64url); the authenticator keeps its own copy, so
38
+ storing it is optional.
39
+
40
+ - Actually write the secret key into `largeBlob`. The keystore record carried
41
+ `secretKey: sk, // Will be moved to largeBlob` — and nothing ever moved it.
42
+ That was the only occurrence of the field in `src/`, and
43
+ `storeEncryptedKeystore()` serialises a fixed whitelist that does not include
44
+ it, so the key was dropped at persist time. Choosing largeBlob therefore
45
+ produced a keystore that worked for the session that created it and could
46
+ never be unlocked again: on the next load `retrieveSKFromLargeBlob()` read a
47
+ blob nobody had written and failed with "No largeBlob data found in
48
+ credential".
49
+
50
+ The key never reached disk in the clear — the whitelist saw to that — but the
51
+ feature was inert. `writeSKToLargeBlob()` now performs the assertion that
52
+ stores it (a blob can only be written during an assertion, never at
53
+ registration, so this costs one extra prompt) and **throws unless the
54
+ authenticator reports `written: true`**. Failing there beats persisting a
55
+ record that cannot be opened.
56
+
57
+ - Carry the authenticator's own answer on the credential. `createCredential()`
58
+ now records `extensionSupport` — what the authenticator agreed to during the
59
+ ceremony — because the raw `PublicKeyCredential` does not survive past that
60
+ function, so the answer is read there or lost. Client support is not
61
+ authenticator support: measured on a macOS platform authenticator in Brave,
62
+ the client advertises `hmacCreateSecret` while the authenticator refuses it
63
+ and PRF works. Reading only the client is what left the demo offering
64
+ hmac-secret and then failing at the ceremony, which is the failure originally
65
+ reported in #9.
66
+
67
+ The encrypted-keystore demo consumes it: methods the authenticator refused
68
+ read "Browser yes, this passkey no" rather than "Supported", become
69
+ unselectable, and a selection it cannot honour falls back automatically. It
70
+ also no longer logs `encrypted: Yes (largeBlob)` off the back of the
71
+ _requested_ options — that line reported intent as though it were outcome, so
72
+ a failed write looked identical to a successful one. It now names the request
73
+ and the authenticator's capability separately, so a mismatch is visible.
74
+
75
+ - Detect WebAuthn extensions by asking the browser instead of inspecting a
76
+ prototype. `checkExtensionSupport()` tested
77
+ `'largeBlob' in PublicKeyCredential.prototype` — but extensions are never
78
+ properties of that interface, they arrive through
79
+ `getClientExtensionResults()`. The test therefore returned `false` in every
80
+ browser ever shipped, including those with complete support, and `hmacSecret`
81
+ was hard-coded `false` beside it. Both together meant the encrypted-keystore
82
+ demo disabled its own headline feature everywhere and could not be switched
83
+ on at all.
84
+
85
+ Measured on Chrome 148: the prototype probe answered `false` for `largeBlob`,
86
+ `prf` and `hmacCreateSecret` while `getClientCapabilities()` reported all
87
+ three `true`. Every extension name behaves the same way, so no browser could
88
+ ever have passed. Closes #9.
89
+
90
+ `checkExtensionSupport()` now reads `getClientCapabilities()` and reports
91
+ `prf` alongside the other two. It also returns `known`, distinguishing "the
92
+ browser says no" from "the browser cannot say" — callers must not treat the
93
+ second as a refusal, or they disable a feature that may well work.
94
+
95
+ ### Added
96
+
97
+ - `extensionSupportFromCredential(credential)` reads what the authenticator
98
+ actually agreed to, from the registration response. This is the authoritative
99
+ answer and the one worth persisting: a browser may support `largeBlob` while
100
+ the security key in front of it does not, and only the ceremony settles that.
101
+ `addLargeBlobToCredentialOptions()` takes an optional support level for the
102
+ same reason — it stays `'required'` by default, because under `'preferred'`
103
+ an unsupported authenticator yields a credential whose secret was silently
104
+ never written.
105
+
106
+ ### Changed
107
+
108
+ - The encrypted-keystore demo offers PRF as a keystore encryption method and
109
+ prefers it, matching the library's own default since 0.4.0. It had hard-coded
110
+ `largeBlob`, so it overrode that default and never exercised PRF at all.
111
+ Methods the browser cannot vouch for are now labelled "Unknown" rather than
112
+ "Not supported", and stay selectable.
113
+
5
114
  ## 0.5.0
6
115
 
7
116
  ### Breaking
package/README.md CHANGED
@@ -290,6 +290,27 @@ Mermaid sequences for scripts:
290
290
  - `SECURITY.md`
291
291
  - `CODE_OF_CONDUCT.md`
292
292
 
293
+ ## Cross-Project Verification
294
+
295
+ This repo's own suite covers the units and runs two OrbitDB peers in-process. The strongest evidence that a release actually works, though, comes from outside it: the replication mode matrix in [`orbitdb-relay`](https://github.com/NiKrause/orbitdb-relay), at `mocha/relay-replication-mode-matrix.mjs`.
296
+
297
+ It drives six alice/bob pairings through a real relay:
298
+
299
+ | Mode | Pairing |
300
+ | --------------------------------------------- | ------------------------------------------ |
301
+ | `alice-worker-bob-hardware-ed25519` | worker keystore ↔ hardware Ed25519 |
302
+ | `alice-worker-bob-hardware-p256` | worker keystore ↔ hardware P-256 |
303
+ | `alice-hardware-ed25519-bob-hardware-p256` | the two hardware curves against each other |
304
+ | `alice-worker-ed25519-bob-worker-ed25519` | worker keystore, both sides |
305
+ | `alice-hardware-ed25519-bob-hardware-ed25519` | hardware Ed25519, both sides |
306
+ | `alice-hardware-p256-bob-hardware-p256` | hardware P-256, both sides |
307
+
308
+ Three things make it worth more than an in-repo test. It runs **out of process and across repos**, against the published package rather than the working tree. It exercises **identity verification between two distinct peers** — different keystores, different identity documents — instead of one instance talking to itself. And the relay **verifies identities itself**, so a third independent verifier sits in the path.
309
+
310
+ That is exactly the surface a change to the signing format breaks. The 0.5.0 signing-context change was validated here before the consumers moved: all six pairings replicate under the new format.
311
+
312
+ If you change anything that touches signing, identity documents or verification, run that matrix — not just this repo's tests.
313
+
293
314
  ## Identity Recovery Summary
294
315
 
295
316
  Current identity recovery behavior in this repo:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@le-space/orbitdb-identity-provider-webauthn-did",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "WebAuthn-based DID identity provider for OrbitDB for hardware-secured wallets and biometric Passkey authentication",
5
5
  "main": "src/index.js",
6
6
  "types": "types/index.d.ts",
package/src/index.js CHANGED
@@ -277,6 +277,7 @@ export {
277
277
  decryptWithAESGCM,
278
278
  addLargeBlobToCredentialOptions,
279
279
  addPRFToCredentialOptions,
280
+ writeSKToLargeBlob,
280
281
  retrieveSKFromLargeBlob,
281
282
  addHmacSecretToCredentialOptions,
282
283
  wrapSKWithPRF,
@@ -287,6 +288,7 @@ export {
287
288
  loadEncryptedKeystore,
288
289
  clearEncryptedKeystore,
289
290
  checkExtensionSupport,
291
+ extensionSupportFromCredential,
290
292
  } from './keystore/encryption.js';
291
293
 
292
294
  export default {
@@ -150,19 +150,37 @@ export async function decryptWithAESGCM(ciphertext, sk, iv) {
150
150
 
151
151
  /**
152
152
  * 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
+ *
153
163
  * @param {Object} credentialOptions - WebAuthn credential creation options
154
164
  * @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
156
167
  */
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
+ );
159
177
 
160
178
  return {
161
179
  ...credentialOptions,
162
180
  extensions: {
163
181
  ...credentialOptions.extensions,
164
182
  largeBlob: {
165
- support: 'required',
183
+ support,
166
184
  write: sk,
167
185
  },
168
186
  },
@@ -195,6 +213,67 @@ export function addPRFToCredentialOptions(
195
213
  };
196
214
  }
197
215
 
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
+
198
277
  /**
199
278
  * Retrieve secret key from WebAuthn credential using largeBlob extension
200
279
  * @param {Uint8Array} credentialId - WebAuthn credential ID
@@ -620,39 +699,95 @@ export async function clearEncryptedKeystore(credentialId) {
620
699
  }
621
700
 
622
701
  /**
623
- * Check if browser supports WebAuthn extensions
624
- * @returns {Promise<Object>} Support status for largeBlob and hmac-secret
702
+ * Which WebAuthn extensions is this *client* willing to negotiate?
703
+ *
704
+ * Answers one question only: would the browser pass the extension through. It
705
+ * cannot tell you whether the authenticator behind it will honour the request —
706
+ * that is settled during a ceremony, by
707
+ * {@link extensionSupportFromCredential}, and belongs to the credential rather
708
+ * than to the browser.
709
+ *
710
+ * `known: false` means the browser is too old to say. That is **not** the same
711
+ * as unsupported: callers should attempt the ceremony and let its result
712
+ * decide, rather than disabling the feature outright.
713
+ *
714
+ * @returns {Promise<{largeBlob: boolean, prf: boolean, hmacSecret: boolean, known: boolean}>}
625
715
  */
626
716
  export async function checkExtensionSupport() {
627
717
  const support = {
628
718
  largeBlob: false,
719
+ prf: false,
629
720
  hmacSecret: false,
721
+ known: false,
630
722
  };
631
723
 
632
- if (!window.PublicKeyCredential) {
724
+ if (typeof globalThis.PublicKeyCredential !== 'function') {
633
725
  return support;
634
726
  }
635
727
 
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
- }
728
+ // getClientCapabilities is the only pre-flight answer that means anything.
729
+ // The previous check asked `'largeBlob' in PublicKeyCredential.prototype`,
730
+ // which tests whether the extension is a property of the credential
731
+ // interface. Extensions never are — they arrive through
732
+ // getClientExtensionResults() — so it returned false in every browser ever
733
+ // shipped, including those with complete support, and silently disabled
734
+ // keystore encryption everywhere.
735
+ if (typeof PublicKeyCredential.getClientCapabilities !== 'function') {
736
+ log('getClientCapabilities unavailable; extension support is unknown');
737
+ return support;
738
+ }
647
739
 
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;
740
+ try {
741
+ const capabilities = await PublicKeyCredential.getClientCapabilities();
742
+ return {
743
+ largeBlob: capabilities['extension:largeBlob'] === true,
744
+ prf: capabilities['extension:prf'] === true,
745
+ hmacSecret: capabilities['extension:hmacCreateSecret'] === true,
746
+ known: true,
747
+ };
651
748
  } catch (error) {
652
749
  log.error('Failed to check extension support: %s', error.message);
750
+ return support;
751
+ }
752
+ }
753
+
754
+ /**
755
+ * What did the authenticator actually agree to, for this credential?
756
+ *
757
+ * The authoritative answer, and the only one worth persisting: a browser may
758
+ * support largeBlob while the security key in front of it does not. Read this
759
+ * from the registration response and store it next to the credential.
760
+ *
761
+ * Requires the ceremony to have asked with `support: 'preferred'` — see
762
+ * {@link addLargeBlobToCredentialOptions}. Asking with `'required'` makes
763
+ * creation fail outright instead of reporting back.
764
+ *
765
+ * @param {PublicKeyCredential} credential - The result of navigator.credentials.create().
766
+ * @returns {{largeBlob: boolean, prf: boolean, hmacSecret: boolean}}
767
+ */
768
+ export function extensionSupportFromCredential(credential) {
769
+ const support = { largeBlob: false, prf: false, hmacSecret: false };
770
+
771
+ if (typeof credential?.getClientExtensionResults !== 'function') {
772
+ return support;
653
773
  }
654
774
 
655
- return support;
775
+ try {
776
+ const results = credential.getClientExtensionResults() ?? {};
777
+ return {
778
+ largeBlob: results.largeBlob?.supported === true,
779
+ // PRF reports either an explicit `enabled` flag or, when the ceremony
780
+ // evaluated a salt straight away, the results themselves.
781
+ prf: results.prf?.enabled === true || results.prf?.results != null,
782
+ hmacSecret: results.hmacCreateSecret === true,
783
+ };
784
+ } catch (error) {
785
+ log.error(
786
+ 'Failed to read extension results from credential: %s',
787
+ error.message
788
+ );
789
+ return support;
790
+ }
656
791
  }
657
792
 
658
793
  export default {
@@ -660,6 +795,7 @@ export default {
660
795
  encryptWithAESGCM,
661
796
  decryptWithAESGCM,
662
797
  addLargeBlobToCredentialOptions,
798
+ writeSKToLargeBlob,
663
799
  retrieveSKFromLargeBlob,
664
800
  addHmacSecretToCredentialOptions,
665
801
  wrapSKWithHmacSecret,
@@ -668,4 +804,5 @@ export default {
668
804
  loadEncryptedKeystore,
669
805
  clearEncryptedKeystore,
670
806
  checkExtensionSupport,
807
+ extensionSupportFromCredential,
671
808
  };
@@ -408,15 +408,24 @@ export class OrbitDBWebAuthnIdentityProvider {
408
408
  } else if (
409
409
  this.keystoreEncryptionMethod === KEYSTORE_ENCRYPTION_METHODS.LARGE_BLOB
410
410
  ) {
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
+
413
423
  encryptedData = {
414
424
  ciphertext,
415
425
  iv,
416
426
  credentialId: this.credential.credentialId,
417
427
  publicKey: publicKeyBytes,
418
428
  keyType,
419
- secretKey: sk, // Will be moved to largeBlob
420
429
  encryptionMethod: KEYSTORE_ENCRYPTION_METHODS.LARGE_BLOB,
421
430
  };
422
431
  } else if (
@@ -82,7 +82,11 @@ export class WebAuthnDIDProvider {
82
82
  * Create a WebAuthn credential for OrbitDB identity
83
83
  * This triggers biometric authentication (Face ID, Touch ID, Windows Hello, etc.)
84
84
  * @param {Object} options - Credential options
85
- * @param {string} options.userId - User ID
85
+ * @param {string} options.userId - Account label shown in the credential
86
+ * picker (`user.name`). A label only: it does not identify the credential,
87
+ * two credentials may carry the same one, and it is never used to look one
88
+ * up. The handle the authenticator files the credential under is generated
89
+ * here and returned as `userHandle`.
86
90
  * @param {string} options.displayName - Display name
87
91
  * @param {string} options.domain - Domain/RP ID
88
92
  * @param {boolean} options.encryptKeystore - Enable keystore encryption
@@ -117,7 +121,19 @@ export class WebAuthnDIDProvider {
117
121
 
118
122
  // Generate challenge for credential creation
119
123
  const challenge = crypto.getRandomValues(new Uint8Array(32));
120
- const userIdBytes = new TextEncoder().encode(userId);
124
+
125
+ // The user handle is an opaque key, not a label. An authenticator stores
126
+ // one discoverable credential per (rp.id, user.id) pair and *replaces* the
127
+ // previous one when both match — silently, with no prompt and nothing to
128
+ // undo. Deriving the handle from a typed name therefore meant two people
129
+ // registering as "anna" on a shared device destroyed each other's passkey,
130
+ // and with it the DID and everything written under it (#45).
131
+ //
132
+ // 64 random bytes, as WebAuthn L2 §5.4.3 recommends. The same section
133
+ // forbids putting personally identifying information here, which a typed
134
+ // name or e-mail address plainly is. The name keeps its rightful place in
135
+ // `user.name` below, where the credential picker shows it.
136
+ const userHandle = crypto.getRandomValues(new Uint8Array(64));
121
137
 
122
138
  webauthnLog('Calling navigator.credentials.create() for user: %s', userId);
123
139
 
@@ -130,7 +146,7 @@ export class WebAuthnDIDProvider {
130
146
  id: domain,
131
147
  },
132
148
  user: {
133
- id: userIdBytes,
149
+ id: userHandle,
134
150
  name: userId,
135
151
  displayName,
136
152
  },
@@ -228,6 +244,17 @@ export class WebAuthnDIDProvider {
228
244
  hasY: !!publicKey.y,
229
245
  });
230
246
 
247
+ // What the authenticator actually agreed to, as opposed to what the
248
+ // browser said it could negotiate. Only the ceremony settles this, and
249
+ // the raw PublicKeyCredential does not survive past this function — so
250
+ // read it here or lose the answer. Callers that offer a choice of
251
+ // keystore encryption method need it: a client can support largeBlob
252
+ // while the key in front of it does not.
253
+ const extensionSupport =
254
+ KeystoreEncryption.extensionSupportFromCredential(credential);
255
+
256
+ webauthnLog('Authenticator extension support: %o', extensionSupport);
257
+
231
258
  const result = {
232
259
  credentialId: WebAuthnDIDProvider.arrayBufferToBase64url(
233
260
  credential.rawId
@@ -236,10 +263,18 @@ export class WebAuthnDIDProvider {
236
263
  publicKey,
237
264
  userId,
238
265
  displayName,
266
+ // Nothing in this package looks a credential up by handle — recovery
267
+ // goes through discoverable credentials, or through an explicit
268
+ // credential ID when those are switched off. It is surfaced anyway
269
+ // because it is the only copy the caller will ever see, and a flow
270
+ // that one day passes `allowCredentials` needs it. Decode with
271
+ // `WebAuthnDIDProvider.base64urlToArrayBuffer()`.
272
+ userHandle: WebAuthnDIDProvider.arrayBufferToBase64url(userHandle),
239
273
  attestationObject: new Uint8Array(
240
274
  credential.response.attestationObject
241
275
  ),
242
276
  prfInput: prfInput || undefined,
277
+ extensionSupport,
243
278
  };
244
279
 
245
280
  webauthnLog('Credential creation completed successfully');
package/types/index.d.ts CHANGED
@@ -91,6 +91,8 @@ export interface WebAuthnCredentialInfo {
91
91
  publicKey: WebAuthnPublicKey | Uint8Array;
92
92
  userId?: string;
93
93
  displayName?: string;
94
+ /** Random 64-byte WebAuthn user handle, base64url. Not derived from userId. */
95
+ userHandle?: string;
94
96
  attestationObject?: Uint8Array;
95
97
  prfInput?: Uint8Array;
96
98
  did?: string;
@@ -99,6 +101,7 @@ export interface WebAuthnCredentialInfo {
99
101
  }
100
102
 
101
103
  export interface CreateCredentialOptions {
104
+ /** Account label for the credential picker. Not an identifier. */
102
105
  userId?: string;
103
106
  displayName?: string;
104
107
  domain?: string;