@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 +9 -2
- package/html/signer.css +71 -0
- package/package.json +1 -1
- package/src/main.js +13 -1
- package/src/rpc.js +4 -0
- package/src/ui.js +42 -1
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
|
-
-
|
|
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
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 (!
|
|
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");
|