@1shotapi/ows-signer 0.4.0 → 0.4.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1shotapi/ows-signer",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
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/handlers.js CHANGED
@@ -1096,6 +1096,13 @@ function handleError(error, correlationId, targetOrigin) {
1096
1096
  return;
1097
1097
  }
1098
1098
 
1099
+ if (name === "AbortError" || message === "ceremonyCancelled") {
1100
+ emitEvent(window.parent, targetOrigin, "SignDenied", correlationId, {
1101
+ reason: "ceremonyCancelled",
1102
+ });
1103
+ return;
1104
+ }
1105
+
1099
1106
  if (name === "NotAllowedError" || message.includes("NotAllowed")) {
1100
1107
  emitEvent(window.parent, targetOrigin, "NotAllowed", correlationId, {
1101
1108
  reason: message || "notAllowed",
@@ -1105,7 +1112,7 @@ function handleError(error, correlationId, targetOrigin) {
1105
1112
 
1106
1113
  if (message === "ceremonyInProgress") {
1107
1114
  emitEvent(window.parent, targetOrigin, "InvalidRequest", correlationId, {
1108
- reason: "ceremonyInProgress",
1115
+ reason: "A request is already pending.",
1109
1116
  });
1110
1117
  return;
1111
1118
  }
package/src/main.js CHANGED
@@ -2,6 +2,7 @@ import { handleRequest } from "./handlers.js";
2
2
  import {
3
3
  abandonCeremony,
4
4
  isValidNesting,
5
+ setCancelConfirmHook,
5
6
  setTrustedParentOrigin,
6
7
  } from "./state.js";
7
8
  import { cancelPendingCeremonyConfirm, initUi } from "./ui.js";
@@ -13,13 +14,14 @@ function bootstrap() {
13
14
  throw new Error("OWS signer root element missing");
14
15
  }
15
16
  initUi(root);
17
+ setCancelConfirmHook(cancelPendingCeremonyConfirm);
16
18
 
17
19
  if (!isValidNesting()) {
18
20
  console.error("[ows-signer] Must be embedded in a wallet iframe (double iframe).");
19
21
  return;
20
22
  }
21
23
 
22
- window.addEventListener("message", async (event) => {
24
+ window.addEventListener("message", (event) => {
23
25
  if (!isValidParentMessage(event)) return;
24
26
 
25
27
  const data = event.data;
@@ -29,7 +31,9 @@ function bootstrap() {
29
31
  /** @type {{ kind?: unknown }} */ (data).kind === "cancel"
30
32
  ) {
31
33
  setTrustedParentOrigin(event.origin);
32
- await abandonCeremony(cancelPendingCeremonyConfirm);
34
+ // Do not await — parent timeout must unlock immediately even if a prior
35
+ // handleRequest is still suspended on credentials.get.
36
+ void abandonCeremony(cancelPendingCeremonyConfirm);
33
37
  return;
34
38
  }
35
39
 
@@ -41,15 +45,18 @@ function bootstrap() {
41
45
  data
42
46
  );
43
47
 
44
- // Drop a stuck Confirm wait (e.g. parent timed out) before starting anew.
45
- await abandonCeremony(cancelPendingCeremonyConfirm);
46
-
47
- await handleRequest(
48
- method,
49
- /** @type {Record<string, unknown>} */ (params),
50
- correlationId,
51
- targetOrigin,
52
- );
48
+ void (async () => {
49
+ // Drop a stuck Confirm wait (e.g. parent timed out) before starting anew.
50
+ await abandonCeremony(cancelPendingCeremonyConfirm);
51
+ await handleRequest(
52
+ method,
53
+ /** @type {Record<string, unknown>} */ (params),
54
+ correlationId,
55
+ targetOrigin,
56
+ );
57
+ })().catch((error) => {
58
+ console.error("[ows-signer] request handler failed", error);
59
+ });
53
60
  });
54
61
  }
55
62
 
package/src/state.js CHANGED
@@ -4,6 +4,9 @@ let recoveryPrivateKey = null;
4
4
  /** @type {Promise<unknown> | null} */
5
5
  let ceremonyPromise = null;
6
6
 
7
+ /** @type {AbortController | null} */
8
+ let ceremonyAbortController = null;
9
+
7
10
  /** @type {string | null} */
8
11
  let trustedParentOrigin = null;
9
12
 
@@ -70,15 +73,65 @@ export function clearRecoveryPrivateKey() {
70
73
  }
71
74
  }
72
75
 
76
+ /**
77
+ * AbortSignal for the active ceremony (WebAuthn `credentials.get/create`).
78
+ * @returns {AbortSignal | undefined}
79
+ */
80
+ export function getCeremonyAbortSignal() {
81
+ return ceremonyAbortController?.signal;
82
+ }
83
+
84
+ /** @type {(() => void) | null} */
85
+ let cancelConfirmHook = null;
86
+
87
+ /**
88
+ * Branding/UI registers the Confirm-cancel hook so steal / abandon can dismiss
89
+ * the panel without a circular import on `ui.js`.
90
+ *
91
+ * @param {(() => void) | null} hook
92
+ */
93
+ export function setCancelConfirmHook(hook) {
94
+ cancelConfirmHook = hook;
95
+ }
96
+
97
+ /**
98
+ * Force-drop the ceremony lock and abort in-flight WebAuthn.
99
+ * Safe to call when no ceremony is active.
100
+ */
101
+ function forceUnlockCeremony() {
102
+ try {
103
+ cancelConfirmHook?.();
104
+ } catch {
105
+ // ignore
106
+ }
107
+ const abort = ceremonyAbortController;
108
+ const pending = ceremonyPromise;
109
+ ceremonyPromise = null;
110
+ ceremonyAbortController = null;
111
+ try {
112
+ abort?.abort();
113
+ } catch {
114
+ // ignore
115
+ }
116
+ if (pending) {
117
+ void pending.catch(() => {
118
+ // Prior ceremony was cancelled, aborted, or failed.
119
+ });
120
+ }
121
+ }
122
+
73
123
  /**
74
124
  * @param {() => Promise<T>} fn
75
125
  * @returns {Promise<T>}
76
126
  * @template T
77
127
  */
78
128
  export async function withCeremony(fn) {
129
+ // Steal any leftover lock (e.g. hung credentials.get after parent timeout).
130
+ // Throwing ceremonyInProgress left Branding retries stuck after RPC timeout.
79
131
  if (ceremonyPromise) {
80
- throw new Error("ceremonyInProgress");
132
+ forceUnlockCeremony();
81
133
  }
134
+ ceremonyAbortController = new AbortController();
82
135
  const promise = fn();
83
136
  ceremonyPromise = promise;
84
137
  try {
@@ -86,24 +139,27 @@ export async function withCeremony(fn) {
86
139
  } finally {
87
140
  if (ceremonyPromise === promise) {
88
141
  ceremonyPromise = null;
142
+ ceremonyAbortController = null;
89
143
  }
90
144
  }
91
145
  }
92
146
 
93
147
  /**
94
- * Cancel an open Confirm UI and wait for any in-flight ceremony lock to clear.
95
- * Used when the parent times out or starts a new RPC.
148
+ * Cancel Confirm UI, abort in-flight WebAuthn, and unlock the ceremony immediately.
149
+ * Used when the parent times out or starts a new RPC. Must not wait forever on
150
+ * hung `credentials.get` — that left Branding retries stuck with ceremonyInProgress.
96
151
  *
97
- * @param {() => void} cancelPendingConfirm
152
+ * @param {() => void} [_cancelPendingConfirm]
98
153
  */
99
- export async function abandonCeremony(cancelPendingConfirm) {
100
- cancelPendingConfirm();
101
- const pending = ceremonyPromise;
102
- if (!pending) return;
103
- try {
104
- await pending;
105
- } catch {
106
- // Prior ceremony was cancelled or failed — lock clears in withCeremony finally.
154
+ export async function abandonCeremony(_cancelPendingConfirm) {
155
+ // Prefer the registered hook (always current); keep the argument for callers.
156
+ forceUnlockCeremony();
157
+ if (typeof _cancelPendingConfirm === "function") {
158
+ try {
159
+ _cancelPendingConfirm();
160
+ } catch {
161
+ // ignore — hook already ran
162
+ }
107
163
  }
108
164
  }
109
165
 
package/src/webauthn.js CHANGED
@@ -1,4 +1,4 @@
1
- import { getRpId } from "./state.js";
1
+ import { getCeremonyAbortSignal, getRpId } from "./state.js";
2
2
  import { PRF_LABEL_SECP256K1 } from "./constants.js";
3
3
  import { bufferToBase64Url } from "./hex.js";
4
4
  import { debugLog, describePrfExtensionResults } from "./debug.js";
@@ -27,6 +27,7 @@ export async function createPasskeyCredential(name, options = {}) {
27
27
  ? Uint8Array.from(atob(options.userId), (c) => c.charCodeAt(0))
28
28
  : randomUserId();
29
29
  const displayName = options.userDisplayName ?? name;
30
+ const signal = getCeremonyAbortSignal();
30
31
 
31
32
  debugLog("createPasskeyCredential userActivation.isActive", {
32
33
  isActive: navigator.userActivation?.isActive ?? false,
@@ -41,7 +42,12 @@ export async function createPasskeyCredential(name, options = {}) {
41
42
  displayName,
42
43
  },
43
44
  challenge: crypto.getRandomValues(new Uint8Array(32)),
44
- pubKeyCredParams: [{ type: "public-key", alg: -7 }],
45
+ // Chromium warns when ES256 (-7) is present without RS256 (-257).
46
+ // Prefer ES256 (PRF); RS256 is listed for authenticator compatibility.
47
+ pubKeyCredParams: [
48
+ { type: "public-key", alg: -7 },
49
+ { type: "public-key", alg: -257 },
50
+ ],
45
51
  authenticatorSelection: {
46
52
  residentKey: "required",
47
53
  requireResidentKey: true,
@@ -55,6 +61,7 @@ export async function createPasskeyCredential(name, options = {}) {
55
61
  },
56
62
  },
57
63
  },
64
+ ...(signal ? { signal } : {}),
58
65
  });
59
66
 
60
67
  if (!credential || !(credential instanceof PublicKeyCredential)) {
@@ -79,6 +86,7 @@ export async function getPasskeyAssertion(challenge, credentialId) {
79
86
  },
80
87
  ]
81
88
  : undefined;
89
+ const signal = getCeremonyAbortSignal();
82
90
 
83
91
  const credential = await navigator.credentials.get({
84
92
  publicKey: {
@@ -94,6 +102,7 @@ export async function getPasskeyAssertion(challenge, credentialId) {
94
102
  },
95
103
  },
96
104
  },
105
+ ...(signal ? { signal } : {}),
97
106
  });
98
107
 
99
108
  if (!credential || !(credential instanceof PublicKeyCredential)) {