@le-space/orbitdb-identity-provider-webauthn-did 0.5.1 → 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,52 @@
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
+
5
51
  ## 0.5.1
6
52
 
7
53
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@le-space/orbitdb-identity-provider-webauthn-did",
3
- "version": "0.5.1",
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",
@@ -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',
@@ -734,27 +753,10 @@ OrbitDBWebAuthnIdentityProviderFunction.type = IDENTITY_TYPES.WEBAUTHN;
734
753
  OrbitDBWebAuthnIdentityProviderFunction.verifyIdentity = async function (
735
754
  identity
736
755
  ) {
737
- try {
738
- // For WebAuthn identities, we need to store the credential info in the identity
739
- // Since WebAuthn verification requires the original credential, not just the public key,
740
- // we'll create a simplified verification that checks the proof structure
741
-
742
- // For WebAuthn, the identity should have been created with our provider,
743
- // so we can trust it if it has the right structure
744
- // Accept both DID format (did:key:...) and hash format (hex string) for backward compatibility
745
- const isValidDID = identity.id && identity.id.startsWith(DID_KEY_PREFIX);
746
- const isValidHash = identity.id && /^[a-f0-9]{64}$/.test(identity.id); // 64-char hex string (legacy)
747
-
748
- if (
749
- identity.type === IDENTITY_TYPES.WEBAUTHN &&
750
- (isValidDID || isValidHash)
751
- ) {
752
- return true;
753
- }
754
-
755
- return false;
756
- } catch (error) {
757
- console.error('WebAuthn static identity verification failed:', error);
758
- return false;
759
- }
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);
760
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
 
@@ -751,77 +753,27 @@ export class WebAuthnDIDProvider {
751
753
  * @param {string} signatureData - Base64url signature envelope.
752
754
  * @returns {Promise<boolean>} True if verification succeeds.
753
755
  */
754
- async verify(signatureData) {
755
- webauthnLog(
756
- 'verify() called with signature length: %d',
757
- signatureData.length
758
- );
759
-
760
- try {
761
- // Decode the WebAuthn proof object
762
- const proofBytes =
763
- WebAuthnDIDProvider.base64urlToArrayBuffer(signatureData);
764
- const proofText = new TextDecoder().decode(proofBytes);
765
- const proof = JSON.parse(proofText);
766
-
767
- // Verify the proof structure
768
- if (!proof.credentialId || !proof.dataHash || !proof.signature) {
769
- throw new WebAuthnVerificationError('Invalid WebAuthn proof structure');
770
- }
771
-
772
- // Check if credential ID matches
773
- webauthnLog('Verification step: checking credential ID');
774
- if (proof.credentialId !== this.credentialId) {
775
- webauthnLog.error(
776
- 'Credential ID mismatch in WebAuthn proof verification'
777
- );
778
- throw new WebAuthnVerificationError('Credential ID mismatch');
779
- }
780
- webauthnLog('Verification step: credential ID check PASSED');
781
-
782
- // Verify client data JSON
783
- webauthnLog('Verification step: checking client data');
784
- if (proof.clientDataJSON) {
785
- const clientData = JSON.parse(proof.clientDataJSON);
786
- if (clientData.type !== WEBAUTHN_CLIENT_DATA_TYPES.GET) {
787
- webauthnLog.error('Invalid WebAuthn proof type: %s', clientData.type);
788
- throw new WebAuthnVerificationError('Invalid WebAuthn proof type');
789
- }
790
- webauthnLog('Verification step: client data check PASSED');
791
- } else {
792
- webauthnLog.error('Invalid client data in WebAuthn proof');
793
- throw new WebAuthnVerificationError('Invalid client data');
794
- }
795
-
796
- // No expiry check. This proof is embedded in an OrbitDB identity
797
- // document, and every entry ever signed under that document has to stay
798
- // validatable — an expiring proof would silently invalidate history.
799
- // The proof attests that this credential signed this payload, which does
800
- // not stop being true. Proofs written by earlier versions still carry a
801
- // timestamp field; it is simply ignored.
802
-
803
- // Verify authenticator data exists
804
- webauthnLog('Verification step: checking authenticator data');
805
- if (!proof.authenticatorData) {
806
- webauthnLog.error('Missing authenticator data in WebAuthn proof');
807
- throw new WebAuthnVerificationError('Missing authenticator data');
808
- }
809
- webauthnLog('Verification step: authenticator data check PASSED');
810
-
811
- webauthnLog('Verification result: SUCCESS');
812
- return true;
813
- } catch (error) {
814
- webauthnLog.error(
815
- 'WebAuthn proof verification failed: %s',
816
- error.message
817
- );
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');
818
772
  return false;
819
773
  }
774
+ return verifyWebAuthnProof(signatureData, data, keyBytes);
820
775
  }
821
776
 
822
- /**
823
- * Utility: Convert ArrayBuffer to base64url
824
- */
825
777
  static arrayBufferToBase64url(buffer) {
826
778
  const bytes = new Uint8Array(buffer);
827
779
  let binary = '';