@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 +9 -2
- package/html/signer.css +8 -0
- package/package.json +1 -1
- package/src/main.js +13 -1
- package/src/rpc.js +4 -0
- package/src/ui.js +100 -0
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
|
@@ -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
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,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
|
});
|