@1shotapi/ows-signer 0.4.2 → 0.4.4

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
@@ -56,8 +56,71 @@ body {
56
56
  text-align: center;
57
57
  }
58
58
 
59
+ body.ows-ceremony-open {
60
+ align-items: stretch;
61
+ justify-content: flex-end;
62
+ background: transparent;
63
+ }
64
+
59
65
  .ows-signer-ui--ceremony {
60
66
  gap: 0;
67
+ margin: 0;
68
+ width: 100%;
69
+ max-width: none;
70
+ height: 100%;
71
+ max-height: 100%;
72
+ min-height: 0;
73
+ padding: 12px 20px 0;
74
+ justify-content: flex-start;
75
+ overflow: hidden;
76
+ border: 1px solid var(--ows-border);
77
+ border-bottom: none;
78
+ border-radius: var(--ows-radius-card) var(--ows-radius-card) 0 0;
79
+ box-shadow: 0 -2px 16px rgb(0 0 0 / 6%);
80
+ animation: ows-sheet-in 280ms cubic-bezier(0.32, 0.72, 0, 1) both;
81
+ }
82
+
83
+ @keyframes ows-sheet-in {
84
+ from {
85
+ transform: translateY(100%);
86
+ }
87
+
88
+ to {
89
+ transform: translateY(0);
90
+ }
91
+ }
92
+
93
+ .ows-ceremony-grab {
94
+ flex: 0 0 auto;
95
+ width: 2.5rem;
96
+ height: 0.25rem;
97
+ border-radius: 999px;
98
+ background: color-mix(in srgb, var(--ows-text-muted) 35%, transparent);
99
+ }
100
+
101
+ .ows-signer-ui--ceremony .ows-ceremony-grab {
102
+ margin: 0 auto 12px;
103
+ }
104
+
105
+ .ows-signer-ui--ceremony .ows-ceremony-header {
106
+ flex-shrink: 0;
107
+ padding: 0;
108
+ margin: 0 0 8px;
109
+ text-align: left;
110
+ }
111
+
112
+ .ows-signer-ui--ceremony .ows-ceremony-body {
113
+ flex: 0 0 auto;
114
+ flex-shrink: 0;
115
+ min-height: auto;
116
+ overflow: visible;
117
+ padding: 0 0 4px;
118
+ margin-bottom: 12px;
119
+ text-align: left;
120
+ }
121
+
122
+ .ows-signer-ui--ceremony .ows-ceremony-actions {
123
+ padding-bottom: max(16px, env(safe-area-inset-bottom, 0px));
61
124
  }
62
125
 
63
126
  .ows-signer-ui:empty {
@@ -106,6 +169,14 @@ body {
106
169
  color: var(--ows-text);
107
170
  }
108
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
+
109
180
  .ows-ceremony-body {
110
181
  flex: 0 1 auto;
111
182
  min-height: 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1shotapi/ows-signer",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
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,20 @@
1
1
  import { to0xHex } from "./hex.js";
2
2
  import { debugLog, describeCeremonyEnvironment } from "./debug.js";
3
+ import { getTrustedParentOrigin } from "./state.js";
4
+
5
+ /**
6
+ * Reveal / clipboard must not run when the signer document is hidden or
7
+ * unfocused (clickjack / obscured-frame mitigation).
8
+ * @returns {boolean}
9
+ */
10
+ export function isSignerDocumentInteractive() {
11
+ if (typeof document === "undefined") return false;
12
+ if (document.visibilityState !== "visible") return false;
13
+ if (typeof document.hasFocus === "function" && !document.hasFocus()) {
14
+ return false;
15
+ }
16
+ return true;
17
+ }
3
18
 
4
19
  /** @type {HTMLElement | null} */
5
20
  let root = null;
@@ -66,7 +81,14 @@ export function initUi(container) {
66
81
  root.className = "ows-signer-ui";
67
82
  }
68
83
 
84
+ function setCeremonyOpen(open) {
85
+ if (typeof document !== "undefined" && document.body) {
86
+ document.body.classList.toggle("ows-ceremony-open", open);
87
+ }
88
+ }
89
+
69
90
  export function clearUi() {
91
+ setCeremonyOpen(false);
70
92
  if (root) {
71
93
  root.innerHTML = "";
72
94
  root.className = "ows-signer-ui";
@@ -170,11 +192,16 @@ export function promptCeremonyConfirm(fields, runOnConfirm) {
170
192
  cancelPendingCeremonyConfirm();
171
193
  clearUi();
172
194
  root.className = "ows-signer-ui ows-signer-ui--ceremony";
195
+ setCeremonyOpen(true);
173
196
  const ui = resolveCeremonyUi(fields);
174
197
 
175
198
  return new Promise((resolve, reject) => {
176
199
  pendingConfirmReject = reject;
177
200
 
201
+ const grab = document.createElement("div");
202
+ grab.className = "ows-ceremony-grab";
203
+ grab.setAttribute("aria-hidden", "true");
204
+
178
205
  const header = document.createElement("h2");
179
206
  header.className = "ows-ceremony-header";
180
207
  header.textContent = ui.header;
@@ -186,6 +213,13 @@ export function promptCeremonyConfirm(fields, runOnConfirm) {
186
213
  text.textContent = ui.explanation;
187
214
  body.append(text);
188
215
 
216
+ const requesterOrigin =
217
+ getTrustedParentOrigin() ?? window.location.origin;
218
+ const originLine = document.createElement("p");
219
+ originLine.className = "ows-ceremony-origin";
220
+ originLine.textContent = `Requester: ${requesterOrigin}`;
221
+ body.append(originLine);
222
+
189
223
  const actions = document.createElement("div");
190
224
  actions.className = "ows-ceremony-actions";
191
225
 
@@ -238,7 +272,7 @@ export function promptCeremonyConfirm(fields, runOnConfirm) {
238
272
  });
239
273
 
240
274
  actions.append(confirmBtn, denyBtn);
241
- root.append(header, body, actions);
275
+ root.append(grab, header, body, actions);
242
276
  });
243
277
  }
244
278
 
@@ -246,6 +280,9 @@ export function promptCeremonyConfirm(fields, runOnConfirm) {
246
280
  * Show the private key hex with Copy + Done. Resolves when the user clicks Done
247
281
  * (or the UI is cleared), so Branding can keep the ceremony panel open.
248
282
  *
283
+ * Requires the signer document to be visible and focused before showing the key
284
+ * or writing the clipboard.
285
+ *
249
286
  * @param {Uint8Array} privateKey
250
287
  * @returns {Promise<void>}
251
288
  */
@@ -253,6 +290,9 @@ export function showPrivateKey(privateKey) {
253
290
  if (!root) {
254
291
  return Promise.reject(new Error("uiNotInitialized"));
255
292
  }
293
+ if (!isSignerDocumentInteractive()) {
294
+ return Promise.reject(new Error("signerNotInteractive"));
295
+ }
256
296
  cancelPendingCeremonyConfirm();
257
297
  clearUi();
258
298
  const hex = to0xHex(privateKey);
@@ -265,6 +305,7 @@ export function showPrivateKey(privateKey) {
265
305
  copyBtn.className = "ows-copy";
266
306
  copyBtn.textContent = "Copy";
267
307
  copyBtn.addEventListener("click", async () => {
308
+ if (!isSignerDocumentInteractive()) return;
268
309
  await navigator.clipboard.writeText(pre.textContent ?? "");
269
310
  });
270
311
  const doneBtn = document.createElement("button");