@breeztech/breez-sdk-spark 0.15.1 → 0.16.1-dev1

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.
Files changed (51) hide show
  1. package/breez-sdk-spark.tgz +0 -0
  2. package/bundler/breez_sdk_spark_wasm.d.ts +511 -215
  3. package/bundler/breez_sdk_spark_wasm.js +1 -1
  4. package/bundler/breez_sdk_spark_wasm_bg.js +567 -414
  5. package/bundler/breez_sdk_spark_wasm_bg.wasm +0 -0
  6. package/bundler/breez_sdk_spark_wasm_bg.wasm.d.ts +55 -46
  7. package/bundler/storage/index.js +205 -15
  8. package/deno/breez_sdk_spark_wasm.d.ts +511 -215
  9. package/deno/breez_sdk_spark_wasm.js +567 -414
  10. package/deno/breez_sdk_spark_wasm_bg.wasm +0 -0
  11. package/deno/breez_sdk_spark_wasm_bg.wasm.d.ts +55 -46
  12. package/nodejs/breez_sdk_spark_wasm.d.ts +511 -215
  13. package/nodejs/breez_sdk_spark_wasm.js +578 -421
  14. package/nodejs/breez_sdk_spark_wasm_bg.wasm +0 -0
  15. package/nodejs/breez_sdk_spark_wasm_bg.wasm.d.ts +55 -46
  16. package/nodejs/index.js +10 -10
  17. package/nodejs/index.mjs +12 -8
  18. package/nodejs/mysql-session-store/errors.cjs +13 -0
  19. package/nodejs/{mysql-session-manager → mysql-session-store}/index.cjs +24 -21
  20. package/nodejs/{mysql-session-manager → mysql-session-store}/migrations.cjs +17 -11
  21. package/nodejs/mysql-session-store/package.json +9 -0
  22. package/nodejs/mysql-storage/index.cjs +229 -111
  23. package/nodejs/mysql-storage/migrations.cjs +37 -2
  24. package/nodejs/mysql-token-store/index.cjs +99 -79
  25. package/nodejs/mysql-token-store/migrations.cjs +59 -2
  26. package/nodejs/mysql-tree-store/index.cjs +15 -9
  27. package/nodejs/mysql-tree-store/migrations.cjs +16 -2
  28. package/nodejs/package.json +2 -2
  29. package/nodejs/postgres-session-store/errors.cjs +13 -0
  30. package/nodejs/{postgres-session-manager → postgres-session-store}/index.cjs +23 -23
  31. package/nodejs/{postgres-session-manager → postgres-session-store}/migrations.cjs +14 -14
  32. package/nodejs/postgres-session-store/package.json +9 -0
  33. package/nodejs/postgres-storage/index.cjs +174 -107
  34. package/nodejs/postgres-storage/migrations.cjs +24 -0
  35. package/nodejs/postgres-token-store/index.cjs +89 -64
  36. package/nodejs/postgres-token-store/migrations.cjs +44 -0
  37. package/nodejs/storage/index.cjs +167 -113
  38. package/nodejs/storage/migrations.cjs +23 -0
  39. package/package.json +6 -1
  40. package/ssr/index.js +52 -28
  41. package/web/breez_sdk_spark_wasm.d.ts +566 -261
  42. package/web/breez_sdk_spark_wasm.js +567 -414
  43. package/web/breez_sdk_spark_wasm_bg.wasm +0 -0
  44. package/web/breez_sdk_spark_wasm_bg.wasm.d.ts +55 -46
  45. package/web/passkey-prf-provider/index.d.ts +203 -0
  46. package/web/passkey-prf-provider/index.js +733 -0
  47. package/web/storage/index.js +205 -15
  48. package/nodejs/mysql-session-manager/errors.cjs +0 -13
  49. package/nodejs/mysql-session-manager/package.json +0 -9
  50. package/nodejs/postgres-session-manager/errors.cjs +0 -13
  51. package/nodejs/postgres-session-manager/package.json +0 -9
