@1shotapi/ows-signer 0.3.4 → 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/README.md +15 -0
- package/html/index.html +1 -99
- package/html/signer.css +190 -0
- package/package.json +2 -1
- package/src/handlers.js +15 -8
- package/src/main.js +18 -11
- package/src/state.js +68 -12
- package/src/webauthn.js +41 -2
package/README.md
CHANGED
|
@@ -88,6 +88,7 @@ Ceremony UI params (optional on WebAuthn-triggering methods): `explanationHeader
|
|
|
88
88
|
|
|
89
89
|
```
|
|
90
90
|
html/index.html Entry document
|
|
91
|
+
html/signer.css Ceremony / reveal UI styles (linked; no inline CSS)
|
|
91
92
|
src/main.js Bootstrap + message listener
|
|
92
93
|
src/handlers.js RPC dispatch
|
|
93
94
|
src/webauthn.js Passkey ceremonies
|
|
@@ -96,6 +97,20 @@ src/crypto/vendor/ Vendored @noble/secp256k1 + ed25519 (MIT)
|
|
|
96
97
|
reference/ 1ShotPay reference (not published)
|
|
97
98
|
```
|
|
98
99
|
|
|
100
|
+
## Content Security Policy
|
|
101
|
+
|
|
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
|
+
|
|
104
|
+
Canonical header (single line in production):
|
|
105
|
+
|
|
106
|
+
```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
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
- 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`).
|
|
112
|
+
- Keep iframe Permissions Policy `allow` for WebAuthn / clipboard (set before `src` in `createSignerIframe`) — that is not CSP.
|
|
113
|
+
|
|
99
114
|
## On-chain
|
|
100
115
|
|
|
101
116
|
See `@1shotapi/ows-onchain`.
|
package/html/index.html
CHANGED
|
@@ -4,105 +4,7 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>OWS Custody Signer</title>
|
|
7
|
-
<
|
|
8
|
-
:root {
|
|
9
|
-
color-scheme: light dark;
|
|
10
|
-
font-family: system-ui, sans-serif;
|
|
11
|
-
}
|
|
12
|
-
html,
|
|
13
|
-
body {
|
|
14
|
-
height: 100%;
|
|
15
|
-
margin: 0;
|
|
16
|
-
overflow: hidden;
|
|
17
|
-
}
|
|
18
|
-
body {
|
|
19
|
-
box-sizing: border-box;
|
|
20
|
-
}
|
|
21
|
-
.ows-signer-ui {
|
|
22
|
-
display: flex;
|
|
23
|
-
flex-direction: column;
|
|
24
|
-
gap: 8px;
|
|
25
|
-
box-sizing: border-box;
|
|
26
|
-
width: 100%;
|
|
27
|
-
height: 100%;
|
|
28
|
-
max-width: 100%;
|
|
29
|
-
padding: 12px;
|
|
30
|
-
overflow: hidden;
|
|
31
|
-
}
|
|
32
|
-
.ows-signer-ui--ceremony {
|
|
33
|
-
gap: 0;
|
|
34
|
-
}
|
|
35
|
-
.ows-key {
|
|
36
|
-
margin: 0;
|
|
37
|
-
padding: 8px;
|
|
38
|
-
border: 1px solid #8884;
|
|
39
|
-
border-radius: 4px;
|
|
40
|
-
font-family: ui-monospace, monospace;
|
|
41
|
-
font-size: 12px;
|
|
42
|
-
word-break: break-all;
|
|
43
|
-
white-space: pre-wrap;
|
|
44
|
-
overflow: auto;
|
|
45
|
-
flex: 1 1 auto;
|
|
46
|
-
min-height: 0;
|
|
47
|
-
}
|
|
48
|
-
.ows-password {
|
|
49
|
-
padding: 8px;
|
|
50
|
-
border: 1px solid #8884;
|
|
51
|
-
border-radius: 4px;
|
|
52
|
-
font: inherit;
|
|
53
|
-
}
|
|
54
|
-
.ows-ceremony-header {
|
|
55
|
-
flex: 0 0 auto;
|
|
56
|
-
margin: 0 0 8px;
|
|
57
|
-
font-size: 1.1rem;
|
|
58
|
-
font-weight: 700;
|
|
59
|
-
line-height: 1.3;
|
|
60
|
-
}
|
|
61
|
-
.ows-ceremony-body {
|
|
62
|
-
flex: 1 1 auto;
|
|
63
|
-
min-height: 0;
|
|
64
|
-
overflow-x: hidden;
|
|
65
|
-
overflow-y: auto;
|
|
66
|
-
margin: 0 0 12px;
|
|
67
|
-
}
|
|
68
|
-
.ows-ceremony-text {
|
|
69
|
-
margin: 0;
|
|
70
|
-
font-size: 0.95rem;
|
|
71
|
-
line-height: 1.4;
|
|
72
|
-
opacity: 0.9;
|
|
73
|
-
}
|
|
74
|
-
.ows-ceremony-actions {
|
|
75
|
-
flex: 0 0 auto;
|
|
76
|
-
display: flex;
|
|
77
|
-
flex-wrap: wrap;
|
|
78
|
-
gap: 8px;
|
|
79
|
-
}
|
|
80
|
-
.ows-actions {
|
|
81
|
-
flex: 0 0 auto;
|
|
82
|
-
display: flex;
|
|
83
|
-
flex-wrap: wrap;
|
|
84
|
-
gap: 8px;
|
|
85
|
-
}
|
|
86
|
-
.ows-copy,
|
|
87
|
-
.ows-confirm,
|
|
88
|
-
.ows-deny {
|
|
89
|
-
padding: 8px 12px;
|
|
90
|
-
border: 1px solid #8884;
|
|
91
|
-
border-radius: 4px;
|
|
92
|
-
background: transparent;
|
|
93
|
-
font: inherit;
|
|
94
|
-
cursor: pointer;
|
|
95
|
-
align-self: flex-start;
|
|
96
|
-
}
|
|
97
|
-
.ows-confirm {
|
|
98
|
-
font-weight: 600;
|
|
99
|
-
}
|
|
100
|
-
.ows-confirm:disabled,
|
|
101
|
-
.ows-deny:disabled {
|
|
102
|
-
opacity: 0.45;
|
|
103
|
-
cursor: default;
|
|
104
|
-
}
|
|
105
|
-
</style>
|
|
7
|
+
<link rel="stylesheet" href="./signer.css" />
|
|
106
8
|
</head>
|
|
107
9
|
<body>
|
|
108
10
|
<div id="ows-signer-root"></div>
|
package/html/signer.css
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
color-scheme: light;
|
|
3
|
+
--ows-bg: #f3f4f6;
|
|
4
|
+
--ows-surface: #ffffff;
|
|
5
|
+
--ows-border: #e5e7eb;
|
|
6
|
+
--ows-text: #111827;
|
|
7
|
+
--ows-text-muted: #6b7280;
|
|
8
|
+
--ows-primary: #2a9aa8;
|
|
9
|
+
--ows-primary-hover: #238a97;
|
|
10
|
+
--ows-radius-card: 20px;
|
|
11
|
+
--ows-radius-control: 10px;
|
|
12
|
+
font-family:
|
|
13
|
+
system-ui,
|
|
14
|
+
-apple-system,
|
|
15
|
+
"Segoe UI",
|
|
16
|
+
Roboto,
|
|
17
|
+
Helvetica,
|
|
18
|
+
Arial,
|
|
19
|
+
sans-serif;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
html,
|
|
23
|
+
body {
|
|
24
|
+
height: 100%;
|
|
25
|
+
margin: 0;
|
|
26
|
+
overflow: hidden;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
body {
|
|
30
|
+
box-sizing: border-box;
|
|
31
|
+
display: flex;
|
|
32
|
+
align-items: center;
|
|
33
|
+
justify-content: center;
|
|
34
|
+
background: var(--ows-bg);
|
|
35
|
+
color: var(--ows-text);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.ows-signer-ui {
|
|
39
|
+
display: flex;
|
|
40
|
+
flex-direction: column;
|
|
41
|
+
gap: 12px;
|
|
42
|
+
box-sizing: border-box;
|
|
43
|
+
align-items: stretch;
|
|
44
|
+
justify-content: center;
|
|
45
|
+
margin: auto;
|
|
46
|
+
width: calc(100% - 24px);
|
|
47
|
+
max-width: 360px;
|
|
48
|
+
height: auto;
|
|
49
|
+
max-height: calc(100% - 24px);
|
|
50
|
+
padding: 28px 24px;
|
|
51
|
+
overflow: auto;
|
|
52
|
+
background: var(--ows-surface);
|
|
53
|
+
border: 1px solid var(--ows-border);
|
|
54
|
+
border-radius: var(--ows-radius-card);
|
|
55
|
+
box-shadow: 0 1px 2px rgb(0 0 0 / 4%);
|
|
56
|
+
text-align: center;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.ows-signer-ui--ceremony {
|
|
60
|
+
gap: 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.ows-signer-ui:empty {
|
|
64
|
+
display: none;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.ows-key {
|
|
68
|
+
margin: 0;
|
|
69
|
+
padding: 12px;
|
|
70
|
+
border: 1px solid var(--ows-border);
|
|
71
|
+
border-radius: var(--ows-radius-control);
|
|
72
|
+
background: var(--ows-surface);
|
|
73
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
74
|
+
font-size: 12px;
|
|
75
|
+
color: var(--ows-text);
|
|
76
|
+
word-break: break-all;
|
|
77
|
+
white-space: pre-wrap;
|
|
78
|
+
overflow: auto;
|
|
79
|
+
flex: 1 1 auto;
|
|
80
|
+
min-height: 0;
|
|
81
|
+
text-align: left;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.ows-password {
|
|
85
|
+
box-sizing: border-box;
|
|
86
|
+
width: 100%;
|
|
87
|
+
padding: 12px 14px;
|
|
88
|
+
border: 1px solid var(--ows-border);
|
|
89
|
+
border-radius: var(--ows-radius-control);
|
|
90
|
+
background: var(--ows-surface);
|
|
91
|
+
color: var(--ows-text);
|
|
92
|
+
font: inherit;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.ows-password:focus {
|
|
96
|
+
outline: 2px solid color-mix(in srgb, var(--ows-primary) 45%, transparent);
|
|
97
|
+
outline-offset: 1px;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.ows-ceremony-header {
|
|
101
|
+
flex: 0 0 auto;
|
|
102
|
+
margin: 0 0 8px;
|
|
103
|
+
font-size: 1.25rem;
|
|
104
|
+
font-weight: 700;
|
|
105
|
+
line-height: 1.3;
|
|
106
|
+
color: var(--ows-text);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.ows-ceremony-body {
|
|
110
|
+
flex: 0 1 auto;
|
|
111
|
+
min-height: 0;
|
|
112
|
+
overflow-x: hidden;
|
|
113
|
+
overflow-y: auto;
|
|
114
|
+
margin: 0 0 24px;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.ows-ceremony-text {
|
|
118
|
+
margin: 0;
|
|
119
|
+
font-size: 1rem;
|
|
120
|
+
font-weight: 400;
|
|
121
|
+
line-height: 1.45;
|
|
122
|
+
color: var(--ows-text-muted);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.ows-ceremony-actions,
|
|
126
|
+
.ows-actions {
|
|
127
|
+
flex: 0 0 auto;
|
|
128
|
+
display: flex;
|
|
129
|
+
flex-direction: column;
|
|
130
|
+
flex-wrap: nowrap;
|
|
131
|
+
gap: 12px;
|
|
132
|
+
width: 100%;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.ows-copy,
|
|
136
|
+
.ows-confirm,
|
|
137
|
+
.ows-deny {
|
|
138
|
+
box-sizing: border-box;
|
|
139
|
+
width: 100%;
|
|
140
|
+
min-height: 44px;
|
|
141
|
+
padding: 12px 16px;
|
|
142
|
+
border-radius: var(--ows-radius-control);
|
|
143
|
+
font: inherit;
|
|
144
|
+
font-size: 1rem;
|
|
145
|
+
font-weight: 600;
|
|
146
|
+
line-height: 1.2;
|
|
147
|
+
cursor: pointer;
|
|
148
|
+
align-self: stretch;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.ows-confirm {
|
|
152
|
+
border: none;
|
|
153
|
+
background: var(--ows-primary);
|
|
154
|
+
color: #ffffff;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.ows-confirm:hover:not(:disabled) {
|
|
158
|
+
background: var(--ows-primary-hover);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.ows-confirm:focus-visible {
|
|
162
|
+
outline: 2px solid var(--ows-primary);
|
|
163
|
+
outline-offset: 2px;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.ows-deny,
|
|
167
|
+
.ows-copy {
|
|
168
|
+
border: 1px solid var(--ows-border);
|
|
169
|
+
background: var(--ows-surface);
|
|
170
|
+
color: #4b5563;
|
|
171
|
+
font-weight: 500;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.ows-deny:hover:not(:disabled),
|
|
175
|
+
.ows-copy:hover:not(:disabled) {
|
|
176
|
+
background: #f9fafb;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.ows-deny:focus-visible,
|
|
180
|
+
.ows-copy:focus-visible {
|
|
181
|
+
outline: 2px solid color-mix(in srgb, var(--ows-primary) 50%, transparent);
|
|
182
|
+
outline-offset: 2px;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.ows-confirm:disabled,
|
|
186
|
+
.ows-deny:disabled,
|
|
187
|
+
.ows-copy:disabled {
|
|
188
|
+
opacity: 0.45;
|
|
189
|
+
cursor: default;
|
|
190
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@1shotapi/ows-signer",
|
|
3
|
-
"version": "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": [
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
],
|
|
11
11
|
"exports": {
|
|
12
12
|
"./html": "./html/index.html",
|
|
13
|
+
"./html/signer.css": "./html/signer.css",
|
|
13
14
|
"./src/*": "./src/*"
|
|
14
15
|
},
|
|
15
16
|
"scripts": {
|
package/src/handlers.js
CHANGED
|
@@ -41,7 +41,7 @@ import {
|
|
|
41
41
|
} from "./ui.js";
|
|
42
42
|
import {
|
|
43
43
|
createPasskeyCredential,
|
|
44
|
-
|
|
44
|
+
getAssertionFieldsBase64Url,
|
|
45
45
|
getCredentialId,
|
|
46
46
|
getPasskeyAssertion,
|
|
47
47
|
getCosePublicKeyBase64Url,
|
|
@@ -885,8 +885,8 @@ async function handleGetPublicKey(params, correlationId, targetOrigin) {
|
|
|
885
885
|
});
|
|
886
886
|
|
|
887
887
|
if (challenge) {
|
|
888
|
-
const
|
|
889
|
-
if (
|
|
888
|
+
const assertion = getAssertionFieldsBase64Url(credential);
|
|
889
|
+
if (assertion) {
|
|
890
890
|
emitEvent(
|
|
891
891
|
window.parent,
|
|
892
892
|
targetOrigin,
|
|
@@ -894,7 +894,7 @@ async function handleGetPublicKey(params, correlationId, targetOrigin) {
|
|
|
894
894
|
correlationId,
|
|
895
895
|
{
|
|
896
896
|
challenge: to0xHex(challenge),
|
|
897
|
-
|
|
897
|
+
assertion,
|
|
898
898
|
},
|
|
899
899
|
);
|
|
900
900
|
}
|
|
@@ -1014,9 +1014,9 @@ async function handleExecuteBatch(params, correlationId, targetOrigin) {
|
|
|
1014
1014
|
};
|
|
1015
1015
|
}
|
|
1016
1016
|
if (challenge && credential) {
|
|
1017
|
-
const
|
|
1018
|
-
if (
|
|
1019
|
-
data.
|
|
1017
|
+
const assertion = getAssertionFieldsBase64Url(credential);
|
|
1018
|
+
if (assertion) {
|
|
1019
|
+
data.assertion = assertion;
|
|
1020
1020
|
}
|
|
1021
1021
|
}
|
|
1022
1022
|
|
|
@@ -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: "
|
|
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",
|
|
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
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
|
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}
|
|
152
|
+
* @param {() => void} [_cancelPendingConfirm]
|
|
98
153
|
*/
|
|
99
|
-
export async function abandonCeremony(
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if (
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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)) {
|
|
@@ -245,3 +254,33 @@ export function getAssertionSignatureBase64Url(credential) {
|
|
|
245
254
|
}
|
|
246
255
|
return null;
|
|
247
256
|
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Full WebAuthn assertion fields for host/relayer auth (base64url).
|
|
260
|
+
* @param {PublicKeyCredential} credential
|
|
261
|
+
* @returns {{
|
|
262
|
+
* authenticatorData: string,
|
|
263
|
+
* clientDataJSON: string,
|
|
264
|
+
* signature: string,
|
|
265
|
+
* credentialId: string,
|
|
266
|
+
* } | null}
|
|
267
|
+
*/
|
|
268
|
+
export function getAssertionFieldsBase64Url(credential) {
|
|
269
|
+
const response = credential.response;
|
|
270
|
+
if (
|
|
271
|
+
!("authenticatorData" in response) ||
|
|
272
|
+
!response.authenticatorData ||
|
|
273
|
+
!("clientDataJSON" in response) ||
|
|
274
|
+
!response.clientDataJSON ||
|
|
275
|
+
!("signature" in response) ||
|
|
276
|
+
!response.signature
|
|
277
|
+
) {
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
return {
|
|
281
|
+
authenticatorData: bufferToBase64Url(response.authenticatorData),
|
|
282
|
+
clientDataJSON: bufferToBase64Url(response.clientDataJSON),
|
|
283
|
+
signature: bufferToBase64Url(response.signature),
|
|
284
|
+
credentialId: getCredentialId(credential),
|
|
285
|
+
};
|
|
286
|
+
}
|