@txnlab/use-wallet-passkey 0.0.0-stage → 5.2.0-next.3

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/dist/index.js ADDED
@@ -0,0 +1,730 @@
1
+ import algosdk from "algosdk";
2
+ import { canonify } from "canonify";
3
+ import { BaseWallet, ScopeType, SignDataError, base64ToByteArray, byteArrayToBase64, encodeEmptySignature, flattenTxnGroup, isSignedTxn, isTransactionArray } from "@txnlab/use-wallet/adapter";
4
+ import { sha512_256 } from "@noble/hashes/sha2";
5
+ //#region src/derivation.ts
6
+ /**
7
+ * The derivation from a passkey's PRF output to a Falcon-1024 account.
8
+ *
9
+ * ```
10
+ * entropy = PRF(eval.first = PRF_LABEL_PREFIX + context) // 32 bytes
11
+ * keygenSeed = SHA-512/256("PQK" || "f1" || entropy)
12
+ * {pk, sk} = falcon-1024 generateKey(keygenSeed) // falcon_det1024
13
+ * address = algosdk.addressFromPQKey(FALCON_1024_SCHEME, pk)
14
+ * phrase = algosdk.mnemonicFromSeed(entropy) // 25 words
15
+ * ```
16
+ *
17
+ * The keygen seed matches go-algorand's `algokey pq` (`derivePQKeySeed`),
18
+ * algosdk's `pq25WordMnemonicToSeed`, Lute's Falcon25 accounts, Pera and
19
+ * ARC-76, so the recovery phrase restores the same account in any of them.
20
+ *
21
+ * NEVER CHANGE `PRF_LABEL_PREFIX`: the label is the PRF input, so changing it
22
+ * changes every account derived from it.
23
+ */
24
+ /** The PRF input prefix. Frozen: changing it moves every account. */
25
+ const PRF_LABEL_PREFIX = "@txnlab/use-wallet-passkey:falcon-1024:v1:";
26
+ /**
27
+ * A `context` may not contain `:`, so a future account slot can be encoded as
28
+ * a `:<n>` suffix without colliding with any slot-0 label.
29
+ */
30
+ const CONTEXT_PATTERN = /^[^:]{0,128}$/;
31
+ function assertValidContext(context) {
32
+ if (!CONTEXT_PATTERN.test(context)) throw new Error("passkey: `context` must be at most 128 characters and must not contain \":\"");
33
+ }
34
+ /** The PRF input (`eval.first`) for an account context */
35
+ function prfInput(context) {
36
+ assertValidContext(context);
37
+ return new TextEncoder().encode(PRF_LABEL_PREFIX + context);
38
+ }
39
+ const PQK_PREFIX = new Uint8Array([
40
+ 80,
41
+ 81,
42
+ 75,
43
+ 102,
44
+ 49
45
+ ]);
46
+ /** keygenSeed = SHA-512/256("PQK" || "f1" || entropy) */
47
+ function keygenSeedFromEntropy(entropy) {
48
+ if (entropy.length !== 32) throw new Error("entropy must be 32 bytes");
49
+ const input = new Uint8Array(PQK_PREFIX.length + entropy.length);
50
+ input.set(PQK_PREFIX);
51
+ input.set(entropy, PQK_PREFIX.length);
52
+ try {
53
+ return sha512_256(input);
54
+ } finally {
55
+ input.fill(0);
56
+ }
57
+ }
58
+ //#endregion
59
+ //#region src/errors.ts
60
+ var PasskeyError = class extends Error {
61
+ code;
62
+ constructor(code, message, options) {
63
+ super(message, options);
64
+ this.name = "PasskeyError";
65
+ this.code = code;
66
+ }
67
+ };
68
+ /**
69
+ * Classifies a WebAuthn failure after the fact. Never gate on user-agent
70
+ * sniffing up front: these errors are the only reliable signal.
71
+ */
72
+ function toPasskeyError(error) {
73
+ if (error instanceof PasskeyError) return error;
74
+ const name = error instanceof Error ? error.name : void 0;
75
+ const message = error instanceof Error ? error.message : String(error);
76
+ switch (name) {
77
+ case "NotAllowedError":
78
+ case "AbortError": return new PasskeyError("cancelled", "The passkey request was cancelled or timed out. In an in-app browser, open the page in Safari or Chrome.", { cause: error });
79
+ case "NotSupportedError": return new PasskeyError("unsupported", "This browser does not support passkeys here. Open the page in a full browser.", { cause: error });
80
+ case "SecurityError": return new PasskeyError("invalid-rp-id", `The passkey relying party ID is not valid for this origin: ${message}`, { cause: error });
81
+ default: return new PasskeyError("failed", message, { cause: error });
82
+ }
83
+ }
84
+ //#endregion
85
+ //#region src/icon.ts
86
+ const icon = `
87
+ <svg viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
88
+ <rect fill="#1F2A44" width="40" height="40" rx="8"/>
89
+ <circle cx="16" cy="13.5" r="5" fill="none" stroke="#FFFFFF" stroke-width="2.5"/>
90
+ <path d="M7.5 30c0-4.7 3.8-8.5 8.5-8.5 1.6 0 3.1.4 4.3 1.2" fill="none" stroke="#FFFFFF" stroke-width="2.5" stroke-linecap="round"/>
91
+ <circle cx="27" cy="23" r="3.6" fill="none" stroke="#7DD3FC" stroke-width="2.4"/>
92
+ <path d="M27 26.6V33m0-3h2.6M27 32.4h2" fill="none" stroke="#7DD3FC" stroke-width="2.4" stroke-linecap="round"/>
93
+ </svg>
94
+ `;
95
+ //#endregion
96
+ //#region src/key-store.ts
97
+ /**
98
+ * The built-in key store: a dedicated module worker started from a Blob URL.
99
+ * The worker module is inlined into this package, so a consumer's bundler has
100
+ * no worker entry to resolve; the page's CSP must allow `worker-src blob:` and
101
+ * `'wasm-unsafe-eval'`. `lock` terminates the worker, which drops its WASM
102
+ * heap.
103
+ */
104
+ var WorkerKeyStore = class {
105
+ options;
106
+ worker = null;
107
+ isUnlocked = false;
108
+ nextId = 1;
109
+ pending = /* @__PURE__ */ new Map();
110
+ constructor(options = {}) {
111
+ this.options = options;
112
+ }
113
+ get unlocked() {
114
+ return this.isUnlocked;
115
+ }
116
+ async unlock(entropy) {
117
+ const buffer = new Uint8Array(entropy).buffer;
118
+ entropy.fill(0);
119
+ this.lock();
120
+ try {
121
+ this.worker = await this.start();
122
+ const publicKey = await this.call({
123
+ op: "unlock",
124
+ entropy: buffer,
125
+ idleTimeoutMs: this.options.idleTimeoutMs
126
+ }, [buffer]);
127
+ this.isUnlocked = true;
128
+ return publicKey;
129
+ } catch (error) {
130
+ new Uint8Array(buffer).fill(0);
131
+ this.lock();
132
+ throw error;
133
+ }
134
+ }
135
+ sign(bytes) {
136
+ if (!this.isUnlocked) return Promise.reject(new PasskeyError("key-store-failed", "The passkey key store is locked."));
137
+ return this.call({
138
+ op: "sign",
139
+ bytes
140
+ });
141
+ }
142
+ lock() {
143
+ this.isUnlocked = false;
144
+ if (!this.worker) return;
145
+ this.worker.terminate();
146
+ this.worker = null;
147
+ const error = new PasskeyError("key-store-failed", "The passkey key store was locked.");
148
+ for (const { reject } of this.pending.values()) reject(error);
149
+ this.pending.clear();
150
+ }
151
+ async start() {
152
+ const { WORKER_SOURCE } = await import("./source.generated-CISa6YOa.js");
153
+ const { startTimeoutMs = 1e4 } = this.options;
154
+ const url = URL.createObjectURL(new Blob([WORKER_SOURCE], { type: "text/javascript" }));
155
+ return new Promise((resolve, reject) => {
156
+ let worker;
157
+ try {
158
+ worker = new Worker(url, {
159
+ type: "module",
160
+ name: "use-wallet-passkey"
161
+ });
162
+ } catch (error) {
163
+ URL.revokeObjectURL(url);
164
+ reject(this.startError(error));
165
+ return;
166
+ }
167
+ const done = () => {
168
+ clearTimeout(timer);
169
+ URL.revokeObjectURL(url);
170
+ };
171
+ const timer = setTimeout(() => {
172
+ done();
173
+ worker.terminate();
174
+ reject(new PasskeyError("key-store-failed", `The passkey key worker did not start within ${startTimeoutMs} ms.`));
175
+ }, startTimeoutMs);
176
+ worker.onmessage = (event) => {
177
+ if (event.data.type !== "ready") return;
178
+ done();
179
+ worker.onmessage = (event) => this.handle(event.data);
180
+ worker.onerror = (event) => {
181
+ event.preventDefault();
182
+ this.lock();
183
+ };
184
+ resolve(worker);
185
+ };
186
+ worker.onerror = (event) => {
187
+ event.preventDefault();
188
+ done();
189
+ worker.terminate();
190
+ reject(this.startError(event.message));
191
+ };
192
+ });
193
+ }
194
+ startError(cause) {
195
+ const detail = cause instanceof Error ? cause.message : cause ? String(cause) : "";
196
+ return new PasskeyError("key-store-failed", "The passkey key worker failed to start. If the page has a Content Security Policy, it must allow worker-src blob: and script-src 'wasm-unsafe-eval'." + (detail ? ` (${detail})` : ""), { cause });
197
+ }
198
+ call(request, transfer = []) {
199
+ const worker = this.worker;
200
+ if (!worker) return Promise.reject(new PasskeyError("key-store-failed", "The passkey key store is locked."));
201
+ const id = this.nextId++;
202
+ return new Promise((resolve, reject) => {
203
+ this.pending.set(id, {
204
+ resolve,
205
+ reject
206
+ });
207
+ worker.postMessage({
208
+ ...request,
209
+ id
210
+ }, transfer);
211
+ });
212
+ }
213
+ handle(message) {
214
+ if (message.type === "locked") {
215
+ this.lock();
216
+ this.options.onIdleLock?.();
217
+ return;
218
+ }
219
+ if (message.type !== "result") return;
220
+ const entry = this.pending.get(message.id);
221
+ if (!entry) return;
222
+ this.pending.delete(message.id);
223
+ if (message.ok) entry.resolve(message.value);
224
+ else entry.reject(new PasskeyError("key-store-failed", message.error));
225
+ }
226
+ };
227
+ //#endregion
228
+ //#region src/record.ts
229
+ const LOCAL_STORAGE_PASSKEY_KEY = "@txnlab/use-wallet:v5:passkey";
230
+ function loadRecord() {
231
+ if (typeof localStorage === "undefined") return null;
232
+ try {
233
+ const raw = localStorage.getItem(LOCAL_STORAGE_PASSKEY_KEY);
234
+ if (!raw) return null;
235
+ const stored = JSON.parse(raw);
236
+ if (stored.version !== 1 || typeof stored.rpId !== "string" || typeof stored.context !== "string" || typeof stored.credentialId !== "string" || typeof stored.publicKey !== "string" || typeof stored.salt !== "number" || typeof stored.address !== "string") return null;
237
+ return {
238
+ version: 1,
239
+ rpId: stored.rpId,
240
+ context: stored.context,
241
+ credentialId: base64ToByteArray(stored.credentialId),
242
+ publicKey: base64ToByteArray(stored.publicKey),
243
+ salt: stored.salt,
244
+ address: stored.address
245
+ };
246
+ } catch {
247
+ return null;
248
+ }
249
+ }
250
+ function saveRecord(record) {
251
+ if (typeof localStorage === "undefined") return;
252
+ const stored = {
253
+ ...record,
254
+ credentialId: byteArrayToBase64(record.credentialId),
255
+ publicKey: byteArrayToBase64(record.publicKey)
256
+ };
257
+ localStorage.setItem(LOCAL_STORAGE_PASSKEY_KEY, JSON.stringify(stored));
258
+ }
259
+ function removeRecord() {
260
+ if (typeof localStorage === "undefined") return;
261
+ localStorage.removeItem(LOCAL_STORAGE_PASSKEY_KEY);
262
+ }
263
+ //#endregion
264
+ //#region src/webauthn.ts
265
+ /**
266
+ * WebAuthn ceremonies.
267
+ *
268
+ * Each function calls `navigator.credentials.*` synchronously, before its
269
+ * first `await`. WebKit (every iOS browser) drops the user gesture across an
270
+ * await, so callers must also start the ceremony before awaiting anything.
271
+ *
272
+ * PRF output depends on whether the user was verified (CTAP2 `hmac-secret`
273
+ * keeps separate secrets for verified and presence-only ceremonies), so every
274
+ * ceremony that evaluates the PRF uses `userVerification: 'required'`. Only
275
+ * `assertUser`, which evaluates nothing, may use `'preferred'`.
276
+ */
277
+ function randomBytes(length) {
278
+ return crypto.getRandomValues(new Uint8Array(length));
279
+ }
280
+ function bytesEqual$1(a, b) {
281
+ return a.length === b.length && a.every((byte, i) => byte === b[i]);
282
+ }
283
+ function getCredentials() {
284
+ if (typeof navigator === "undefined" || !navigator.credentials || !globalThis.PublicKeyCredential) throw new PasskeyError("unsupported", "This browser does not support passkeys.");
285
+ return navigator.credentials;
286
+ }
287
+ /** Copy a WebAuthn buffer so the result doesn't alias the credential object */
288
+ function copy(buffer) {
289
+ return ArrayBuffer.isView(buffer) ? new Uint8Array(new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength)) : new Uint8Array(new Uint8Array(buffer));
290
+ }
291
+ /**
292
+ * Create a discoverable passkey with the PRF extension enabled. Returns its
293
+ * credential ID. The create-time `prf.enabled` flag is unreliable (1Password
294
+ * on iOS returns `{}`, Windows Hello reports `false` but works), so callers
295
+ * confirm PRF support with a scoped `evaluatePrf` right after.
296
+ */
297
+ function createPasskey({ rpId, rpName, userName }) {
298
+ let pending;
299
+ try {
300
+ pending = getCredentials().create({ publicKey: {
301
+ rp: {
302
+ id: rpId,
303
+ name: rpName
304
+ },
305
+ user: {
306
+ id: randomBytes(16),
307
+ name: userName,
308
+ displayName: userName
309
+ },
310
+ challenge: randomBytes(32),
311
+ pubKeyCredParams: [
312
+ {
313
+ type: "public-key",
314
+ alg: -8
315
+ },
316
+ {
317
+ type: "public-key",
318
+ alg: -7
319
+ },
320
+ {
321
+ type: "public-key",
322
+ alg: -257
323
+ }
324
+ ],
325
+ authenticatorSelection: {
326
+ residentKey: "required",
327
+ requireResidentKey: true,
328
+ userVerification: "required"
329
+ },
330
+ extensions: { prf: {} }
331
+ } });
332
+ } catch (error) {
333
+ return Promise.reject(toPasskeyError(error));
334
+ }
335
+ return pending.then((credential) => {
336
+ if (!(credential instanceof PublicKeyCredential)) throw new PasskeyError("cancelled", "No passkey was created.");
337
+ return copy(credential.rawId);
338
+ }, (error) => {
339
+ throw toPasskeyError(error);
340
+ });
341
+ }
342
+ /** Run a PRF assertion with `userVerification: 'required'` */
343
+ function evaluatePrf({ rpId, prfInput, credentialId }) {
344
+ let pending;
345
+ try {
346
+ pending = getCredentials().get({ publicKey: {
347
+ challenge: randomBytes(32),
348
+ rpId,
349
+ userVerification: "required",
350
+ allowCredentials: credentialId ? [{
351
+ type: "public-key",
352
+ id: copy(credentialId)
353
+ }] : void 0,
354
+ extensions: { prf: { eval: { first: copy(prfInput) } } }
355
+ } });
356
+ } catch (error) {
357
+ return Promise.reject(toPasskeyError(error));
358
+ }
359
+ return pending.then((credential) => {
360
+ if (!(credential instanceof PublicKeyCredential)) throw new PasskeyError("cancelled", "No passkey was selected.");
361
+ const rawId = copy(credential.rawId);
362
+ if (credentialId && !bytesEqual$1(rawId, credentialId)) throw new PasskeyError("key-mismatch", "A different passkey answered the request.");
363
+ const first = credential.getClientExtensionResults().prf?.results?.first;
364
+ if (!first) throw new PasskeyError("prf-unsupported", "This passkey's provider doesn't support the PRF extension. Use a passkey from a provider that does, such as iCloud Keychain, Google Password Manager, 1Password, Windows Hello or a FIDO2 security key.");
365
+ const prf = copy(first);
366
+ if (prf.length !== 32) {
367
+ prf.fill(0);
368
+ throw new PasskeyError("prf-unsupported", `Unexpected PRF output length: ${prf.length}`);
369
+ }
370
+ return {
371
+ credentialId: rawId,
372
+ prf
373
+ };
374
+ }, (error) => {
375
+ throw toPasskeyError(error);
376
+ });
377
+ }
378
+ /**
379
+ * Run a bare assertion as a consent prompt: no PRF, so it derives nothing and
380
+ * may use `'preferred'`. There is no server to check its signature; it only
381
+ * proves the user approved the prompt on this page.
382
+ */
383
+ function assertUser({ rpId, credentialId, userVerification }) {
384
+ let pending;
385
+ try {
386
+ pending = getCredentials().get({ publicKey: {
387
+ challenge: randomBytes(32),
388
+ rpId,
389
+ userVerification,
390
+ allowCredentials: [{
391
+ type: "public-key",
392
+ id: copy(credentialId)
393
+ }]
394
+ } });
395
+ } catch (error) {
396
+ return Promise.reject(toPasskeyError(error));
397
+ }
398
+ return pending.then((credential) => {
399
+ if (!(credential instanceof PublicKeyCredential)) throw new PasskeyError("cancelled", "The passkey prompt was dismissed.");
400
+ }, (error) => {
401
+ throw toPasskeyError(error);
402
+ });
403
+ }
404
+ //#endregion
405
+ //#region src/adapter.ts
406
+ /**
407
+ * Passkey Wallet Adapter for Algorand
408
+ *
409
+ * Derives a Falcon-1024 (post-quantum) account from a passkey's WebAuthn PRF
410
+ * output. There is no extension, app or seed phrase to set up; the passkey
411
+ * provider (iCloud Keychain, Google Password Manager, 1Password, Windows
412
+ * Hello, a security key) syncs and protects the credential.
413
+ *
414
+ * SECURITY CONSIDERATIONS:
415
+ * - The account is bound to the relying party ID (by default the page's
416
+ * hostname). Whoever controls that domain can run a PRF ceremony and recover
417
+ * the key with one tap, and the account is stranded if the domain is lost,
418
+ * so offer `exportRecoveryPhrase`.
419
+ * - Only public data is persisted: the credential ID, public key, address
420
+ * salt and address. Never the PRF output, the entropy or the private key.
421
+ * - The private key lives in a dedicated worker, never on the main thread.
422
+ * Without `session`, the worker is terminated after every request.
423
+ */
424
+ const ICON = `data:image/svg+xml;base64,${btoa(icon)}`;
425
+ const DEFAULT_IDLE_TIMEOUT_MS = 9e5;
426
+ function bytesEqual(a, b) {
427
+ return a.length === b.length && a.every((byte, i) => byte === b[i]);
428
+ }
429
+ function emptySignatureFor(record) {
430
+ return encodeEmptySignature({ pqsig: {
431
+ sch: algosdk.FALCON_1024_SCHEME,
432
+ slt: record.salt,
433
+ pk: record.publicKey,
434
+ sig: /* @__PURE__ */ new Uint8Array()
435
+ } });
436
+ }
437
+ async function sha256(data) {
438
+ return new Uint8Array(await crypto.subtle.digest("SHA-256", new Uint8Array(data)));
439
+ }
440
+ var PasskeyAdapter = class extends BaseWallet {
441
+ keyStore = null;
442
+ constructor(params) {
443
+ super(params);
444
+ assertValidContext(this.context);
445
+ }
446
+ static defaultMetadata = {
447
+ name: "Passkey",
448
+ icon: ICON
449
+ };
450
+ get rpId() {
451
+ return this.options.rpId ?? location.hostname;
452
+ }
453
+ get rpName() {
454
+ return this.options.rpName ?? this.rpId;
455
+ }
456
+ get context() {
457
+ return this.options.context ?? "";
458
+ }
459
+ /** True when the key stays unlocked between requests */
460
+ get isSession() {
461
+ return !!this.options.session || !!this.options.keyStore;
462
+ }
463
+ createKeyStore() {
464
+ if (this.options.keyStore) return this.options.keyStore();
465
+ const { session } = this.options;
466
+ return new WorkerKeyStore({
467
+ idleTimeoutMs: session ? (typeof session === "object" ? session.idleTimeoutMs : void 0) ?? DEFAULT_IDLE_TIMEOUT_MS : 0,
468
+ onIdleLock: () => this.logger.info("Session locked after inactivity")
469
+ });
470
+ }
471
+ getKeyStore() {
472
+ this.keyStore ??= this.createKeyStore();
473
+ return this.keyStore;
474
+ }
475
+ /** Drop the key after a request, unless a session keeps it */
476
+ release() {
477
+ if (!this.isSession) this.keyStore?.lock();
478
+ }
479
+ /**
480
+ * Sign in with an existing passkey, or create one with `{ create: true }`.
481
+ * Show both choices: a missing passkey and a cancelled prompt raise the same
482
+ * error, so the adapter can't tell which one happened.
483
+ */
484
+ connect = async (args) => {
485
+ const rpId = this.rpId;
486
+ const context = this.context;
487
+ const input = prfInput(context);
488
+ this.logger.info(args?.create ? "Creating passkey..." : "Connecting...");
489
+ let result;
490
+ try {
491
+ if (args?.create) result = await evaluatePrf({
492
+ rpId,
493
+ prfInput: input,
494
+ credentialId: await createPasskey({
495
+ rpId,
496
+ rpName: this.rpName,
497
+ userName: args.userName ?? this.options.userName ?? `${this.rpName} account`
498
+ })
499
+ });
500
+ else result = await evaluatePrf({
501
+ rpId,
502
+ prfInput: input
503
+ });
504
+ this.keyStore?.lock();
505
+ const publicKey = await this.getKeyStore().unlock(result.prf);
506
+ this.release();
507
+ const { address, salt } = algosdk.addressFromPQKey(algosdk.FALCON_1024_SCHEME, publicKey);
508
+ const record = {
509
+ version: 1,
510
+ rpId,
511
+ context,
512
+ credentialId: result.credentialId,
513
+ publicKey,
514
+ salt,
515
+ address: address.toString()
516
+ };
517
+ saveRecord(record);
518
+ const walletAccount = {
519
+ name: `${this.metadata.name} Account`,
520
+ address: record.address,
521
+ emptySignature: emptySignatureFor(record)
522
+ };
523
+ const walletState = {
524
+ accounts: [walletAccount],
525
+ activeAccount: walletAccount
526
+ };
527
+ this.store.addWallet(walletState);
528
+ this.logger.info("Connected successfully", walletState);
529
+ return [walletAccount];
530
+ } catch (error) {
531
+ result?.prf.fill(0);
532
+ this.keyStore?.lock();
533
+ this.logger.error("Error connecting:", error.message);
534
+ throw error;
535
+ }
536
+ };
537
+ disconnect = async () => {
538
+ this.logger.info("Disconnecting...");
539
+ this.keyStore?.lock();
540
+ this.keyStore = null;
541
+ removeRecord();
542
+ this.onDisconnect();
543
+ this.logger.info("Disconnected");
544
+ };
545
+ /** Restores the account without a ceremony; the key unlocks on the first signature */
546
+ resumeSession = async () => {
547
+ const walletState = this.store.getWalletState();
548
+ if (!walletState) {
549
+ this.logger.info("No session to resume");
550
+ return;
551
+ }
552
+ const record = loadRecord();
553
+ if (!record || !this.matchesRecord(record, walletState)) {
554
+ this.logger.info("No passkey record for this session, disconnecting...");
555
+ await this.disconnect();
556
+ return;
557
+ }
558
+ this.logger.info("Session resumed");
559
+ };
560
+ matchesRecord(record, walletState) {
561
+ return record.rpId === this.rpId && record.context === this.context && walletState.accounts.length === 1 && walletState.accounts[0].address === record.address;
562
+ }
563
+ requireRecord() {
564
+ const walletState = this.store.getWalletState();
565
+ const record = loadRecord();
566
+ if (!walletState || !record || !this.matchesRecord(record, walletState)) throw new PasskeyError("not-connected", "No passkey account is connected.");
567
+ return record;
568
+ }
569
+ /**
570
+ * Make sure the key is unlocked and the user approved this request. When the
571
+ * key is locked, the PRF ceremony that unlocks it is the approval. Starts its
572
+ * prompt before any await.
573
+ */
574
+ async authorize(request, record) {
575
+ const keyStore = this.getKeyStore();
576
+ if (!keyStore.unlocked) {
577
+ const { prf } = await evaluatePrf({
578
+ rpId: record.rpId,
579
+ prfInput: prfInput(record.context),
580
+ credentialId: record.credentialId
581
+ });
582
+ if (!bytesEqual(await keyStore.unlock(prf), record.publicKey)) {
583
+ keyStore.lock();
584
+ throw new PasskeyError("key-mismatch", "This passkey derived a different key than the connected account. Disconnect and connect again.");
585
+ }
586
+ return;
587
+ }
588
+ const confirmation = this.options.confirm?.(request) ?? "presence";
589
+ if (confirmation === "silent") return;
590
+ await assertUser({
591
+ rpId: record.rpId,
592
+ credentialId: record.credentialId,
593
+ userVerification: confirmation === "presence" ? "preferred" : "required"
594
+ });
595
+ }
596
+ processTxns(txnGroup, indexesToSign, address) {
597
+ return txnGroup.filter((txn, index) => (!indexesToSign || indexesToSign.includes(index)) && txn.sender.toString() === address);
598
+ }
599
+ processEncodedTxns(txnGroup, indexesToSign, address) {
600
+ const txnsToSign = [];
601
+ txnGroup.forEach((txnBuffer, index) => {
602
+ const isSigned = isSignedTxn(algosdk.msgpackRawDecode(txnBuffer));
603
+ const txn = isSigned ? algosdk.decodeSignedTransaction(txnBuffer).txn : algosdk.decodeUnsignedTransaction(txnBuffer);
604
+ if ((!indexesToSign || indexesToSign.includes(index)) && !isSigned && txn.sender.toString() === address) txnsToSign.push(txn);
605
+ });
606
+ return txnsToSign;
607
+ }
608
+ /**
609
+ * Signs the transactions sent from the connected account. Each one carries a
610
+ * Falcon-1024 `pqsig` (about 3.2 KB) and pays an extra 2× the minimum fee;
611
+ * get fees by simulating with `emptyTransactionSigner`.
612
+ */
613
+ signTransactions = async (txnGroup, indexesToSign) => {
614
+ const record = this.requireRecord();
615
+ const txnsToSign = isTransactionArray(txnGroup) ? this.processTxns(flattenTxnGroup(txnGroup), indexesToSign, record.address) : this.processEncodedTxns(flattenTxnGroup(txnGroup), indexesToSign, record.address);
616
+ if (txnsToSign.length === 0) return [];
617
+ try {
618
+ this.logger.debug("Signing transactions...", { txnsToSign });
619
+ await this.authorize({
620
+ type: "transactions",
621
+ transactions: txnsToSign
622
+ }, record);
623
+ const keyStore = this.getKeyStore();
624
+ const { txnSigner } = algosdk.addressWithSignersFromRawFalcon1024Signer({
625
+ falcon1024PublicKey: record.publicKey,
626
+ falcon1024Signer: (bytes) => keyStore.sign(bytes)
627
+ });
628
+ const signedTxns = await txnSigner(txnsToSign, txnsToSign.map((_, index) => index));
629
+ this.logger.debug("Transactions signed successfully");
630
+ return signedTxns;
631
+ } catch (error) {
632
+ this.logger.error("Error signing transactions:", error.message);
633
+ throw error;
634
+ } finally {
635
+ this.release();
636
+ }
637
+ };
638
+ canSignData = true;
639
+ /**
640
+ * Signs an ARC-60 authentication request (SIWA) with the account's Falcon
641
+ * key, scheme `falcon-1024-det`. The signature covers
642
+ * `SHA-256(data) || SHA-256(authenticatorData)`.
643
+ */
644
+ signData = async (data, metadata) => {
645
+ const record = this.requireRecord();
646
+ if (metadata.encoding !== "base64") throw new SignDataError("Failed decoding", 4602);
647
+ if (metadata.scope !== ScopeType.AUTH) throw new SignDataError("Invalid Scope", 4600);
648
+ let bytes;
649
+ let json;
650
+ let siwa;
651
+ try {
652
+ bytes = algosdk.base64ToBytes(data);
653
+ json = new TextDecoder("utf-8", {
654
+ fatal: true,
655
+ ignoreBOM: true
656
+ }).decode(bytes);
657
+ siwa = JSON.parse(json);
658
+ } catch {
659
+ throw new SignDataError("Bad JSON", 4609);
660
+ }
661
+ if (!siwa || typeof siwa !== "object" || canonify(siwa) !== json) throw new SignDataError("Bad JSON", 4609);
662
+ if (siwa.domain !== location.host) throw new SignDataError("Failed Domain Auth", 4610);
663
+ if (siwa.type !== "falcon-1024-det" || siwa.account_address !== record.address) throw new SignDataError("Invalid Signer", 4603);
664
+ try {
665
+ await this.authorize({
666
+ type: "data",
667
+ siwa
668
+ }, record);
669
+ const stdSignData = await this.createStdSignData(data);
670
+ if (!bytesEqual(stdSignData.signer, record.publicKey)) throw new SignDataError("Invalid Signer", 4603);
671
+ const toSign = new Uint8Array([...await sha256(bytes), ...await sha256(stdSignData.authenticatorData)]);
672
+ const signature = await this.getKeyStore().sign(toSign);
673
+ return {
674
+ ...stdSignData,
675
+ signature
676
+ };
677
+ } catch (error) {
678
+ this.logger.error("Error signing data:", error.message);
679
+ throw error;
680
+ } finally {
681
+ this.release();
682
+ }
683
+ };
684
+ /**
685
+ * Returns the account's 25-word recovery phrase after a fresh passkey
686
+ * ceremony with full user verification. Show it with a warning that it must
687
+ * be imported as a Falcon / quantum account (Lute: "Falcon25 account";
688
+ * Pera: quantum import; `algokey pq import --scheme falcon-1024`): the same
689
+ * words in a regular 25-word import restore a different, empty account.
690
+ */
691
+ exportRecoveryPhrase = async () => {
692
+ const record = this.requireRecord();
693
+ const { prf } = await evaluatePrf({
694
+ rpId: record.rpId,
695
+ prfInput: prfInput(record.context),
696
+ credentialId: record.credentialId
697
+ });
698
+ const check = this.createKeyStore();
699
+ try {
700
+ if (!bytesEqual(await check.unlock(new Uint8Array(prf)), record.publicKey)) throw new PasskeyError("key-mismatch", "This passkey derived a different key than the connected account.");
701
+ return {
702
+ scheme: "falcon-1024",
703
+ phrase: algosdk.mnemonicFromSeed(prf)
704
+ };
705
+ } finally {
706
+ check.lock();
707
+ prf.fill(0);
708
+ }
709
+ };
710
+ };
711
+ //#endregion
712
+ //#region src/index.ts
713
+ const WALLET_ID = "passkey";
714
+ function passkey(options) {
715
+ const { metadata, ...adapterOptions } = options ?? {};
716
+ assertValidContext(adapterOptions.context ?? "");
717
+ return {
718
+ id: WALLET_ID,
719
+ metadata: {
720
+ ...PasskeyAdapter.defaultMetadata,
721
+ ...metadata
722
+ },
723
+ Adapter: PasskeyAdapter,
724
+ options: Object.keys(adapterOptions).length > 0 ? adapterOptions : void 0
725
+ };
726
+ }
727
+ //#endregion
728
+ export { LOCAL_STORAGE_PASSKEY_KEY, PRF_LABEL_PREFIX, PasskeyAdapter, PasskeyError, WALLET_ID, WorkerKeyStore, keygenSeedFromEntropy, passkey, prfInput };
729
+
730
+ //# sourceMappingURL=index.js.map