@totalreclaw/totalreclaw 3.2.3 → 3.3.0-rc.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 +31 -2
- package/config.ts +5 -0
- package/index.ts +185 -8
- package/package.json +3 -2
- package/pair-cli.ts +351 -0
- package/pair-crypto.ts +474 -0
- package/pair-http.ts +527 -0
- package/pair-page.ts +835 -0
- package/pair-session-store.ts +764 -0
package/pair-page.ts
ADDED
|
@@ -0,0 +1,835 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pair-page — the self-contained HTML + JS + CSS bundle served by the
|
|
3
|
+
* `/plugin/totalreclaw/pair/finish` handler. The phone or laptop
|
|
4
|
+
* browser loads this, reads the gateway's ephemeral public key from
|
|
5
|
+
* the URL fragment (`#pk=...`), runs the client-side pairing flow
|
|
6
|
+
* ENTIRELY in the browser, and POSTs the encrypted payload back.
|
|
7
|
+
*
|
|
8
|
+
* Brand tokens imported from the v5b.html public site (colors, font
|
|
9
|
+
* stack). Typography falls back to system fonts for mobile parity —
|
|
10
|
+
* we don't ship Euclid Circular A bytes over the pairing HTTP surface.
|
|
11
|
+
*
|
|
12
|
+
* Scope and guarantees
|
|
13
|
+
* --------------------
|
|
14
|
+
* - ENTIRELY self-contained: no external asset references, no CDN,
|
|
15
|
+
* no Google Fonts, no script-src. The page uses only inline CSS,
|
|
16
|
+
* inline JS, and Web Platform APIs. Works offline once loaded.
|
|
17
|
+
* - No `fs.*` reads, no env-var reads — this module just exports a
|
|
18
|
+
* function that builds and returns the page text. Scanner clean by
|
|
19
|
+
* construction.
|
|
20
|
+
* - The browser code runs with no secret-bearing network sends
|
|
21
|
+
* except the final encrypted AEAD blob. All mnemonic handling
|
|
22
|
+
* happens in-JS; no console.log calls; cleared from memory after
|
|
23
|
+
* submission.
|
|
24
|
+
* - Mobile-first CSS, scales up to desktop.
|
|
25
|
+
*
|
|
26
|
+
* Security copy (user ratification 2026-04-20, phone-page wave):
|
|
27
|
+
* - Frame as "your TotalReclaw account key", not "recovery phrase".
|
|
28
|
+
* - Bolded "Use it ONLY with TotalReclaw" warning.
|
|
29
|
+
* - Concrete storage suggestions (password manager / encrypted
|
|
30
|
+
* notes / written-in-safe).
|
|
31
|
+
* - "With it you can:" + "Without it:" consequence blocks.
|
|
32
|
+
* - All copy surfaces BEFORE the acknowledgment gate.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { wordlist as BIP39_ENGLISH_WORDLIST } from '@scure/bip39/wordlists/english.js';
|
|
36
|
+
|
|
37
|
+
export interface PairPageTemplateInputs {
|
|
38
|
+
/** Session id — embedded as a JS constant; also visible in URL. */
|
|
39
|
+
sid: string;
|
|
40
|
+
/** Session mode — drives the UI branch. */
|
|
41
|
+
mode: 'generate' | 'import';
|
|
42
|
+
/** Session expiresAt in ms — the countdown timer reads this. */
|
|
43
|
+
expiresAtMs: number;
|
|
44
|
+
/**
|
|
45
|
+
* The server's pathname base for the 3 API endpoints. All three live
|
|
46
|
+
* under this prefix. Default: "/plugin/totalreclaw/pair".
|
|
47
|
+
*/
|
|
48
|
+
apiBase: string;
|
|
49
|
+
/** Current wall-clock (ms) — used to compute initial countdown. */
|
|
50
|
+
nowMs: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Pre-computed BIP-39 wordlist literal — emitted into the page once at
|
|
55
|
+
// import time so we don't re-stringify on every render. The `@scure/bip39`
|
|
56
|
+
// wordlist is a newline-delimited string; we split and JSON.stringify to
|
|
57
|
+
// emit a safe JS array literal.
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
function buildBip39Literal(): string {
|
|
61
|
+
// `@scure/bip39` exports the English wordlist as a `string[]` of length 2048.
|
|
62
|
+
const words = BIP39_ENGLISH_WORDLIST;
|
|
63
|
+
if (!Array.isArray(words) || words.length !== 2048) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`pair-page: expected 2048 BIP-39 words, got ${Array.isArray(words) ? words.length : typeof words}`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
// JSON.stringify produces double-quoted strings; concatenate into a
|
|
69
|
+
// compact literal (no leading/trailing comma).
|
|
70
|
+
return words.map((w) => JSON.stringify(w)).join(',');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const BIP39_WORDLIST_JS_ARRAY = buildBip39Literal();
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Escape a string for safe embedding between `<script>` tags.
|
|
77
|
+
*
|
|
78
|
+
* `JSON.stringify` produces a valid JS string literal but does NOT
|
|
79
|
+
* escape `<`, `>`, `&`, or U+2028/U+2029. All four are dangerous when
|
|
80
|
+
* the resulting string lands inside a `<script>` block:
|
|
81
|
+
* - `</script>` in the payload would close the script element and
|
|
82
|
+
* let subsequent bytes parse as HTML (XSS).
|
|
83
|
+
* - `<!--` / `-->` can start an HTML comment that would hide code
|
|
84
|
+
* from the browser under some parser modes.
|
|
85
|
+
* - U+2028 / U+2029 are line terminators in JS even though JSON
|
|
86
|
+
* permits them, which can break tooling.
|
|
87
|
+
*
|
|
88
|
+
* Our inputs (sid, apiBase, mode) are internally-controlled today, but
|
|
89
|
+
* defense-in-depth is cheap and matches OWASP's recommended pattern.
|
|
90
|
+
*/
|
|
91
|
+
function escForJsString(s: string): string {
|
|
92
|
+
return JSON.stringify(s)
|
|
93
|
+
.replace(/</g, '\\u003c')
|
|
94
|
+
.replace(/>/g, '\\u003e')
|
|
95
|
+
.replace(/&/g, '\\u0026')
|
|
96
|
+
.replace(/\u2028/g, '\\u2028')
|
|
97
|
+
.replace(/\u2029/g, '\\u2029');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Build the full HTML page. The mode selector + runtime parameters are
|
|
102
|
+
* baked in as JS constants so the page doesn't need any additional
|
|
103
|
+
* query round-trip on load.
|
|
104
|
+
*/
|
|
105
|
+
export function renderPairPage(inputs: PairPageTemplateInputs): string {
|
|
106
|
+
const { sid, mode, expiresAtMs, apiBase, nowMs } = inputs;
|
|
107
|
+
|
|
108
|
+
// All JS-embedded values must pass through escForJsString — it emits
|
|
109
|
+
// proper JS string literals (including quotes) so we interpolate them
|
|
110
|
+
// as bare expressions.
|
|
111
|
+
const SID = escForJsString(sid);
|
|
112
|
+
const MODE = escForJsString(mode);
|
|
113
|
+
const API_BASE = escForJsString(apiBase);
|
|
114
|
+
const EXPIRES_AT = String(expiresAtMs);
|
|
115
|
+
const NOW = String(nowMs);
|
|
116
|
+
|
|
117
|
+
return `<!doctype html>
|
|
118
|
+
<html lang="en">
|
|
119
|
+
<head>
|
|
120
|
+
<meta charset="utf-8" />
|
|
121
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
122
|
+
<meta name="robots" content="noindex, nofollow" />
|
|
123
|
+
<meta name="color-scheme" content="dark" />
|
|
124
|
+
<title>TotalReclaw — Pair your account</title>
|
|
125
|
+
<style>
|
|
126
|
+
/* Brand tokens from public site v5b.html */
|
|
127
|
+
:root {
|
|
128
|
+
--bg: #0B0B1A;
|
|
129
|
+
--bg-card: rgba(16, 14, 32, 0.65);
|
|
130
|
+
--text: rgba(255, 255, 255, 0.78);
|
|
131
|
+
--text-dim: rgba(255, 255, 255, 0.50);
|
|
132
|
+
--text-bright: #F0EDF8;
|
|
133
|
+
--text-white: rgba(255, 255, 255, 0.96);
|
|
134
|
+
--purple: #7B5CFF;
|
|
135
|
+
--purple-dim: rgba(123, 92, 255, 0.14);
|
|
136
|
+
--purple-ring: rgba(123, 92, 255, 0.28);
|
|
137
|
+
--orange: #D4943A;
|
|
138
|
+
--success: #34d399;
|
|
139
|
+
--danger: #f87171;
|
|
140
|
+
--border: rgba(255, 255, 255, 0.08);
|
|
141
|
+
--border-accent: rgba(123, 92, 255, 0.24);
|
|
142
|
+
--mono: 'SF Mono', 'Fira Code', 'Cascadia Code', 'Menlo', 'Consolas', monospace;
|
|
143
|
+
--sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
* { box-sizing: border-box; }
|
|
147
|
+
html, body { margin: 0; padding: 0; }
|
|
148
|
+
html { font-size: 16px; }
|
|
149
|
+
@media (min-width: 720px) { html { font-size: 17px; } }
|
|
150
|
+
body {
|
|
151
|
+
background: var(--bg);
|
|
152
|
+
color: var(--text);
|
|
153
|
+
font-family: var(--sans);
|
|
154
|
+
min-height: 100vh;
|
|
155
|
+
-webkit-font-smoothing: antialiased;
|
|
156
|
+
-moz-osx-font-smoothing: grayscale;
|
|
157
|
+
background-image:
|
|
158
|
+
radial-gradient(circle at 50% 0%, rgba(123, 92, 255, 0.12), transparent 55%),
|
|
159
|
+
radial-gradient(circle at 10% 90%, rgba(212, 148, 58, 0.06), transparent 45%);
|
|
160
|
+
background-attachment: fixed;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.wrap {
|
|
164
|
+
max-width: 560px;
|
|
165
|
+
margin: 0 auto;
|
|
166
|
+
padding: 1.5rem 1.25rem 3rem;
|
|
167
|
+
}
|
|
168
|
+
@media (min-width: 720px) { .wrap { padding: 3rem 1.5rem; } }
|
|
169
|
+
|
|
170
|
+
.brand {
|
|
171
|
+
display: flex; align-items: center; gap: 0.6rem;
|
|
172
|
+
margin-bottom: 1.5rem;
|
|
173
|
+
font-weight: 600; font-size: 0.95rem;
|
|
174
|
+
color: var(--text-bright); letter-spacing: 0.02em;
|
|
175
|
+
}
|
|
176
|
+
.brand-dot {
|
|
177
|
+
width: 10px; height: 10px; border-radius: 50%;
|
|
178
|
+
background: var(--purple);
|
|
179
|
+
box-shadow: 0 0 14px var(--purple-ring);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.card {
|
|
183
|
+
background: var(--bg-card);
|
|
184
|
+
border: 1px solid var(--border);
|
|
185
|
+
border-radius: 14px;
|
|
186
|
+
padding: 1.5rem;
|
|
187
|
+
backdrop-filter: blur(14px);
|
|
188
|
+
-webkit-backdrop-filter: blur(14px);
|
|
189
|
+
animation: fade-in 0.4s ease-out;
|
|
190
|
+
}
|
|
191
|
+
@media (min-width: 720px) { .card { padding: 2rem; } }
|
|
192
|
+
|
|
193
|
+
h1 {
|
|
194
|
+
margin: 0 0 0.5rem;
|
|
195
|
+
font-size: 1.55rem; font-weight: 600; letter-spacing: -0.01em;
|
|
196
|
+
color: var(--text-white); line-height: 1.2;
|
|
197
|
+
}
|
|
198
|
+
h2 {
|
|
199
|
+
margin: 1.75rem 0 0.75rem;
|
|
200
|
+
font-size: 1.05rem; font-weight: 600;
|
|
201
|
+
color: var(--text-bright); letter-spacing: 0.01em;
|
|
202
|
+
}
|
|
203
|
+
p { margin: 0 0 0.85rem; line-height: 1.55; }
|
|
204
|
+
strong { color: var(--text-bright); font-weight: 600; }
|
|
205
|
+
em { color: var(--orange); font-style: normal; font-weight: 600; }
|
|
206
|
+
small { color: var(--text-dim); font-size: 0.82rem; }
|
|
207
|
+
|
|
208
|
+
ul { padding-left: 1.1rem; margin: 0.25rem 0 0.85rem; }
|
|
209
|
+
li { margin-bottom: 0.35rem; line-height: 1.5; }
|
|
210
|
+
|
|
211
|
+
.countdown {
|
|
212
|
+
display: inline-flex; align-items: center; gap: 0.35rem;
|
|
213
|
+
margin-top: 0.25rem;
|
|
214
|
+
padding: 0.22rem 0.6rem;
|
|
215
|
+
background: var(--purple-dim); border: 1px solid var(--border-accent);
|
|
216
|
+
border-radius: 999px;
|
|
217
|
+
font-size: 0.76rem; color: var(--text-bright); font-weight: 500;
|
|
218
|
+
font-variant-numeric: tabular-nums;
|
|
219
|
+
}
|
|
220
|
+
.countdown.expired { background: rgba(248, 113, 113, 0.12); border-color: rgba(248, 113, 113, 0.35); color: var(--danger); }
|
|
221
|
+
|
|
222
|
+
.callout {
|
|
223
|
+
border-left: 3px solid var(--purple);
|
|
224
|
+
padding: 0.75rem 0.95rem; margin: 1rem 0;
|
|
225
|
+
background: var(--purple-dim);
|
|
226
|
+
border-radius: 0 8px 8px 0;
|
|
227
|
+
font-size: 0.92rem;
|
|
228
|
+
}
|
|
229
|
+
.callout.warn { border-left-color: var(--orange); background: rgba(212, 148, 58, 0.07); }
|
|
230
|
+
.callout.danger { border-left-color: var(--danger); background: rgba(248, 113, 113, 0.08); }
|
|
231
|
+
|
|
232
|
+
input[type=text], input[type=number], textarea {
|
|
233
|
+
width: 100%;
|
|
234
|
+
padding: 0.75rem 0.9rem;
|
|
235
|
+
margin: 0.25rem 0 0.5rem;
|
|
236
|
+
background: rgba(255,255,255,0.03);
|
|
237
|
+
color: var(--text-bright);
|
|
238
|
+
border: 1px solid var(--border);
|
|
239
|
+
border-radius: 8px;
|
|
240
|
+
font-family: var(--mono);
|
|
241
|
+
font-size: 1rem;
|
|
242
|
+
transition: border-color 0.2s, background 0.2s;
|
|
243
|
+
}
|
|
244
|
+
input:focus, textarea:focus {
|
|
245
|
+
outline: none;
|
|
246
|
+
border-color: var(--purple);
|
|
247
|
+
background: rgba(123, 92, 255, 0.05);
|
|
248
|
+
}
|
|
249
|
+
input.code-input {
|
|
250
|
+
font-size: 1.25rem; letter-spacing: 0.3em; text-align: center;
|
|
251
|
+
font-variant-numeric: tabular-nums;
|
|
252
|
+
}
|
|
253
|
+
textarea { min-height: 5.5em; resize: vertical; }
|
|
254
|
+
|
|
255
|
+
button {
|
|
256
|
+
display: inline-flex; align-items: center; justify-content: center; gap: 0.4rem;
|
|
257
|
+
padding: 0.8rem 1.25rem;
|
|
258
|
+
background: var(--purple); color: var(--text-white);
|
|
259
|
+
border: none; border-radius: 8px;
|
|
260
|
+
font-family: var(--sans); font-size: 0.95rem; font-weight: 500;
|
|
261
|
+
cursor: pointer;
|
|
262
|
+
transition: background 0.2s, transform 0.1s, box-shadow 0.2s;
|
|
263
|
+
min-width: 140px;
|
|
264
|
+
}
|
|
265
|
+
button:hover:not(:disabled) { background: #8b6dff; box-shadow: 0 4px 20px rgba(123, 92, 255, 0.35); }
|
|
266
|
+
button:active:not(:disabled) { transform: translateY(1px); }
|
|
267
|
+
button:disabled { opacity: 0.45; cursor: not-allowed; }
|
|
268
|
+
button.secondary {
|
|
269
|
+
background: transparent; color: var(--text);
|
|
270
|
+
border: 1px solid var(--border);
|
|
271
|
+
}
|
|
272
|
+
button.secondary:hover:not(:disabled) { border-color: var(--border-accent); color: var(--text-bright); background: var(--purple-dim); box-shadow: none; }
|
|
273
|
+
|
|
274
|
+
.btn-row { display: flex; flex-wrap: wrap; gap: 0.6rem; margin-top: 1rem; }
|
|
275
|
+
|
|
276
|
+
.mnemonic-grid {
|
|
277
|
+
display: grid;
|
|
278
|
+
grid-template-columns: repeat(2, 1fr);
|
|
279
|
+
gap: 0.5rem 0.75rem;
|
|
280
|
+
margin: 0.75rem 0;
|
|
281
|
+
font-family: var(--mono);
|
|
282
|
+
font-size: 0.95rem;
|
|
283
|
+
}
|
|
284
|
+
@media (min-width: 520px) { .mnemonic-grid { grid-template-columns: repeat(3, 1fr); } }
|
|
285
|
+
@media (min-width: 720px) { .mnemonic-grid { grid-template-columns: repeat(4, 1fr); } }
|
|
286
|
+
|
|
287
|
+
.mnemonic-word {
|
|
288
|
+
display: flex; align-items: baseline; gap: 0.35rem;
|
|
289
|
+
padding: 0.55rem 0.7rem;
|
|
290
|
+
background: rgba(255,255,255,0.035);
|
|
291
|
+
border: 1px solid var(--border);
|
|
292
|
+
border-radius: 7px;
|
|
293
|
+
animation: word-in 0.3s ease-out backwards;
|
|
294
|
+
}
|
|
295
|
+
.mnemonic-word .num {
|
|
296
|
+
color: var(--text-dim); font-size: 0.78rem; font-weight: 500;
|
|
297
|
+
min-width: 1.5rem; text-align: right;
|
|
298
|
+
}
|
|
299
|
+
.mnemonic-word .word { color: var(--text-bright); font-weight: 500; }
|
|
300
|
+
|
|
301
|
+
.pulse {
|
|
302
|
+
display: inline-block;
|
|
303
|
+
width: 10px; height: 10px; border-radius: 50%;
|
|
304
|
+
background: var(--purple);
|
|
305
|
+
box-shadow: 0 0 0 0 rgba(123, 92, 255, 0.5);
|
|
306
|
+
animation: pulse 1.6s infinite ease-out;
|
|
307
|
+
margin-right: 0.5rem; vertical-align: middle;
|
|
308
|
+
}
|
|
309
|
+
.check {
|
|
310
|
+
display: inline-block; width: 14px; height: 14px;
|
|
311
|
+
color: var(--success); vertical-align: middle; margin-right: 0.5rem;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
@keyframes fade-in {
|
|
315
|
+
from { opacity: 0; transform: translateY(6px); }
|
|
316
|
+
to { opacity: 1; transform: translateY(0); }
|
|
317
|
+
}
|
|
318
|
+
@keyframes word-in {
|
|
319
|
+
from { opacity: 0; transform: translateY(4px); }
|
|
320
|
+
to { opacity: 1; transform: translateY(0); }
|
|
321
|
+
}
|
|
322
|
+
@keyframes pulse {
|
|
323
|
+
0% { box-shadow: 0 0 0 0 rgba(123, 92, 255, 0.5); }
|
|
324
|
+
75% { box-shadow: 0 0 0 12px rgba(123, 92, 255, 0); }
|
|
325
|
+
100% { box-shadow: 0 0 0 0 rgba(123, 92, 255, 0); }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
.hidden { display: none !important; }
|
|
329
|
+
.center { text-align: center; }
|
|
330
|
+
.mt-2 { margin-top: 1.25rem; }
|
|
331
|
+
.mono { font-family: var(--mono); }
|
|
332
|
+
|
|
333
|
+
/* Reduced-motion preference */
|
|
334
|
+
@media (prefers-reduced-motion: reduce) {
|
|
335
|
+
*, *::before, *::after { animation-duration: 0.01s !important; transition-duration: 0.01s !important; }
|
|
336
|
+
}
|
|
337
|
+
</style>
|
|
338
|
+
</head>
|
|
339
|
+
<body>
|
|
340
|
+
<div class="wrap">
|
|
341
|
+
<div class="brand">
|
|
342
|
+
<span class="brand-dot"></span>
|
|
343
|
+
<span>TotalReclaw</span>
|
|
344
|
+
</div>
|
|
345
|
+
|
|
346
|
+
<div class="card" id="stage">
|
|
347
|
+
<!-- Stages render dynamically. Initial content is Stage 0: code entry. -->
|
|
348
|
+
<div id="stage-body">Loading pairing session…</div>
|
|
349
|
+
<div class="countdown" id="countdown" aria-live="polite"></div>
|
|
350
|
+
</div>
|
|
351
|
+
|
|
352
|
+
<p class="mt-2 center"><small>End-to-end encrypted. Gateway does not see your key in plaintext.</small></p>
|
|
353
|
+
</div>
|
|
354
|
+
|
|
355
|
+
<script>
|
|
356
|
+
"use strict";
|
|
357
|
+
(function() {
|
|
358
|
+
// ---------- Constants injected by the server ----------
|
|
359
|
+
const SID = ${SID};
|
|
360
|
+
const MODE = ${MODE}; // "generate" or "import"
|
|
361
|
+
const API_BASE = ${API_BASE}; // e.g. "/plugin/totalreclaw/pair"
|
|
362
|
+
const EXPIRES_AT_MS = ${EXPIRES_AT};
|
|
363
|
+
const SERVER_NOW_MS = ${NOW};
|
|
364
|
+
// Client-observed time at page load (used to adjust for clock skew).
|
|
365
|
+
const CLIENT_EPOCH_AT_LOAD = Date.now();
|
|
366
|
+
|
|
367
|
+
const HKDF_INFO = "totalreclaw-pair-v1";
|
|
368
|
+
|
|
369
|
+
// ---------- Small utilities ----------
|
|
370
|
+
function $(sel, root) { return (root || document).querySelector(sel); }
|
|
371
|
+
function el(html) { const t = document.createElement("template"); t.innerHTML = html.trim(); return t.content.firstElementChild; }
|
|
372
|
+
function render(nodeOrHtml) {
|
|
373
|
+
const body = $("#stage-body");
|
|
374
|
+
body.innerHTML = "";
|
|
375
|
+
if (typeof nodeOrHtml === "string") body.innerHTML = nodeOrHtml;
|
|
376
|
+
else body.appendChild(nodeOrHtml);
|
|
377
|
+
}
|
|
378
|
+
function b64urlFromBytes(bytes) {
|
|
379
|
+
let s = "";
|
|
380
|
+
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
|
|
381
|
+
return btoa(s).replace(/\\+/g, "-").replace(/\\//g, "_").replace(/=+$/, "");
|
|
382
|
+
}
|
|
383
|
+
function bytesFromB64url(s) {
|
|
384
|
+
const pad = s.length % 4 === 2 ? "==" : s.length % 4 === 3 ? "=" : "";
|
|
385
|
+
const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/") + pad);
|
|
386
|
+
const out = new Uint8Array(bin.length);
|
|
387
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
388
|
+
return out;
|
|
389
|
+
}
|
|
390
|
+
function zeroBytes(u8) { if (u8 && u8.fill) u8.fill(0); }
|
|
391
|
+
|
|
392
|
+
// ---------- Parse the pk from the URL fragment ----------
|
|
393
|
+
function parseFragmentPk() {
|
|
394
|
+
const hash = window.location.hash || "";
|
|
395
|
+
const m = /(?:^#|&)pk=([A-Za-z0-9_-]+)/.exec(hash);
|
|
396
|
+
return m ? m[1] : null;
|
|
397
|
+
}
|
|
398
|
+
const PK_GATEWAY_B64 = parseFragmentPk();
|
|
399
|
+
|
|
400
|
+
// ---------- Countdown ----------
|
|
401
|
+
function updateCountdown() {
|
|
402
|
+
const elNode = $("#countdown");
|
|
403
|
+
if (!elNode) return;
|
|
404
|
+
const clientNow = Date.now();
|
|
405
|
+
const serverNow = SERVER_NOW_MS + (clientNow - CLIENT_EPOCH_AT_LOAD);
|
|
406
|
+
const remaining = Math.max(0, EXPIRES_AT_MS - serverNow);
|
|
407
|
+
const mm = Math.floor(remaining / 60000);
|
|
408
|
+
const ss = Math.floor((remaining % 60000) / 1000);
|
|
409
|
+
if (remaining === 0) {
|
|
410
|
+
elNode.classList.add("expired");
|
|
411
|
+
elNode.textContent = "Expired";
|
|
412
|
+
} else {
|
|
413
|
+
elNode.textContent = "Expires in " + mm + ":" + String(ss).padStart(2, "0");
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
setInterval(updateCountdown, 1000);
|
|
417
|
+
|
|
418
|
+
// ---------- Bip39 wordlist (English), inlined as base64url for compactness ----------
|
|
419
|
+
// The full English BIP-39 wordlist — 2048 words, one per line. This is
|
|
420
|
+
// the canonical list from the BIP-39 spec. We inline it so the page is
|
|
421
|
+
// fully self-contained. ~13KB raw, compresses well over gzip.
|
|
422
|
+
const BIP39_WORDLIST = [${BIP39_WORDLIST_JS_ARRAY}];
|
|
423
|
+
|
|
424
|
+
if (BIP39_WORDLIST.length !== 2048) {
|
|
425
|
+
render(renderError("Internal: bundled wordlist has " + BIP39_WORDLIST.length + " entries (expected 2048). Cannot proceed."));
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function generateMnemonic128() {
|
|
430
|
+
const entropy = new Uint8Array(16);
|
|
431
|
+
crypto.getRandomValues(entropy);
|
|
432
|
+
return entropyToMnemonic(entropy);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async function entropyToMnemonic(entropy) {
|
|
436
|
+
// BIP-39: checksum = first (len/4) bits of SHA-256(entropy), appended.
|
|
437
|
+
// 128-bit entropy → 4-bit checksum → 132 bits → 12 words × 11 bits.
|
|
438
|
+
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", entropy));
|
|
439
|
+
const csBits = entropy.length / 4; // 4 for 128
|
|
440
|
+
const bits = [];
|
|
441
|
+
for (let i = 0; i < entropy.length; i++) {
|
|
442
|
+
for (let b = 7; b >= 0; b--) bits.push((entropy[i] >> b) & 1);
|
|
443
|
+
}
|
|
444
|
+
for (let i = 0; i < csBits; i++) bits.push((digest[0] >> (7 - i)) & 1);
|
|
445
|
+
const words = [];
|
|
446
|
+
for (let i = 0; i < bits.length; i += 11) {
|
|
447
|
+
let n = 0;
|
|
448
|
+
for (let k = 0; k < 11; k++) n = (n << 1) | bits[i + k];
|
|
449
|
+
words.push(BIP39_WORDLIST[n]);
|
|
450
|
+
}
|
|
451
|
+
return words.join(" ");
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async function validateMnemonic(phrase) {
|
|
455
|
+
const words = phrase.trim().toLowerCase().split(/\\s+/);
|
|
456
|
+
if (words.length !== 12) return false;
|
|
457
|
+
const bits = [];
|
|
458
|
+
for (const w of words) {
|
|
459
|
+
const idx = BIP39_WORDLIST.indexOf(w);
|
|
460
|
+
if (idx < 0) return false;
|
|
461
|
+
for (let b = 10; b >= 0; b--) bits.push((idx >> b) & 1);
|
|
462
|
+
}
|
|
463
|
+
// 132 bits: 128 entropy + 4 checksum.
|
|
464
|
+
const entropy = new Uint8Array(16);
|
|
465
|
+
for (let i = 0; i < 128; i++) entropy[i >> 3] |= bits[i] << (7 - (i & 7));
|
|
466
|
+
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", entropy));
|
|
467
|
+
for (let i = 0; i < 4; i++) {
|
|
468
|
+
const got = bits[128 + i];
|
|
469
|
+
const want = (digest[0] >> (7 - i)) & 1;
|
|
470
|
+
if (got !== want) return false;
|
|
471
|
+
}
|
|
472
|
+
return true;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// ---------- Crypto shims: prefer WebCrypto; fall back to JS path ----------
|
|
476
|
+
// WebCrypto's x25519 + HKDF + ChaCha20-Poly1305 availability is Safari 17+
|
|
477
|
+
// and modern Chromium / Firefox. If absent we render an error — the page
|
|
478
|
+
// is self-contained, and we elect not to bundle @noble/curves + ciphers
|
|
479
|
+
// for the MVP (tracked as Wave 3.1 polish follow-up).
|
|
480
|
+
async function ensureWebCryptoSupport() {
|
|
481
|
+
if (!window.crypto || !crypto.subtle) return false;
|
|
482
|
+
try {
|
|
483
|
+
const kp = await crypto.subtle.generateKey({ name: "X25519" }, true, ["deriveBits"]);
|
|
484
|
+
return !!kp.privateKey && !!kp.publicKey;
|
|
485
|
+
} catch (e) {
|
|
486
|
+
return false;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
async function x25519GenerateKeyPair() {
|
|
491
|
+
const kp = await crypto.subtle.generateKey({ name: "X25519" }, true, ["deriveBits"]);
|
|
492
|
+
const rawPub = new Uint8Array(await crypto.subtle.exportKey("raw", kp.publicKey));
|
|
493
|
+
return { keyPair: kp, rawPubB64: b64urlFromBytes(rawPub) };
|
|
494
|
+
}
|
|
495
|
+
async function x25519DeriveShared(privateKey, peerPubRaw) {
|
|
496
|
+
const peerPub = await crypto.subtle.importKey("raw", peerPubRaw, { name: "X25519" }, false, []);
|
|
497
|
+
const sharedBits = await crypto.subtle.deriveBits({ name: "X25519", public: peerPub }, privateKey, 256);
|
|
498
|
+
return new Uint8Array(sharedBits);
|
|
499
|
+
}
|
|
500
|
+
async function hkdfSha256(sharedBytes, saltBytes, infoBytes, outLen) {
|
|
501
|
+
const key = await crypto.subtle.importKey("raw", sharedBytes, { name: "HKDF" }, false, ["deriveBits"]);
|
|
502
|
+
const bits = await crypto.subtle.deriveBits(
|
|
503
|
+
{ name: "HKDF", hash: "SHA-256", salt: saltBytes, info: infoBytes },
|
|
504
|
+
key, outLen * 8,
|
|
505
|
+
);
|
|
506
|
+
return new Uint8Array(bits);
|
|
507
|
+
}
|
|
508
|
+
// AEAD: WebCrypto offers AES-GCM universally; ChaCha20-Poly1305 support is
|
|
509
|
+
// newer. We attempt chacha first; if it throws, we abort (do NOT silently
|
|
510
|
+
// swap ciphers — that would mismatch the gateway).
|
|
511
|
+
async function aeadEncryptChaCha(keyBytes, nonce, sid, plaintext) {
|
|
512
|
+
const key = await crypto.subtle.importKey(
|
|
513
|
+
"raw", keyBytes,
|
|
514
|
+
{ name: "ChaCha20-Poly1305" },
|
|
515
|
+
false, ["encrypt"],
|
|
516
|
+
);
|
|
517
|
+
const adBytes = new TextEncoder().encode(sid);
|
|
518
|
+
const ct = new Uint8Array(await crypto.subtle.encrypt(
|
|
519
|
+
{ name: "ChaCha20-Poly1305", iv: nonce, additionalData: adBytes, tagLength: 128 },
|
|
520
|
+
key, plaintext,
|
|
521
|
+
));
|
|
522
|
+
return ct;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
async function chaChaSupported() {
|
|
526
|
+
try {
|
|
527
|
+
const k = new Uint8Array(32);
|
|
528
|
+
const n = new Uint8Array(12);
|
|
529
|
+
const key = await crypto.subtle.importKey("raw", k, { name: "ChaCha20-Poly1305" }, false, ["encrypt"]);
|
|
530
|
+
await crypto.subtle.encrypt({ name: "ChaCha20-Poly1305", iv: n, additionalData: new Uint8Array(0), tagLength: 128 }, key, new Uint8Array(0));
|
|
531
|
+
return true;
|
|
532
|
+
} catch (e) { return false; }
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// ---------- Stage renderers ----------
|
|
536
|
+
function renderError(msg) {
|
|
537
|
+
const d = el('<div><h1>Something went wrong</h1><p>' + escapeHtml(msg) + '</p><p><small>Start over from your terminal: <code>openclaw totalreclaw pair</code></small></p></div>');
|
|
538
|
+
return d;
|
|
539
|
+
}
|
|
540
|
+
function escapeHtml(s) {
|
|
541
|
+
return String(s).replace(/[&<>"]/g, function(c) {
|
|
542
|
+
return c === "&" ? "&" : c === "<" ? "<" : c === ">" ? ">" : """;
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Stage 1: code entry
|
|
547
|
+
function renderCodeEntry() {
|
|
548
|
+
const node = el(
|
|
549
|
+
'<div>' +
|
|
550
|
+
'<h1>Pair with your gateway</h1>' +
|
|
551
|
+
'<p>Enter the 6-digit code shown in your terminal. This prevents a bystander from hijacking this pairing.</p>' +
|
|
552
|
+
'<input id="code-in" class="code-input" inputmode="numeric" pattern="[0-9]*" maxlength="6" autocomplete="one-time-code" autofocus />' +
|
|
553
|
+
'<div id="code-error" class="callout danger hidden"></div>' +
|
|
554
|
+
'<div class="btn-row">' +
|
|
555
|
+
'<button id="code-continue">Continue</button>' +
|
|
556
|
+
'</div>' +
|
|
557
|
+
'</div>'
|
|
558
|
+
);
|
|
559
|
+
render(node);
|
|
560
|
+
const input = $("#code-in");
|
|
561
|
+
input.addEventListener("input", function() {
|
|
562
|
+
input.value = input.value.replace(/\\D+/g, "").slice(0, 6);
|
|
563
|
+
});
|
|
564
|
+
$("#code-continue").addEventListener("click", function() { onCodeSubmit(); });
|
|
565
|
+
input.addEventListener("keydown", function(e) { if (e.key === "Enter") onCodeSubmit(); });
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
async function onCodeSubmit() {
|
|
569
|
+
const code = $("#code-in").value;
|
|
570
|
+
if (!/^\\d{6}$/.test(code)) {
|
|
571
|
+
showInlineError("code-error", "Enter all 6 digits.");
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
$("#code-continue").disabled = true;
|
|
575
|
+
|
|
576
|
+
try {
|
|
577
|
+
const url = API_BASE + "/start?sid=" + encodeURIComponent(SID) + "&c=" + encodeURIComponent(code);
|
|
578
|
+
const r = await window.fetch(url, { method: "GET", cache: "no-store" });
|
|
579
|
+
if (r.status === 403) {
|
|
580
|
+
const j = await r.json().catch(function(){ return {}; });
|
|
581
|
+
showInlineError("code-error", j.error === "attempts_exhausted"
|
|
582
|
+
? "Too many wrong codes. This pairing session is locked out. Start over from your terminal."
|
|
583
|
+
: "Code doesn't match. Double-check your terminal.");
|
|
584
|
+
$("#code-continue").disabled = false;
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
if (r.status === 410) { showInlineError("code-error", "Session expired. Start over from your terminal."); return; }
|
|
588
|
+
if (r.status === 404) { showInlineError("code-error", "Session not found. Start over from your terminal."); return; }
|
|
589
|
+
if (!r.ok) { showInlineError("code-error", "Gateway error: " + r.status); return; }
|
|
590
|
+
const meta = await r.json();
|
|
591
|
+
// Proceed to the mnemonic stage based on mode.
|
|
592
|
+
if (MODE === "generate") await renderGenerateFlow(meta);
|
|
593
|
+
else await renderImportFlow(meta);
|
|
594
|
+
} catch (err) {
|
|
595
|
+
showInlineError("code-error", "Network error. Try again.");
|
|
596
|
+
$("#code-continue").disabled = false;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function showInlineError(id, msg) {
|
|
601
|
+
const e = $("#" + id);
|
|
602
|
+
if (!e) return;
|
|
603
|
+
e.textContent = msg;
|
|
604
|
+
e.classList.remove("hidden");
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Stage 2a: Generate a mnemonic in-browser, show it with safety copy + ack gate
|
|
608
|
+
async function renderGenerateFlow(meta) {
|
|
609
|
+
const mnemonic = await generateMnemonic128();
|
|
610
|
+
const words = mnemonic.split(" ");
|
|
611
|
+
let gridHtml = '';
|
|
612
|
+
for (let i = 0; i < words.length; i++) {
|
|
613
|
+
gridHtml += '<div class="mnemonic-word" style="animation-delay:' + (i * 30) + 'ms"><span class="num">' + (i+1) + '.</span><span class="word">' + escapeHtml(words[i]) + '</span></div>';
|
|
614
|
+
}
|
|
615
|
+
const node = el(
|
|
616
|
+
'<div>' +
|
|
617
|
+
'<h1>This is your TotalReclaw account key</h1>' +
|
|
618
|
+
'<p>Write it down before continuing. This 12-word phrase is the ONLY way to restore your memories if you switch machines or lose access.</p>' +
|
|
619
|
+
'<h2>Your key</h2>' +
|
|
620
|
+
'<div class="mnemonic-grid">' + gridHtml + '</div>' +
|
|
621
|
+
'<div class="btn-row"><button id="copy" class="secondary">Copy to clipboard</button></div>' +
|
|
622
|
+
'<div class="callout warn"><strong>Use it ONLY with TotalReclaw.</strong> <em>Never</em> reuse this phrase for a crypto wallet, banking, email, or any other service. TotalReclaw account keys must be dedicated.</div>' +
|
|
623
|
+
'<h2>Store it somewhere safe</h2>' +
|
|
624
|
+
'<ul>' +
|
|
625
|
+
'<li>A password manager (1Password, Bitwarden, Apple Keychain, etc.)</li>' +
|
|
626
|
+
'<li>An encrypted notes app (Notes with end-to-end encryption, Standard Notes, Obsidian vault)</li>' +
|
|
627
|
+
'<li>Written on paper in a physical safe</li>' +
|
|
628
|
+
'</ul>' +
|
|
629
|
+
'<h2>With this key you can:</h2>' +
|
|
630
|
+
'<ul>' +
|
|
631
|
+
'<li>Restore your TotalReclaw account on any new device</li>' +
|
|
632
|
+
'<li>Import your memories into Hermes, OpenClaw, the MCP client, or any other TotalReclaw-enabled agent</li>' +
|
|
633
|
+
'<li>Reset your gateway without losing a single memory</li>' +
|
|
634
|
+
'</ul>' +
|
|
635
|
+
'<div class="callout danger"><strong>Without it:</strong> you permanently lose access to all memories across all agents. TotalReclaw cannot recover it for you.</div>' +
|
|
636
|
+
'<h2>Confirm</h2>' +
|
|
637
|
+
'<p>Before you continue, retype three of your words:</p>' +
|
|
638
|
+
'<div id="ack-probes"></div>' +
|
|
639
|
+
'<div id="ack-error" class="callout danger hidden"></div>' +
|
|
640
|
+
'<div class="btn-row">' +
|
|
641
|
+
'<button id="ack-submit">I have saved it — continue</button>' +
|
|
642
|
+
'</div>' +
|
|
643
|
+
'</div>'
|
|
644
|
+
);
|
|
645
|
+
render(node);
|
|
646
|
+
|
|
647
|
+
$("#copy").addEventListener("click", function() {
|
|
648
|
+
navigator.clipboard.writeText(mnemonic).then(function() {
|
|
649
|
+
$("#copy").textContent = "Copied \u2713";
|
|
650
|
+
}).catch(function() {});
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
// Random 3 distinct probe indices
|
|
654
|
+
const probe = pickDistinctIndices(words.length, 3);
|
|
655
|
+
const probeEl = $("#ack-probes");
|
|
656
|
+
for (const idx of probe) {
|
|
657
|
+
const row = el(
|
|
658
|
+
'<div style="display:flex; align-items:center; gap:0.75rem; margin:0.5rem 0;">' +
|
|
659
|
+
'<span style="min-width:5ch; color:var(--text-dim);">Word #' + (idx+1) + '</span>' +
|
|
660
|
+
'<input type="text" autocapitalize="none" autocorrect="off" spellcheck="false" data-idx="' + idx + '" />' +
|
|
661
|
+
'</div>'
|
|
662
|
+
);
|
|
663
|
+
probeEl.appendChild(row);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
$("#ack-submit").addEventListener("click", async function() {
|
|
667
|
+
const inputs = probeEl.querySelectorAll("input[data-idx]");
|
|
668
|
+
for (const inp of inputs) {
|
|
669
|
+
const idx = parseInt(inp.getAttribute("data-idx"), 10);
|
|
670
|
+
if (inp.value.trim().toLowerCase() !== words[idx]) {
|
|
671
|
+
showInlineError("ack-error", "Word #" + (idx+1) + " doesn't match. Check your written copy and try again.");
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
$("#ack-error").classList.add("hidden");
|
|
676
|
+
$("#ack-submit").disabled = true;
|
|
677
|
+
await submitEncrypted(mnemonic);
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function pickDistinctIndices(n, k) {
|
|
682
|
+
const pool = [];
|
|
683
|
+
for (let i = 0; i < n; i++) pool.push(i);
|
|
684
|
+
const out = [];
|
|
685
|
+
for (let i = 0; i < k; i++) {
|
|
686
|
+
const j = Math.floor(Math.random() * pool.length);
|
|
687
|
+
out.push(pool[j]); pool.splice(j, 1);
|
|
688
|
+
}
|
|
689
|
+
out.sort(function(a,b) { return a-b; });
|
|
690
|
+
return out;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// Stage 2b: Import an existing phrase
|
|
694
|
+
async function renderImportFlow(meta) {
|
|
695
|
+
const node = el(
|
|
696
|
+
'<div>' +
|
|
697
|
+
'<h1>Import your TotalReclaw account key</h1>' +
|
|
698
|
+
'<p>Paste your existing 12-word TotalReclaw key below. It is processed ENTIRELY in your browser and encrypted before it leaves this page.</p>' +
|
|
699
|
+
'<div class="callout warn"><strong>Use it ONLY with TotalReclaw.</strong> Do <em>not</em> paste a phrase that controls a crypto wallet, banking account, email, or any other service. TotalReclaw account keys must be dedicated.</div>' +
|
|
700
|
+
'<textarea id="phrase" autocapitalize="none" autocorrect="off" spellcheck="false" placeholder="word1 word2 word3 ... word12"></textarea>' +
|
|
701
|
+
'<div id="phrase-error" class="callout danger hidden"></div>' +
|
|
702
|
+
'<p><small>Checksum is verified in your browser. Invalid phrases are rejected before upload.</small></p>' +
|
|
703
|
+
'<div class="btn-row">' +
|
|
704
|
+
'<button id="import-submit">Upload encrypted</button>' +
|
|
705
|
+
'</div>' +
|
|
706
|
+
'</div>'
|
|
707
|
+
);
|
|
708
|
+
render(node);
|
|
709
|
+
$("#import-submit").addEventListener("click", async function() {
|
|
710
|
+
const raw = $("#phrase").value.normalize("NFKC").toLowerCase().trim().split(/\\s+/).join(" ");
|
|
711
|
+
const ok = await validateMnemonic(raw);
|
|
712
|
+
if (!ok) {
|
|
713
|
+
showInlineError("phrase-error", "That is not a valid 12-word BIP-39 phrase. Check spelling, word count, and that the checksum is intact.");
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
$("#phrase-error").classList.add("hidden");
|
|
717
|
+
$("#import-submit").disabled = true;
|
|
718
|
+
await submitEncrypted(raw);
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// Stage 3: encrypt + upload
|
|
723
|
+
async function submitEncrypted(mnemonic) {
|
|
724
|
+
render('<div class="center"><span class="pulse"></span><span>Encrypting\u2026</span></div>');
|
|
725
|
+
|
|
726
|
+
// 1. Sanity: we MUST have pk_G from the URL fragment. Without it we cannot
|
|
727
|
+
// authenticate the gateway key and the whole point of the protocol is
|
|
728
|
+
// defeated.
|
|
729
|
+
if (!PK_GATEWAY_B64) {
|
|
730
|
+
render(renderError("Missing gateway public key in the URL fragment. This link is malformed; start over from your terminal."));
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
const pkGateway = bytesFromB64url(PK_GATEWAY_B64);
|
|
734
|
+
if (pkGateway.length !== 32) {
|
|
735
|
+
render(renderError("Gateway public key is the wrong size. Start over from your terminal."));
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// 2. Ensure WebCrypto supports our ciphers.
|
|
740
|
+
if (!(await ensureWebCryptoSupport())) {
|
|
741
|
+
render(renderError("Your browser does not support modern cryptographic APIs (X25519). Please update your browser and try again, or use a different device. Supported: Chrome 123+, Firefox 130+, Safari 17+."));
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
if (!(await chaChaSupported())) {
|
|
745
|
+
render(renderError("Your browser does not support ChaCha20-Poly1305. Update your browser or use a different device."));
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
try {
|
|
750
|
+
// 3. Generate ephemeral x25519 keypair
|
|
751
|
+
const dev = await x25519GenerateKeyPair();
|
|
752
|
+
render('<div class="center"><span class="pulse"></span><span>Pairing\u2026 deriving shared key</span></div>');
|
|
753
|
+
|
|
754
|
+
// 4. ECDH + HKDF
|
|
755
|
+
const shared = await x25519DeriveShared(dev.keyPair.privateKey, pkGateway);
|
|
756
|
+
const salt = new TextEncoder().encode(SID);
|
|
757
|
+
const info = new TextEncoder().encode(HKDF_INFO);
|
|
758
|
+
const kEnc = await hkdfSha256(shared, salt, info, 32);
|
|
759
|
+
|
|
760
|
+
// 5. Encrypt (AEAD with sid as AD)
|
|
761
|
+
const nonce = new Uint8Array(12);
|
|
762
|
+
crypto.getRandomValues(nonce);
|
|
763
|
+
const ptBytes = new TextEncoder().encode(mnemonic);
|
|
764
|
+
const ct = await aeadEncryptChaCha(kEnc, nonce, SID, ptBytes);
|
|
765
|
+
|
|
766
|
+
// 6. Zero sensitive buffers BEFORE sending. The JS GC will run
|
|
767
|
+
// whenever it runs; explicit zeroing is best-effort but honours
|
|
768
|
+
// the design doc's "zero after post-ack" requirement.
|
|
769
|
+
zeroBytes(kEnc); zeroBytes(shared); zeroBytes(ptBytes);
|
|
770
|
+
|
|
771
|
+
// 7. Submit
|
|
772
|
+
render('<div class="center"><span class="pulse"></span><span>Uploading encrypted key\u2026</span></div>');
|
|
773
|
+
const body = {
|
|
774
|
+
v: 1,
|
|
775
|
+
sid: SID,
|
|
776
|
+
pk_d: dev.rawPubB64,
|
|
777
|
+
nonce: b64urlFromBytes(nonce),
|
|
778
|
+
ct: b64urlFromBytes(ct),
|
|
779
|
+
};
|
|
780
|
+
const r = await window.fetch(API_BASE + "/respond", {
|
|
781
|
+
method: "POST",
|
|
782
|
+
headers: {"Content-Type": "application/json"},
|
|
783
|
+
cache: "no-store",
|
|
784
|
+
body: JSON.stringify(body),
|
|
785
|
+
});
|
|
786
|
+
if (r.status >= 200 && r.status < 300) {
|
|
787
|
+
const res = await r.json().catch(function(){ return {ok: true}; });
|
|
788
|
+
renderSuccess(res);
|
|
789
|
+
} else if (r.status === 410) {
|
|
790
|
+
render(renderError("Session expired before you submitted. Start over from your terminal."));
|
|
791
|
+
} else if (r.status === 409) {
|
|
792
|
+
render(renderError("This pairing session has already been used. Start over if you need to re-pair."));
|
|
793
|
+
} else if (r.status === 400) {
|
|
794
|
+
const j = await r.json().catch(function(){ return {}; });
|
|
795
|
+
render(renderError("Encryption failed validation on the gateway. Usually caused by a bad QR scan or a mid-session clock change. Start over. (" + (j.error || r.status) + ")"));
|
|
796
|
+
} else {
|
|
797
|
+
render(renderError("Gateway error: " + r.status));
|
|
798
|
+
}
|
|
799
|
+
} catch (err) {
|
|
800
|
+
render(renderError("Encryption or upload failed: " + (err && err.message ? err.message : String(err))));
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function renderSuccess(res) {
|
|
805
|
+
const node = el(
|
|
806
|
+
'<div>' +
|
|
807
|
+
'<h1><svg class="check" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M2 8.5l4 4 8-10" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg>Paired</h1>' +
|
|
808
|
+
'<p>Your TotalReclaw account is now active on this gateway. You can close this tab and go back to your terminal or chat client.</p>' +
|
|
809
|
+
'<p><small>Account id: <span class="mono">' + escapeHtml((res && res.accountId) || "(generated)") + '</span></small></p>' +
|
|
810
|
+
'</div>'
|
|
811
|
+
);
|
|
812
|
+
render(node);
|
|
813
|
+
const c = $("#countdown"); if (c) c.textContent = "Done";
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// ---------- Boot ----------
|
|
817
|
+
window.addEventListener("DOMContentLoaded", function() {
|
|
818
|
+
updateCountdown();
|
|
819
|
+
if (!PK_GATEWAY_B64) {
|
|
820
|
+
render(renderError("No gateway key in the URL fragment. The QR scanner may have stripped it. Try re-scanning or paste the full URL from your terminal."));
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
// Basic browser-feature smoke test.
|
|
824
|
+
if (!(window.crypto && crypto.subtle && crypto.getRandomValues)) {
|
|
825
|
+
render(renderError("Your browser does not expose WebCrypto. Update the browser or use Chrome/Safari/Firefox."));
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
renderCodeEntry();
|
|
829
|
+
});
|
|
830
|
+
})();
|
|
831
|
+
</script>
|
|
832
|
+
</body>
|
|
833
|
+
</html>
|
|
834
|
+
`;
|
|
835
|
+
}
|