@@ -0,0 +1,733 @@
1
+ /**
2
+ * Built-in browser PRF provider: implements `PrfProvider` over the
3
+ * WebAuthn PRF extension, using discoverable credentials so no
4
+ * credential storage is needed.
5
+ *
6
+ * @example
7
+ * ```javascript
8
+ * import { PasskeyClient } from '@breeztech/breez-sdk-spark'
9
+ * import { PasskeyProvider } from '@breeztech/breez-sdk-spark/passkey-prf-provider'
10
+ *
11
+ * const provider = new PasskeyProvider()
12
+ * const client = new PasskeyClient(provider)
13
+ * const { wallet } = await client.signIn({ label: 'personal' })
14
+ * ```
15
+ */
16
+
17
+ import { PasskeyClient as SdkPasskeyClient } from '../breez_sdk_spark_wasm.js';
18
+
19
+ /** Breez's shared RP ID, exposed as `PasskeyProvider.BREEZ_RP_ID`. */
20
+ const BREEZ_RP_ID = 'keys.breez.technology';
21
+
22
+ /** Default `rpName` for the zero-config {@link PasskeyClient} path. */
23
+ const DEFAULT_RP_NAME = 'Breez';
24
+
25
+ // WebAuthn collapses "no matching credential" and "user dismissed" into
26
+ // one NotAllowedError, but a no-credential fast-fail resolves before any
27
+ // UI shows while a dismiss takes seconds. Elapsed time below this is
28
+ // classified as no-credential, at or above as user-cancel.
29
+ const NO_CRED_FAST_FAIL_MS = 250;
30
+
31
+ // iOS and Android tear the biometric sheet down around 55s of inactivity
32
+ // with the same generic NotAllowedError. Elapsed time at or above this is
33
+ // reclassified as a timeout rather than a user-cancel.
34
+ const BIOMETRIC_TIMEOUT_MS = 55_000;
35
+
36
+ /**
37
+ * Generate cryptographically random bytes.
38
+ * @param {number} length
39
+ * @returns {Uint8Array}
40
+ */
41
+ function randomBytes(length) {
42
+ const bytes = new Uint8Array(length);
43
+ crypto.getRandomValues(bytes);
44
+ return bytes;
45
+ }
46
+
47
+ /**
48
+ * Extract AAGUID + BE flag from a create response via WebAuthn L2
49
+ * `getAuthenticatorData()`. authData layout once the AT flag is set:
50
+ * byte 32 = flags (BE=bit3, AT=bit6), bytes 37 to 53 = AAGUID.
51
+ * Returns null when the accessor or attested credential data is absent.
52
+ *
53
+ * @param {PublicKeyCredential} credential
54
+ * @returns {{ aaguid: Uint8Array, backupEligible: boolean } | null}
55
+ */
56
+ function extractRegistrationMetadata(credential) {
57
+ try {
58
+ const response = credential.response;
59
+ if (!response || typeof response.getAuthenticatorData !== 'function') {
60
+ return null;
61
+ }
62
+ const authData = new Uint8Array(response.getAuthenticatorData());
63
+ if (authData.length < 53) return null;
64
+ const flags = authData[32];
65
+ const hasAttestedCredData = (flags & 0x40) !== 0;
66
+ if (!hasAttestedCredData) return null;
67
+ const backupEligible = (flags & 0x08) !== 0;
68
+ const aaguid = authData.slice(37, 53);
69
+ return { aaguid, backupEligible };
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Thrown by `createPasskey` when an entry in `excludeCredentials`
77
+ * matches a credential already on the device. Route the user to
78
+ * sign-in rather than treating it as a generic registration failure.
79
+ */
80
+ export class PasskeyAlreadyExistsError extends Error {
81
+ constructor(message = 'A passkey for this RP already exists on this device') {
82
+ super(message);
83
+ this.name = 'PasskeyAlreadyExistsError';
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Thrown when the OS biometric prompt times out (around 55s) before
89
+ * any user interaction. Distinct from a cancel: hosts may auto-retry
90
+ * since the user did not deliberately abandon the flow.
91
+ */
92
+ export class PasskeyTimedOutError extends Error {
93
+ constructor(message = 'Authenticator timed out') {
94
+ super(message);
95
+ this.name = 'PasskeyTimedOutError';
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Thrown when `deriveSeeds` cannot match a credential for this RP on
101
+ * the device. `message` carries diagnostic detail.
102
+ */
103
+ export class PasskeyCredentialNotFoundError extends Error {
104
+ constructor(message = 'Credential not found') {
105
+ super(message);
106
+ this.name = 'PasskeyCredentialNotFoundError';
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Thrown when the user actively dismisses the OS passkey prompt.
112
+ * Distinct from `PasskeyTimedOutError`: hosts should not auto-retry a
113
+ * deliberate cancel.
114
+ */
115
+ export class PasskeyUserCancelledError extends Error {
116
+ constructor(message = 'User cancelled authentication') {
117
+ super(message);
118
+ this.name = 'PasskeyUserCancelledError';
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Built-in passkey-based PRF provider for browser environments.
124
+ */
125
+ export class PasskeyProvider {
126
+ /**
127
+ * Breez's shared `keys.breez.technology` RP. Pass as `rpId` to opt
128
+ * in (Breez-registered apps only); other apps pass their own domain.
129
+ */
130
+ static get BREEZ_RP_ID() { return BREEZ_RP_ID; }
131
+
132
+ /**
133
+ * Default `rpName` for the zero-config {@link PasskeyClient} /
134
+ * {@link PasskeyClientBuilder} path.
135
+ */
136
+ static get DEFAULT_RP_NAME() { return DEFAULT_RP_NAME; }
137
+
138
+ /**
139
+ * @param {import('../breez_sdk_spark_wasm.js').PasskeyProviderOptions} [options]
140
+ * Relying Party and user identity. `rpId` defaults to
141
+ * `PasskeyProvider.BREEZ_RP_ID`, `rpName` to `"Breez"`, `userName` to
142
+ * `rpName`, `userDisplayName` to `userName`. The same
143
+ * `PasskeyProviderOptions` is settable on `PasskeyConfig` for the
144
+ * zero-config client.
145
+ * @param {object} [webOptions] - Web-only knobs.
146
+ * @param {'platform'|'cross-platform'} [webOptions.authenticatorAttachment]
147
+ * Narrows the create-time chooser to one authenticator class.
148
+ * `'platform'` allows only the local authenticator (Touch ID, Face
149
+ * ID, Windows Hello, iCloud Keychain); `'cross-platform'` only
150
+ * roaming keys (USB, NFC, BLE, hybrid). Unset shows all.
151
+ * @param {Array<'client-device'|'security-key'|'hybrid'>} [webOptions.hints]
152
+ * WebAuthn L3 priority hints applied to both create and get,
153
+ * ordering the classes a supporting browser offers first (ignored
154
+ * otherwise). Pass `['client-device']` to favor the platform
155
+ * authenticator. Only standards-track lever for the sign-in picker,
156
+ * where `authenticatorAttachment` is not allowed.
157
+ * @param {number} [webOptions.defaultTimeoutMs] - Default WebAuthn
158
+ * `timeout` (ms) for create and get. A hint only: platforms cap
159
+ * around 60s. Set it 5 to 10s under the cap so a host-side timeout
160
+ * heuristic can fire first.
161
+ */
162
+ constructor(options = {}, webOptions = {}) {
163
+ const rpId = options.rpId ?? BREEZ_RP_ID;
164
+ const rpName = options.rpName ?? DEFAULT_RP_NAME;
165
+ if (typeof rpId !== 'string' || rpId.length === 0) {
166
+ throw new Error('PasskeyProvider: rpId must be a non-empty string.');
167
+ }
168
+ if (typeof rpName !== 'string' || rpName.length === 0) {
169
+ throw new Error('PasskeyProvider: rpName must be a non-empty string.');
170
+ }
171
+ this.rpId = rpId;
172
+ this.rpName = rpName;
173
+ this.userName = options.userName || this.rpName;
174
+ this.userDisplayName = options.userDisplayName || this.userName;
175
+ this.authenticatorAttachment = webOptions.authenticatorAttachment;
176
+ this.hints = webOptions.hints;
177
+ this.defaultTimeoutMs = webOptions.defaultTimeoutMs;
178
+
179
+ /**
180
+ * Credential ID asserted in the most recent ceremony. Reset at
181
+ * the start of {@link deriveSeeds}, read into its return value.
182
+ * @private
183
+ */
184
+ this._lastObservedCredentialId = null;
185
+ }
186
+
187
+ /**
188
+ * Single-salt seed derivation. Private helper backing
189
+ * {@link deriveSeeds}; the public surface only exposes the bulk
190
+ * form.
191
+ * @private
192
+ */
193
+ async _deriveSeed(salt, options = {}) {
194
+ const saltBytes = new TextEncoder().encode(salt);
195
+ return await this._getAssertionWithPrf(saltBytes, options);
196
+ }
197
+
198
+ /**
199
+ * Derive one 32-byte PRF output per salt (in input order), pairing
200
+ * salts into a single ceremony where the authenticator supports it.
201
+ * Worst case is one prompt per salt.
202
+ *
203
+ * @param {string[]} salts - Caller-ordered.
204
+ * @param {DeriveSeedOptions} [options]
205
+ * @returns {Promise<{ seeds: Uint8Array[], credentialId: Uint8Array | null }>}
206
+ * One output per salt plus the credential ID observed in the same
207
+ * assertion (null when none was seen).
208
+ */
209
+ async deriveSeeds(salts, options = {}) {
210
+ if (!Array.isArray(salts) || salts.length === 0) {
211
+ return { seeds: [], credentialId: null };
212
+ }
213
+
214
+ // Reset so the result reflects only this call's ceremonies.
215
+ this._lastObservedCredentialId = null;
216
+
217
+ const seeds = [];
218
+ if (salts.length === 1) {
219
+ seeds.push(await this._deriveSeed(salts[0], options));
220
+ return { seeds, credentialId: this._lastObservedCredentialId };
221
+ }
222
+
223
+ // After the first assertion, pin the rest of this call to the
224
+ // credential it resolved to, so every salt derives from one passkey
225
+ // even when a chunk splits (dropped `prf.eval.second`, or 3+ salts).
226
+ let activeOptions = options;
227
+ const pinToObserved = () => {
228
+ if (this._lastObservedCredentialId != null) {
229
+ activeOptions = { ...options, _pinnedCredentialId: this._lastObservedCredentialId };
230
+ }
231
+ };
232
+
233
+ let idx = 0;
234
+ while (idx < salts.length) {
235
+ if (idx + 1 < salts.length) {
236
+ const pair = await this._tryDualSaltAssertion(salts[idx], salts[idx + 1], activeOptions);
237
+ pinToObserved();
238
+ seeds.push(pair[0]);
239
+ if (pair[1] != null) {
240
+ seeds.push(pair[1]);
241
+ idx += 2;
242
+ continue;
243
+ }
244
+ seeds.push(await this._deriveSeed(salts[idx + 1], activeOptions));
245
+ idx += 2;
246
+ } else {
247
+ seeds.push(await this._deriveSeed(salts[idx], activeOptions));
248
+ idx += 1;
249
+ }
250
+ }
251
+ return { seeds, credentialId: this._lastObservedCredentialId };
252
+ }
253
+
254
+ /**
255
+ * Register a new PRF-capable passkey (one ceremony, no seed
256
+ * derivation). Browsers allow multiple credentials per RP, so pass
257
+ * `excludeCredentials` (already-registered IDs) to surface a repeat
258
+ * registration as `PasskeyAlreadyExistsError`.
259
+ *
260
+ * @param {Uint8Array[]} [excludeCredentials]
261
+ * @returns {Promise<PasskeyCredential>} `aaguid`/`backupEligible`
262
+ * are null on browsers without `getAuthenticatorData()`.
263
+ */
264
+ async createPasskey(excludeCredentials = []) {
265
+ return await this._registerCredential(excludeCredentials);
266
+ }
267
+
268
+ /**
269
+ * Check if a PRF-capable passkey is available on this device.
270
+ *
271
+ * @returns {Promise<boolean>} true if WebAuthn with PRF extension is likely supported.
272
+ */
273
+ async isSupported() {
274
+ try {
275
+ if (typeof window === 'undefined' || !window.PublicKeyCredential) {
276
+ return false;
277
+ }
278
+ if (typeof PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable !== 'function') {
279
+ return false;
280
+ }
281
+ return await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
282
+ } catch {
283
+ return false;
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Check whether the configured rpId is a valid WebAuthn scope for
289
+ * the current origin (must be a registrable suffix of
290
+ * `window.location.hostname`, or equal to it). Mirrors the browser's
291
+ * own rule so a misconfigured rpId is diagnosed with a concrete
292
+ * reason instead of an opaque `SecurityError` at ceremony time.
293
+ * `Skipped` (no `window.location`) is never a false-negative: the
294
+ * browser enforces the rule itself at call time.
295
+ *
296
+ * @returns {Promise<{kind: 'Associated'} |
297
+ * {kind: 'NotAssociated', source: string, reason: string} |
298
+ * {kind: 'Skipped', reason: string}>}
299
+ */
300
+ async checkDomainAssociation() {
301
+ if (typeof window === 'undefined' || !window.location || !window.location.hostname) {
302
+ return {
303
+ kind: 'Skipped',
304
+ reason: 'No window.location context (SSR / test runner); browser will enforce rpId scope at WebAuthn call time',
305
+ };
306
+ }
307
+
308
+ const hostname = window.location.hostname.toLowerCase();
309
+ const rpId = (this.rpId || '').toLowerCase();
310
+
311
+ if (!rpId) {
312
+ return {
313
+ kind: 'NotAssociated',
314
+ source: 'WebAuthn rpId scope check',
315
+ reason: 'Provider was constructed with empty rpId; WebAuthn ceremonies will fail',
316
+ };
317
+ }
318
+
319
+ if (rpId === hostname) {
320
+ return { kind: 'Associated' };
321
+ }
322
+
323
+ // Registrable-suffix rule: rpId must be an ancestor domain of
324
+ // hostname (rpId="example.com" is valid at "app.example.com").
325
+ // Dot-aligned suffix is the spec shortcut; skipping the full
326
+ // Public Suffix List check (would reject rpId="co.uk") is fine
327
+ // for Breez's deployment profile and avoids a heavy dependency.
328
+ if (hostname.endsWith('.' + rpId)) {
329
+ return { kind: 'Associated' };
330
+ }
331
+
332
+ return {
333
+ kind: 'NotAssociated',
334
+ source: 'WebAuthn rpId scope check',
335
+ reason: `rpId "${rpId}" is not a registrable suffix of window.location.hostname "${hostname}". ` +
336
+ `WebAuthn ceremonies from this origin will fail with SecurityError.`,
337
+ };
338
+ }
339
+
340
+ /**
341
+ * @param {Uint8Array} saltBytes
342
+ * @param {DeriveSeedOptions} options
343
+ * @returns {Promise<Uint8Array>}
344
+ * @private
345
+ */
346
+ async _getAssertionWithPrf(saltBytes, options) {
347
+ const credential = await this._performAssertion(
348
+ { first: saltBytes },
349
+ options,
350
+ );
351
+ const ext = credential.getClientExtensionResults();
352
+ if (!ext.prf || !ext.prf.results || !ext.prf.results.first) {
353
+ throw new Error('PRF not supported by authenticator');
354
+ }
355
+ return new Uint8Array(ext.prf.results.first);
356
+ }
357
+
358
+ /**
359
+ * Dual-salt assertion. Returns `[first, second|null]`; `second` is
360
+ * null when the authenticator drops `prf.eval.second`.
361
+ *
362
+ * @param {string} salt1
363
+ * @param {string} salt2
364
+ * @param {DeriveSeedOptions} options
365
+ * @returns {Promise<[Uint8Array, Uint8Array|null]>}
366
+ * @private
367
+ */
368
+ async _tryDualSaltAssertion(salt1, salt2, options) {
369
+ const enc = new TextEncoder();
370
+ const salt1Bytes = enc.encode(salt1);
371
+ const salt2Bytes = enc.encode(salt2);
372
+ return await this._getDualSaltAssertionWithPrf(salt1Bytes, salt2Bytes, options);
373
+ }
374
+
375
+ /**
376
+ * @param {Uint8Array} salt1Bytes
377
+ * @param {Uint8Array} salt2Bytes
378
+ * @param {DeriveSeedOptions} options
379
+ * @returns {Promise<[Uint8Array, Uint8Array|null]>}
380
+ * @private
381
+ */
382
+ async _getDualSaltAssertionWithPrf(salt1Bytes, salt2Bytes, options) {
383
+ const credential = await this._performAssertion(
384
+ { first: salt1Bytes, second: salt2Bytes },
385
+ options,
386
+ );
387
+ const ext = credential.getClientExtensionResults();
388
+ if (!ext.prf || !ext.prf.results || !ext.prf.results.first) {
389
+ throw new Error('PRF not supported by authenticator');
390
+ }
391
+ const first = new Uint8Array(ext.prf.results.first);
392
+ const second = ext.prf.results.second
393
+ ? new Uint8Array(ext.prf.results.second)
394
+ : null;
395
+ return [first, second];
396
+ }
397
+
398
+ /**
399
+ * Build assertion options and run the ceremony. Shared by single-
400
+ * and dual-salt paths.
401
+ *
402
+ * @param {{ first: Uint8Array, second?: Uint8Array }} prfEval
403
+ * @param {DeriveSeedOptions} options
404
+ * @returns {Promise<PublicKeyCredential>}
405
+ * @private
406
+ */
407
+ async _performAssertion(prfEval, options) {
408
+ let allowList;
409
+ if (options._pinnedCredentialId != null) {
410
+ // A later assertion in a multi-ceremony derive: pin to the
411
+ // credential the first one resolved to, so the pin can't be
412
+ // re-widened to another credential.
413
+ allowList = [options._pinnedCredentialId];
414
+ } else {
415
+ allowList = options.allowCredentials || [];
416
+ }
417
+ const allowCredentials = allowList.map((id) => ({
418
+ type: 'public-key',
419
+ id,
420
+ }));
421
+
422
+ const publicKey = {
423
+ challenge: randomBytes(32),
424
+ rpId: this.rpId,
425
+ allowCredentials,
426
+ userVerification: 'required',
427
+ extensions: { prf: { eval: prfEval } },
428
+ };
429
+ if (Array.isArray(this.hints) && this.hints.length > 0) {
430
+ publicKey.hints = [...this.hints];
431
+ }
432
+ if (typeof this.defaultTimeoutMs === 'number' && this.defaultTimeoutMs > 0) {
433
+ publicKey.timeout = this.defaultTimeoutMs;
434
+ }
435
+
436
+ const requestOptions = { publicKey };
437
+
438
+ let credential;
439
+ const startedAt = (typeof performance !== 'undefined' && performance.now)
440
+ ? performance.now()
441
+ : Date.now();
442
+ try {
443
+ credential = await navigator.credentials.get(requestOptions);
444
+ } catch (error) {
445
+ const elapsed = ((typeof performance !== 'undefined' && performance.now)
446
+ ? performance.now()
447
+ : Date.now()) - startedAt;
448
+ throw this._mapAssertionError(error, elapsed);
449
+ }
450
+ if (!credential) {
451
+ throw new PasskeyCredentialNotFoundError();
452
+ }
453
+
454
+ this._lastObservedCredentialId = new Uint8Array(credential.rawId);
455
+ return credential;
456
+ }
457
+
458
+ /**
459
+ * Register a new discoverable credential with PRF extension enabled.
460
+ * @param {Uint8Array[]} [excludeCredentials=[]] - Already-registered
461
+ * IDs; a match makes the authenticator refuse, preventing a
462
+ * duplicate registration on the same device.
463
+ * @returns {Promise<{ credentialId: Uint8Array, userId: Uint8Array, aaguid: Uint8Array | null, backupEligible: boolean | null }>}
464
+ * @private
465
+ */
466
+ async _registerCredential(excludeCredentials = []) {
467
+ // Fresh per-call user.id: reusing one across creates on the same
468
+ // rpId silently overwrites the prior credential on some
469
+ // authenticators. (WebAuthn requires 1 to 64 bytes.)
470
+ const resolvedUserId = randomBytes(16);
471
+
472
+ const authenticatorSelection = {
473
+ residentKey: 'required',
474
+ requireResidentKey: true,
475
+ userVerification: 'required',
476
+ };
477
+ if (this.authenticatorAttachment) {
478
+ authenticatorSelection.authenticatorAttachment = this.authenticatorAttachment;
479
+ }
480
+
481
+ const publicKeyOptions = {
482
+ challenge: randomBytes(32),
483
+ rp: {
484
+ id: this.rpId,
485
+ name: this.rpName,
486
+ },
487
+ user: {
488
+ id: resolvedUserId,
489
+ name: this.userName,
490
+ displayName: this.userDisplayName,
491
+ },
492
+ pubKeyCredParams: [
493
+ { type: 'public-key', alg: -7 }, // ES256 (P-256)
494
+ { type: 'public-key', alg: -257 }, // RS256
495
+ ],
496
+ authenticatorSelection,
497
+ // Explicit so future security review can't read it as ambient.
498
+ attestation: 'none',
499
+ extensions: { prf: {} },
500
+ };
501
+
502
+ if (Array.isArray(this.hints) && this.hints.length > 0) {
503
+ // Defensive copy; the host could otherwise mutate mid-ceremony.
504
+ publicKeyOptions.hints = [...this.hints];
505
+ }
506
+
507
+ if (Array.isArray(excludeCredentials) && excludeCredentials.length > 0) {
508
+ publicKeyOptions.excludeCredentials = excludeCredentials.map((id) => ({
509
+ type: 'public-key',
510
+ id,
511
+ }));
512
+ }
513
+
514
+ if (typeof this.defaultTimeoutMs === 'number' && this.defaultTimeoutMs > 0) {
515
+ publicKeyOptions.timeout = this.defaultTimeoutMs;
516
+ }
517
+
518
+ const createOptions = { publicKey: publicKeyOptions };
519
+
520
+ let credential;
521
+ const createStartedAt = (typeof performance !== 'undefined' && performance.now)
522
+ ? performance.now()
523
+ : Date.now();
524
+ try {
525
+ credential = await navigator.credentials.create(createOptions);
526
+ } catch (error) {
527
+ // The browser raises InvalidStateError when an
528
+ // excludeCredentials entry already exists on the device.
529
+ // Surface it as a typed error so callers can route to sign-in.
530
+ if (error instanceof DOMException && error.name === 'InvalidStateError') {
531
+ throw new PasskeyAlreadyExistsError(error.message);
532
+ }
533
+ const elapsed = ((typeof performance !== 'undefined' && performance.now)
534
+ ? performance.now()
535
+ : Date.now()) - createStartedAt;
536
+ throw this._mapError(error, elapsed);
537
+ }
538
+
539
+ if (!credential) {
540
+ throw new Error('Credential creation failed');
541
+ }
542
+
543
+ // Fail loudly if the provider didn't acknowledge PRF: without it
544
+ // the assertion side fails silently later, and WebAuthn has no
545
+ // deletion API so the orphan credential lingers until removed in
546
+ // OS settings.
547
+ const extensionResults = credential.getClientExtensionResults();
548
+ if (!extensionResults.prf || !extensionResults.prf.enabled) {
549
+ throw new Error(
550
+ 'Passkey created, but the active credential provider does not '
551
+ + 'support the WebAuthn PRF extension. On iOS, only iCloud '
552
+ + 'Keychain currently supports PRF: switch the default '
553
+ + 'provider in Settings → Passwords → Password Options. '
554
+ + 'The orphan passkey can be removed in the same settings panel.'
555
+ );
556
+ }
557
+
558
+ const meta = extractRegistrationMetadata(credential);
559
+ return {
560
+ credentialId: new Uint8Array(credential.rawId),
561
+ userId: resolvedUserId,
562
+ aaguid: meta ? meta.aaguid : null,
563
+ backupEligible: meta ? meta.backupEligible : null,
564
+ };
565
+ }
566
+
567
+ /**
568
+ * Threshold (ms) at which an elapsed `NotAllowedError` is treated as
569
+ * a timeout rather than a user cancel. The OS biometric inactivity
570
+ * window is the ceiling; a host-configured `defaultTimeoutMs` shorter
571
+ * than that wins so a real timeout near the host limit isn't
572
+ * misreported as a cancel.
573
+ * @returns {number}
574
+ * @private
575
+ */
576
+ _cancelVsTimeoutThresholdMs() {
577
+ return (typeof this.defaultTimeoutMs === 'number'
578
+ && this.defaultTimeoutMs > 0
579
+ && this.defaultTimeoutMs < BIOMETRIC_TIMEOUT_MS)
580
+ ? this.defaultTimeoutMs
581
+ : BIOMETRIC_TIMEOUT_MS;
582
+ }
583
+
584
+ /**
585
+ * Map a `navigator.credentials.get` failure into a typed error.
586
+ * `elapsedMs` resolves the `NotAllowedError` ambiguity (cancel vs
587
+ * no-credential vs timeout, which all share the error) by elapsed
588
+ * time, since only the cancel path shows dismissable UI.
589
+ * @param {Error} error
590
+ * @param {number} elapsedMs
591
+ * @returns {Error}
592
+ * @private
593
+ */
594
+ _mapAssertionError(error, elapsedMs) {
595
+ if (!error) return new Error('Unknown WebAuthn error');
596
+ if (error.name === 'NotAllowedError') {
597
+ if (elapsedMs < NO_CRED_FAST_FAIL_MS) {
598
+ return new PasskeyCredentialNotFoundError();
599
+ }
600
+ if (elapsedMs >= this._cancelVsTimeoutThresholdMs()) {
601
+ return new PasskeyTimedOutError();
602
+ }
603
+ return new PasskeyUserCancelledError();
604
+ }
605
+ return this._mapError(error);
606
+ }
607
+
608
+ /**
609
+ * Map non-assertion WebAuthn errors (registration path).
610
+ * @param {Error} error
611
+ * @param {number} [elapsedMs] When given, reclassifies a long-running
612
+ * NotAllowedError as a timeout instead of a user-cancel; otherwise
613
+ * a substring heuristic applies.
614
+ * @returns {Error}
615
+ * @private
616
+ */
617
+ _mapError(error, elapsedMs) {
618
+ if (!error) return new Error('Unknown WebAuthn error');
619
+ switch (error.name) {
620
+ case 'NotAllowedError':
621
+ if (typeof elapsedMs === 'number'
622
+ && elapsedMs >= this._cancelVsTimeoutThresholdMs()) {
623
+ return new PasskeyTimedOutError();
624
+ }
625
+ // Registration NotAllowedError isn't usefully timed
626
+ // (no fast-fail equivalent), so keep the substring
627
+ // heuristic and fall back to the raw error.
628
+ if (error.message && (
629
+ error.message.includes('cancelled') ||
630
+ error.message.includes('canceled') ||
631
+ error.message.includes('denied') ||
632
+ error.message.includes('The operation either timed out or was not allowed')
633
+ )) {
634
+ return new PasskeyUserCancelledError();
635
+ }
636
+ return error;
637
+ case 'SecurityError':
638
+ case 'InvalidStateError':
639
+ return new Error(`Authentication failed: ${error.message}`);
640
+ case 'AbortError':
641
+ return new PasskeyUserCancelledError();
642
+ default:
643
+ return error;
644
+ }
645
+ }
646
+ }
647
+
648
+ /**
649
+ * Builder for a {@link PasskeyClient} with a caller-supplied
650
+ * `PrfProvider`. Use it when you need a configured {@link PasskeyProvider}
651
+ * (web-specific options like `authenticatorAttachment` or timeout overrides)
652
+ * or a custom PRF backend.
653
+ * For the zero-config Breez-RP case, use the {@link PasskeyClient}
654
+ * constructor directly.
655
+ *
656
+ * @example
657
+ * ```javascript
658
+ * const provider = new PasskeyProvider({ rpId, rpName })
659
+ * const client = new PasskeyClientBuilder(breezApiKey)
660
+ * .withPrfProvider(provider)
661
+ * .build()
662
+ * ```
663
+ */
664
+ export class PasskeyClientBuilder {
665
+ /**
666
+ * @param {string} [breezApiKey] - Breez relay key for authenticated
667
+ * (NIP-42) label storage. Omit for public relays only.
668
+ * @param {import('../breez_sdk_spark_wasm.js').PasskeyConfig} [config] -
669
+ * `providerOptions` configures the built-in provider (ignored when
670
+ * one is injected via {@link withPrfProvider}); `defaultLabel` is
671
+ * the label-store default.
672
+ */
673
+ constructor(breezApiKey, config = {}) {
674
+ this._breezApiKey = breezApiKey;
675
+ this._config = config ?? {};
676
+ this._provider = null;
677
+ }
678
+
679
+ /**
680
+ * Inject the `PrfProvider` the client derives seeds through,
681
+ * superseding the config's `providerOptions`.
682
+ * @param {PrfProvider} provider
683
+ * @returns {PasskeyClientBuilder} this, for chaining.
684
+ */
685
+ withPrfProvider(provider) {
686
+ this._provider = provider;
687
+ return this;
688
+ }
689
+
690
+ /**
691
+ * Build the client, defaulting to a browser {@link PasskeyProvider}
692
+ * on the config's `providerOptions` (default: the Breez RP) when no
693
+ * provider was injected.
694
+ * @returns {import('../breez_sdk_spark_wasm.js').PasskeyClient}
695
+ */
696
+ build() {
697
+ const provider =
698
+ this._provider ??
699
+ new PasskeyProvider(this._config.providerOptions ?? {});
700
+ return new SdkPasskeyClient(provider, this._breezApiKey, this._config);
701
+ }
702
+ }
703
+
704
+ /**
705
+ * High-level passkey client. The zero-config constructor wires the
706
+ * built-in browser {@link PasskeyProvider} on the Breez shared RP, so a
707
+ * Breez-registered app needs only its relay key.
708
+ *
709
+ * ```javascript
710
+ * const client = new PasskeyClient(breezApiKey)
711
+ * const { wallet } = await client.signIn({ label: 'personal' })
712
+ * ```
713
+ *
714
+ * Apps with their own RP or a custom PRF backend inject their own
715
+ * provider through {@link PasskeyClientBuilder}. The instance is the
716
+ * underlying SDK client (`checkAvailability`, `register`, `signIn`,
717
+ * `labels()`).
718
+ */
719
+ export class PasskeyClient {
720
+ /**
721
+ * @param {string} [breezApiKey] - Breez relay key for authenticated
722
+ * (NIP-42) label storage. Omit for public relays only.
723
+ * @param {import('../breez_sdk_spark_wasm.js').PasskeyConfig} [config] -
724
+ * Optional `providerOptions` for the built-in provider (default: the
725
+ * Breez shared RP) plus `defaultLabel`.
726
+ * @returns {import('../breez_sdk_spark_wasm.js').PasskeyClient}
727
+ */
728
+ constructor(breezApiKey, config) {
729
+ // Returning an object from a constructor yields it from `new`, so
730
+ // callers get the underlying SDK client with no delegation layer.
731
+ return new PasskeyClientBuilder(breezApiKey, config).build();
732
+ }
733
+ }