@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 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?` (`rpName`, `userDisplayName`, `userId`) | `KeyDerived`, `CredentialCreated` |
41
- | `signDigest` | `digestData`, `scheme`, `credentialId?` | `KeyDerived` (PRF path), `DigestSigned` |
42
- | `revealPrivateKey` | `credentialId?` | `KeyDerived` (+ DOM display) |
43
- | `createRecoveryData` | `passwordText`, `buttonText`, `minPasswordLength`, `credentialId?` | `KeyDerived`, `RecoveryDataCreated` |
44
- | `recoverKey` | `aes256EncryptedPrivateKey`, `passwordText`, `buttonText`, `credentialId?` | DOM display; `RecoverySessionStarted` or re-bind → `RecoverySessionCleared` |
45
- | `getPublicKey` | `credentialId?`, `challenge?` | `KeyDerived`, `PublicKey`, `ChallengeSigned?` |
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?` | `KeyDerived` (PRF path), `AES256Encrypted` |
48
- | `decryptAES256` | `ciphertexts[]`, `credentialId?` | `KeyDerived` (PRF path), `AES256Decrypted` |
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`, `InvalidRequest`.
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
- padding: 8px;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1shotapi/ows-signer",
3
- "version": "0.2.2",
3
+ "version": "0.3.1",
4
4
  "description": "OWS Signing Layer — plain JavaScript WebAuthn PRF key custody with curve-based signing (on-chain via EIP-8244)",
5
5
  "type": "module",
6
6
  "files": [
package/src/constants.js CHANGED
@@ -27,4 +27,5 @@ export const METHODS = [
27
27
  "clearRecoverySession",
28
28
  "encryptAES256",
29
29
  "decryptAES256",
30
+ "executeBatch",
30
31
  ];
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 createPasskeyCredential(name, options);
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 (typeof digestData !== "string" || typeof scheme !== "string") {
249
- emitInvalid(correlationId, targetOrigin, "invalidParams");
363
+ const parsed = parseDigestItems(params.digests);
364
+ if (!parsed.ok) {
365
+ emitInvalid(correlationId, targetOrigin, parsed.reason);
250
366
  return;
251
367
  }
252
- if (
253
- !SIGN_SCHEMES.includes(
254
- /** @type {import('./constants.js').SignScheme} */ (scheme),
255
- )
256
- ) {
257
- emitInvalid(correlationId, targetOrigin, "unknownScheme");
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
- const signature = await signWithScheme(
272
- /** @type {import('./constants.js').SignScheme} */ (scheme),
273
- digest,
274
- cached,
275
- ed25519Seed,
276
- );
277
- zeroize(ed25519Seed);
278
- emitEvent(window.parent, targetOrigin, "DigestSigned", correlationId, {
279
- digest: digestData,
280
- scheme,
281
- signature,
282
- credentialId: credentialId ?? null,
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 getPasskeyAssertion(undefined, credentialId);
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
- const signature = await signWithScheme(
299
- /** @type {import('./constants.js').SignScheme} */ (scheme),
300
- digest,
301
- keys.secp256k1PrivateKey,
302
- ed25519Seed,
303
- );
304
- zeroize(keys.secp256k1PrivateKey);
305
- zeroize(ed25519Seed);
306
-
307
- emitEvent(window.parent, targetOrigin, "DigestSigned", correlationId, {
308
- digest: digestData,
309
- scheme,
310
- signature,
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 getPasskeyAssertion(undefined, credentialId);
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
- showPrivateKey(keys.secp256k1PrivateKey);
335
- zeroize(keys.secp256k1PrivateKey);
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 getPasskeyAssertion(undefined, credentialId);
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 getPasskeyAssertion(undefined, credentialId);
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 getPasskeyAssertion(undefined, credentialId);
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 getPasskeyAssertion(undefined, credentialId);
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 getPasskeyAssertion(challenge, credentialId);
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
- if (!parseRequest(event.data)) return;
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
- event.data
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 = null;
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 `&lt;script&gt;`-shaped bait.
35
+ text = text
36
+ .replace(/&lt;/gi, "<")
37
+ .replace(/&gt;/gi, ">")
38
+ .replace(/&quot;/gi, '"')
39
+ .replace(/&#39;/g, "'")
40
+ .replace(/&amp;/gi, "&");
41
+ // Strip again after entity decode (e.g. `&lt;img onerror=...&gt;`).
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) root.innerHTML = "";
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) return;
235
+ if (!root) {
236
+ return Promise.reject(new Error("uiNotInitialized"));
237
+ }
238
+ cancelPendingCeremonyConfirm();
24
239
  clearUi();
25
- const pre = document.createElement("pre");
26
- pre.className = "ows-key";
27
- pre.textContent = to0xHex(privateKey);
28
- const copyBtn = document.createElement("button");
29
- copyBtn.type = "button";
30
- copyBtn.className = "ows-copy";
31
- copyBtn.textContent = "Copy";
32
- copyBtn.addEventListener("click", async () => {
33
- await navigator.clipboard.writeText(pre.textContent ?? "");
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 = passwordText;
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 = buttonText;
293
+ button.textContent = safeButton || "Continue";
58
294
  button.addEventListener("click", () => {
59
295
  if (input.value.length < minPasswordLength) {
60
296
  reject(new Error("passwordTooShort"));