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

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,161 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.5.2
6
+
7
+ ### Security
8
+
9
+ - **Identity verification checked shapes, not signatures**
10
+ ([GHSA-326j-4cc3-4rrg](https://github.com/Le-Space/orbitdb-identity-provider-webauthn-did/security/advisories/GHSA-326j-4cc3-4rrg)).
11
+ Four checks that decide whether an identity may speak for a DID verified
12
+ nothing: the static `verifyIdentity` OrbitDB calls accepted any `did:key:` of
13
+ type `webauthn`; `WebAuthnDIDProvider.verify()` accepted any proof with the
14
+ right fields; varsig verification returned `true` whenever
15
+ `window.__PLAYWRIGHT__` was set; and `verifyVarsigIdentity` never compared the
16
+ id with the key signing for it. Anyone who knew a DID could write as it
17
+ wherever write access was granted to that DID.
18
+
19
+ `src/webauthn/proof-verification.js` now decides it. A credential DID (P-256)
20
+ is bound through the WebAuthn assertion in `signatures.publicKey`, verified
21
+ against the key the DID itself encodes. A keystore DID (Ed25519, secp256k1)
22
+ must be exactly the key that signs. A varsig identity must carry the DID of
23
+ its own key. The test-mode branches are gone from `src/`.
24
+
25
+ **Check before upgrading** — three things that were accepted and now are not:
26
+ - Entries written under `useKeystoreDID` by 0.5.1 or earlier. Those identities
27
+ signed with a key unrelated to their DID (see _Fixed_), which makes their
28
+ entries indistinguishable from forgeries, so upgraded peers refuse them.
29
+ Identities with the default credential DID are unaffected.
30
+ - Legacy 64-hex identity ids. They cannot be bound to any key.
31
+ - `WebAuthnDIDProvider.verify(signature)` with one argument. It now needs the
32
+ signed data and the public key: `verify(signature, data, publicKey)`.
33
+
34
+ ### Fixed
35
+
36
+ - `useKeystoreDID` with `keystoreKeyType: 'Ed25519'` signed entries with a
37
+ secp256k1 key. The provider stored its key under the credential's P-256 DID;
38
+ OrbitDB looked it up under the keystore DID, found nothing and generated a
39
+ fresh secp256k1 key, so only the DID was Ed25519. The key now goes under the
40
+ DID OrbitDB looks it up by, encrypted and unencrypted.
41
+ - The Ed25519 demo kept `prfInput` across a reload as a plain object, which the
42
+ browser refused as a PRF input; the worker's seed then fell back to the
43
+ credential ID and its archive could no longer be opened.
44
+
45
+ ### Tests
46
+
47
+ - `tests/identity-forgery.test.js`: every forgery above is refused, and beside
48
+ each the genuine identity still passes. The browser suites use a Chromium
49
+ virtual authenticator instead of mocks that signed with 64 zero bytes.
50
+
51
+ ## 0.5.1
52
+
53
+ ### Fixed
54
+
55
+ - The WebAuthn user handle is 64 random bytes instead of the UTF-8 of whatever
56
+ the caller passes as `userId`. An authenticator keeps one discoverable
57
+ credential per `(rp.id, user.id)` and **replaces** the previous one when both
58
+ match — silently, with no prompt and nothing to undo. Deriving the handle from
59
+ a typed name therefore meant two people registering as "anna" on a shared
60
+ device destroyed each other's passkey, and with it the DID and every entry
61
+ signed under it. The same happened to one person re-entering their usual name
62
+ after clearing storage. WebAuthn L2 §5.4.3 also forbids putting personally
63
+ identifying information in the handle, which a name or e-mail plainly is, and
64
+ the handle is stored in the authenticator indefinitely.
65
+
66
+ `userId` keeps its rightful place as `user.name`, the label the credential
67
+ picker shows. It is a label only: it identifies nothing, two credentials may
68
+ carry the same one, and nothing in this package looks a credential up by it.
69
+ Closes #45.
70
+
71
+ Nothing needs migrating. Recovery goes through discoverable credentials
72
+ (`readLargeBlobMetadata` calls `get()` with no `allowCredentials`), or through
73
+ an explicit credential ID where those are switched off — no path resolves a
74
+ credential by handle, so credentials registered under the old scheme keep
75
+ working untouched. The derived `did:key` comes from the credential's public
76
+ key and does not change.
77
+
78
+ Consumers should expect a behavioural difference: re-registering under a name
79
+ that was used before now **adds** a passkey instead of replacing one. That is
80
+ the point — a replaced passkey is data loss, a second entry is a choice — but
81
+ it means the picker can show several, so `user.name` and `displayName` should
82
+ be distinguishing enough to pick from. The new random handle is returned as
83
+ `credential.userHandle` (base64url); the authenticator keeps its own copy, so
84
+ storing it is optional.
85
+
86
+ - Actually write the secret key into `largeBlob`. The keystore record carried
87
+ `secretKey: sk, // Will be moved to largeBlob` — and nothing ever moved it.
88
+ That was the only occurrence of the field in `src/`, and
89
+ `storeEncryptedKeystore()` serialises a fixed whitelist that does not include
90
+ it, so the key was dropped at persist time. Choosing largeBlob therefore
91
+ produced a keystore that worked for the session that created it and could
92
+ never be unlocked again: on the next load `retrieveSKFromLargeBlob()` read a
93
+ blob nobody had written and failed with "No largeBlob data found in
94
+ credential".
95
+
96
+ The key never reached disk in the clear — the whitelist saw to that — but the
97
+ feature was inert. `writeSKToLargeBlob()` now performs the assertion that
98
+ stores it (a blob can only be written during an assertion, never at
99
+ registration, so this costs one extra prompt) and **throws unless the
100
+ authenticator reports `written: true`**. Failing there beats persisting a
101
+ record that cannot be opened.
102
+
103
+ - Carry the authenticator's own answer on the credential. `createCredential()`
104
+ now records `extensionSupport` — what the authenticator agreed to during the
105
+ ceremony — because the raw `PublicKeyCredential` does not survive past that
106
+ function, so the answer is read there or lost. Client support is not
107
+ authenticator support: measured on a macOS platform authenticator in Brave,
108
+ the client advertises `hmacCreateSecret` while the authenticator refuses it
109
+ and PRF works. Reading only the client is what left the demo offering
110
+ hmac-secret and then failing at the ceremony, which is the failure originally
111
+ reported in #9.
112
+
113
+ The encrypted-keystore demo consumes it: methods the authenticator refused
114
+ read "Browser yes, this passkey no" rather than "Supported", become
115
+ unselectable, and a selection it cannot honour falls back automatically. It
116
+ also no longer logs `encrypted: Yes (largeBlob)` off the back of the
117
+ _requested_ options — that line reported intent as though it were outcome, so
118
+ a failed write looked identical to a successful one. It now names the request
119
+ and the authenticator's capability separately, so a mismatch is visible.
120
+
121
+ - Detect WebAuthn extensions by asking the browser instead of inspecting a
122
+ prototype. `checkExtensionSupport()` tested
123
+ `'largeBlob' in PublicKeyCredential.prototype` — but extensions are never
124
+ properties of that interface, they arrive through
125
+ `getClientExtensionResults()`. The test therefore returned `false` in every
126
+ browser ever shipped, including those with complete support, and `hmacSecret`
127
+ was hard-coded `false` beside it. Both together meant the encrypted-keystore
128
+ demo disabled its own headline feature everywhere and could not be switched
129
+ on at all.
130
+
131
+ Measured on Chrome 148: the prototype probe answered `false` for `largeBlob`,
132
+ `prf` and `hmacCreateSecret` while `getClientCapabilities()` reported all
133
+ three `true`. Every extension name behaves the same way, so no browser could
134
+ ever have passed. Closes #9.
135
+
136
+ `checkExtensionSupport()` now reads `getClientCapabilities()` and reports
137
+ `prf` alongside the other two. It also returns `known`, distinguishing "the
138
+ browser says no" from "the browser cannot say" — callers must not treat the
139
+ second as a refusal, or they disable a feature that may well work.
140
+
141
+ ### Added
142
+
143
+ - `extensionSupportFromCredential(credential)` reads what the authenticator
144
+ actually agreed to, from the registration response. This is the authoritative
145
+ answer and the one worth persisting: a browser may support `largeBlob` while
146
+ the security key in front of it does not, and only the ceremony settles that.
147
+ `addLargeBlobToCredentialOptions()` takes an optional support level for the
148
+ same reason — it stays `'required'` by default, because under `'preferred'`
149
+ an unsupported authenticator yields a credential whose secret was silently
150
+ never written.
151
+
152
+ ### Changed
153
+
154
+ - The encrypted-keystore demo offers PRF as a keystore encryption method and
155
+ prefers it, matching the library's own default since 0.4.0. It had hard-coded
156
+ `largeBlob`, so it overrode that default and never exercised PRF at all.
157
+ Methods the browser cannot vouch for are now labelled "Unknown" rather than
158
+ "Not supported", and stay selectable.
159
+
5
160
  ## 0.5.0
6
161
 
7
162
  ### 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.2",
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",
@@ -145,7 +145,7 @@
145
145
  "iso-web": "^3.1.2",
146
146
  "iso-webauthn-varsig": "npm:@le-space/iso-webauthn-varsig@^0.3.0",
147
147
  "conf>ajv": "^8.18.0",
148
- "ajv>fast-uri": "^3.1.4"
148
+ "ajv>fast-uri": "^3.1.6"
149
149
  },
150
150
  "onlyBuiltDependencies": [
151
151
  "@ipshipyard/node-datachannel",
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
  };
@@ -9,6 +9,7 @@ import { base58btc } from 'multiformats/bases/base58';
9
9
  import * as KeystoreEncryption from './encryption.js';
10
10
  import { ensureDerivedSigningKey } from './derived-signing-key.js';
11
11
  import { WebAuthnDIDProvider } from '../webauthn/provider.js';
12
+ import { verifyWebAuthnIdentityBinding } from '../webauthn/proof-verification.js';
12
13
  import {
13
14
  identityProofKey,
14
15
  loadIdentityProof,
@@ -171,12 +172,24 @@ export class OrbitDBWebAuthnIdentityProvider {
171
172
  const publicKeyBytes = normalizeByteArray(encryptedData.publicKey);
172
173
  const keyType = encryptedData.keyType || this.keystoreKeyType;
173
174
 
174
- return this.createDIDFromKeystorePublicKey(
175
+ const did = this.createDIDFromKeystorePublicKey(
175
176
  publicKeyBytes,
176
177
  keyType,
177
178
  varint,
178
179
  base58btc
179
180
  );
181
+ // OrbitDB signs with whatever key it finds under the id, and creates a
182
+ // secp256k1 one when there is none — so without this the DID said
183
+ // Ed25519 and the entries were signed by an unrelated key.
184
+ if (
185
+ this.unlockedKeypair?.privateKey &&
186
+ !(await this.keystore.getKey(did))
187
+ ) {
188
+ await this.keystore.addKey(did, {
189
+ privateKey: this.unlockedKeypair.privateKey,
190
+ });
191
+ }
192
+ return did;
180
193
  }
181
194
 
182
195
  // Get the keystore's identity ID (this will be used to retrieve the key)
@@ -293,12 +306,18 @@ export class OrbitDBWebAuthnIdentityProvider {
293
306
  keystoreKey.type
294
307
  );
295
308
 
296
- return this.createDIDFromKeystorePublicKey(
309
+ const did = this.createDIDFromKeystorePublicKey(
297
310
  publicKeyBytes,
298
311
  keystoreKey.type,
299
312
  varint,
300
313
  base58btc
301
314
  );
315
+ // Stored above under the credential's P-256 DID; OrbitDB looks it up under
316
+ // the DID returned here, found nothing, and generated a secp256k1 key.
317
+ if (!(await this.keystore.getKey(did))) {
318
+ await this.keystore.addKey(did, { privateKey: keystoreKey.raw });
319
+ }
320
+ return did;
302
321
  } catch (error) {
303
322
  identityLog.error(
304
323
  'Failed to create Ed25519 DID from keystore: %s',
@@ -408,15 +427,24 @@ export class OrbitDBWebAuthnIdentityProvider {
408
427
  } else if (
409
428
  this.keystoreEncryptionMethod === KEYSTORE_ENCRYPTION_METHODS.LARGE_BLOB
410
429
  ) {
411
- // For largeBlob, we need to store SK during next authentication
412
- // Store it temporarily for wrapping
430
+ // Write the key into the authenticator before recording anything.
431
+ // This used to be `secretKey: sk, // Will be moved to largeBlob` — and
432
+ // nothing ever moved it: the field was dropped at serialization, so
433
+ // the key survived only in memory and the keystore could never be
434
+ // unlocked again. Throws if the authenticator refuses, which is better
435
+ // than persisting a record that cannot be opened.
436
+ await KeystoreEncryption.writeSKToLargeBlob(
437
+ this.credential.rawCredentialId,
438
+ sk,
439
+ window.location.hostname
440
+ );
441
+
413
442
  encryptedData = {
414
443
  ciphertext,
415
444
  iv,
416
445
  credentialId: this.credential.credentialId,
417
446
  publicKey: publicKeyBytes,
418
447
  keyType,
419
- secretKey: sk, // Will be moved to largeBlob
420
448
  encryptionMethod: KEYSTORE_ENCRYPTION_METHODS.LARGE_BLOB,
421
449
  };
422
450
  } else if (
@@ -725,27 +753,10 @@ OrbitDBWebAuthnIdentityProviderFunction.type = IDENTITY_TYPES.WEBAUTHN;
725
753
  OrbitDBWebAuthnIdentityProviderFunction.verifyIdentity = async function (
726
754
  identity
727
755
  ) {
728
- try {
729
- // For WebAuthn identities, we need to store the credential info in the identity
730
- // Since WebAuthn verification requires the original credential, not just the public key,
731
- // we'll create a simplified verification that checks the proof structure
732
-
733
- // For WebAuthn, the identity should have been created with our provider,
734
- // so we can trust it if it has the right structure
735
- // Accept both DID format (did:key:...) and hash format (hex string) for backward compatibility
736
- const isValidDID = identity.id && identity.id.startsWith(DID_KEY_PREFIX);
737
- const isValidHash = identity.id && /^[a-f0-9]{64}$/.test(identity.id); // 64-char hex string (legacy)
738
-
739
- if (
740
- identity.type === IDENTITY_TYPES.WEBAUTHN &&
741
- (isValidDID || isValidHash)
742
- ) {
743
- return true;
744
- }
745
-
746
- return false;
747
- } catch (error) {
748
- console.error('WebAuthn static identity verification failed:', error);
749
- return false;
750
- }
756
+ // The check OrbitDB relies on to decide whether this identity may speak for
757
+ // its DID. It used to accept any `did:key:` of type `webauthn`, so a peer
758
+ // holding only its own key could write as anyone in a write list
759
+ // (GHSA-326j-4cc3-4rrg). See ../webauthn/proof-verification.js.
760
+ if (identity?.type !== IDENTITY_TYPES.WEBAUTHN) return false;
761
+ return verifyWebAuthnIdentityBinding(identity);
751
762
  };
@@ -19,16 +19,13 @@ import {
19
19
  toBytes,
20
20
  } from './utils.js';
21
21
  import { buildCredentialRequestOptions } from '../webauthn/config.js';
22
- import { KEY_TYPES, WEBAUTHN_CLIENT_DATA_TYPES } from '../constants.js';
22
+ import { KEY_TYPES } from '../constants.js';
23
23
  import {
24
24
  VarsigVerificationError,
25
25
  WebAuthnAuthenticationError,
26
26
  } from '../errors.js';
27
27
  import { logWebAuthnResponse } from '../webauthn/debug-log.js';
28
28
 
29
- const isTestMode = () =>
30
- typeof window !== 'undefined' && window.__PLAYWRIGHT__ === true;
31
-
32
29
  /**
33
30
  * Run a WebAuthn assertion for a payload.
34
31
  * @param {Object} credential - WebAuthn credential info.
@@ -54,27 +51,6 @@ async function runWebAuthnAssertionForPayload(
54
51
  const signedBytes = bindContext(domainLabel, payloadBytes);
55
52
  const challengeBytes = await buildChallengeBytes(signedBytes);
56
53
 
57
- if (isTestMode()) {
58
- return {
59
- rpId,
60
- origin,
61
- challengeBytes,
62
- algorithm: credential.algorithm,
63
- publicKey: credential.publicKey,
64
- assertion: {
65
- authenticatorData: new Uint8Array(37),
66
- clientDataJSON: new TextEncoder().encode(
67
- JSON.stringify({
68
- type: WEBAUTHN_CLIENT_DATA_TYPES.GET,
69
- challenge: 'test',
70
- origin,
71
- })
72
- ),
73
- signature: new Uint8Array(64),
74
- },
75
- };
76
- }
77
-
78
54
  const assertion = await navigator.credentials.get(
79
55
  buildCredentialRequestOptions({
80
56
  rpId,
@@ -114,15 +90,6 @@ async function runWebAuthnAssertionForPayload(
114
90
  * @returns {Promise<{varsig: Uint8Array, clientData: Object, verification: Object, signatureValid: boolean}>}
115
91
  */
116
92
  async function buildVarsigOutput(assertionData) {
117
- if (isTestMode()) {
118
- return {
119
- varsig: new Uint8Array([1, 2, 3]),
120
- clientData: {},
121
- verification: { valid: true },
122
- signatureValid: true,
123
- };
124
- }
125
-
126
93
  const { assertion, algorithm, origin, rpId, challengeBytes, publicKey } =
127
94
  assertionData;
128
95
 
@@ -200,10 +167,6 @@ async function verifyVarsigForPayload(
200
167
  payloadBytes,
201
168
  domainLabel
202
169
  ) {
203
- if (isTestMode()) {
204
- return true;
205
- }
206
-
207
170
  let decoded;
208
171
  try {
209
172
  decoded = decodeWebAuthnVarsigV1(signature);
@@ -252,6 +215,7 @@ async function verifyVarsigForPayload(
252
215
  }
253
216
 
254
217
  export {
218
+ algorithmFromPublicKey,
255
219
  buildVarsigOutput,
256
220
  runWebAuthnAssertionForPayload,
257
221
  verifyVarsigForPayload,
@@ -7,9 +7,6 @@ import { toArrayBuffer } from './utils.js';
7
7
  import { buildAuthenticatorSelection } from '../webauthn/config.js';
8
8
  import { logWebAuthnResponse } from '../webauthn/debug-log.js';
9
9
 
10
- const isTestMode = () =>
11
- typeof window !== 'undefined' && window.__PLAYWRIGHT__ === true;
12
-
13
10
  /**
14
11
  * Extract public key info from a WebAuthn attestation object.
15
12
  * @param {Uint8Array} attestationObject - Raw attestation object bytes.
@@ -81,23 +78,6 @@ async function createWebAuthnVarsigCredential(options = {}) {
81
78
  ...options,
82
79
  };
83
80
 
84
- if (isTestMode()) {
85
- const credentialId = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]);
86
- const publicKey = new Uint8Array(32);
87
- for (let i = 0; i < publicKey.length; i++) {
88
- publicKey[i] = i + 1;
89
- }
90
- const algorithm = 'Ed25519';
91
- const did = DIDKey.fromPublicKey(algorithm, publicKey).did;
92
- return {
93
- credentialId,
94
- publicKey,
95
- did,
96
- algorithm,
97
- cose: { kty: 1, alg: -8, crv: 6 },
98
- };
99
- }
100
-
101
81
  const publicKey = {
102
82
  rp: { name: 'OrbitDB Varsig Identity', id: domain },
103
83
  user: {
@@ -10,7 +10,7 @@ import { DIDKey } from 'iso-did';
10
10
  import { concat } from 'iso-webauthn-varsig';
11
11
  import { DEFAULT_DOMAIN_LABELS } from './domain.js';
12
12
  import { encoder, toBytes } from './utils.js';
13
- import { verifyVarsigForPayload } from './assertion.js';
13
+ import { verifyVarsigForPayload, algorithmFromPublicKey } from './assertion.js';
14
14
  import { WebAuthnVarsigProvider } from './provider.js';
15
15
 
16
16
  const IDENTITY_CODEC = dagCbor;
@@ -330,6 +330,21 @@ export async function decodeVarsigIdentityFromBytes(bytes, domainLabels = {}) {
330
330
  */
331
331
  export async function verifyVarsigIdentity(identity, domainLabels = {}) {
332
332
  if (!identity || !identity.publicKey || !identity.signatures) return false;
333
+
334
+ // The id has to be the DID of the key that signs for it. Both signatures
335
+ // below are checked against `identity.publicKey`, so without this anyone
336
+ // with a passkey on the same origin could sign a stranger's DID with their
337
+ // own key and pass.
338
+ let expectedDid;
339
+ try {
340
+ expectedDid = DIDKey.fromPublicKey(
341
+ algorithmFromPublicKey(identity.publicKey),
342
+ identity.publicKey
343
+ ).did;
344
+ } catch {
345
+ return false;
346
+ }
347
+ if (identity.id !== expectedDid) return false;
333
348
  const labels = { ...DEFAULT_DOMAIN_LABELS, ...domainLabels };
334
349
 
335
350
  const idBytes = encoder.encode(identity.id);
@@ -0,0 +1,235 @@
1
+ /**
2
+ * @module ProofVerification
3
+ * @description
4
+ * Checks that an identity's claim to a DID is backed by the key that DID
5
+ * encodes.
6
+ *
7
+ * OrbitDB verifies `signatures.id` against the identity's own `publicKey`, which
8
+ * any key satisfies for itself, and then asks the provider whether the rest
9
+ * holds. So this is the only place where "this identity may speak for that DID"
10
+ * is decided — and until GHSA-326j-4cc3-4rrg it decided nothing: the static
11
+ * `verifyIdentity` looked at the type and the `did:key:` prefix, and
12
+ * `WebAuthnDIDProvider.verify()` looked at the proof's fields. A peer holding
13
+ * only its own key could claim any DID in any write list.
14
+ *
15
+ * Two kinds of DID reach here, and each is bound differently:
16
+ *
17
+ * - **P-256, the credential's own key** (multicodec 0x1200). The passkey signed
18
+ * `publicKey + signatures.id` during `createIdentity`; that WebAuthn assertion
19
+ * is `signatures.publicKey`. It is verified against the key the DID encodes,
20
+ * so no lookup is needed — the DID *is* the verification key.
21
+ * - **Ed25519 or secp256k1, a keystore key** (`useKeystoreDID`). The DID is the
22
+ * signing key itself, so `publicKey` must be exactly the key it encodes;
23
+ * OrbitDB has already checked that key signed the id.
24
+ *
25
+ * Anything else — including the legacy 64-hex ids — cannot be bound to a key,
26
+ * and is refused rather than waved through.
27
+ */
28
+ import { varint } from 'multiformats';
29
+ import { base58btc } from 'multiformats/bases/base58';
30
+ import { unwrapEC2Signature } from 'iso-passkeys';
31
+
32
+ import { DID_KEY_PREFIX, WEBAUTHN_CLIENT_DATA_TYPES } from '../constants.js';
33
+
34
+ export const MULTICODEC = Object.freeze({
35
+ P256_PUB: 0x1200,
36
+ ED25519_PUB: 0xed,
37
+ SECP256K1_PUB: 0xe7,
38
+ });
39
+
40
+ const FLAG_USER_PRESENT = 0x01;
41
+ // rpIdHash (32) + flags (1) + signCount (4)
42
+ const AUTH_DATA_MIN_LENGTH = 37;
43
+ const P1363_SIGNATURE_LENGTH = 64;
44
+
45
+ function base64urlToBytes(value) {
46
+ const base64 = value.replace(/-/g, '+').replace(/_/g, '/');
47
+ const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
48
+ return Uint8Array.from(atob(padded), (character) => character.charCodeAt(0));
49
+ }
50
+
51
+ function bytesToBase64url(bytes) {
52
+ let binary = '';
53
+ for (const byte of bytes) binary += String.fromCharCode(byte);
54
+ return btoa(binary)
55
+ .replace(/\+/g, '-')
56
+ .replace(/\//g, '_')
57
+ .replace(/=+$/, '');
58
+ }
59
+
60
+ function concat(a, b) {
61
+ const out = new Uint8Array(a.length + b.length);
62
+ out.set(a, 0);
63
+ out.set(b, a.length);
64
+ return out;
65
+ }
66
+
67
+ async function sha256(bytes) {
68
+ return new Uint8Array(await crypto.subtle.digest('SHA-256', bytes));
69
+ }
70
+
71
+ /**
72
+ * @param {Uint8Array} bytes
73
+ * @returns {string} lowercase hex
74
+ */
75
+ export function bytesToHex(bytes) {
76
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(
77
+ ''
78
+ );
79
+ }
80
+
81
+ /**
82
+ * Split a `did:key` into its multicodec and raw public key.
83
+ * @param {string} did
84
+ * @returns {{ codec: number, keyBytes: Uint8Array }}
85
+ */
86
+ export function decodeDidKey(did) {
87
+ if (typeof did !== 'string' || !did.startsWith(`${DID_KEY_PREFIX}z`)) {
88
+ throw new Error('Not a base58btc did:key');
89
+ }
90
+ const bytes = base58btc.decode(did.slice(DID_KEY_PREFIX.length));
91
+ const [codec, prefixLength] = varint.decode(bytes);
92
+ return { codec, keyBytes: bytes.subarray(prefixLength) };
93
+ }
94
+
95
+ /**
96
+ * A P-256 public key as raw SEC1 bytes, from either raw bytes or `{ x, y }`.
97
+ * @param {Uint8Array|{x: ArrayLike<number>, y: ArrayLike<number>}|undefined} publicKey
98
+ * @returns {Uint8Array|null}
99
+ */
100
+ export function p256PublicKeyBytes(publicKey) {
101
+ if (publicKey instanceof Uint8Array) {
102
+ if (publicKey.length === 65 && publicKey[0] === 0x04) return publicKey;
103
+ if (publicKey.length === 33) return publicKey;
104
+ return null;
105
+ }
106
+ if (publicKey?.x && publicKey?.y) {
107
+ const x = Uint8Array.from(publicKey.x);
108
+ const y = Uint8Array.from(publicKey.y);
109
+ if (x.length !== 32 || y.length !== 32) return null;
110
+ const out = new Uint8Array(65);
111
+ out[0] = 0x04;
112
+ out.set(x, 1);
113
+ out.set(y, 33);
114
+ return out;
115
+ }
116
+ return null;
117
+ }
118
+
119
+ /**
120
+ * Verify a proof produced by `WebAuthnDIDProvider.sign(data)`.
121
+ *
122
+ * Checks, in order: the client data type; that the challenge is the one
123
+ * `sign()` derives — SHA-256(rawCredentialId ‖ data) — so the assertion is
124
+ * about this data and no other; that the user was present; and the ECDSA
125
+ * P-256 signature over `authenticatorData ‖ SHA-256(clientDataJSON)` against
126
+ * the given key. Never throws: a malformed proof is simply not valid.
127
+ *
128
+ * Origin and rpId are not checked. They defend an authentication ceremony
129
+ * against relaying; here the question is only whether the holder of this key
130
+ * signed this data, which the signature answers wherever it was made.
131
+ *
132
+ * @param {string} encodedProof base64url JSON, as returned by `sign()`
133
+ * @param {string|Uint8Array} data what was signed
134
+ * @param {Uint8Array} publicKeyBytes P-256 public key, SEC1
135
+ * @returns {Promise<boolean>}
136
+ */
137
+ export async function verifyWebAuthnProof(encodedProof, data, publicKeyBytes) {
138
+ try {
139
+ if (typeof encodedProof !== 'string' || !publicKeyBytes) return false;
140
+ const proof = JSON.parse(
141
+ new TextDecoder().decode(base64urlToBytes(encodedProof))
142
+ );
143
+ const { credentialId, authenticatorData, clientDataJSON, signature } =
144
+ proof ?? {};
145
+ if (
146
+ typeof credentialId !== 'string' ||
147
+ typeof authenticatorData !== 'string' ||
148
+ typeof clientDataJSON !== 'string' ||
149
+ typeof signature !== 'string'
150
+ ) {
151
+ return false;
152
+ }
153
+
154
+ const clientData = JSON.parse(clientDataJSON);
155
+ if (clientData?.type !== WEBAUTHN_CLIENT_DATA_TYPES.GET) return false;
156
+
157
+ const dataBytes =
158
+ typeof data === 'string'
159
+ ? new TextEncoder().encode(data)
160
+ : new Uint8Array(data);
161
+ const expectedChallenge = bytesToBase64url(
162
+ await sha256(concat(base64urlToBytes(credentialId), dataBytes))
163
+ );
164
+ if (clientData.challenge !== expectedChallenge) return false;
165
+
166
+ const authData = base64urlToBytes(authenticatorData);
167
+ if (authData.length < AUTH_DATA_MIN_LENGTH) return false;
168
+ if ((authData[32] & FLAG_USER_PRESENT) === 0) return false;
169
+
170
+ const signedBytes = concat(
171
+ authData,
172
+ await sha256(new TextEncoder().encode(clientDataJSON))
173
+ );
174
+ const signatureBytes = base64urlToBytes(signature);
175
+ const p1363 =
176
+ signatureBytes.length === P1363_SIGNATURE_LENGTH
177
+ ? signatureBytes
178
+ : Uint8Array.from(unwrapEC2Signature(signatureBytes));
179
+
180
+ const key = await crypto.subtle.importKey(
181
+ 'raw',
182
+ publicKeyBytes,
183
+ { name: 'ECDSA', namedCurve: 'P-256' },
184
+ false,
185
+ ['verify']
186
+ );
187
+ return await crypto.subtle.verify(
188
+ { name: 'ECDSA', hash: 'SHA-256' },
189
+ key,
190
+ p1363,
191
+ signedBytes
192
+ );
193
+ } catch {
194
+ return false;
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Whether an OrbitDB identity of type `webauthn` may speak for its `id`.
200
+ * @param {{id: string, publicKey: string, signatures: {id: string, publicKey: string}}} identity
201
+ * @returns {Promise<boolean>}
202
+ */
203
+ export async function verifyWebAuthnIdentityBinding(identity) {
204
+ if (
205
+ !identity ||
206
+ typeof identity.publicKey !== 'string' ||
207
+ !identity.signatures
208
+ ) {
209
+ return false;
210
+ }
211
+
212
+ let decoded;
213
+ try {
214
+ decoded = decodeDidKey(identity.id);
215
+ } catch {
216
+ return false;
217
+ }
218
+
219
+ if (decoded.codec === MULTICODEC.P256_PUB) {
220
+ return verifyWebAuthnProof(
221
+ identity.signatures.publicKey,
222
+ identity.publicKey + identity.signatures.id,
223
+ decoded.keyBytes
224
+ );
225
+ }
226
+
227
+ if (
228
+ decoded.codec === MULTICODEC.ED25519_PUB ||
229
+ decoded.codec === MULTICODEC.SECP256K1_PUB
230
+ ) {
231
+ return bytesToHex(decoded.keyBytes) === identity.publicKey.toLowerCase();
232
+ }
233
+
234
+ return false;
235
+ }
@@ -14,20 +14,22 @@ import {
14
14
  DID_KEY_PREFIX,
15
15
  IDENTITY_TYPES,
16
16
  KEYSTORE_ENCRYPTION_METHODS,
17
- WEBAUTHN_CLIENT_DATA_TYPES,
18
17
  } from '../constants.js';
19
18
  import {
20
19
  WebAuthnIdentityError,
21
20
  WebAuthnAuthenticationError,
22
21
  WebAuthnCredentialError,
23
22
  WebAuthnNotSupportedError,
24
- WebAuthnVerificationError,
25
23
  } from '../errors.js';
26
24
  import {
27
25
  buildAuthenticatorSelection,
28
26
  buildCredentialRequestOptions,
29
27
  } from './config.js';
30
28
  import { logWebAuthnResponse } from './debug-log.js';
29
+ import {
30
+ p256PublicKeyBytes,
31
+ verifyWebAuthnProof,
32
+ } from './proof-verification.js';
31
33
 
32
34
  const webauthnLog = logger('orbitdb-identity-provider-webauthn-did:webauthn');
33
35
 
@@ -82,7 +84,11 @@ export class WebAuthnDIDProvider {
82
84
  * Create a WebAuthn credential for OrbitDB identity
83
85
  * This triggers biometric authentication (Face ID, Touch ID, Windows Hello, etc.)
84
86
  * @param {Object} options - Credential options
85
- * @param {string} options.userId - User ID
87
+ * @param {string} options.userId - Account label shown in the credential
88
+ * picker (`user.name`). A label only: it does not identify the credential,
89
+ * two credentials may carry the same one, and it is never used to look one
90
+ * up. The handle the authenticator files the credential under is generated
91
+ * here and returned as `userHandle`.
86
92
  * @param {string} options.displayName - Display name
87
93
  * @param {string} options.domain - Domain/RP ID
88
94
  * @param {boolean} options.encryptKeystore - Enable keystore encryption
@@ -117,7 +123,19 @@ export class WebAuthnDIDProvider {
117
123
 
118
124
  // Generate challenge for credential creation
119
125
  const challenge = crypto.getRandomValues(new Uint8Array(32));
120
- const userIdBytes = new TextEncoder().encode(userId);
126
+
127
+ // The user handle is an opaque key, not a label. An authenticator stores
128
+ // one discoverable credential per (rp.id, user.id) pair and *replaces* the
129
+ // previous one when both match — silently, with no prompt and nothing to
130
+ // undo. Deriving the handle from a typed name therefore meant two people
131
+ // registering as "anna" on a shared device destroyed each other's passkey,
132
+ // and with it the DID and everything written under it (#45).
133
+ //
134
+ // 64 random bytes, as WebAuthn L2 §5.4.3 recommends. The same section
135
+ // forbids putting personally identifying information here, which a typed
136
+ // name or e-mail address plainly is. The name keeps its rightful place in
137
+ // `user.name` below, where the credential picker shows it.
138
+ const userHandle = crypto.getRandomValues(new Uint8Array(64));
121
139
 
122
140
  webauthnLog('Calling navigator.credentials.create() for user: %s', userId);
123
141
 
@@ -130,7 +148,7 @@ export class WebAuthnDIDProvider {
130
148
  id: domain,
131
149
  },
132
150
  user: {
133
- id: userIdBytes,
151
+ id: userHandle,
134
152
  name: userId,
135
153
  displayName,
136
154
  },
@@ -228,6 +246,17 @@ export class WebAuthnDIDProvider {
228
246
  hasY: !!publicKey.y,
229
247
  });
230
248
 
249
+ // What the authenticator actually agreed to, as opposed to what the
250
+ // browser said it could negotiate. Only the ceremony settles this, and
251
+ // the raw PublicKeyCredential does not survive past this function — so
252
+ // read it here or lose the answer. Callers that offer a choice of
253
+ // keystore encryption method need it: a client can support largeBlob
254
+ // while the key in front of it does not.
255
+ const extensionSupport =
256
+ KeystoreEncryption.extensionSupportFromCredential(credential);
257
+
258
+ webauthnLog('Authenticator extension support: %o', extensionSupport);
259
+
231
260
  const result = {
232
261
  credentialId: WebAuthnDIDProvider.arrayBufferToBase64url(
233
262
  credential.rawId
@@ -236,10 +265,18 @@ export class WebAuthnDIDProvider {
236
265
  publicKey,
237
266
  userId,
238
267
  displayName,
268
+ // Nothing in this package looks a credential up by handle — recovery
269
+ // goes through discoverable credentials, or through an explicit
270
+ // credential ID when those are switched off. It is surfaced anyway
271
+ // because it is the only copy the caller will ever see, and a flow
272
+ // that one day passes `allowCredentials` needs it. Decode with
273
+ // `WebAuthnDIDProvider.base64urlToArrayBuffer()`.
274
+ userHandle: WebAuthnDIDProvider.arrayBufferToBase64url(userHandle),
239
275
  attestationObject: new Uint8Array(
240
276
  credential.response.attestationObject
241
277
  ),
242
278
  prfInput: prfInput || undefined,
279
+ extensionSupport,
243
280
  };
244
281
 
245
282
  webauthnLog('Credential creation completed successfully');
@@ -716,77 +753,27 @@ export class WebAuthnDIDProvider {
716
753
  * @param {string} signatureData - Base64url signature envelope.
717
754
  * @returns {Promise<boolean>} True if verification succeeds.
718
755
  */
719
- async verify(signatureData) {
720
- webauthnLog(
721
- 'verify() called with signature length: %d',
722
- signatureData.length
723
- );
724
-
725
- try {
726
- // Decode the WebAuthn proof object
727
- const proofBytes =
728
- WebAuthnDIDProvider.base64urlToArrayBuffer(signatureData);
729
- const proofText = new TextDecoder().decode(proofBytes);
730
- const proof = JSON.parse(proofText);
731
-
732
- // Verify the proof structure
733
- if (!proof.credentialId || !proof.dataHash || !proof.signature) {
734
- throw new WebAuthnVerificationError('Invalid WebAuthn proof structure');
735
- }
736
-
737
- // Check if credential ID matches
738
- webauthnLog('Verification step: checking credential ID');
739
- if (proof.credentialId !== this.credentialId) {
740
- webauthnLog.error(
741
- 'Credential ID mismatch in WebAuthn proof verification'
742
- );
743
- throw new WebAuthnVerificationError('Credential ID mismatch');
744
- }
745
- webauthnLog('Verification step: credential ID check PASSED');
746
-
747
- // Verify client data JSON
748
- webauthnLog('Verification step: checking client data');
749
- if (proof.clientDataJSON) {
750
- const clientData = JSON.parse(proof.clientDataJSON);
751
- if (clientData.type !== WEBAUTHN_CLIENT_DATA_TYPES.GET) {
752
- webauthnLog.error('Invalid WebAuthn proof type: %s', clientData.type);
753
- throw new WebAuthnVerificationError('Invalid WebAuthn proof type');
754
- }
755
- webauthnLog('Verification step: client data check PASSED');
756
- } else {
757
- webauthnLog.error('Invalid client data in WebAuthn proof');
758
- throw new WebAuthnVerificationError('Invalid client data');
759
- }
760
-
761
- // No expiry check. This proof is embedded in an OrbitDB identity
762
- // document, and every entry ever signed under that document has to stay
763
- // validatable — an expiring proof would silently invalidate history.
764
- // The proof attests that this credential signed this payload, which does
765
- // not stop being true. Proofs written by earlier versions still carry a
766
- // timestamp field; it is simply ignored.
767
-
768
- // Verify authenticator data exists
769
- webauthnLog('Verification step: checking authenticator data');
770
- if (!proof.authenticatorData) {
771
- webauthnLog.error('Missing authenticator data in WebAuthn proof');
772
- throw new WebAuthnVerificationError('Missing authenticator data');
773
- }
774
- webauthnLog('Verification step: authenticator data check PASSED');
775
-
776
- webauthnLog('Verification result: SUCCESS');
777
- return true;
778
- } catch (error) {
779
- webauthnLog.error(
780
- 'WebAuthn proof verification failed: %s',
781
- error.message
782
- );
756
+ /**
757
+ * Verify a proof from `sign(data)` against a P-256 key.
758
+ *
759
+ * This used to check that the proof had the right fields and return true —
760
+ * no signature, no challenge — so any proof-shaped JSON passed
761
+ * (GHSA-326j-4cc3-4rrg).
762
+ *
763
+ * @param {string} signatureData base64url proof from `sign()`
764
+ * @param {string|Uint8Array} data the signed data
765
+ * @param {Uint8Array|{x: ArrayLike<number>, y: ArrayLike<number>}} publicKey P-256 key
766
+ * @returns {Promise<boolean>}
767
+ */
768
+ async verify(signatureData, data, publicKey) {
769
+ const keyBytes = p256PublicKeyBytes(publicKey);
770
+ if (!keyBytes || data === undefined) {
771
+ webauthnLog('verify() needs the signed data and a P-256 public key');
783
772
  return false;
784
773
  }
774
+ return verifyWebAuthnProof(signatureData, data, keyBytes);
785
775
  }
786
776
 
787
- /**
788
- * Utility: Convert ArrayBuffer to base64url
789
- */
790
777
  static arrayBufferToBase64url(buffer) {
791
778
  const bytes = new Uint8Array(buffer);
792
779
  let binary = '';
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;