@1shotapi/ows-signer 0.2.2 → 0.3.1
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/README.md +14 -11
- package/html/index.html +59 -2
- package/package.json +1 -1
- package/src/constants.js +1 -0
- package/src/handlers.js +381 -58
- package/src/main.js +19 -3
- package/src/state.js +20 -1
- package/src/ui.js +250 -14
package/README.md
CHANGED
|
@@ -37,17 +37,20 @@ Only accepts messages when `event.source === window.parent`. Replies use the par
|
|
|
37
37
|
| Method | Params | Success events |
|
|
38
38
|
|--------|--------|----------------|
|
|
39
39
|
| `getVersion` | — | `Version` |
|
|
40
|
-
| `createCredential` | `name`, `options?` (
|
|
41
|
-
| `signDigest` | `
|
|
42
|
-
| `
|
|
43
|
-
| `
|
|
44
|
-
| `
|
|
45
|
-
| `
|
|
40
|
+
| `createCredential` | `name`, `options?` (incl. ceremony UI) | `KeyDerived`, `CredentialCreated` |
|
|
41
|
+
| `signDigest` | `digests[]`, `credentialId?`, ceremony UI? | `KeyDerived` (PRF path), `DigestSigned` (`results[]`) |
|
|
42
|
+
| `executeBatch` | batch fields + ceremony UI? | `KeyDerived` (when keys derived), `BatchExecuted` |
|
|
43
|
+
| `revealPrivateKey` | `credentialId?`, ceremony UI? | `KeyDerived`, then `PrivateKeyRevealed` (after user dismisses DOM key UI) |
|
|
44
|
+
| `createRecoveryData` | passphrase fields + ceremony UI? | `KeyDerived`, `RecoveryDataCreated` |
|
|
45
|
+
| `recoverKey` | envelope + passphrase fields + ceremony UI? | DOM display; `RecoverySessionStarted` or re-bind → `RecoverySessionCleared` |
|
|
46
|
+
| `getPublicKey` | `credentialId?`, `challenge?`, ceremony UI? | `KeyDerived`, `PublicKey`, `ChallengeSigned?` |
|
|
46
47
|
| `clearRecoverySession` | — | `RecoverySessionCleared` |
|
|
47
|
-
| `encryptAES256` | `plaintexts[]`, `credentialId
|
|
48
|
-
| `decryptAES256` | `ciphertexts[]`, `credentialId
|
|
48
|
+
| `encryptAES256` | `plaintexts[]`, `credentialId?`, ceremony UI? | `KeyDerived` (PRF path), `AES256Encrypted` |
|
|
49
|
+
| `decryptAES256` | `ciphertexts[]`, `credentialId?`, ceremony UI? | `KeyDerived` (PRF path), `AES256Decrypted` |
|
|
49
50
|
|
|
50
|
-
Failure events: `NotAllowed
|
|
51
|
+
Failure events: `SignDenied` (user cancelled Confirm UI), `NotAllowed` (WebAuthn cancel/policy), `InvalidRequest`.
|
|
52
|
+
|
|
53
|
+
Ceremony UI params (optional on WebAuthn-triggering methods): `explanationHeader`, `explanationText`, `confirmButtonText`, `denyButtonText`. Defaults: “Confirm passkey” / “Your device will ask for a passkey to continue.” / “Continue” / “Cancel”. Confirm click starts WebAuthn in the same turn (mobile activation).
|
|
51
54
|
|
|
52
55
|
`createCredential` options:
|
|
53
56
|
|
|
@@ -59,7 +62,7 @@ Failure events: `NotAllowed`, `InvalidRequest`.
|
|
|
59
62
|
|
|
60
63
|
`secp256k1-ecdsa`, `secp256k1-ecdsa-recoverable`, `secp256k1-bip340` (not yet implemented), `ed25519`.
|
|
61
64
|
|
|
62
|
-
`digestData` is `0x`-prefixed hex. Hash schemes require 32 bytes.
|
|
65
|
+
`digestData` is `0x`-prefixed hex. Hash schemes require 32 bytes. `signDigest` and `executeBatch` take **arrays** of digests so one passkey ceremony covers many signatures. Empty `digests` arrays succeed without a ceremony. `executeBatch` with a `challenge` always forces a real WebAuthn assertion (recovery session alone cannot produce an assertion signature).
|
|
63
66
|
|
|
64
67
|
## PRF labels
|
|
65
68
|
|
|
@@ -77,7 +80,7 @@ Failure events: `NotAllowed`, `InvalidRequest`.
|
|
|
77
80
|
|
|
78
81
|
## Recovery session
|
|
79
82
|
|
|
80
|
-
`recoverKey` caches the secp256k1 scalar in memory for subsequent `signDigest` / `encryptAES256` / `decryptAES256` calls without PRF. Cleared via `clearRecoverySession` or successful `credentialId` re-bind after recovery.
|
|
83
|
+
`recoverKey` caches the secp256k1 scalar in memory for subsequent `signDigest` / `encryptAES256` / `decryptAES256` / `executeBatch` (without `challenge`) calls without PRF. Cleared via `clearRecoverySession` or successful `credentialId` re-bind after recovery.
|
|
81
84
|
|
|
82
85
|
## Layout
|
|
83
86
|
|
package/html/index.html
CHANGED
|
@@ -9,15 +9,28 @@
|
|
|
9
9
|
color-scheme: light dark;
|
|
10
10
|
font-family: system-ui, sans-serif;
|
|
11
11
|
}
|
|
12
|
+
html,
|
|
12
13
|
body {
|
|
14
|
+
height: 100%;
|
|
13
15
|
margin: 0;
|
|
14
|
-
|
|
16
|
+
overflow: hidden;
|
|
17
|
+
}
|
|
18
|
+
body {
|
|
19
|
+
box-sizing: border-box;
|
|
15
20
|
}
|
|
16
21
|
.ows-signer-ui {
|
|
17
22
|
display: flex;
|
|
18
23
|
flex-direction: column;
|
|
19
24
|
gap: 8px;
|
|
25
|
+
box-sizing: border-box;
|
|
26
|
+
width: 100%;
|
|
27
|
+
height: 100%;
|
|
20
28
|
max-width: 100%;
|
|
29
|
+
padding: 12px;
|
|
30
|
+
overflow: hidden;
|
|
31
|
+
}
|
|
32
|
+
.ows-signer-ui--ceremony {
|
|
33
|
+
gap: 0;
|
|
21
34
|
}
|
|
22
35
|
.ows-key {
|
|
23
36
|
margin: 0;
|
|
@@ -28,6 +41,9 @@
|
|
|
28
41
|
font-size: 12px;
|
|
29
42
|
word-break: break-all;
|
|
30
43
|
white-space: pre-wrap;
|
|
44
|
+
overflow: auto;
|
|
45
|
+
flex: 1 1 auto;
|
|
46
|
+
min-height: 0;
|
|
31
47
|
}
|
|
32
48
|
.ows-password {
|
|
33
49
|
padding: 8px;
|
|
@@ -35,8 +51,41 @@
|
|
|
35
51
|
border-radius: 4px;
|
|
36
52
|
font: inherit;
|
|
37
53
|
}
|
|
54
|
+
.ows-ceremony-header {
|
|
55
|
+
flex: 0 0 auto;
|
|
56
|
+
margin: 0 0 8px;
|
|
57
|
+
font-size: 1.1rem;
|
|
58
|
+
font-weight: 700;
|
|
59
|
+
line-height: 1.3;
|
|
60
|
+
}
|
|
61
|
+
.ows-ceremony-body {
|
|
62
|
+
flex: 1 1 auto;
|
|
63
|
+
min-height: 0;
|
|
64
|
+
overflow-x: hidden;
|
|
65
|
+
overflow-y: auto;
|
|
66
|
+
margin: 0 0 12px;
|
|
67
|
+
}
|
|
68
|
+
.ows-ceremony-text {
|
|
69
|
+
margin: 0;
|
|
70
|
+
font-size: 0.95rem;
|
|
71
|
+
line-height: 1.4;
|
|
72
|
+
opacity: 0.9;
|
|
73
|
+
}
|
|
74
|
+
.ows-ceremony-actions {
|
|
75
|
+
flex: 0 0 auto;
|
|
76
|
+
display: flex;
|
|
77
|
+
flex-wrap: wrap;
|
|
78
|
+
gap: 8px;
|
|
79
|
+
}
|
|
80
|
+
.ows-actions {
|
|
81
|
+
flex: 0 0 auto;
|
|
82
|
+
display: flex;
|
|
83
|
+
flex-wrap: wrap;
|
|
84
|
+
gap: 8px;
|
|
85
|
+
}
|
|
38
86
|
.ows-copy,
|
|
39
|
-
.ows-confirm
|
|
87
|
+
.ows-confirm,
|
|
88
|
+
.ows-deny {
|
|
40
89
|
padding: 8px 12px;
|
|
41
90
|
border: 1px solid #8884;
|
|
42
91
|
border-radius: 4px;
|
|
@@ -45,6 +94,14 @@
|
|
|
45
94
|
cursor: pointer;
|
|
46
95
|
align-self: flex-start;
|
|
47
96
|
}
|
|
97
|
+
.ows-confirm {
|
|
98
|
+
font-weight: 600;
|
|
99
|
+
}
|
|
100
|
+
.ows-confirm:disabled,
|
|
101
|
+
.ows-deny:disabled {
|
|
102
|
+
opacity: 0.45;
|
|
103
|
+
cursor: default;
|
|
104
|
+
}
|
|
48
105
|
</style>
|
|
49
106
|
</head>
|
|
50
107
|
<body>
|
package/package.json
CHANGED
package/src/constants.js
CHANGED
package/src/handlers.js
CHANGED
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
withCeremony,
|
|
31
31
|
zeroize,
|
|
32
32
|
} from "./state.js";
|
|
33
|
-
import { clearUi, promptPassphrase, showPrivateKey } from "./ui.js";
|
|
33
|
+
import { clearUi, CeremonyDeniedError, promptCeremonyConfirm, promptPassphrase, showPrivateKey } from "./ui.js";
|
|
34
34
|
import {
|
|
35
35
|
createPasskeyCredential,
|
|
36
36
|
getAssertionSignatureBase64Url,
|
|
@@ -157,12 +157,34 @@ export async function handleRequest(
|
|
|
157
157
|
case "decryptAES256":
|
|
158
158
|
await handleDecryptAES256(params, correlationId, targetOrigin);
|
|
159
159
|
return;
|
|
160
|
+
|
|
161
|
+
case "executeBatch":
|
|
162
|
+
await handleExecuteBatch(params, correlationId, targetOrigin);
|
|
163
|
+
return;
|
|
160
164
|
}
|
|
161
165
|
} catch (error) {
|
|
162
166
|
handleError(error, correlationId, targetOrigin);
|
|
163
167
|
}
|
|
164
168
|
}
|
|
165
169
|
|
|
170
|
+
/**
|
|
171
|
+
* @param {Record<string, unknown>} params
|
|
172
|
+
* @returns {{
|
|
173
|
+
* explanationHeader?: unknown,
|
|
174
|
+
* explanationText?: unknown,
|
|
175
|
+
* confirmButtonText?: unknown,
|
|
176
|
+
* denyButtonText?: unknown,
|
|
177
|
+
* }}
|
|
178
|
+
*/
|
|
179
|
+
function ceremonyFieldsFromParams(params) {
|
|
180
|
+
return {
|
|
181
|
+
explanationHeader: params.explanationHeader,
|
|
182
|
+
explanationText: params.explanationText,
|
|
183
|
+
confirmButtonText: params.confirmButtonText,
|
|
184
|
+
denyButtonText: params.denyButtonText,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
166
188
|
/**
|
|
167
189
|
* PRF bytes are returned on assertion (`prf.eval`), not registration (`prf.enable`).
|
|
168
190
|
* Some platforms may return results on create; otherwise run a follow-up get.
|
|
@@ -184,6 +206,7 @@ async function credentialForKeyDerivation(credential, credentialId) {
|
|
|
184
206
|
debugLog("no PRF results on registration; running assertion with prf.eval", {
|
|
185
207
|
credentialId: id,
|
|
186
208
|
});
|
|
209
|
+
// Same RPC — no second Confirm (one screen per call).
|
|
187
210
|
const assertion = await getPasskeyAssertion(undefined, id);
|
|
188
211
|
debugLog(
|
|
189
212
|
"assertion extension results",
|
|
@@ -205,13 +228,15 @@ async function handleCreateCredential(params, correlationId, targetOrigin) {
|
|
|
205
228
|
}
|
|
206
229
|
const options =
|
|
207
230
|
params.options && typeof params.options === "object"
|
|
208
|
-
? /** @type {{ rpName?: string, userDisplayName?: string, userId?: string }} */ (
|
|
231
|
+
? /** @type {{ rpName?: string, userDisplayName?: string, userId?: string, explanationHeader?: string, explanationText?: string, confirmButtonText?: string, denyButtonText?: string }} */ (
|
|
209
232
|
params.options
|
|
210
233
|
)
|
|
211
234
|
: {};
|
|
212
235
|
|
|
213
236
|
await withCeremony(async () => {
|
|
214
|
-
const credential = await
|
|
237
|
+
const credential = await promptCeremonyConfirm(options, () =>
|
|
238
|
+
createPasskeyCredential(name, options),
|
|
239
|
+
);
|
|
215
240
|
const credentialId = getCredentialId(credential);
|
|
216
241
|
const prfCredential = await credentialForKeyDerivation(
|
|
217
242
|
credential,
|
|
@@ -234,58 +259,146 @@ async function handleCreateCredential(params, correlationId, targetOrigin) {
|
|
|
234
259
|
});
|
|
235
260
|
}
|
|
236
261
|
|
|
262
|
+
/**
|
|
263
|
+
* @typedef {{
|
|
264
|
+
* digestData: string,
|
|
265
|
+
* scheme: import('./constants.js').SignScheme,
|
|
266
|
+
* digest: Uint8Array,
|
|
267
|
+
* }} ParsedDigestItem
|
|
268
|
+
*/
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* @param {unknown} digests
|
|
272
|
+
* @returns {{ ok: true, items: ParsedDigestItem[] } | { ok: false, reason: string }}
|
|
273
|
+
*/
|
|
274
|
+
function parseDigestItems(digests) {
|
|
275
|
+
if (!Array.isArray(digests)) {
|
|
276
|
+
return { ok: false, reason: "invalidParams" };
|
|
277
|
+
}
|
|
278
|
+
/** @type {ParsedDigestItem[]} */
|
|
279
|
+
const items = [];
|
|
280
|
+
for (const entry of digests) {
|
|
281
|
+
if (!entry || typeof entry !== "object") {
|
|
282
|
+
return { ok: false, reason: "invalidParams" };
|
|
283
|
+
}
|
|
284
|
+
const record = /** @type {Record<string, unknown>} */ (entry);
|
|
285
|
+
const digestData = record.digestData;
|
|
286
|
+
const scheme = record.scheme;
|
|
287
|
+
if (typeof digestData !== "string" || typeof scheme !== "string") {
|
|
288
|
+
return { ok: false, reason: "invalidParams" };
|
|
289
|
+
}
|
|
290
|
+
if (
|
|
291
|
+
!SIGN_SCHEMES.includes(
|
|
292
|
+
/** @type {import('./constants.js').SignScheme} */ (scheme),
|
|
293
|
+
)
|
|
294
|
+
) {
|
|
295
|
+
return { ok: false, reason: "unknownScheme" };
|
|
296
|
+
}
|
|
297
|
+
const digest = parse0xHex(digestData);
|
|
298
|
+
try {
|
|
299
|
+
validateSignPayload(
|
|
300
|
+
/** @type {import('./constants.js').SignScheme} */ (scheme),
|
|
301
|
+
digest,
|
|
302
|
+
);
|
|
303
|
+
} catch (error) {
|
|
304
|
+
return {
|
|
305
|
+
ok: false,
|
|
306
|
+
reason: error instanceof Error ? error.message : "invalidPayload",
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
items.push({
|
|
310
|
+
digestData,
|
|
311
|
+
scheme: /** @type {import('./constants.js').SignScheme} */ (scheme),
|
|
312
|
+
digest,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
return { ok: true, items };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* @param {ParsedDigestItem[]} items
|
|
320
|
+
* @param {Uint8Array} secp256k1PrivateKey
|
|
321
|
+
* @param {Uint8Array} ed25519Seed
|
|
322
|
+
* @param {string | null} credentialId
|
|
323
|
+
* @returns {Promise<Array<{
|
|
324
|
+
* digest: string,
|
|
325
|
+
* scheme: string,
|
|
326
|
+
* signature: string,
|
|
327
|
+
* credentialId: string | null,
|
|
328
|
+
* }>>}
|
|
329
|
+
*/
|
|
330
|
+
async function signParsedDigests(
|
|
331
|
+
items,
|
|
332
|
+
secp256k1PrivateKey,
|
|
333
|
+
ed25519Seed,
|
|
334
|
+
credentialId,
|
|
335
|
+
) {
|
|
336
|
+
/** @type {Array<{ digest: string, scheme: string, signature: string, credentialId: string | null }>} */
|
|
337
|
+
const results = [];
|
|
338
|
+
for (const item of items) {
|
|
339
|
+
const signature = await signWithScheme(
|
|
340
|
+
item.scheme,
|
|
341
|
+
item.digest,
|
|
342
|
+
secp256k1PrivateKey,
|
|
343
|
+
ed25519Seed,
|
|
344
|
+
);
|
|
345
|
+
results.push({
|
|
346
|
+
digest: item.digestData,
|
|
347
|
+
scheme: item.scheme,
|
|
348
|
+
signature,
|
|
349
|
+
credentialId,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
return results;
|
|
353
|
+
}
|
|
354
|
+
|
|
237
355
|
/**
|
|
238
356
|
* @param {Record<string, unknown>} params
|
|
239
357
|
* @param {string | undefined} correlationId
|
|
240
358
|
* @param {string} targetOrigin
|
|
241
359
|
*/
|
|
242
360
|
async function handleSignDigest(params, correlationId, targetOrigin) {
|
|
243
|
-
const digestData = params.digestData;
|
|
244
|
-
const scheme = params.scheme;
|
|
245
361
|
const credentialId =
|
|
246
362
|
typeof params.credentialId === "string" ? params.credentialId : undefined;
|
|
247
|
-
|
|
248
|
-
if (
|
|
249
|
-
emitInvalid(correlationId, targetOrigin,
|
|
363
|
+
const parsed = parseDigestItems(params.digests);
|
|
364
|
+
if (!parsed.ok) {
|
|
365
|
+
emitInvalid(correlationId, targetOrigin, parsed.reason);
|
|
250
366
|
return;
|
|
251
367
|
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
368
|
+
const { items } = parsed;
|
|
369
|
+
|
|
370
|
+
if (items.length === 0) {
|
|
371
|
+
emitEvent(window.parent, targetOrigin, "DigestSigned", correlationId, {
|
|
372
|
+
results: [],
|
|
373
|
+
});
|
|
258
374
|
return;
|
|
259
375
|
}
|
|
260
376
|
|
|
261
|
-
const digest = parse0xHex(digestData);
|
|
262
|
-
validateSignPayload(
|
|
263
|
-
/** @type {import('./constants.js').SignScheme} */ (scheme),
|
|
264
|
-
digest,
|
|
265
|
-
);
|
|
266
|
-
|
|
267
377
|
if (hasRecoverySession()) {
|
|
268
378
|
const cached = getRecoveryPrivateKey();
|
|
269
379
|
if (!cached) throw new Error("recoverySessionEmpty");
|
|
270
380
|
const ed25519Seed = await ed25519SeedFromSecp256k1Scalar(cached);
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
}
|
|
381
|
+
try {
|
|
382
|
+
const results = await signParsedDigests(
|
|
383
|
+
items,
|
|
384
|
+
cached,
|
|
385
|
+
ed25519Seed,
|
|
386
|
+
credentialId ?? null,
|
|
387
|
+
);
|
|
388
|
+
emitEvent(window.parent, targetOrigin, "DigestSigned", correlationId, {
|
|
389
|
+
results,
|
|
390
|
+
});
|
|
391
|
+
} finally {
|
|
392
|
+
zeroize(ed25519Seed);
|
|
393
|
+
}
|
|
284
394
|
return;
|
|
285
395
|
}
|
|
286
396
|
|
|
287
397
|
await withCeremony(async () => {
|
|
288
|
-
const credential = await
|
|
398
|
+
const credential = await promptCeremonyConfirm(
|
|
399
|
+
ceremonyFieldsFromParams(params),
|
|
400
|
+
() => getPasskeyAssertion(undefined, credentialId),
|
|
401
|
+
);
|
|
289
402
|
const keys = await deriveKeysFromCredential(credential);
|
|
290
403
|
const ed25519Seed = await ed25519SeedFromCredential(credential);
|
|
291
404
|
emitKeyDerived(
|
|
@@ -294,22 +407,20 @@ async function handleSignDigest(params, correlationId, targetOrigin) {
|
|
|
294
407
|
keys.secp256k1PublicKey,
|
|
295
408
|
keys.ed25519PublicKey,
|
|
296
409
|
);
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
credentialId: credentialId ?? getCredentialId(credential),
|
|
312
|
-
});
|
|
410
|
+
try {
|
|
411
|
+
const results = await signParsedDigests(
|
|
412
|
+
items,
|
|
413
|
+
keys.secp256k1PrivateKey,
|
|
414
|
+
ed25519Seed,
|
|
415
|
+
credentialId ?? getCredentialId(credential),
|
|
416
|
+
);
|
|
417
|
+
emitEvent(window.parent, targetOrigin, "DigestSigned", correlationId, {
|
|
418
|
+
results,
|
|
419
|
+
});
|
|
420
|
+
} finally {
|
|
421
|
+
zeroize(keys.secp256k1PrivateKey);
|
|
422
|
+
zeroize(ed25519Seed);
|
|
423
|
+
}
|
|
313
424
|
});
|
|
314
425
|
}
|
|
315
426
|
|
|
@@ -323,7 +434,10 @@ async function handleRevealPrivateKey(params, correlationId, targetOrigin) {
|
|
|
323
434
|
typeof params.credentialId === "string" ? params.credentialId : undefined;
|
|
324
435
|
|
|
325
436
|
await withCeremony(async () => {
|
|
326
|
-
const credential = await
|
|
437
|
+
const credential = await promptCeremonyConfirm(
|
|
438
|
+
ceremonyFieldsFromParams(params),
|
|
439
|
+
() => getPasskeyAssertion(undefined, credentialId),
|
|
440
|
+
);
|
|
327
441
|
const keys = await deriveKeysFromCredential(credential);
|
|
328
442
|
emitKeyDerived(
|
|
329
443
|
targetOrigin,
|
|
@@ -331,9 +445,21 @@ async function handleRevealPrivateKey(params, correlationId, targetOrigin) {
|
|
|
331
445
|
keys.secp256k1PublicKey,
|
|
332
446
|
keys.ed25519PublicKey,
|
|
333
447
|
);
|
|
334
|
-
|
|
335
|
-
|
|
448
|
+
try {
|
|
449
|
+
const dismiss = showPrivateKey(keys.secp256k1PrivateKey);
|
|
450
|
+
zeroize(keys.secp256k1PrivateKey);
|
|
451
|
+
await dismiss;
|
|
452
|
+
} finally {
|
|
453
|
+
zeroize(keys.secp256k1PrivateKey);
|
|
454
|
+
}
|
|
336
455
|
});
|
|
456
|
+
emitEvent(
|
|
457
|
+
window.parent,
|
|
458
|
+
targetOrigin,
|
|
459
|
+
"PrivateKeyRevealed",
|
|
460
|
+
correlationId,
|
|
461
|
+
{},
|
|
462
|
+
);
|
|
337
463
|
}
|
|
338
464
|
|
|
339
465
|
/**
|
|
@@ -390,7 +516,10 @@ async function handleCreateRecoveryData(params, correlationId, targetOrigin) {
|
|
|
390
516
|
}
|
|
391
517
|
|
|
392
518
|
await withCeremony(async () => {
|
|
393
|
-
const credential = await
|
|
519
|
+
const credential = await promptCeremonyConfirm(
|
|
520
|
+
ceremonyFieldsFromParams(params),
|
|
521
|
+
() => getPasskeyAssertion(undefined, credentialId),
|
|
522
|
+
);
|
|
394
523
|
const keys = await deriveKeysFromCredential(credential);
|
|
395
524
|
emitKeyDerived(
|
|
396
525
|
targetOrigin,
|
|
@@ -455,7 +584,9 @@ async function handleRecoverKey(params, correlationId, targetOrigin) {
|
|
|
455
584
|
|
|
456
585
|
if (credentialId) {
|
|
457
586
|
await withCeremony(async () => {
|
|
458
|
-
await
|
|
587
|
+
await promptCeremonyConfirm(ceremonyFieldsFromParams(params), () =>
|
|
588
|
+
getPasskeyAssertion(undefined, credentialId),
|
|
589
|
+
);
|
|
459
590
|
clearRecoveryPrivateKey();
|
|
460
591
|
});
|
|
461
592
|
emitEvent(
|
|
@@ -522,7 +653,10 @@ async function handleEncryptAES256(params, correlationId, targetOrigin) {
|
|
|
522
653
|
}
|
|
523
654
|
|
|
524
655
|
await withCeremony(async () => {
|
|
525
|
-
const credential = await
|
|
656
|
+
const credential = await promptCeremonyConfirm(
|
|
657
|
+
ceremonyFieldsFromParams(params),
|
|
658
|
+
() => getPasskeyAssertion(undefined, credentialId),
|
|
659
|
+
);
|
|
526
660
|
const keys = await deriveKeysFromCredential(credential);
|
|
527
661
|
emitKeyDerived(
|
|
528
662
|
targetOrigin,
|
|
@@ -579,7 +713,10 @@ async function handleDecryptAES256(params, correlationId, targetOrigin) {
|
|
|
579
713
|
}
|
|
580
714
|
|
|
581
715
|
await withCeremony(async () => {
|
|
582
|
-
const credential = await
|
|
716
|
+
const credential = await promptCeremonyConfirm(
|
|
717
|
+
ceremonyFieldsFromParams(params),
|
|
718
|
+
() => getPasskeyAssertion(undefined, credentialId),
|
|
719
|
+
);
|
|
583
720
|
const keys = await deriveKeysFromCredential(credential);
|
|
584
721
|
emitKeyDerived(
|
|
585
722
|
targetOrigin,
|
|
@@ -615,7 +752,10 @@ async function handleGetPublicKey(params, correlationId, targetOrigin) {
|
|
|
615
752
|
: undefined;
|
|
616
753
|
|
|
617
754
|
await withCeremony(async () => {
|
|
618
|
-
const credential = await
|
|
755
|
+
const credential = await promptCeremonyConfirm(
|
|
756
|
+
ceremonyFieldsFromParams(params),
|
|
757
|
+
() => getPasskeyAssertion(challenge, credentialId),
|
|
758
|
+
);
|
|
619
759
|
const keys = await deriveKeysFromCredential(credential);
|
|
620
760
|
emitKeyDerived(
|
|
621
761
|
targetOrigin,
|
|
@@ -651,6 +791,182 @@ async function handleGetPublicKey(params, correlationId, targetOrigin) {
|
|
|
651
791
|
});
|
|
652
792
|
}
|
|
653
793
|
|
|
794
|
+
/**
|
|
795
|
+
* Mixed ceremony: digests + AES + public key + optional WebAuthn challenge.
|
|
796
|
+
*
|
|
797
|
+
* @param {Record<string, unknown>} params
|
|
798
|
+
* @param {string | undefined} correlationId
|
|
799
|
+
* @param {string} targetOrigin
|
|
800
|
+
*/
|
|
801
|
+
async function handleExecuteBatch(params, correlationId, targetOrigin) {
|
|
802
|
+
const credentialId =
|
|
803
|
+
typeof params.credentialId === "string" ? params.credentialId : undefined;
|
|
804
|
+
const includePublicKey = params.includePublicKey === true;
|
|
805
|
+
const challengeHex =
|
|
806
|
+
typeof params.challenge === "string" ? params.challenge : undefined;
|
|
807
|
+
const challenge = challengeHex ? parse0xHex(challengeHex) : undefined;
|
|
808
|
+
|
|
809
|
+
/** @type {ParsedDigestItem[] | undefined} */
|
|
810
|
+
let digestItems;
|
|
811
|
+
if (params.digests !== undefined) {
|
|
812
|
+
const parsed = parseDigestItems(params.digests);
|
|
813
|
+
if (!parsed.ok) {
|
|
814
|
+
emitInvalid(correlationId, targetOrigin, parsed.reason);
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
digestItems = parsed.items;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/** @type {string[] | undefined} */
|
|
821
|
+
let plaintexts;
|
|
822
|
+
if (params.plaintexts !== undefined) {
|
|
823
|
+
if (!isStringArray(params.plaintexts)) {
|
|
824
|
+
emitInvalid(correlationId, targetOrigin, "invalidParams");
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
plaintexts = params.plaintexts;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/** @type {string[] | undefined} */
|
|
831
|
+
let ciphertextsIn;
|
|
832
|
+
if (params.ciphertexts !== undefined) {
|
|
833
|
+
if (!isStringArray(params.ciphertexts)) {
|
|
834
|
+
emitInvalid(correlationId, targetOrigin, "invalidParams");
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
ciphertextsIn = params.ciphertexts;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
const hasDigests = (digestItems?.length ?? 0) > 0;
|
|
841
|
+
const hasPlaintexts = (plaintexts?.length ?? 0) > 0;
|
|
842
|
+
const hasCiphertexts = (ciphertextsIn?.length ?? 0) > 0;
|
|
843
|
+
if (
|
|
844
|
+
!hasDigests &&
|
|
845
|
+
!hasPlaintexts &&
|
|
846
|
+
!hasCiphertexts &&
|
|
847
|
+
!includePublicKey &&
|
|
848
|
+
!challenge
|
|
849
|
+
) {
|
|
850
|
+
emitInvalid(correlationId, targetOrigin, "emptyBatch");
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* @param {Uint8Array} secp256k1PrivateKey
|
|
856
|
+
* @param {Uint8Array} ed25519Seed
|
|
857
|
+
* @param {Uint8Array} secp256k1PublicKey
|
|
858
|
+
* @param {Uint8Array} ed25519PublicKey
|
|
859
|
+
* @param {string | null} resolvedCredentialId
|
|
860
|
+
* @param {PublicKeyCredential | null} credential
|
|
861
|
+
*/
|
|
862
|
+
async function runOps(
|
|
863
|
+
secp256k1PrivateKey,
|
|
864
|
+
ed25519Seed,
|
|
865
|
+
secp256k1PublicKey,
|
|
866
|
+
ed25519PublicKey,
|
|
867
|
+
resolvedCredentialId,
|
|
868
|
+
credential,
|
|
869
|
+
) {
|
|
870
|
+
/** @type {Record<string, unknown>} */
|
|
871
|
+
const data = { credentialId: resolvedCredentialId };
|
|
872
|
+
|
|
873
|
+
if (hasDigests && digestItems) {
|
|
874
|
+
data.results = await signParsedDigests(
|
|
875
|
+
digestItems,
|
|
876
|
+
secp256k1PrivateKey,
|
|
877
|
+
ed25519Seed,
|
|
878
|
+
resolvedCredentialId,
|
|
879
|
+
);
|
|
880
|
+
}
|
|
881
|
+
if (hasPlaintexts && plaintexts) {
|
|
882
|
+
data.ciphertexts = await encryptAes256Batch(
|
|
883
|
+
plaintexts,
|
|
884
|
+
secp256k1PrivateKey,
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
if (hasCiphertexts && ciphertextsIn) {
|
|
888
|
+
data.plaintexts = await decryptAes256Batch(
|
|
889
|
+
ciphertextsIn,
|
|
890
|
+
secp256k1PrivateKey,
|
|
891
|
+
);
|
|
892
|
+
}
|
|
893
|
+
if (includePublicKey) {
|
|
894
|
+
data.publicKey = {
|
|
895
|
+
credentialId: resolvedCredentialId ?? undefined,
|
|
896
|
+
cosePublicKey: credential
|
|
897
|
+
? getCosePublicKeyBase64Url(credential)
|
|
898
|
+
: null,
|
|
899
|
+
secp256k1PublicKey: to0xHex(secp256k1PublicKey),
|
|
900
|
+
ed25519PublicKey: to0xHex(ed25519PublicKey),
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
if (challenge && credential) {
|
|
904
|
+
const signature = getAssertionSignatureBase64Url(credential);
|
|
905
|
+
if (signature) {
|
|
906
|
+
data.challengeSignature = signature;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
emitEvent(window.parent, targetOrigin, "BatchExecuted", correlationId, data);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// Challenge always forces a real assertion (assertion signature required).
|
|
914
|
+
if (challenge || !hasRecoverySession()) {
|
|
915
|
+
await withCeremony(async () => {
|
|
916
|
+
const credential = await promptCeremonyConfirm(
|
|
917
|
+
ceremonyFieldsFromParams(params),
|
|
918
|
+
() => getPasskeyAssertion(challenge, credentialId),
|
|
919
|
+
);
|
|
920
|
+
const keys = await deriveKeysFromCredential(credential);
|
|
921
|
+
const ed25519Seed = await ed25519SeedFromCredential(credential);
|
|
922
|
+
emitKeyDerived(
|
|
923
|
+
targetOrigin,
|
|
924
|
+
correlationId,
|
|
925
|
+
keys.secp256k1PublicKey,
|
|
926
|
+
keys.ed25519PublicKey,
|
|
927
|
+
);
|
|
928
|
+
try {
|
|
929
|
+
await runOps(
|
|
930
|
+
keys.secp256k1PrivateKey,
|
|
931
|
+
ed25519Seed,
|
|
932
|
+
keys.secp256k1PublicKey,
|
|
933
|
+
keys.ed25519PublicKey,
|
|
934
|
+
credentialId ?? getCredentialId(credential),
|
|
935
|
+
credential,
|
|
936
|
+
);
|
|
937
|
+
} finally {
|
|
938
|
+
zeroize(keys.secp256k1PrivateKey);
|
|
939
|
+
zeroize(ed25519Seed);
|
|
940
|
+
}
|
|
941
|
+
});
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
const cached = getRecoveryPrivateKey();
|
|
946
|
+
if (!cached) throw new Error("recoverySessionEmpty");
|
|
947
|
+
const ed25519Seed = await ed25519SeedFromSecp256k1Scalar(cached);
|
|
948
|
+
const secp256k1PublicKey = secpGetPublicKey(cached, false);
|
|
949
|
+
const ed25519PublicKey = await edGetPublicKeyAsync(ed25519Seed);
|
|
950
|
+
emitKeyDerived(
|
|
951
|
+
targetOrigin,
|
|
952
|
+
correlationId,
|
|
953
|
+
secp256k1PublicKey,
|
|
954
|
+
ed25519PublicKey,
|
|
955
|
+
);
|
|
956
|
+
try {
|
|
957
|
+
await runOps(
|
|
958
|
+
cached,
|
|
959
|
+
ed25519Seed,
|
|
960
|
+
secp256k1PublicKey,
|
|
961
|
+
ed25519PublicKey,
|
|
962
|
+
credentialId ?? null,
|
|
963
|
+
null,
|
|
964
|
+
);
|
|
965
|
+
} finally {
|
|
966
|
+
zeroize(ed25519Seed);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
654
970
|
/**
|
|
655
971
|
* @param {unknown} error
|
|
656
972
|
* @param {string | undefined} correlationId
|
|
@@ -660,6 +976,13 @@ function handleError(error, correlationId, targetOrigin) {
|
|
|
660
976
|
const name = error instanceof Error ? error.name : "";
|
|
661
977
|
const message = error instanceof Error ? error.message : String(error);
|
|
662
978
|
|
|
979
|
+
if (error instanceof CeremonyDeniedError || name === "CeremonyDeniedError") {
|
|
980
|
+
emitEvent(window.parent, targetOrigin, "SignDenied", correlationId, {
|
|
981
|
+
reason: message || "signDenied",
|
|
982
|
+
});
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
|
|
663
986
|
if (name === "NotAllowedError" || message.includes("NotAllowed")) {
|
|
664
987
|
emitEvent(window.parent, targetOrigin, "NotAllowed", correlationId, {
|
|
665
988
|
reason: message || "notAllowed",
|
package/src/main.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { handleRequest } from "./handlers.js";
|
|
2
2
|
import {
|
|
3
|
+
abandonCeremony,
|
|
3
4
|
isValidNesting,
|
|
4
5
|
setTrustedParentOrigin,
|
|
5
6
|
} from "./state.js";
|
|
6
|
-
import { initUi } from "./ui.js";
|
|
7
|
+
import { cancelPendingCeremonyConfirm, initUi } from "./ui.js";
|
|
7
8
|
import { isValidParentMessage, parseRequest } from "./rpc.js";
|
|
8
9
|
|
|
9
10
|
function bootstrap() {
|
|
@@ -20,14 +21,29 @@ function bootstrap() {
|
|
|
20
21
|
|
|
21
22
|
window.addEventListener("message", async (event) => {
|
|
22
23
|
if (!isValidParentMessage(event)) return;
|
|
23
|
-
|
|
24
|
+
|
|
25
|
+
const data = event.data;
|
|
26
|
+
if (
|
|
27
|
+
data &&
|
|
28
|
+
typeof data === "object" &&
|
|
29
|
+
/** @type {{ kind?: unknown }} */ (data).kind === "cancel"
|
|
30
|
+
) {
|
|
31
|
+
setTrustedParentOrigin(event.origin);
|
|
32
|
+
await abandonCeremony(cancelPendingCeremonyConfirm);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (!parseRequest(data)) return;
|
|
24
37
|
|
|
25
38
|
setTrustedParentOrigin(event.origin);
|
|
26
39
|
const targetOrigin = event.origin;
|
|
27
40
|
const { method, correlationId, params = {} } = /** @type {{ method: string, correlationId?: string, params?: Record<string, unknown> }} */ (
|
|
28
|
-
|
|
41
|
+
data
|
|
29
42
|
);
|
|
30
43
|
|
|
44
|
+
// Drop a stuck Confirm wait (e.g. parent timed out) before starting anew.
|
|
45
|
+
await abandonCeremony(cancelPendingCeremonyConfirm);
|
|
46
|
+
|
|
31
47
|
await handleRequest(
|
|
32
48
|
method,
|
|
33
49
|
/** @type {Record<string, unknown>} */ (params),
|
package/src/state.js
CHANGED
|
@@ -84,7 +84,26 @@ export async function withCeremony(fn) {
|
|
|
84
84
|
try {
|
|
85
85
|
return await promise;
|
|
86
86
|
} finally {
|
|
87
|
-
ceremonyPromise
|
|
87
|
+
if (ceremonyPromise === promise) {
|
|
88
|
+
ceremonyPromise = null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Cancel an open Confirm UI and wait for any in-flight ceremony lock to clear.
|
|
95
|
+
* Used when the parent times out or starts a new RPC.
|
|
96
|
+
*
|
|
97
|
+
* @param {() => void} cancelPendingConfirm
|
|
98
|
+
*/
|
|
99
|
+
export async function abandonCeremony(cancelPendingConfirm) {
|
|
100
|
+
cancelPendingConfirm();
|
|
101
|
+
const pending = ceremonyPromise;
|
|
102
|
+
if (!pending) return;
|
|
103
|
+
try {
|
|
104
|
+
await pending;
|
|
105
|
+
} catch {
|
|
106
|
+
// Prior ceremony was cancelled or failed — lock clears in withCeremony finally.
|
|
88
107
|
}
|
|
89
108
|
}
|
|
90
109
|
|
package/src/ui.js
CHANGED
|
@@ -3,6 +3,59 @@ import { to0xHex } from "./hex.js";
|
|
|
3
3
|
/** @type {HTMLElement | null} */
|
|
4
4
|
let root = null;
|
|
5
5
|
|
|
6
|
+
/** @type {((reason?: Error) => void) | null} */
|
|
7
|
+
let pendingConfirmReject = null;
|
|
8
|
+
|
|
9
|
+
const DEFAULT_CEREMONY_HEADER = "Confirm passkey";
|
|
10
|
+
const DEFAULT_CEREMONY_TEXT =
|
|
11
|
+
"Your device will ask for a passkey to continue.";
|
|
12
|
+
const DEFAULT_CONFIRM_TEXT = "Continue";
|
|
13
|
+
const DEFAULT_DENY_TEXT = "Cancel";
|
|
14
|
+
|
|
15
|
+
const MAX_HEADER_LENGTH = 120;
|
|
16
|
+
const MAX_EXPLANATION_LENGTH = 2_000;
|
|
17
|
+
const MAX_BUTTON_LABEL_LENGTH = 64;
|
|
18
|
+
const MAX_PASSPHRASE_PROMPT_LENGTH = 200;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Plain-text sanitize Branding-supplied copy before it touches the DOM.
|
|
22
|
+
* Tags are stripped; remaining text is assigned via `textContent` (never HTML).
|
|
23
|
+
*
|
|
24
|
+
* @param {unknown} value
|
|
25
|
+
* @param {{ maxLength?: number, allowNewlines?: boolean }} [options]
|
|
26
|
+
* @returns {string}
|
|
27
|
+
*/
|
|
28
|
+
export function sanitizeDisplayText(value, options = {}) {
|
|
29
|
+
if (typeof value !== "string") return "";
|
|
30
|
+
const maxLength = options.maxLength ?? 500;
|
|
31
|
+
const allowNewlines = options.allowNewlines === true;
|
|
32
|
+
|
|
33
|
+
let text = value.replace(/<[^>]*>/g, "");
|
|
34
|
+
// Decode a few common entities so stripped markup cannot leave `<script>`-shaped bait.
|
|
35
|
+
text = text
|
|
36
|
+
.replace(/</gi, "<")
|
|
37
|
+
.replace(/>/gi, ">")
|
|
38
|
+
.replace(/"/gi, '"')
|
|
39
|
+
.replace(/'/g, "'")
|
|
40
|
+
.replace(/&/gi, "&");
|
|
41
|
+
// Strip again after entity decode (e.g. `<img onerror=...>`).
|
|
42
|
+
text = text.replace(/<[^>]*>/g, "");
|
|
43
|
+
|
|
44
|
+
if (allowNewlines) {
|
|
45
|
+
text = text.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "");
|
|
46
|
+
text = text.replace(/\r\n?/g, "\n");
|
|
47
|
+
} else {
|
|
48
|
+
text = text.replace(/[\u0000-\u001F\u007F]/g, " ");
|
|
49
|
+
text = text.replace(/\s+/g, " ");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
text = text.trim();
|
|
53
|
+
if (text.length > maxLength) {
|
|
54
|
+
text = text.slice(0, maxLength).trimEnd();
|
|
55
|
+
}
|
|
56
|
+
return text;
|
|
57
|
+
}
|
|
58
|
+
|
|
6
59
|
/**
|
|
7
60
|
* @param {HTMLElement} container
|
|
8
61
|
*/
|
|
@@ -13,26 +66,202 @@ export function initUi(container) {
|
|
|
13
66
|
}
|
|
14
67
|
|
|
15
68
|
export function clearUi() {
|
|
16
|
-
if (root)
|
|
69
|
+
if (root) {
|
|
70
|
+
root.innerHTML = "";
|
|
71
|
+
root.className = "ows-signer-ui";
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Error thrown when the user cancels the ceremony Confirm UI.
|
|
77
|
+
* Mapped to the `SignDenied` event by handlers.
|
|
78
|
+
*/
|
|
79
|
+
export class CeremonyDeniedError extends Error {
|
|
80
|
+
constructor(message = "signDenied") {
|
|
81
|
+
super(message);
|
|
82
|
+
this.name = "CeremonyDeniedError";
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Abort an open Confirm panel (e.g. client timeout or superseding RPC).
|
|
88
|
+
* Does nothing if no confirm is waiting.
|
|
89
|
+
*/
|
|
90
|
+
export function cancelPendingCeremonyConfirm() {
|
|
91
|
+
if (!pendingConfirmReject) return;
|
|
92
|
+
const reject = pendingConfirmReject;
|
|
93
|
+
pendingConfirmReject = null;
|
|
94
|
+
clearUi();
|
|
95
|
+
reject(new CeremonyDeniedError("ceremonyCancelled"));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* @typedef {{
|
|
100
|
+
* explanationHeader?: string,
|
|
101
|
+
* explanationText?: string,
|
|
102
|
+
* confirmButtonText?: string,
|
|
103
|
+
* denyButtonText?: string,
|
|
104
|
+
* }} CeremonyUiFields
|
|
105
|
+
*/
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Resolve ceremony copy with defaults.
|
|
109
|
+
* @param {CeremonyUiFields | Record<string, unknown> | undefined} fields
|
|
110
|
+
*/
|
|
111
|
+
export function resolveCeremonyUi(fields) {
|
|
112
|
+
const src = fields && typeof fields === "object" ? fields : {};
|
|
113
|
+
const header = sanitizeDisplayText(src.explanationHeader, {
|
|
114
|
+
maxLength: MAX_HEADER_LENGTH,
|
|
115
|
+
});
|
|
116
|
+
const explanation = sanitizeDisplayText(src.explanationText, {
|
|
117
|
+
maxLength: MAX_EXPLANATION_LENGTH,
|
|
118
|
+
allowNewlines: true,
|
|
119
|
+
});
|
|
120
|
+
const confirmButtonText = sanitizeDisplayText(src.confirmButtonText, {
|
|
121
|
+
maxLength: MAX_BUTTON_LABEL_LENGTH,
|
|
122
|
+
});
|
|
123
|
+
const denyButtonText = sanitizeDisplayText(src.denyButtonText, {
|
|
124
|
+
maxLength: MAX_BUTTON_LABEL_LENGTH,
|
|
125
|
+
});
|
|
126
|
+
return {
|
|
127
|
+
header: header || DEFAULT_CEREMONY_HEADER,
|
|
128
|
+
explanation: explanation || DEFAULT_CEREMONY_TEXT,
|
|
129
|
+
confirmButtonText: confirmButtonText || DEFAULT_CONFIRM_TEXT,
|
|
130
|
+
denyButtonText: denyButtonText || DEFAULT_DENY_TEXT,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Show Confirm/Cancel. On Confirm, runs `runOnConfirm` **synchronously from the
|
|
136
|
+
* click handler** so WebAuthn keeps user activation / document focus on mobile.
|
|
137
|
+
*
|
|
138
|
+
* Copy stays visible while the ceremony runs; buttons are disabled until it
|
|
139
|
+
* settles. The panel is cleared only on deny, success, failure, or cancel.
|
|
140
|
+
*
|
|
141
|
+
* Layout: header + actions stay fixed; only explanation text scrolls.
|
|
142
|
+
* Branding strings are sanitized then set with `textContent` (plain text only).
|
|
143
|
+
*
|
|
144
|
+
* @template T
|
|
145
|
+
* @param {CeremonyUiFields | Record<string, unknown> | undefined} fields
|
|
146
|
+
* @param {() => T | Promise<T>} runOnConfirm
|
|
147
|
+
* @returns {Promise<T>}
|
|
148
|
+
*/
|
|
149
|
+
export function promptCeremonyConfirm(fields, runOnConfirm) {
|
|
150
|
+
if (!root) {
|
|
151
|
+
return Promise.reject(new Error("uiNotInitialized"));
|
|
152
|
+
}
|
|
153
|
+
cancelPendingCeremonyConfirm();
|
|
154
|
+
clearUi();
|
|
155
|
+
root.className = "ows-signer-ui ows-signer-ui--ceremony";
|
|
156
|
+
const ui = resolveCeremonyUi(fields);
|
|
157
|
+
|
|
158
|
+
return new Promise((resolve, reject) => {
|
|
159
|
+
pendingConfirmReject = reject;
|
|
160
|
+
|
|
161
|
+
const header = document.createElement("h2");
|
|
162
|
+
header.className = "ows-ceremony-header";
|
|
163
|
+
header.textContent = ui.header;
|
|
164
|
+
|
|
165
|
+
const body = document.createElement("div");
|
|
166
|
+
body.className = "ows-ceremony-body";
|
|
167
|
+
const text = document.createElement("p");
|
|
168
|
+
text.className = "ows-ceremony-text";
|
|
169
|
+
text.textContent = ui.explanation;
|
|
170
|
+
body.append(text);
|
|
171
|
+
|
|
172
|
+
const actions = document.createElement("div");
|
|
173
|
+
actions.className = "ows-ceremony-actions";
|
|
174
|
+
|
|
175
|
+
const confirmBtn = document.createElement("button");
|
|
176
|
+
confirmBtn.type = "button";
|
|
177
|
+
confirmBtn.className = "ows-confirm";
|
|
178
|
+
confirmBtn.textContent = ui.confirmButtonText;
|
|
179
|
+
|
|
180
|
+
const denyBtn = document.createElement("button");
|
|
181
|
+
denyBtn.type = "button";
|
|
182
|
+
denyBtn.className = "ows-deny";
|
|
183
|
+
denyBtn.textContent = ui.denyButtonText;
|
|
184
|
+
|
|
185
|
+
const finishWaiting = () => {
|
|
186
|
+
pendingConfirmReject = null;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const settleSuccess = (result) => {
|
|
190
|
+
// Cancel may have already cleared the waiter / panel.
|
|
191
|
+
if (pendingConfirmReject !== reject) return;
|
|
192
|
+
finishWaiting();
|
|
193
|
+
clearUi();
|
|
194
|
+
resolve(result);
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const settleFailure = (error) => {
|
|
198
|
+
if (pendingConfirmReject !== reject) return;
|
|
199
|
+
finishWaiting();
|
|
200
|
+
clearUi();
|
|
201
|
+
reject(error);
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
confirmBtn.addEventListener("click", () => {
|
|
205
|
+
confirmBtn.disabled = true;
|
|
206
|
+
denyBtn.disabled = true;
|
|
207
|
+
try {
|
|
208
|
+
// Start WebAuthn in the same turn as the click (no prior await).
|
|
209
|
+
// Keep copy visible until the ceremony settles.
|
|
210
|
+
Promise.resolve(runOnConfirm()).then(settleSuccess, settleFailure);
|
|
211
|
+
} catch (error) {
|
|
212
|
+
settleFailure(error);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
denyBtn.addEventListener("click", () => {
|
|
217
|
+
finishWaiting();
|
|
218
|
+
clearUi();
|
|
219
|
+
reject(new CeremonyDeniedError());
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
actions.append(confirmBtn, denyBtn);
|
|
223
|
+
root.append(header, body, actions);
|
|
224
|
+
});
|
|
17
225
|
}
|
|
18
226
|
|
|
19
227
|
/**
|
|
228
|
+
* Show the private key hex with Copy + Done. Resolves when the user clicks Done
|
|
229
|
+
* (or the UI is cleared), so Branding can keep the ceremony panel open.
|
|
230
|
+
*
|
|
20
231
|
* @param {Uint8Array} privateKey
|
|
232
|
+
* @returns {Promise<void>}
|
|
21
233
|
*/
|
|
22
234
|
export function showPrivateKey(privateKey) {
|
|
23
|
-
if (!root)
|
|
235
|
+
if (!root) {
|
|
236
|
+
return Promise.reject(new Error("uiNotInitialized"));
|
|
237
|
+
}
|
|
238
|
+
cancelPendingCeremonyConfirm();
|
|
24
239
|
clearUi();
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
240
|
+
const hex = to0xHex(privateKey);
|
|
241
|
+
return new Promise((resolve) => {
|
|
242
|
+
const pre = document.createElement("pre");
|
|
243
|
+
pre.className = "ows-key";
|
|
244
|
+
pre.textContent = hex;
|
|
245
|
+
const copyBtn = document.createElement("button");
|
|
246
|
+
copyBtn.type = "button";
|
|
247
|
+
copyBtn.className = "ows-copy";
|
|
248
|
+
copyBtn.textContent = "Copy";
|
|
249
|
+
copyBtn.addEventListener("click", async () => {
|
|
250
|
+
await navigator.clipboard.writeText(pre.textContent ?? "");
|
|
251
|
+
});
|
|
252
|
+
const doneBtn = document.createElement("button");
|
|
253
|
+
doneBtn.type = "button";
|
|
254
|
+
doneBtn.className = "ows-confirm";
|
|
255
|
+
doneBtn.textContent = "Done";
|
|
256
|
+
doneBtn.addEventListener("click", () => {
|
|
257
|
+
clearUi();
|
|
258
|
+
resolve();
|
|
259
|
+
});
|
|
260
|
+
const actions = document.createElement("div");
|
|
261
|
+
actions.className = "ows-actions";
|
|
262
|
+
actions.append(copyBtn, doneBtn);
|
|
263
|
+
root.append(pre, actions);
|
|
34
264
|
});
|
|
35
|
-
root.append(pre, copyBtn);
|
|
36
265
|
}
|
|
37
266
|
|
|
38
267
|
/**
|
|
@@ -45,16 +274,23 @@ export function promptPassphrase(passwordText, buttonText, minPasswordLength) {
|
|
|
45
274
|
if (!root) {
|
|
46
275
|
return Promise.reject(new Error("uiNotInitialized"));
|
|
47
276
|
}
|
|
277
|
+
cancelPendingCeremonyConfirm();
|
|
48
278
|
clearUi();
|
|
279
|
+
const safePlaceholder = sanitizeDisplayText(passwordText, {
|
|
280
|
+
maxLength: MAX_PASSPHRASE_PROMPT_LENGTH,
|
|
281
|
+
});
|
|
282
|
+
const safeButton = sanitizeDisplayText(buttonText, {
|
|
283
|
+
maxLength: MAX_BUTTON_LABEL_LENGTH,
|
|
284
|
+
});
|
|
49
285
|
return new Promise((resolve, reject) => {
|
|
50
286
|
const input = document.createElement("input");
|
|
51
287
|
input.type = "password";
|
|
52
288
|
input.className = "ows-password";
|
|
53
|
-
input.placeholder =
|
|
289
|
+
input.placeholder = safePlaceholder || "Passphrase";
|
|
54
290
|
const button = document.createElement("button");
|
|
55
291
|
button.type = "button";
|
|
56
292
|
button.className = "ows-confirm";
|
|
57
|
-
button.textContent =
|
|
293
|
+
button.textContent = safeButton || "Continue";
|
|
58
294
|
button.addEventListener("click", () => {
|
|
59
295
|
if (input.value.length < minPasswordLength) {
|
|
60
296
|
reject(new Error("passwordTooShort"));
|