@1shotapi/ows-signer 0.2.2 → 0.3.0

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` (+ DOM display) |
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,35 @@
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
+ }
38
80
  .ows-copy,
39
- .ows-confirm {
81
+ .ows-confirm,
82
+ .ows-deny {
40
83
  padding: 8px 12px;
41
84
  border: 1px solid #8884;
42
85
  border-radius: 4px;
@@ -45,6 +88,14 @@
45
88
  cursor: pointer;
46
89
  align-self: flex-start;
47
90
  }
91
+ .ows-confirm {
92
+ font-weight: 600;
93
+ }
94
+ .ows-confirm:disabled,
95
+ .ows-deny:disabled {
96
+ opacity: 0.45;
97
+ cursor: default;
98
+ }
48
99
  </style>
49
100
  </head>
50
101
  <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.0",
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,
@@ -390,7 +504,10 @@ async function handleCreateRecoveryData(params, correlationId, targetOrigin) {
390
504
  }
391
505
 
392
506
  await withCeremony(async () => {
393
- const credential = await getPasskeyAssertion(undefined, credentialId);
507
+ const credential = await promptCeremonyConfirm(
508
+ ceremonyFieldsFromParams(params),
509
+ () => getPasskeyAssertion(undefined, credentialId),
510
+ );
394
511
  const keys = await deriveKeysFromCredential(credential);
395
512
  emitKeyDerived(
396
513
  targetOrigin,
@@ -455,7 +572,9 @@ async function handleRecoverKey(params, correlationId, targetOrigin) {
455
572
 
456
573
  if (credentialId) {
457
574
  await withCeremony(async () => {
458
- await getPasskeyAssertion(undefined, credentialId);
575
+ await promptCeremonyConfirm(ceremonyFieldsFromParams(params), () =>
576
+ getPasskeyAssertion(undefined, credentialId),
577
+ );
459
578
  clearRecoveryPrivateKey();
460
579
  });
461
580
  emitEvent(
@@ -522,7 +641,10 @@ async function handleEncryptAES256(params, correlationId, targetOrigin) {
522
641
  }
523
642
 
524
643
  await withCeremony(async () => {
525
- const credential = await getPasskeyAssertion(undefined, credentialId);
644
+ const credential = await promptCeremonyConfirm(
645
+ ceremonyFieldsFromParams(params),
646
+ () => getPasskeyAssertion(undefined, credentialId),
647
+ );
526
648
  const keys = await deriveKeysFromCredential(credential);
527
649
  emitKeyDerived(
528
650
  targetOrigin,
@@ -579,7 +701,10 @@ async function handleDecryptAES256(params, correlationId, targetOrigin) {
579
701
  }
580
702
 
581
703
  await withCeremony(async () => {
582
- const credential = await getPasskeyAssertion(undefined, credentialId);
704
+ const credential = await promptCeremonyConfirm(
705
+ ceremonyFieldsFromParams(params),
706
+ () => getPasskeyAssertion(undefined, credentialId),
707
+ );
583
708
  const keys = await deriveKeysFromCredential(credential);
584
709
  emitKeyDerived(
585
710
  targetOrigin,
@@ -615,7 +740,10 @@ async function handleGetPublicKey(params, correlationId, targetOrigin) {
615
740
  : undefined;
616
741
 
617
742
  await withCeremony(async () => {
618
- const credential = await getPasskeyAssertion(challenge, credentialId);
743
+ const credential = await promptCeremonyConfirm(
744
+ ceremonyFieldsFromParams(params),
745
+ () => getPasskeyAssertion(challenge, credentialId),
746
+ );
619
747
  const keys = await deriveKeysFromCredential(credential);
620
748
  emitKeyDerived(
621
749
  targetOrigin,
@@ -651,6 +779,182 @@ async function handleGetPublicKey(params, correlationId, targetOrigin) {
651
779
  });
652
780
  }
653
781
 
782
+ /**
783
+ * Mixed ceremony: digests + AES + public key + optional WebAuthn challenge.
784
+ *
785
+ * @param {Record<string, unknown>} params
786
+ * @param {string | undefined} correlationId
787
+ * @param {string} targetOrigin
788
+ */
789
+ async function handleExecuteBatch(params, correlationId, targetOrigin) {
790
+ const credentialId =
791
+ typeof params.credentialId === "string" ? params.credentialId : undefined;
792
+ const includePublicKey = params.includePublicKey === true;
793
+ const challengeHex =
794
+ typeof params.challenge === "string" ? params.challenge : undefined;
795
+ const challenge = challengeHex ? parse0xHex(challengeHex) : undefined;
796
+
797
+ /** @type {ParsedDigestItem[] | undefined} */
798
+ let digestItems;
799
+ if (params.digests !== undefined) {
800
+ const parsed = parseDigestItems(params.digests);
801
+ if (!parsed.ok) {
802
+ emitInvalid(correlationId, targetOrigin, parsed.reason);
803
+ return;
804
+ }
805
+ digestItems = parsed.items;
806
+ }
807
+
808
+ /** @type {string[] | undefined} */
809
+ let plaintexts;
810
+ if (params.plaintexts !== undefined) {
811
+ if (!isStringArray(params.plaintexts)) {
812
+ emitInvalid(correlationId, targetOrigin, "invalidParams");
813
+ return;
814
+ }
815
+ plaintexts = params.plaintexts;
816
+ }
817
+
818
+ /** @type {string[] | undefined} */
819
+ let ciphertextsIn;
820
+ if (params.ciphertexts !== undefined) {
821
+ if (!isStringArray(params.ciphertexts)) {
822
+ emitInvalid(correlationId, targetOrigin, "invalidParams");
823
+ return;
824
+ }
825
+ ciphertextsIn = params.ciphertexts;
826
+ }
827
+
828
+ const hasDigests = (digestItems?.length ?? 0) > 0;
829
+ const hasPlaintexts = (plaintexts?.length ?? 0) > 0;
830
+ const hasCiphertexts = (ciphertextsIn?.length ?? 0) > 0;
831
+ if (
832
+ !hasDigests &&
833
+ !hasPlaintexts &&
834
+ !hasCiphertexts &&
835
+ !includePublicKey &&
836
+ !challenge
837
+ ) {
838
+ emitInvalid(correlationId, targetOrigin, "emptyBatch");
839
+ return;
840
+ }
841
+
842
+ /**
843
+ * @param {Uint8Array} secp256k1PrivateKey
844
+ * @param {Uint8Array} ed25519Seed
845
+ * @param {Uint8Array} secp256k1PublicKey
846
+ * @param {Uint8Array} ed25519PublicKey
847
+ * @param {string | null} resolvedCredentialId
848
+ * @param {PublicKeyCredential | null} credential
849
+ */
850
+ async function runOps(
851
+ secp256k1PrivateKey,
852
+ ed25519Seed,
853
+ secp256k1PublicKey,
854
+ ed25519PublicKey,
855
+ resolvedCredentialId,
856
+ credential,
857
+ ) {
858
+ /** @type {Record<string, unknown>} */
859
+ const data = { credentialId: resolvedCredentialId };
860
+
861
+ if (hasDigests && digestItems) {
862
+ data.results = await signParsedDigests(
863
+ digestItems,
864
+ secp256k1PrivateKey,
865
+ ed25519Seed,
866
+ resolvedCredentialId,
867
+ );
868
+ }
869
+ if (hasPlaintexts && plaintexts) {
870
+ data.ciphertexts = await encryptAes256Batch(
871
+ plaintexts,
872
+ secp256k1PrivateKey,
873
+ );
874
+ }
875
+ if (hasCiphertexts && ciphertextsIn) {
876
+ data.plaintexts = await decryptAes256Batch(
877
+ ciphertextsIn,
878
+ secp256k1PrivateKey,
879
+ );
880
+ }
881
+ if (includePublicKey) {
882
+ data.publicKey = {
883
+ credentialId: resolvedCredentialId ?? undefined,
884
+ cosePublicKey: credential
885
+ ? getCosePublicKeyBase64Url(credential)
886
+ : null,
887
+ secp256k1PublicKey: to0xHex(secp256k1PublicKey),
888
+ ed25519PublicKey: to0xHex(ed25519PublicKey),
889
+ };
890
+ }
891
+ if (challenge && credential) {
892
+ const signature = getAssertionSignatureBase64Url(credential);
893
+ if (signature) {
894
+ data.challengeSignature = signature;
895
+ }
896
+ }
897
+
898
+ emitEvent(window.parent, targetOrigin, "BatchExecuted", correlationId, data);
899
+ }
900
+
901
+ // Challenge always forces a real assertion (assertion signature required).
902
+ if (challenge || !hasRecoverySession()) {
903
+ await withCeremony(async () => {
904
+ const credential = await promptCeremonyConfirm(
905
+ ceremonyFieldsFromParams(params),
906
+ () => getPasskeyAssertion(challenge, credentialId),
907
+ );
908
+ const keys = await deriveKeysFromCredential(credential);
909
+ const ed25519Seed = await ed25519SeedFromCredential(credential);
910
+ emitKeyDerived(
911
+ targetOrigin,
912
+ correlationId,
913
+ keys.secp256k1PublicKey,
914
+ keys.ed25519PublicKey,
915
+ );
916
+ try {
917
+ await runOps(
918
+ keys.secp256k1PrivateKey,
919
+ ed25519Seed,
920
+ keys.secp256k1PublicKey,
921
+ keys.ed25519PublicKey,
922
+ credentialId ?? getCredentialId(credential),
923
+ credential,
924
+ );
925
+ } finally {
926
+ zeroize(keys.secp256k1PrivateKey);
927
+ zeroize(ed25519Seed);
928
+ }
929
+ });
930
+ return;
931
+ }
932
+
933
+ const cached = getRecoveryPrivateKey();
934
+ if (!cached) throw new Error("recoverySessionEmpty");
935
+ const ed25519Seed = await ed25519SeedFromSecp256k1Scalar(cached);
936
+ const secp256k1PublicKey = secpGetPublicKey(cached, false);
937
+ const ed25519PublicKey = await edGetPublicKeyAsync(ed25519Seed);
938
+ emitKeyDerived(
939
+ targetOrigin,
940
+ correlationId,
941
+ secp256k1PublicKey,
942
+ ed25519PublicKey,
943
+ );
944
+ try {
945
+ await runOps(
946
+ cached,
947
+ ed25519Seed,
948
+ secp256k1PublicKey,
949
+ ed25519PublicKey,
950
+ credentialId ?? null,
951
+ null,
952
+ );
953
+ } finally {
954
+ zeroize(ed25519Seed);
955
+ }
956
+ }
957
+
654
958
  /**
655
959
  * @param {unknown} error
656
960
  * @param {string | undefined} correlationId
@@ -660,6 +964,13 @@ function handleError(error, correlationId, targetOrigin) {
660
964
  const name = error instanceof Error ? error.name : "";
661
965
  const message = error instanceof Error ? error.message : String(error);
662
966
 
967
+ if (error instanceof CeremonyDeniedError || name === "CeremonyDeniedError") {
968
+ emitEvent(window.parent, targetOrigin, "SignDenied", correlationId, {
969
+ reason: message || "signDenied",
970
+ });
971
+ return;
972
+ }
973
+
663
974
  if (name === "NotAllowedError" || message.includes("NotAllowed")) {
664
975
  emitEvent(window.parent, targetOrigin, "NotAllowed", correlationId, {
665
976
  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,7 +66,162 @@ 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
  /**
@@ -21,6 +229,7 @@ export function clearUi() {
21
229
  */
22
230
  export function showPrivateKey(privateKey) {
23
231
  if (!root) return;
232
+ cancelPendingCeremonyConfirm();
24
233
  clearUi();
25
234
  const pre = document.createElement("pre");
26
235
  pre.className = "ows-key";
@@ -45,16 +254,23 @@ export function promptPassphrase(passwordText, buttonText, minPasswordLength) {
45
254
  if (!root) {
46
255
  return Promise.reject(new Error("uiNotInitialized"));
47
256
  }
257
+ cancelPendingCeremonyConfirm();
48
258
  clearUi();
259
+ const safePlaceholder = sanitizeDisplayText(passwordText, {
260
+ maxLength: MAX_PASSPHRASE_PROMPT_LENGTH,
261
+ });
262
+ const safeButton = sanitizeDisplayText(buttonText, {
263
+ maxLength: MAX_BUTTON_LABEL_LENGTH,
264
+ });
49
265
  return new Promise((resolve, reject) => {
50
266
  const input = document.createElement("input");
51
267
  input.type = "password";
52
268
  input.className = "ows-password";
53
- input.placeholder = passwordText;
269
+ input.placeholder = safePlaceholder || "Passphrase";
54
270
  const button = document.createElement("button");
55
271
  button.type = "button";
56
272
  button.className = "ows-confirm";
57
- button.textContent = buttonText;
273
+ button.textContent = safeButton || "Continue";
58
274
  button.addEventListener("click", () => {
59
275
  if (input.value.length < minPasswordLength) {
60
276
  reject(new Error("passwordTooShort"));