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.
@@ -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>