@1shotapi/ows-signer 0.4.3 → 0.4.5

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
@@ -101,16 +101,23 @@ reference/ 1ShotPay reference (not published)
101
101
 
102
102
  Serve the Signing Layer document (and its `/src/*` modules) with a **fail-closed** CSP. The signer has no legitimate network I/O — only same-origin scripts/styles and `postMessage` / WebAuthn / WebCrypto.
103
103
 
104
+ **Branding and Signing must share the same origin** (e.g. `https://wallet.example/` + `/signer/`). Cross-origin embeds of a shared CDN/IPFS signer are not supported: a hostile Branding could otherwise drive passkey-gated signing with attacker-written Confirm copy. The Signing Layer rejects parent `postMessage` when `event.origin !== location.origin`, and hosts should set `frame-ancestors 'self'` (optionally `X-Frame-Options: SAMEORIGIN`) so foreign pages cannot frame `/signer/` at all.
105
+
104
106
  Canonical header (single line in production):
105
107
 
106
108
  ```http
107
- Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'none'; img-src 'none'; font-src 'none'; media-src 'none'; object-src 'none'; child-src 'none'; frame-src 'none'; worker-src 'none'; manifest-src 'none'; base-uri 'none'; form-action 'none'; upgrade-insecure-requests
109
+ Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'none'; img-src 'none'; font-src 'none'; media-src 'none'; object-src 'none'; child-src 'none'; frame-src 'none'; worker-src 'none'; manifest-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'; upgrade-insecure-requests
108
110
  ```
109
111
 
110
112
  - Apply on the **Signing Layer origin path** (e.g. nginx `location /signer/`), not on the Branding Layer SPA.
111
- - **Omit `frame-ancestors`** so arbitrary Branding Layers may embed the signer. Nesting safety remains in JS (`event.source === window.parent`, `window.parent !== window.top`).
113
+ - Nesting safety also remains in JS (`event.source === window.parent`, `window.parent !== window.top`, same-origin parent).
114
+ - Reveal / export UI requires the signer document to be **visible and focused** before showing the key or writing the clipboard (defense against obscured-frame clickjacking).
112
115
  - Keep iframe Permissions Policy `allow` for WebAuthn / clipboard (set before `src` in `createSignerIframe`) — that is not CSP.
113
116
 
117
+ ## Audits
118
+
119
+ See [audits/](audits/) for scoped security review notes (AI-assisted).
120
+
114
121
  ## On-chain
115
122
 
116
123
  See `@1shotapi/ows-onchain`.
package/html/signer.css CHANGED
@@ -169,6 +169,14 @@ body.ows-ceremony-open {
169
169
  color: var(--ows-text);
170
170
  }
171
171
 
