@1shotapi/ows-signer 0.4.4 → 0.4.6

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/html/signer.css CHANGED
@@ -123,6 +123,20 @@ body.ows-ceremony-open {
123
123
  padding-bottom: max(16px, env(safe-area-inset-bottom, 0px));
124
124
  }
125
125
 
126
+ .ows-signer-ui--key-entry .ows-ceremony-body {
127
+ flex: 1 1 auto;
128
+ min-height: 0;
129
+ overflow-y: auto;
130
+ }
131
+
132
+ .ows-signer-ui--key-entry .ows-password {
133
+ margin-top: 12px;
134
+ }
135
+
136
+ .ows-signer-ui--key-entry .ows-password + .ows-ceremony-text {
137
+ margin-top: 8px;
138
+ }
139
+
126
140
  .ows-signer-ui:empty {
127
141
  display: none;
128
142
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1shotapi/ows-signer",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
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/ui.js CHANGED
@@ -3,8 +3,9 @@ import { debugLog, describeCeremonyEnvironment } from "./debug.js";
3
3
  import { getTrustedParentOrigin } from "./state.js";
4
4
 
5
5
  /**
6
- * Reveal / clipboard must not run when the signer document is hidden or
7
- * unfocused (clickjack / obscured-frame mitigation).
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.
8
9
  * @returns {boolean}
9
10
  */
10
11
  export function isSignerDocumentInteractive() {
@@ -16,6 +17,46 @@ export function isSignerDocumentInteractive() {
16
17
  return true;
17
18
  }
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
+ }
59
+
19
60
  /** @type {HTMLElement | null} */
20
61
  let root = null;
21
62
 
@@ -112,6 +153,7 @@ export class CeremonyDeniedError extends Error {
112
153
  */
113
154
  export function cancelPendingCeremonyConfirm() {
114
155
  if (!pendingConfirmReject) return;
156
+ clearRevealInteraction();
115
157
  const reject = pendingConfirmReject;
116
158
  pendingConfirmReject = null;
117
159
  clearUi();
@@ -190,6 +232,7 @@ export function promptCeremonyConfirm(fields, runOnConfirm) {
190
232
  return Promise.reject(new Error("uiNotInitialized"));
191
233
  }
192
234
  cancelPendingCeremonyConfirm();
235
+ clearRevealInteraction();
193
236
  clearUi();
194
237
  root.className = "ows-signer-ui ows-signer-ui--ceremony";
195
238
  setCeremonyOpen(true);
@@ -246,6 +289,7 @@ export function promptCeremonyConfirm(fields, runOnConfirm) {
246
289
  };
247
290
 
248
291
  const settleFailure = (error) => {
292
+ clearRevealInteraction();
249
293
  if (pendingConfirmReject !== reject) return;
250
294
  finishWaiting();
251
295
  clearUi();
@@ -255,6 +299,9 @@ export function promptCeremonyConfirm(fields, runOnConfirm) {
255
299
  confirmBtn.addEventListener("click", () => {
256
300
  confirmBtn.disabled = true;
257
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();
258
305
  try {
259
306
  // Start WebAuthn in the same turn as the click (no prior await).
260
307
  // Keep copy visible until the ceremony settles.
@@ -266,6 +313,7 @@ export function promptCeremonyConfirm(fields, runOnConfirm) {
266
313
  });
267
314
 
268
315
  denyBtn.addEventListener("click", () => {
316
+ clearRevealInteraction();
269
317
  finishWaiting();
270
318
  clearUi();
271
319
  reject(new CeremonyDeniedError());
@@ -280,8 +328,10 @@ export function promptCeremonyConfirm(fields, runOnConfirm) {
280
328
  * Show the private key hex with Copy + Done. Resolves when the user clicks Done
281
329
  * (or the UI is cleared), so Branding can keep the ceremony panel open.
282
330
  *
283
- * Requires the signer document to be visible and focused before showing the key
284
- * or writing the clipboard.
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.
285
335
  *
286
336
  * @param {Uint8Array} privateKey
287
337
  * @returns {Promise<void>}
@@ -290,7 +340,24 @@ export function showPrivateKey(privateKey) {
290
340
  if (!root) {
291
341
  return Promise.reject(new Error("uiNotInitialized"));
292
342
  }
293
- if (!isSignerDocumentInteractive()) {
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
+ ) {
294
361
  return Promise.reject(new Error("signerNotInteractive"));
295
362
  }
296
363
  cancelPendingCeremonyConfirm();
@@ -334,6 +401,7 @@ export function promptPassphrase(passwordText, buttonText, minPasswordLength) {
334
401
  return Promise.reject(new Error("uiNotInitialized"));
335
402
  }
336
403
  cancelPendingCeremonyConfirm();
404
+ clearRevealInteraction();
337
405
  clearUi();
338
406
  const safePlaceholder = sanitizeDisplayText(passwordText, {
339
407
  maxLength: MAX_PASSPHRASE_PROMPT_LENGTH,
@@ -352,9 +420,12 @@ export function promptPassphrase(passwordText, buttonText, minPasswordLength) {
352
420
  button.textContent = safeButton || "Continue";
353
421
  button.addEventListener("click", () => {
354
422
  if (input.value.length < minPasswordLength) {
423
+ clearRevealInteraction();
355
424
  reject(new Error("passwordTooShort"));
356
425
  return;
357
426
  }
427
+ // recoverKey shows the key after this click's awaits, once focus may be gone.
428
+ noteRevealInteraction();
358
429
  resolve(input.value);
359
430
  clearUi();
360
431
  });
@@ -374,14 +445,24 @@ export function promptPrivateKey() {
374
445
  }
375
446
  cancelPendingCeremonyConfirm();
376
447
  clearUi();
448
+ root.className =
449
+ "ows-signer-ui ows-signer-ui--ceremony ows-signer-ui--key-entry";
450
+ setCeremonyOpen(true);
377
451
  return new Promise((resolve, reject) => {
452
+ const grab = document.createElement("div");
453
+ grab.className = "ows-ceremony-grab";
454
+ grab.setAttribute("aria-hidden", "true");
455
+
378
456
  const header = document.createElement("h2");
379
457
  header.className = "ows-ceremony-header";
380
458
  header.textContent = "Import private key";
381
- const body = document.createElement("p");
382
- body.className = "ows-ceremony-text";
383
- body.textContent =
384
- "Paste your secp256k1 private key. It stays in this secure panel and is never sent to the host.";
459
+
460
+ const body = document.createElement("div");
461
+ body.className = "ows-ceremony-body";
462
+ const text = document.createElement("p");
463
+ text.className = "ows-ceremony-text";
464
+ text.textContent =
465
+ "Paste your EVM private key to recover your 1Shot wallet accounts.";
385
466
  const input = document.createElement("input");
386
467
  input.type = "password";
387
468
  input.className = "ows-password";
@@ -392,6 +473,8 @@ export function promptPrivateKey() {
392
473
  errorEl.className = "ows-ceremony-text";
393
474
  errorEl.style.color = "tomato";
394
475
  errorEl.hidden = true;
476
+ body.append(text, input, errorEl);
477
+
395
478
  const confirmBtn = document.createElement("button");
396
479
  confirmBtn.type = "button";
397
480
  confirmBtn.className = "ows-confirm";
@@ -401,7 +484,7 @@ export function promptPrivateKey() {
401
484
  denyBtn.className = "ows-deny";
402
485
  denyBtn.textContent = "Cancel";
403
486
  const actions = document.createElement("div");
404
- actions.className = "ows-actions";
487
+ actions.className = "ows-ceremony-actions";
405
488
  actions.append(confirmBtn, denyBtn);
406
489
 
407
490
  const finish = (fn) => {
@@ -422,7 +505,7 @@ export function promptPrivateKey() {
422
505
  finish(() => reject(new CeremonyDeniedError()));
423
506
  });
424
507
 
425
- root.append(header, body, input, errorEl, actions);
508
+ root.append(grab, header, body, actions);
426
509
  input.focus();
427
510
  });
428
511
  }