@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/LICENSE +21 -0
- package/README.md +114 -2
- package/dist/index.d.ts +224 -0
- package/dist/index.js +730 -0
- package/dist/index.js.map +1 -0
- package/dist/source.generated-CISa6YOa.js +6 -0
- package/dist/source.generated-CISa6YOa.js.map +1 -0
- package/package.json +53 -3
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
|