@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.
- package/breez-sdk-spark.tgz +0 -0
- package/bundler/breez_sdk_spark_wasm.d.ts +511 -215
- package/bundler/breez_sdk_spark_wasm.js +1 -1
- package/bundler/breez_sdk_spark_wasm_bg.js +567 -414
- package/bundler/breez_sdk_spark_wasm_bg.wasm +0 -0
- package/bundler/breez_sdk_spark_wasm_bg.wasm.d.ts +55 -46
- package/bundler/storage/index.js +205 -15
- package/deno/breez_sdk_spark_wasm.d.ts +511 -215
- package/deno/breez_sdk_spark_wasm.js +567 -414
- package/deno/breez_sdk_spark_wasm_bg.wasm +0 -0
- package/deno/breez_sdk_spark_wasm_bg.wasm.d.ts +55 -46
- package/nodejs/breez_sdk_spark_wasm.d.ts +511 -215
- package/nodejs/breez_sdk_spark_wasm.js +578 -421
- package/nodejs/breez_sdk_spark_wasm_bg.wasm +0 -0
- package/nodejs/breez_sdk_spark_wasm_bg.wasm.d.ts +55 -46
- package/nodejs/index.js +10 -10
- package/nodejs/index.mjs +12 -8
- package/nodejs/mysql-session-store/errors.cjs +13 -0
- package/nodejs/{mysql-session-manager → mysql-session-store}/index.cjs +24 -21
- package/nodejs/{mysql-session-manager → mysql-session-store}/migrations.cjs +17 -11
- package/nodejs/mysql-session-store/package.json +9 -0
- package/nodejs/mysql-storage/index.cjs +229 -111
- package/nodejs/mysql-storage/migrations.cjs +37 -2
- package/nodejs/mysql-token-store/index.cjs +99 -79
- package/nodejs/mysql-token-store/migrations.cjs +59 -2
- package/nodejs/mysql-tree-store/index.cjs +15 -9
- package/nodejs/mysql-tree-store/migrations.cjs +16 -2
- package/nodejs/package.json +2 -2
- package/nodejs/postgres-session-store/errors.cjs +13 -0
- package/nodejs/{postgres-session-manager → postgres-session-store}/index.cjs +23 -23
- package/nodejs/{postgres-session-manager → postgres-session-store}/migrations.cjs +14 -14
- package/nodejs/postgres-session-store/package.json +9 -0
- package/nodejs/postgres-storage/index.cjs +174 -107
- package/nodejs/postgres-storage/migrations.cjs +24 -0
- package/nodejs/postgres-token-store/index.cjs +89 -64
- package/nodejs/postgres-token-store/migrations.cjs +44 -0
- package/nodejs/storage/index.cjs +167 -113
- package/nodejs/storage/migrations.cjs +23 -0
- package/package.json +6 -1
- package/ssr/index.js +52 -28
- package/web/breez_sdk_spark_wasm.d.ts +566 -261
- package/web/breez_sdk_spark_wasm.js +567 -414
- package/web/breez_sdk_spark_wasm_bg.wasm +0 -0
- package/web/breez_sdk_spark_wasm_bg.wasm.d.ts +55 -46
- package/web/passkey-prf-provider/index.d.ts +203 -0
- package/web/passkey-prf-provider/index.js +733 -0
- package/web/storage/index.js +205 -15
- package/nodejs/mysql-session-manager/errors.cjs +0 -13
- package/nodejs/mysql-session-manager/package.json +0 -9
- package/nodejs/postgres-session-manager/errors.cjs +0 -13
- 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
|
+
}
|