172
+ .ows-ceremony-origin {
173
+ margin: 8px 0 0;
174
+ font-size: 0.75rem;
175
+ line-height: 1.4;
176
+ color: var(--ows-text-muted);
177
+ word-break: break-all;
178
+ }
179
+
172
180
  .ows-ceremony-body {
173
181
  flex: 0 1 auto;
174
182
  min-height: 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1shotapi/ows-signer",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
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/main.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { handleRequest } from "./handlers.js";
2
2
  import {
3
3
  abandonCeremony,
4
+ getTrustedParentOrigin,
4
5
  isValidNesting,
5
6
  setCancelConfirmHook,
6
7
  setTrustedParentOrigin,
@@ -8,6 +9,17 @@ import {
8
9
  import { cancelPendingCeremonyConfirm, initUi } from "./ui.js";
9
10
  import { isValidParentMessage, parseRequest } from "./rpc.js";
10
11
 
12
+ /**
13
+ * @param {MessageEvent} event
14
+ * @returns {boolean}
15
+ */
16
+ function acceptParentMessage(event) {
17
+ if (!isValidParentMessage(event)) return false;
18
+ const trusted = getTrustedParentOrigin();
19
+ if (trusted !== null && event.origin !== trusted) return false;
20
+ return true;
21
+ }
22
+
11
23
  function bootstrap() {
12
24
  const root = document.getElementById("ows-signer-root");
13
25
  if (!root) {
@@ -22,7 +34,7 @@ function bootstrap() {
22
34
  }
23
35
 
24
36
  window.addEventListener("message", (event) => {
25
- if (!isValidParentMessage(event)) return;
37
+ if (!acceptParentMessage(event)) return;
26
38
 
27
39
  const data = event.data;
28
40
  if (
package/src/rpc.js CHANGED
@@ -23,6 +23,9 @@ export function emitEvent(target, targetOrigin, event, correlationId, data) {
23
23
  }
24
24
 
25
25
  /**
26
+ * Branding↔Signing must be same origin. Reject cross-origin parents
27
+ * (e.g. sketchy.ru embedding wallet.1shotapi.com/signer/).
28
+ *
26
29
  * @param {MessageEvent} event
27
30
  * @returns {boolean}
28
31
  */
@@ -30,6 +33,7 @@ export function isValidParentMessage(event) {
30
33
  if (event.source !== window.parent) return false;
31
34
  if (window.parent === window.top) return false;
32
35
  if (event.source === window) return false;
36
+ if (event.origin !== window.location.origin) return false;
33
37
  return true;
34
38
  }
35
39
 
package/src/ui.js CHANGED
@@ -1,5 +1,61 @@
1
1
  import { to0xHex } from "./hex.js";
2
2
  import { debugLog, describeCeremonyEnvironment } from "./debug.js";
3
+ import { getTrustedParentOrigin } from "./state.js";
4
+
5
+ /**
6
+ * Clickjack / obscured-frame gate. True when the signer document is visible
7
+ * and focused. Reveal snapshots this at the Confirm or passphrase click
8
+ * ({@link canRevealPrivateKey}); clipboard writes check it again on Copy.
9
+ * @returns {boolean}
10
+ */
11
+ export function isSignerDocumentInteractive() {
12
+ if (typeof document === "undefined") return false;
13
+ if (document.visibilityState !== "visible") return false;
14
+ if (typeof document.hasFocus === "function" && !document.hasFocus()) {
15
+ return false;
16
+ }
17
+ return true;
18
+ }
19
+
20
+ /**
21
+ * Key hex may be shown when the document is visible and either focused now
22
+ * or this ceremony's Confirm / passphrase click happened while it was.
23
+ * The passkey sheet blurs the iframe before reveal, so focus at reveal time
24
+ * alone rejects a ceremony the user just confirmed.
25
+ * Clipboard writes do not use this helper.
26
+ *
27
+ * @param {{
28
+ * visibilityState: string,
29
+ * hasFocus: boolean,
30
+ * confirmedWhileInteractive: boolean,
31
+ * }} state
32
+ * @returns {boolean}
33
+ */
34
+ export function canRevealPrivateKey(state) {
35
+ if (state.visibilityState !== "visible") return false;
36
+ return Boolean(state.hasFocus || state.confirmedWhileInteractive);
37
+ }
38
+
39
+ /**
40
+ * Set on a Confirm or passphrase click that ran while the signer document
41
+ * was visible and focused. Consumed by {@link showPrivateKey}.
42
+ * @type {boolean}
43
+ */
44
+ let revealConfirmedWhileInteractive = false;
45
+
46
+ function noteRevealInteraction() {
47
+ revealConfirmedWhileInteractive = isSignerDocumentInteractive();
48
+ }
49
+
50
+ function clearRevealInteraction() {
51
+ revealConfirmedWhileInteractive = false;
52
+ }
53
+
54
+ function takeRevealInteraction() {
55
+ const confirmed = revealConfirmedWhileInteractive;
56
+ revealConfirmedWhileInteractive = false;
57
+ return confirmed;
58
+ }
3
59
 
4
60
  /** @type {HTMLElement | null} */
5
61
  let root = null;
@@ -97,6 +153,7 @@ export class CeremonyDeniedError extends Error {
97
153
  */
98
154
  export function cancelPendingCeremonyConfirm() {
99
155
  if (!pendingConfirmReject) return;
156
+ clearRevealInteraction();
100
157
  const reject = pendingConfirmReject;
101
158
  pendingConfirmReject = null;
102
159
  clearUi();
@@ -175,6 +232,7 @@ export function promptCeremonyConfirm(fields, runOnConfirm) {
175
232
  return Promise.reject(new Error("uiNotInitialized"));
176
233
  }
177
234
  cancelPendingCeremonyConfirm();
235
+ clearRevealInteraction();
178
236
  clearUi();
179
237
  root.className = "ows-signer-ui ows-signer-ui--ceremony";
180
238
  setCeremonyOpen(true);
@@ -198,6 +256,13 @@ export function promptCeremonyConfirm(fields, runOnConfirm) {
198
256
  text.textContent = ui.explanation;
199
257
  body.append(text);
200
258
 
259
+ const requesterOrigin =
260
+ getTrustedParentOrigin() ?? window.location.origin;
261
+ const originLine = document.createElement("p");
262
+ originLine.className = "ows-ceremony-origin";
263
+ originLine.textContent = `Requester: ${requesterOrigin}`;
264
+ body.append(originLine);
265
+
201
266
  const actions = document.createElement("div");
202
267
  actions.className = "ows-ceremony-actions";
203
268
 
@@ -224,6 +289,7 @@ export function promptCeremonyConfirm(fields, runOnConfirm) {
224
289
  };
225
290
 
226
291
  const settleFailure = (error) => {
292
+ clearRevealInteraction();
227
293
  if (pendingConfirmReject !== reject) return;
228
294
  finishWaiting();
229
295
  clearUi();
@@ -233,6 +299,9 @@ export function promptCeremonyConfirm(fields, runOnConfirm) {
233
299
  confirmBtn.addEventListener("click", () => {
234
300
  confirmBtn.disabled = true;
235
301
  denyBtn.disabled = true;
302
+ // Snapshot focus before credentials.get(). The passkey sheet blurs
303
+ // this iframe, so reveal cannot rely on hasFocus() afterwards.
304
+ noteRevealInteraction();
236
305
  try {
237
306
  // Start WebAuthn in the same turn as the click (no prior await).
238
307
  // Keep copy visible until the ceremony settles.
@@ -244,6 +313,7 @@ export function promptCeremonyConfirm(fields, runOnConfirm) {
244
313
  });
245
314
 
246
315
  denyBtn.addEventListener("click", () => {
316
+ clearRevealInteraction();
247
317
  finishWaiting();
248
318
  clearUi();
249
319
  reject(new CeremonyDeniedError());
@@ -258,6 +328,11 @@ export function promptCeremonyConfirm(fields, runOnConfirm) {
258
328
  * Show the private key hex with Copy + Done. Resolves when the user clicks Done
259
329
  * (or the UI is cleared), so Branding can keep the ceremony panel open.
260
330
  *
331
+ * The document must be visible. Focus is required unless this ceremony's
332
+ * Confirm or passphrase click already happened while the signer was
333
+ * interactive — the passkey sheet blurs the iframe before reveal.
334
+ * Clipboard writes still require a focused document at the Copy click.
335
+ *
261
336
  * @param {Uint8Array} privateKey
262
337
  * @returns {Promise<void>}
263
338
  */
@@ -265,6 +340,26 @@ export function showPrivateKey(privateKey) {
265
340
  if (!root) {
266
341
  return Promise.reject(new Error("uiNotInitialized"));
267
342
  }
343
+ try {
344
+ window.focus();
345
+ } catch {
346
+ // Nested iframe focus can be ignored after the passkey sheet.
347
+ }
348
+ const visibilityState =
349
+ typeof document !== "undefined" ? document.visibilityState : "hidden";
350
+ const hasFocus =
351
+ typeof document !== "undefined" &&
352
+ (typeof document.hasFocus !== "function" || document.hasFocus());
353
+ const confirmedWhileInteractive = takeRevealInteraction();
354
+ if (
355
+ !canRevealPrivateKey({
356
+ visibilityState,
357
+ hasFocus,
358
+ confirmedWhileInteractive,
359
+ })
360
+ ) {
361
+ return Promise.reject(new Error("signerNotInteractive"));
362
+ }
268
363
  cancelPendingCeremonyConfirm();
269
364
  clearUi();
270
365
  const hex = to0xHex(privateKey);
@@ -277,6 +372,7 @@ export function showPrivateKey(privateKey) {
277
372
  copyBtn.className = "ows-copy";
278
373
  copyBtn.textContent = "Copy";
279
374
  copyBtn.addEventListener("click", async () => {
375
+ if (!isSignerDocumentInteractive()) return;
280
376
  await navigator.clipboard.writeText(pre.textContent ?? "");
281
377
  });
282
378
  const doneBtn = document.createElement("button");
@@ -305,6 +401,7 @@ export function promptPassphrase(passwordText, buttonText, minPasswordLength) {
305
401
  return Promise.reject(new Error("uiNotInitialized"));
306
402
  }
307
403
  cancelPendingCeremonyConfirm();
404
+ clearRevealInteraction();
308
405
  clearUi();
309
406
  const safePlaceholder = sanitizeDisplayText(passwordText, {
310
407
  maxLength: MAX_PASSPHRASE_PROMPT_LENGTH,
@@ -323,9 +420,12 @@ export function promptPassphrase(passwordText, buttonText, minPasswordLength) {
323
420
  button.textContent = safeButton || "Continue";
324
421
  button.addEventListener("click", () => {
325
422
  if (input.value.length < minPasswordLength) {
423
+ clearRevealInteraction();
326
424
  reject(new Error("passwordTooShort"));
327
425
  return;
328
426
  }
427
+ // recoverKey shows the key after this click's awaits, once focus may be gone.
428
+ noteRevealInteraction();
329
429
  resolve(input.value);
330
430
  clearUi();
331
431
  });