sema-cli 0.1.0
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 +78 -0
- package/experiments/spike-shell-stream/bin/analytics.js +209 -0
- package/experiments/spike-shell-stream/bin/sema.js +322 -0
- package/experiments/spike-shell-stream/bin/start.js +387 -0
- package/experiments/spike-shell-stream/mac-agent/agent.js +450 -0
- package/experiments/spike-shell-stream/mac-agent/analyzer.js +189 -0
- package/experiments/spike-shell-stream/mac-agent/analyzer.test.js +307 -0
- package/experiments/spike-shell-stream/mac-agent/session.js +38 -0
- package/experiments/spike-shell-stream/mobile-web/inbox.html +431 -0
- package/experiments/spike-shell-stream/mobile-web/index.html +1093 -0
- package/experiments/spike-shell-stream/mobile-web/landing.html +586 -0
- package/experiments/spike-shell-stream/mobile-web/pair.html +304 -0
- package/experiments/spike-shell-stream/relay-server/server.js +1085 -0
- package/experiments/spike-shell-stream/shared/crypto.js +138 -0
- package/experiments/spike-shell-stream/shared/crypto.test.js +350 -0
- package/package.json +52 -0
|
@@ -0,0 +1,1093 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Sema</title>
|
|
7
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css" />
|
|
8
|
+
<style>
|
|
9
|
+
:root {
|
|
10
|
+
color-scheme: dark;
|
|
11
|
+
font-family: Inter, ui-sans-serif, system-ui, -apple-system, sans-serif;
|
|
12
|
+
background: #101417;
|
|
13
|
+
color: #f4f1e8;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
* { box-sizing: border-box; }
|
|
17
|
+
|
|
18
|
+
body {
|
|
19
|
+
margin: 0;
|
|
20
|
+
min-height: 100vh;
|
|
21
|
+
min-height: 100dvh;
|
|
22
|
+
background: #101417;
|
|
23
|
+
display: flex;
|
|
24
|
+
flex-direction: column;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
header {
|
|
28
|
+
display: flex;
|
|
29
|
+
align-items: center;
|
|
30
|
+
justify-content: space-between;
|
|
31
|
+
gap: 12px;
|
|
32
|
+
padding: 10px 14px;
|
|
33
|
+
border-bottom: 1px solid #2b3337;
|
|
34
|
+
flex-shrink: 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
h1 {
|
|
38
|
+
margin: 0;
|
|
39
|
+
font-size: 16px;
|
|
40
|
+
font-weight: 700;
|
|
41
|
+
flex: 1;
|
|
42
|
+
overflow: hidden;
|
|
43
|
+
text-overflow: ellipsis;
|
|
44
|
+
white-space: nowrap;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.back-btn {
|
|
48
|
+
color: #4ec9b0;
|
|
49
|
+
text-decoration: none;
|
|
50
|
+
font-size: 14px;
|
|
51
|
+
flex-shrink: 0;
|
|
52
|
+
padding: 4px 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.status {
|
|
56
|
+
min-width: 100px;
|
|
57
|
+
border: 1px solid #3a444a;
|
|
58
|
+
border-radius: 6px;
|
|
59
|
+
padding: 5px 10px;
|
|
60
|
+
color: #d7d0c0;
|
|
61
|
+
font-size: 12px;
|
|
62
|
+
text-align: center;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.status.connected { border-color: #4f9b72; color: #91e0b1; }
|
|
66
|
+
.status.waiting { border-color: #a77a3d; color: #f0c076; }
|
|
67
|
+
|
|
68
|
+
#terminal-container {
|
|
69
|
+
flex: 1;
|
|
70
|
+
min-height: 0;
|
|
71
|
+
padding: 4px;
|
|
72
|
+
overflow: hidden;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
#terminal {
|
|
76
|
+
width: 100%;
|
|
77
|
+
height: 100%;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
form {
|
|
81
|
+
display: grid;
|
|
82
|
+
grid-template-columns: 1fr auto;
|
|
83
|
+
gap: 8px;
|
|
84
|
+
padding: 10px 14px;
|
|
85
|
+
border-top: 1px solid #2b3337;
|
|
86
|
+
background: #141a1d;
|
|
87
|
+
flex-shrink: 0;
|
|
88
|
+
padding-bottom: max(10px, env(safe-area-inset-bottom));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
input {
|
|
92
|
+
width: 100%;
|
|
93
|
+
min-width: 0;
|
|
94
|
+
border: 1px solid #394349;
|
|
95
|
+
border-radius: 8px;
|
|
96
|
+
background: #0b0f11;
|
|
97
|
+
color: #f4f1e8;
|
|
98
|
+
font: inherit;
|
|
99
|
+
font-size: 15px;
|
|
100
|
+
padding: 10px 12px;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
button {
|
|
104
|
+
border: 1px solid #b86b4b;
|
|
105
|
+
border-radius: 8px;
|
|
106
|
+
background: #d97852;
|
|
107
|
+
color: #190e0a;
|
|
108
|
+
font: inherit;
|
|
109
|
+
font-weight: 700;
|
|
110
|
+
padding: 0 16px;
|
|
111
|
+
min-height: 44px;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
button:disabled { opacity: 0.5; }
|
|
115
|
+
|
|
116
|
+
.quick-keys {
|
|
117
|
+
display: flex;
|
|
118
|
+
gap: 6px;
|
|
119
|
+
padding: 6px 14px;
|
|
120
|
+
border-top: 1px solid #2b3337;
|
|
121
|
+
background: #141a1d;
|
|
122
|
+
overflow-x: auto;
|
|
123
|
+
flex-shrink: 0;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.quick-keys button {
|
|
127
|
+
background: #2b3337;
|
|
128
|
+
border-color: #3a444a;
|
|
129
|
+
color: #d7d0c0;
|
|
130
|
+
font-size: 12px;
|
|
131
|
+
padding: 6px 10px;
|
|
132
|
+
min-height: 32px;
|
|
133
|
+
white-space: nowrap;
|
|
134
|
+
flex-shrink: 0;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/* Smart alert notification card */
|
|
138
|
+
.smart-alert {
|
|
139
|
+
display: none;
|
|
140
|
+
flex-direction: column;
|
|
141
|
+
gap: 8px;
|
|
142
|
+
padding: 12px 14px;
|
|
143
|
+
background: #1a2a1f;
|
|
144
|
+
border-bottom: 2px solid #4f9b72;
|
|
145
|
+
flex-shrink: 0;
|
|
146
|
+
animation: slideDown 0.3s ease-out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.smart-alert.visible { display: flex; }
|
|
150
|
+
|
|
151
|
+
.smart-alert-header {
|
|
152
|
+
display: flex;
|
|
153
|
+
align-items: flex-start;
|
|
154
|
+
gap: 8px;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.smart-alert-icon { font-size: 16px; flex-shrink: 0; }
|
|
158
|
+
|
|
159
|
+
.smart-alert-question {
|
|
160
|
+
font-size: 14px;
|
|
161
|
+
font-weight: 600;
|
|
162
|
+
color: #91e0b1;
|
|
163
|
+
line-height: 1.4;
|
|
164
|
+
word-break: break-word;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.smart-alert-context {
|
|
168
|
+
font-size: 12px;
|
|
169
|
+
color: #8a9099;
|
|
170
|
+
line-height: 1.3;
|
|
171
|
+
max-height: 60px;
|
|
172
|
+
overflow: hidden;
|
|
173
|
+
white-space: pre-wrap;
|
|
174
|
+
word-break: break-word;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.smart-alert-actions {
|
|
178
|
+
display: flex;
|
|
179
|
+
gap: 8px;
|
|
180
|
+
flex-wrap: wrap;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.smart-alert-actions button {
|
|
184
|
+
flex: 1;
|
|
185
|
+
min-width: 60px;
|
|
186
|
+
border: 1px solid #4f9b72;
|
|
187
|
+
border-radius: 8px;
|
|
188
|
+
background: #2a4a35;
|
|
189
|
+
color: #91e0b1;
|
|
190
|
+
font-size: 14px;
|
|
191
|
+
font-weight: 600;
|
|
192
|
+
padding: 10px 14px;
|
|
193
|
+
min-height: 40px;
|
|
194
|
+
cursor: pointer;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.smart-alert-actions button:disabled { opacity: 0.5; }
|
|
198
|
+
|
|
199
|
+
.smart-alert-actions button.manual-btn {
|
|
200
|
+
background: transparent;
|
|
201
|
+
border-color: #3a444a;
|
|
202
|
+
color: #8a9099;
|
|
203
|
+
font-size: 12px;
|
|
204
|
+
font-weight: 400;
|
|
205
|
+
flex: 0;
|
|
206
|
+
padding: 10px 12px;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
@keyframes slideDown {
|
|
210
|
+
from { transform: translateY(-100%); opacity: 0; }
|
|
211
|
+
to { transform: translateY(0); opacity: 1; }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/* Fingerprint verification banner */
|
|
215
|
+
.fingerprint-bar {
|
|
216
|
+
display: none;
|
|
217
|
+
align-items: center;
|
|
218
|
+
gap: 8px;
|
|
219
|
+
padding: 8px 12px;
|
|
220
|
+
background: #1a2332;
|
|
221
|
+
border-bottom: 1px solid #1e3a5f;
|
|
222
|
+
font-family: "SF Mono", SFMono-Regular, ui-monospace, Menlo, monospace;
|
|
223
|
+
font-size: 13px;
|
|
224
|
+
color: #79b8ff;
|
|
225
|
+
flex-shrink: 0;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.fingerprint-bar.verified { background: #152a1a; border-color: #1e5f2e; color: #4ec9b0; }
|
|
229
|
+
.fingerprint-bar.mismatch { background: #2a1515; border-color: #5f1e1e; color: #f97583; }
|
|
230
|
+
|
|
231
|
+
.fp-value { flex: 1; letter-spacing: 1px; }
|
|
232
|
+
|
|
233
|
+
.fp-verify-btn {
|
|
234
|
+
background: transparent;
|
|
235
|
+
border: 1px solid currentColor;
|
|
236
|
+
color: inherit;
|
|
237
|
+
padding: 2px 10px;
|
|
238
|
+
border-radius: 4px;
|
|
239
|
+
font-size: 12px;
|
|
240
|
+
font-weight: 600;
|
|
241
|
+
cursor: pointer;
|
|
242
|
+
min-height: auto;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
.fp-verify-btn:disabled { opacity: 0.6; cursor: default; }
|
|
246
|
+
|
|
247
|
+
/* Fingerprint verification dialog overlay */
|
|
248
|
+
.fp-dialog {
|
|
249
|
+
position: fixed;
|
|
250
|
+
inset: 0;
|
|
251
|
+
background: rgba(0, 0, 0, 0.7);
|
|
252
|
+
display: flex;
|
|
253
|
+
align-items: center;
|
|
254
|
+
justify-content: center;
|
|
255
|
+
z-index: 100;
|
|
256
|
+
padding: 24px;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
.fp-dialog-content {
|
|
260
|
+
background: #1a1f24;
|
|
261
|
+
border: 1px solid #2b3337;
|
|
262
|
+
border-radius: 12px;
|
|
263
|
+
padding: 28px 24px;
|
|
264
|
+
max-width: 360px;
|
|
265
|
+
width: 100%;
|
|
266
|
+
text-align: center;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
.fp-dialog h3 {
|
|
270
|
+
margin: 0 0 12px 0;
|
|
271
|
+
font-size: 16px;
|
|
272
|
+
font-weight: 700;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
.fp-dialog p {
|
|
276
|
+
margin: 0 0 16px 0;
|
|
277
|
+
font-size: 14px;
|
|
278
|
+
color: #8a9099;
|
|
279
|
+
line-height: 1.5;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
.fp-display {
|
|
283
|
+
font-family: "SF Mono", SFMono-Regular, ui-monospace, Menlo, monospace;
|
|
284
|
+
font-size: 22px;
|
|
285
|
+
font-weight: 700;
|
|
286
|
+
letter-spacing: 3px;
|
|
287
|
+
padding: 16px;
|
|
288
|
+
background: #0d1117;
|
|
289
|
+
border: 1px solid #2b3337;
|
|
290
|
+
border-radius: 8px;
|
|
291
|
+
margin: 16px 0;
|
|
292
|
+
color: #79b8ff;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
.fp-instruction {
|
|
296
|
+
font-size: 12px !important;
|
|
297
|
+
color: #5a6068 !important;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
.fp-actions {
|
|
301
|
+
display: flex;
|
|
302
|
+
gap: 12px;
|
|
303
|
+
margin-top: 20px;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
.fp-actions button {
|
|
307
|
+
flex: 1;
|
|
308
|
+
border-radius: 8px;
|
|
309
|
+
font-size: 14px;
|
|
310
|
+
font-weight: 600;
|
|
311
|
+
padding: 12px;
|
|
312
|
+
min-height: 44px;
|
|
313
|
+
cursor: pointer;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
.btn-danger {
|
|
317
|
+
background: #3a1a1a;
|
|
318
|
+
border-color: #5f1e1e;
|
|
319
|
+
color: #f97583;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
.btn-primary {
|
|
323
|
+
background: #1a3a2a;
|
|
324
|
+
border-color: #1e5f2e;
|
|
325
|
+
color: #4ec9b0;
|
|
326
|
+
}
|
|
327
|
+
</style>
|
|
328
|
+
</head>
|
|
329
|
+
<body>
|
|
330
|
+
<header>
|
|
331
|
+
<a href="/inbox.html" id="back-btn" class="back-btn">← Sessions</a>
|
|
332
|
+
<h1 id="session-title">Sema</h1>
|
|
333
|
+
<div id="status" class="status waiting">Connecting</div>
|
|
334
|
+
</header>
|
|
335
|
+
|
|
336
|
+
<div id="fingerprint-bar" class="fingerprint-bar">
|
|
337
|
+
<span>🔐</span>
|
|
338
|
+
<span id="fp-value" class="fp-value"></span>
|
|
339
|
+
<button id="fp-verify-btn" class="fp-verify-btn" onclick="verifyFingerprint()">Verify</button>
|
|
340
|
+
</div>
|
|
341
|
+
|
|
342
|
+
<div id="fp-dialog" class="fp-dialog" style="display:none">
|
|
343
|
+
<div class="fp-dialog-content">
|
|
344
|
+
<h3>Verify Connection Security</h3>
|
|
345
|
+
<p>Compare this fingerprint with the one shown on your Mac terminal:</p>
|
|
346
|
+
<div class="fp-display" id="fp-dialog-value"></div>
|
|
347
|
+
<p class="fp-instruction">If they match, tap Confirm. If not, disconnect immediately.</p>
|
|
348
|
+
<div class="fp-actions">
|
|
349
|
+
<button class="btn-danger" onclick="disconnectAndWarn()">Disconnect</button>
|
|
350
|
+
<button class="btn-primary" onclick="confirmFingerprint()">Confirm Match</button>
|
|
351
|
+
</div>
|
|
352
|
+
</div>
|
|
353
|
+
</div>
|
|
354
|
+
|
|
355
|
+
<div id="smart-alert" class="smart-alert">
|
|
356
|
+
<div class="smart-alert-header">
|
|
357
|
+
<span class="smart-alert-icon">⚡</span>
|
|
358
|
+
<span id="smart-alert-question" class="smart-alert-question"></span>
|
|
359
|
+
</div>
|
|
360
|
+
<div id="smart-alert-context" class="smart-alert-context"></div>
|
|
361
|
+
<div id="smart-alert-actions" class="smart-alert-actions"></div>
|
|
362
|
+
</div>
|
|
363
|
+
|
|
364
|
+
<div id="terminal-container">
|
|
365
|
+
<div id="terminal"></div>
|
|
366
|
+
</div>
|
|
367
|
+
|
|
368
|
+
<div class="quick-keys">
|
|
369
|
+
<button type="button" data-ctrl="c">Ctrl+C</button>
|
|
370
|
+
<button type="button" data-ctrl="d">Ctrl+D</button>
|
|
371
|
+
<button type="button" data-ctrl="z">Ctrl+Z</button>
|
|
372
|
+
<button type="button" data-ctrl="l">Ctrl+L</button>
|
|
373
|
+
<button type="button" data-special="tab">Tab</button>
|
|
374
|
+
<button type="button" data-special="up">↑</button>
|
|
375
|
+
<button type="button" data-special="down">↓</button>
|
|
376
|
+
<button type="button" data-special="left">←</button>
|
|
377
|
+
<button type="button" data-special="right">→</button>
|
|
378
|
+
<button type="button" data-special="escape">Esc</button>
|
|
379
|
+
</div>
|
|
380
|
+
|
|
381
|
+
<form id="command-form">
|
|
382
|
+
<input
|
|
383
|
+
id="command"
|
|
384
|
+
autocomplete="off"
|
|
385
|
+
autocapitalize="off"
|
|
386
|
+
spellcheck="false"
|
|
387
|
+
placeholder="Send to Mac"
|
|
388
|
+
/>
|
|
389
|
+
<button id="send" type="submit" disabled>Send</button>
|
|
390
|
+
</form>
|
|
391
|
+
|
|
392
|
+
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
|
|
393
|
+
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
|
|
394
|
+
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
|
|
395
|
+
<script>
|
|
396
|
+
// --- Session & token management ---
|
|
397
|
+
const params = new URLSearchParams(location.search);
|
|
398
|
+
const sessionId = params.get("sessionId");
|
|
399
|
+
|
|
400
|
+
// Multi-session storage helpers
|
|
401
|
+
function getStoredSessions() {
|
|
402
|
+
try {
|
|
403
|
+
return JSON.parse(localStorage.getItem("vc_sessions") || "{}");
|
|
404
|
+
} catch { return {}; }
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function storeSession(id, token, macPublicKey, command) {
|
|
408
|
+
const sessions = getStoredSessions();
|
|
409
|
+
sessions[id] = {
|
|
410
|
+
token: token,
|
|
411
|
+
macPublicKey: macPublicKey || null,
|
|
412
|
+
command: command || null,
|
|
413
|
+
pairedAt: Date.now(),
|
|
414
|
+
};
|
|
415
|
+
localStorage.setItem("vc_sessions", JSON.stringify(sessions));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function removeStoredSession(id) {
|
|
419
|
+
const sessions = getStoredSessions();
|
|
420
|
+
delete sessions[id];
|
|
421
|
+
localStorage.setItem("vc_sessions", JSON.stringify(sessions));
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const storedSessions = getStoredSessions();
|
|
425
|
+
let sessionToken = params.get("sessionToken")
|
|
426
|
+
|| (storedSessions[sessionId] ? storedSessions[sessionId].token : null)
|
|
427
|
+
|| sessionStorage.getItem("sessionToken")
|
|
428
|
+
|| null;
|
|
429
|
+
|
|
430
|
+
// If token came via URL, store in localStorage + sessionStorage and clean URL
|
|
431
|
+
if (params.get("sessionToken")) {
|
|
432
|
+
storeSession(sessionId, params.get("sessionToken"),
|
|
433
|
+
sessionStorage.getItem("macPublicKey"));
|
|
434
|
+
sessionStorage.setItem("sessionToken", params.get("sessionToken"));
|
|
435
|
+
params.delete("sessionToken");
|
|
436
|
+
const cleanUrl = params.toString()
|
|
437
|
+
? `${location.pathname}?${params}`
|
|
438
|
+
: location.pathname;
|
|
439
|
+
history.replaceState(null, "", cleanUrl);
|
|
440
|
+
sessionToken = sessionStorage.getItem("sessionToken")
|
|
441
|
+
|| (storedSessions[sessionId] ? storedSessions[sessionId].token : null);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// No session → redirect to inbox
|
|
445
|
+
if (!sessionId || !sessionToken) {
|
|
446
|
+
window.location.href = "/inbox.html";
|
|
447
|
+
throw new Error("redirecting");
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Set session title from stored command
|
|
451
|
+
const sessionInfo = storedSessions[sessionId];
|
|
452
|
+
const displayName = (sessionInfo && sessionInfo.command) || "Session";
|
|
453
|
+
document.getElementById("session-title").textContent = displayName;
|
|
454
|
+
document.title = displayName + " — Sema";
|
|
455
|
+
|
|
456
|
+
// Back button: close WebSocket before navigating
|
|
457
|
+
document.getElementById("back-btn").addEventListener("click", function(e) {
|
|
458
|
+
e.preventDefault();
|
|
459
|
+
if (typeof socket !== "undefined" && socket) {
|
|
460
|
+
socket.onclose = null; // prevent auto-reconnect
|
|
461
|
+
socket.close();
|
|
462
|
+
}
|
|
463
|
+
window.location.href = "/inbox.html";
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
const statusEl = document.getElementById("status");
|
|
467
|
+
const formEl = document.getElementById("command-form");
|
|
468
|
+
const commandEl = document.getElementById("command");
|
|
469
|
+
const sendEl = document.getElementById("send");
|
|
470
|
+
// --- Smart alert notification system ---
|
|
471
|
+
let audioCtx = null;
|
|
472
|
+
|
|
473
|
+
function getAudioContext() {
|
|
474
|
+
if (!audioCtx) {
|
|
475
|
+
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
|
476
|
+
}
|
|
477
|
+
return audioCtx;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function playAlertSound() {
|
|
481
|
+
try {
|
|
482
|
+
const ctx = getAudioContext();
|
|
483
|
+
const osc = ctx.createOscillator();
|
|
484
|
+
const gain = ctx.createGain();
|
|
485
|
+
osc.connect(gain);
|
|
486
|
+
gain.connect(ctx.destination);
|
|
487
|
+
osc.type = "sine";
|
|
488
|
+
osc.frequency.setValueAtTime(880, ctx.currentTime);
|
|
489
|
+
osc.frequency.setValueAtTime(1100, ctx.currentTime + 0.1);
|
|
490
|
+
gain.gain.setValueAtTime(0.3, ctx.currentTime);
|
|
491
|
+
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3);
|
|
492
|
+
osc.start(ctx.currentTime);
|
|
493
|
+
osc.stop(ctx.currentTime + 0.3);
|
|
494
|
+
} catch (e) {
|
|
495
|
+
// Audio not available
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function showSmartAlert(message) {
|
|
500
|
+
const card = document.getElementById("smart-alert");
|
|
501
|
+
const questionEl = document.getElementById("smart-alert-question");
|
|
502
|
+
const contextEl = document.getElementById("smart-alert-context");
|
|
503
|
+
const actionsEl = document.getElementById("smart-alert-actions");
|
|
504
|
+
|
|
505
|
+
questionEl.textContent = message.question || "Needs attention";
|
|
506
|
+
contextEl.textContent = message.context || "";
|
|
507
|
+
contextEl.style.display = message.context ? "block" : "none";
|
|
508
|
+
|
|
509
|
+
// Build action buttons
|
|
510
|
+
actionsEl.innerHTML = "";
|
|
511
|
+
|
|
512
|
+
const options = message.options || [];
|
|
513
|
+
for (const opt of options) {
|
|
514
|
+
const btn = document.createElement("button");
|
|
515
|
+
btn.type = "button";
|
|
516
|
+
btn.textContent = opt.label;
|
|
517
|
+
btn.addEventListener("click", () => {
|
|
518
|
+
// Debounce: disable all buttons immediately
|
|
519
|
+
actionsEl.querySelectorAll("button").forEach(b => { b.disabled = true; });
|
|
520
|
+
if (opt.data) {
|
|
521
|
+
sendToMac(opt.data);
|
|
522
|
+
} else {
|
|
523
|
+
commandEl.focus();
|
|
524
|
+
}
|
|
525
|
+
hideSmartAlert();
|
|
526
|
+
});
|
|
527
|
+
actionsEl.appendChild(btn);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Always add "Manual" button
|
|
531
|
+
const manualBtn = document.createElement("button");
|
|
532
|
+
manualBtn.type = "button";
|
|
533
|
+
manualBtn.className = "manual-btn";
|
|
534
|
+
manualBtn.textContent = "Manual";
|
|
535
|
+
manualBtn.addEventListener("click", () => {
|
|
536
|
+
hideSmartAlert();
|
|
537
|
+
commandEl.focus();
|
|
538
|
+
});
|
|
539
|
+
actionsEl.appendChild(manualBtn);
|
|
540
|
+
|
|
541
|
+
// Show card
|
|
542
|
+
card.classList.add("visible");
|
|
543
|
+
|
|
544
|
+
// Sound + vibration
|
|
545
|
+
if (navigator.vibrate) navigator.vibrate(200);
|
|
546
|
+
if (document.visibilityState === "visible") playAlertSound();
|
|
547
|
+
if (document.visibilityState === "hidden" && Notification.permission === "granted") {
|
|
548
|
+
new Notification("Sema", {
|
|
549
|
+
body: message.question || "Needs attention",
|
|
550
|
+
icon: "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚡</text></svg>",
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function hideSmartAlert() {
|
|
556
|
+
document.getElementById("smart-alert").classList.remove("visible");
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Request notification permission on first interaction
|
|
560
|
+
if ("Notification" in window && Notification.permission === "default") {
|
|
561
|
+
document.addEventListener("click", function requestPermission() {
|
|
562
|
+
Notification.requestPermission();
|
|
563
|
+
document.removeEventListener("click", requestPermission);
|
|
564
|
+
}, { once: true });
|
|
565
|
+
}
|
|
566
|
+
// --- End smart alert system ---
|
|
567
|
+
|
|
568
|
+
const term = new Terminal({
|
|
569
|
+
cursorBlink: true,
|
|
570
|
+
fontSize: 13,
|
|
571
|
+
fontFamily: '"SF Mono", Menlo, Consolas, monospace',
|
|
572
|
+
theme: {
|
|
573
|
+
background: "#080b0d",
|
|
574
|
+
foreground: "#e8e1d2",
|
|
575
|
+
cursor: "#d97852",
|
|
576
|
+
selectionBackground: "#3a444a",
|
|
577
|
+
},
|
|
578
|
+
allowProposedApi: true,
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
const fitAddon = new FitAddon.FitAddon();
|
|
582
|
+
const webLinksAddon = new WebLinksAddon.WebLinksAddon();
|
|
583
|
+
term.loadAddon(fitAddon);
|
|
584
|
+
term.loadAddon(webLinksAddon);
|
|
585
|
+
term.open(document.getElementById("terminal"));
|
|
586
|
+
fitAddon.fit();
|
|
587
|
+
|
|
588
|
+
let socket;
|
|
589
|
+
let reconnectTimer;
|
|
590
|
+
let authFailed = false;
|
|
591
|
+
|
|
592
|
+
// --- E2E Encryption ---
|
|
593
|
+
const macPublicKeyB64 = sessionStorage.getItem("macPublicKey")
|
|
594
|
+
|| (storedSessions[sessionId] ? storedSessions[sessionId].macPublicKey : null);
|
|
595
|
+
let aesKey = null; // CryptoKey for AES-GCM
|
|
596
|
+
let encryptionReady = false;
|
|
597
|
+
let mobileKeyPair = null; // CryptoKeyPair — persists across key_rotation
|
|
598
|
+
const deviceId = (() => {
|
|
599
|
+
let id = localStorage.getItem("sema_device_id");
|
|
600
|
+
if (!id) {
|
|
601
|
+
id = crypto.randomUUID();
|
|
602
|
+
localStorage.setItem("sema_device_id", id);
|
|
603
|
+
}
|
|
604
|
+
return id;
|
|
605
|
+
})();
|
|
606
|
+
|
|
607
|
+
// Generate mobile keypair and derive shared key with mac
|
|
608
|
+
async function setupEncryption() {
|
|
609
|
+
if (!macPublicKeyB64) {
|
|
610
|
+
console.log("[mobile] No macPublicKey, E2E disabled");
|
|
611
|
+
return false;
|
|
612
|
+
}
|
|
613
|
+
try {
|
|
614
|
+
// Import mac's public key
|
|
615
|
+
const macKeyBytes = Uint8Array.from(atob(macPublicKeyB64), c => c.charCodeAt(0));
|
|
616
|
+
const macPublicKey = await crypto.subtle.importKey(
|
|
617
|
+
"spki", macKeyBytes.buffer, "X25519", true, []
|
|
618
|
+
);
|
|
619
|
+
|
|
620
|
+
// Generate our keypair (module-level — reused for key_rotation)
|
|
621
|
+
mobileKeyPair = await crypto.subtle.generateKey("X25519", true, ["deriveBits"]);
|
|
622
|
+
|
|
623
|
+
// Derive shared secret (256 bits)
|
|
624
|
+
const sharedBits = await crypto.subtle.deriveBits(
|
|
625
|
+
{ name: "X25519", public: macPublicKey },
|
|
626
|
+
mobileKeyPair.privateKey,
|
|
627
|
+
256
|
|
628
|
+
);
|
|
629
|
+
|
|
630
|
+
// HKDF → AES-256 key
|
|
631
|
+
const hkdfKey = await crypto.subtle.importKey("raw", sharedBits, "HKDF", false, ["deriveKey"]);
|
|
632
|
+
aesKey = await crypto.subtle.deriveKey(
|
|
633
|
+
{
|
|
634
|
+
name: "HKDF",
|
|
635
|
+
hash: "SHA-256",
|
|
636
|
+
salt: new TextEncoder().encode("sema-e2e"),
|
|
637
|
+
info: new TextEncoder().encode("aes-256-key"),
|
|
638
|
+
},
|
|
639
|
+
hkdfKey,
|
|
640
|
+
{ name: "AES-GCM", length: 256 },
|
|
641
|
+
false,
|
|
642
|
+
["encrypt", "decrypt"]
|
|
643
|
+
);
|
|
644
|
+
|
|
645
|
+
// Export our public key for key_exchange
|
|
646
|
+
const pubKeySpki = await crypto.subtle.exportKey("spki", mobileKeyPair.publicKey);
|
|
647
|
+
window._mobilePublicKeyB64 = btoa(String.fromCharCode(...new Uint8Array(pubKeySpki)));
|
|
648
|
+
|
|
649
|
+
console.log("[mobile] E2E keys derived");
|
|
650
|
+
return true;
|
|
651
|
+
} catch (err) {
|
|
652
|
+
console.error("[mobile] E2E setup failed:", err);
|
|
653
|
+
return false;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// Encrypt plaintext → { iv, ct, tag } (base64)
|
|
658
|
+
async function encryptPayload(plaintext) {
|
|
659
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
660
|
+
const encoded = new TextEncoder().encode(plaintext);
|
|
661
|
+
const encrypted = await crypto.subtle.encrypt(
|
|
662
|
+
{ name: "AES-GCM", iv },
|
|
663
|
+
aesKey,
|
|
664
|
+
encoded
|
|
665
|
+
);
|
|
666
|
+
const bytes = new Uint8Array(encrypted);
|
|
667
|
+
// Web Crypto appends 16-byte tag to ciphertext
|
|
668
|
+
const ct = bytes.slice(0, bytes.length - 16);
|
|
669
|
+
const tag = bytes.slice(bytes.length - 16);
|
|
670
|
+
return {
|
|
671
|
+
iv: btoa(String.fromCharCode(...iv)),
|
|
672
|
+
ct: btoa(String.fromCharCode(...ct)),
|
|
673
|
+
tag: btoa(String.fromCharCode(...tag)),
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Decrypt { iv, ct, tag } → plaintext string
|
|
678
|
+
async function decryptPayload({ iv, ct, tag }) {
|
|
679
|
+
const ivBytes = Uint8Array.from(atob(iv), c => c.charCodeAt(0));
|
|
680
|
+
const ctBytes = Uint8Array.from(atob(ct), c => c.charCodeAt(0));
|
|
681
|
+
const tagBytes = Uint8Array.from(atob(tag), c => c.charCodeAt(0));
|
|
682
|
+
// Web Crypto expects ct || tag combined
|
|
683
|
+
const combined = new Uint8Array(ctBytes.length + tagBytes.length);
|
|
684
|
+
combined.set(ctBytes);
|
|
685
|
+
combined.set(tagBytes, ctBytes.length);
|
|
686
|
+
const decrypted = await crypto.subtle.decrypt(
|
|
687
|
+
{ name: "AES-GCM", iv: ivBytes },
|
|
688
|
+
aesKey,
|
|
689
|
+
combined
|
|
690
|
+
);
|
|
691
|
+
return new TextDecoder().decode(decrypted);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// Compute fingerprint from X25519 shared bits (must match Node.js computeFingerprint)
|
|
695
|
+
async function computeFingerprint(sharedBits) {
|
|
696
|
+
const prefix = new TextEncoder().encode("sema-fp");
|
|
697
|
+
const combined = new Uint8Array(prefix.length + sharedBits.byteLength);
|
|
698
|
+
combined.set(prefix);
|
|
699
|
+
combined.set(new Uint8Array(sharedBits), prefix.length);
|
|
700
|
+
const hash = await crypto.subtle.digest("SHA-256", combined);
|
|
701
|
+
const bytes = new Uint8Array(hash).slice(0, 6);
|
|
702
|
+
return Array.from(bytes).map(b => b.toString(16).padStart(2, "0")).join(":");
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function setStatus(label, className) {
|
|
706
|
+
statusEl.textContent = label;
|
|
707
|
+
statusEl.className = `status ${className || ""}`;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
async function sendToMac(data) {
|
|
711
|
+
if (!socket || socket.readyState !== WebSocket.OPEN) return;
|
|
712
|
+
if (encryptionReady) {
|
|
713
|
+
const payload = JSON.stringify({ type: "input", data });
|
|
714
|
+
const enc = await encryptPayload(payload);
|
|
715
|
+
socket.send(JSON.stringify({
|
|
716
|
+
type: "input",
|
|
717
|
+
sessionId,
|
|
718
|
+
deviceId,
|
|
719
|
+
...enc,
|
|
720
|
+
}));
|
|
721
|
+
} else {
|
|
722
|
+
socket.send(JSON.stringify({ type: "input", sessionId, data }));
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
async function sendResize() {
|
|
727
|
+
if (!socket || socket.readyState !== WebSocket.OPEN) return;
|
|
728
|
+
if (encryptionReady) {
|
|
729
|
+
const payload = JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows });
|
|
730
|
+
const enc = await encryptPayload(payload);
|
|
731
|
+
socket.send(JSON.stringify({
|
|
732
|
+
type: "resize",
|
|
733
|
+
sessionId,
|
|
734
|
+
deviceId,
|
|
735
|
+
...enc,
|
|
736
|
+
}));
|
|
737
|
+
} else {
|
|
738
|
+
socket.send(JSON.stringify({
|
|
739
|
+
type: "resize",
|
|
740
|
+
sessionId,
|
|
741
|
+
cols: term.cols,
|
|
742
|
+
rows: term.rows,
|
|
743
|
+
}));
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
term.onData((data) => {
|
|
748
|
+
// Filter out terminal DA responses from xterm.js.
|
|
749
|
+
// The Mac's tmux/zsh sends DA queries (\x1b[c, \x1b[>c) during init.
|
|
750
|
+
// xterm.js auto-responds — these must NOT be forwarded as shell input.
|
|
751
|
+
const isResponse =
|
|
752
|
+
/^\x1b\[\?[0-9;]*[a-z]$/i.test(data) ||
|
|
753
|
+
/^\x1b\[>[0-9;]*c$/.test(data) ||
|
|
754
|
+
/^\x1b\[[0-9;]*c$/.test(data);
|
|
755
|
+
if (!isResponse) {
|
|
756
|
+
sendToMac(data);
|
|
757
|
+
}
|
|
758
|
+
hideSmartAlert();
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
term.onResize(() => {
|
|
762
|
+
sendResize();
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
window.addEventListener("resize", () => {
|
|
766
|
+
fitAddon.fit();
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
document.querySelectorAll(".quick-keys button").forEach((btn) => {
|
|
770
|
+
btn.addEventListener("click", () => {
|
|
771
|
+
const ctrl = btn.dataset.ctrl;
|
|
772
|
+
const special = btn.dataset.special;
|
|
773
|
+
|
|
774
|
+
if (ctrl) {
|
|
775
|
+
// Ctrl+A=1, Ctrl+B=2, ..., Ctrl+Z=26
|
|
776
|
+
const code = ctrl.charCodeAt(0) - 96;
|
|
777
|
+
sendToMac(String.fromCharCode(code));
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
if (special) {
|
|
782
|
+
const map = {
|
|
783
|
+
tab: "\t",
|
|
784
|
+
up: "\x1b[A",
|
|
785
|
+
down: "\x1b[B",
|
|
786
|
+
left: "\x1b[D",
|
|
787
|
+
right: "\x1b[C",
|
|
788
|
+
escape: "\x1b",
|
|
789
|
+
};
|
|
790
|
+
if (map[special]) sendToMac(map[special]);
|
|
791
|
+
}
|
|
792
|
+
});
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
// --- Fingerprint verification ---
|
|
796
|
+
|
|
797
|
+
function showFingerprintDialog(fp, isMismatch) {
|
|
798
|
+
document.getElementById("fp-dialog-value").textContent = fp;
|
|
799
|
+
document.getElementById("fp-dialog").style.display = "flex";
|
|
800
|
+
const h3 = document.querySelector(".fp-dialog h3");
|
|
801
|
+
if (isMismatch) {
|
|
802
|
+
h3.textContent = "⚠ Fingerprint Changed";
|
|
803
|
+
} else {
|
|
804
|
+
h3.textContent = "Verify Connection Security";
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function showFingerprint(fp) {
|
|
809
|
+
const bar = document.getElementById("fingerprint-bar");
|
|
810
|
+
const fpValue = document.getElementById("fp-value");
|
|
811
|
+
const verifyBtn = document.getElementById("fp-verify-btn");
|
|
812
|
+
|
|
813
|
+
bar.style.display = "flex";
|
|
814
|
+
fpValue.textContent = fp;
|
|
815
|
+
|
|
816
|
+
const stored = getStoredSessions()[sessionId]?.verifiedFingerprint;
|
|
817
|
+
|
|
818
|
+
if (!stored) {
|
|
819
|
+
// First connection — show dialog, BLOCK input until user confirms
|
|
820
|
+
bar.className = "fingerprint-bar";
|
|
821
|
+
verifyBtn.textContent = "Verify";
|
|
822
|
+
verifyBtn.disabled = false;
|
|
823
|
+
showFingerprintDialog(fp, false);
|
|
824
|
+
// encryptionReady stays false — input disabled until confirmFingerprint()
|
|
825
|
+
} else if (stored === fp) {
|
|
826
|
+
// Matches stored fingerprint — auto-verified, unblock immediately
|
|
827
|
+
bar.className = "fingerprint-bar verified";
|
|
828
|
+
verifyBtn.textContent = "✓ Verified";
|
|
829
|
+
verifyBtn.disabled = true;
|
|
830
|
+
encryptionReady = true;
|
|
831
|
+
setStatus("Connected", "connected");
|
|
832
|
+
sendEl.disabled = false;
|
|
833
|
+
sendResize();
|
|
834
|
+
} else {
|
|
835
|
+
// Mismatch — BLOCK input and warn
|
|
836
|
+
bar.className = "fingerprint-bar mismatch";
|
|
837
|
+
verifyBtn.textContent = "⚠ Changed";
|
|
838
|
+
verifyBtn.disabled = false;
|
|
839
|
+
showFingerprintDialog(fp, true);
|
|
840
|
+
// encryptionReady stays false — input blocked
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function confirmFingerprint() {
|
|
845
|
+
const fp = document.getElementById("fp-value").textContent;
|
|
846
|
+
const sessions = getStoredSessions();
|
|
847
|
+
if (sessions[sessionId]) {
|
|
848
|
+
sessions[sessionId].verifiedFingerprint = fp;
|
|
849
|
+
localStorage.setItem("vc_sessions", JSON.stringify(sessions));
|
|
850
|
+
}
|
|
851
|
+
document.getElementById("fp-dialog").style.display = "none";
|
|
852
|
+
const bar = document.getElementById("fingerprint-bar");
|
|
853
|
+
bar.className = "fingerprint-bar verified";
|
|
854
|
+
document.getElementById("fp-verify-btn").textContent = "✓ Verified";
|
|
855
|
+
document.getElementById("fp-verify-btn").disabled = true;
|
|
856
|
+
|
|
857
|
+
// Unblock input — user has confirmed fingerprint match
|
|
858
|
+
encryptionReady = true;
|
|
859
|
+
setStatus("Connected", "connected");
|
|
860
|
+
sendEl.disabled = false;
|
|
861
|
+
sendResize();
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function verifyFingerprint() {
|
|
865
|
+
const fp = document.getElementById("fp-value").textContent;
|
|
866
|
+
if (fp) showFingerprintDialog(fp, false);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function disconnectAndWarn() {
|
|
870
|
+
document.getElementById("fp-dialog").style.display = "none";
|
|
871
|
+
if (socket) { socket.onclose = null; socket.close(); }
|
|
872
|
+
authFailed = true;
|
|
873
|
+
document.body.innerHTML = `
|
|
874
|
+
<div style="padding:40px;text-align:center;color:#f97583;min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center">
|
|
875
|
+
<h2>⚠ Security Warning</h2>
|
|
876
|
+
<p>Fingerprint mismatch detected. Connection may be intercepted.</p>
|
|
877
|
+
<p>Do NOT enter sensitive information.</p>
|
|
878
|
+
<a href="/inbox.html" style="color:#4ec9b0;margin-top:16px">← Back to Sessions</a>
|
|
879
|
+
</div>`;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// --- End fingerprint verification ---
|
|
883
|
+
|
|
884
|
+
async function connect() {
|
|
885
|
+
clearTimeout(reconnectTimer);
|
|
886
|
+
setStatus("Connecting", "waiting");
|
|
887
|
+
|
|
888
|
+
// Setup E2E encryption before connecting
|
|
889
|
+
if (!aesKey && macPublicKeyB64) {
|
|
890
|
+
await setupEncryption();
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
|
894
|
+
const url = `${protocol}//${location.host}/ws?role=mobile&sessionId=${encodeURIComponent(sessionId)}&sessionToken=${encodeURIComponent(sessionToken)}`;
|
|
895
|
+
socket = new WebSocket(url);
|
|
896
|
+
|
|
897
|
+
socket.addEventListener("open", () => {
|
|
898
|
+
sendEl.disabled = false;
|
|
899
|
+
fitAddon.fit();
|
|
900
|
+
|
|
901
|
+
// Send key_exchange if E2E is set up
|
|
902
|
+
if (aesKey && window._mobilePublicKeyB64) {
|
|
903
|
+
socket.send(JSON.stringify({
|
|
904
|
+
type: "key_exchange",
|
|
905
|
+
deviceId,
|
|
906
|
+
publicKey: window._mobilePublicKeyB64,
|
|
907
|
+
}));
|
|
908
|
+
setStatus("Handshake", "waiting");
|
|
909
|
+
} else {
|
|
910
|
+
// No E2E — immediately connected
|
|
911
|
+
setStatus("Connected", "connected");
|
|
912
|
+
encryptionReady = false;
|
|
913
|
+
sendResize();
|
|
914
|
+
}
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
socket.addEventListener("message", async (event) => {
|
|
918
|
+
const message = JSON.parse(event.data);
|
|
919
|
+
|
|
920
|
+
// Handle key_ack (Phase 1: confirms K_old works, wait for key_rotation)
|
|
921
|
+
if (message.type === "key_ack") {
|
|
922
|
+
try {
|
|
923
|
+
const plaintext = await decryptPayload(message);
|
|
924
|
+
const ack = JSON.parse(plaintext);
|
|
925
|
+
if (ack.type === "key_ack") {
|
|
926
|
+
setStatus("Rotating keys...", "waiting");
|
|
927
|
+
console.log("[mobile] key_ack received, awaiting key_rotation");
|
|
928
|
+
}
|
|
929
|
+
} catch (err) {
|
|
930
|
+
console.error("[mobile] key_ack decryption failed:", err);
|
|
931
|
+
setStatus("Crypto Error", "waiting");
|
|
932
|
+
}
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// Handle key_rotation (Phase 2: derive new key with mac ephemeral)
|
|
937
|
+
if (message.type === "key_rotation" && message.iv && message.ct && message.tag) {
|
|
938
|
+
try {
|
|
939
|
+
// Decrypt with K_old (current aesKey)
|
|
940
|
+
const plaintext = await decryptPayload(message);
|
|
941
|
+
const rotation = JSON.parse(plaintext);
|
|
942
|
+
|
|
943
|
+
if (rotation.type === "key_rotation" && rotation.publicKey && mobileKeyPair) {
|
|
944
|
+
// Import mac's ephemeral public key
|
|
945
|
+
const macEphBytes = Uint8Array.from(atob(rotation.publicKey), c => c.charCodeAt(0));
|
|
946
|
+
const macEphPub = await crypto.subtle.importKey(
|
|
947
|
+
"spki", macEphBytes.buffer, "X25519", true, []
|
|
948
|
+
);
|
|
949
|
+
|
|
950
|
+
// Derive new shared secret: DH(macEphPub, mobilePriv)
|
|
951
|
+
const newSharedBits = await crypto.subtle.deriveBits(
|
|
952
|
+
{ name: "X25519", public: macEphPub },
|
|
953
|
+
mobileKeyPair.privateKey,
|
|
954
|
+
256
|
|
955
|
+
);
|
|
956
|
+
|
|
957
|
+
// Derive new AES key via HKDF
|
|
958
|
+
const hkdfKey = await crypto.subtle.importKey("raw", newSharedBits, "HKDF", false, ["deriveKey"]);
|
|
959
|
+
const newAesKey = await crypto.subtle.deriveKey(
|
|
960
|
+
{
|
|
961
|
+
name: "HKDF", hash: "SHA-256",
|
|
962
|
+
salt: new TextEncoder().encode("sema-e2e"),
|
|
963
|
+
info: new TextEncoder().encode("aes-256-key"),
|
|
964
|
+
},
|
|
965
|
+
hkdfKey,
|
|
966
|
+
{ name: "AES-GCM", length: 256 },
|
|
967
|
+
false,
|
|
968
|
+
["encrypt", "decrypt"]
|
|
969
|
+
);
|
|
970
|
+
|
|
971
|
+
// Compute fingerprint from new shared secret
|
|
972
|
+
const fp = await computeFingerprint(newSharedBits);
|
|
973
|
+
|
|
974
|
+
// Switch to new key
|
|
975
|
+
aesKey = newAesKey;
|
|
976
|
+
|
|
977
|
+
// Send key_rot_ack encrypted with new key
|
|
978
|
+
const ackPayload = await encryptPayload(JSON.stringify({ type: "key_rot_ack" }));
|
|
979
|
+
socket.send(JSON.stringify({
|
|
980
|
+
type: "key_rot_ack",
|
|
981
|
+
sessionId,
|
|
982
|
+
deviceId,
|
|
983
|
+
...ackPayload,
|
|
984
|
+
}));
|
|
985
|
+
|
|
986
|
+
// Show fingerprint — encryptionReady set by showFingerprint or confirmFingerprint
|
|
987
|
+
showFingerprint(fp);
|
|
988
|
+
console.log("[mobile] key rotation complete, fingerprint:", fp);
|
|
989
|
+
}
|
|
990
|
+
} catch (err) {
|
|
991
|
+
console.error("[mobile] key_rotation failed:", err);
|
|
992
|
+
setStatus("Crypto Error", "waiting");
|
|
993
|
+
}
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// Decrypt output if E2E is active
|
|
998
|
+
if (message.type === "output" && message.iv && message.ct && message.tag) {
|
|
999
|
+
try {
|
|
1000
|
+
const plaintext = await decryptPayload(message);
|
|
1001
|
+
const decrypted = JSON.parse(plaintext);
|
|
1002
|
+
term.write(decrypted.data || "");
|
|
1003
|
+
} catch (err) {
|
|
1004
|
+
// Decryption failed (e.g., stale history with old key) — silently ignore
|
|
1005
|
+
console.debug("[mobile] output decrypt failed (stale?):", err.message);
|
|
1006
|
+
}
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// Plaintext output (no E2E or fallback)
|
|
1011
|
+
if (message.type === "output") {
|
|
1012
|
+
term.write(message.data || "");
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
if (message.type === "status") {
|
|
1017
|
+
// Don't override handshake status
|
|
1018
|
+
if (!encryptionReady && aesKey) return;
|
|
1019
|
+
setStatus(
|
|
1020
|
+
message.macConnected ? "Connected" : "No Mac",
|
|
1021
|
+
message.macConnected ? "connected" : "waiting",
|
|
1022
|
+
);
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
if (message.type === "error") {
|
|
1027
|
+
term.write(`\r\n\x1b[31m[Error] ${message.message}\x1b[0m\r\n`);
|
|
1028
|
+
if (message.message && (message.message.includes("expired") || message.message.includes("Invalid session token") || message.message.includes("Missing") || message.message.includes("not found"))) {
|
|
1029
|
+
authFailed = true;
|
|
1030
|
+
sessionStorage.removeItem("sessionToken");
|
|
1031
|
+
removeStoredSession(sessionId);
|
|
1032
|
+
setStatus("Session expired", "waiting");
|
|
1033
|
+
setTimeout(() => { location.href = "/inbox.html"; }, 2000);
|
|
1034
|
+
}
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
// Decrypt smart_alert if E2E is active
|
|
1039
|
+
if (message.type === "smart_alert" && message.iv && message.ct && message.tag) {
|
|
1040
|
+
try {
|
|
1041
|
+
const plaintext = await decryptPayload(message);
|
|
1042
|
+
const decrypted = JSON.parse(plaintext);
|
|
1043
|
+
showSmartAlert(decrypted);
|
|
1044
|
+
} catch (err) {
|
|
1045
|
+
console.error("[mobile] smart_alert decrypt failed:", err);
|
|
1046
|
+
}
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// Plaintext smart_alert
|
|
1051
|
+
if (message.type === "smart_alert") {
|
|
1052
|
+
showSmartAlert(message);
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// Legacy alert — no longer shown (quality gate)
|
|
1057
|
+
if (message.type === "alert") {
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
socket.addEventListener("close", () => {
|
|
1063
|
+
sendEl.disabled = true;
|
|
1064
|
+
encryptionReady = false;
|
|
1065
|
+
aesKey = null; // Force re-setupEncryption on reconnect (fresh DH)
|
|
1066
|
+
// mobileKeyPair preserved — reused for new DH exchange
|
|
1067
|
+
// Hide fingerprint bar on disconnect
|
|
1068
|
+
document.getElementById("fingerprint-bar").style.display = "none";
|
|
1069
|
+
if (authFailed) return;
|
|
1070
|
+
setStatus("Reconnecting", "waiting");
|
|
1071
|
+
reconnectTimer = setTimeout(connect, 1000);
|
|
1072
|
+
});
|
|
1073
|
+
|
|
1074
|
+
socket.addEventListener("error", () => {
|
|
1075
|
+
socket.close();
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
formEl.addEventListener("submit", (event) => {
|
|
1080
|
+
event.preventDefault();
|
|
1081
|
+
const command = commandEl.value;
|
|
1082
|
+
if (!command.trim() && command !== "") return;
|
|
1083
|
+
sendToMac(command + "\r");
|
|
1084
|
+
commandEl.value = "";
|
|
1085
|
+
commandEl.focus();
|
|
1086
|
+
hideSmartAlert();
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
connect();
|
|
1090
|
+
|
|
1091
|
+
</script>
|
|
1092
|
+
</body>
|
|
1093
|
+
</html>
|