Skip to content

Commit d8ff8ca

Browse files
alicup29claude
andcommitted
Re-add E2E encryption JS code stripped by linter
The linter repeatedly strips the E2E encryption code from app.js. Re-adds all required components: - Constructor state variables (_e2eSessionId, _e2ePrivateKey, etc.) - Web Crypto functions (keypair gen, ECDH, HKDF, AES-GCM encrypt/decrypt) - Key exchange orchestration with timeout + retry - SSE decryption handler in sseConnect() - Transparent encryption in apiWorkerMessage() - Key exchange triggers in sjEnterStep3() and sjGoToInferenceStep() Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 06f0671 commit d8ff8ca

File tree

1 file changed

+207
-3
lines changed

1 file changed

+207
-3
lines changed

dashboard/app.js

Lines changed: 207 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,12 @@ class SleapRTCDashboard {
145145

146146
// Active job tracking
147147
this.activeJobs = new Map(); // jobId → job state
148+
149+
// E2E encryption state (per-session, cleared on page refresh)
150+
this._e2eSessionId = null;
151+
this._e2ePrivateKey = null; // CryptoKey (P-256 ECDH)
152+
this._e2eSharedKey = null; // CryptoKey (AES-256-GCM)
153+
this._e2eReady = false;
148154
this._workerPollInterval = null;
149155

150156
this.init();
@@ -662,10 +668,25 @@ class SleapRTCDashboard {
662668
const url = `${CONFIG.RELAY_SERVER}/stream/${encodeURIComponent(channel)}`;
663669
const es = new EventSource(url);
664670
const handlers = {};
671+
const dashboard = this;
665672

666-
es.onmessage = (event) => {
673+
es.onmessage = async (event) => {
667674
let data;
668675
try { data = JSON.parse(event.data); } catch { return; }
676+
677+
// Handle encrypted relay messages — decrypt and re-dispatch
678+
if (data.type === 'encrypted_relay') {
679+
if (!dashboard._e2eReady || !dashboard._e2eSharedKey) {
680+
return; // No key yet, discard
681+
}
682+
if (data.session_id !== dashboard._e2eSessionId) {
683+
return; // Stale session, discard
684+
}
685+
const decrypted = await dashboard._e2eDecrypt(data.nonce, data.ciphertext);
686+
if (!decrypted) return; // Decryption failed, discard silently
687+
data = decrypted;
688+
}
689+
669690
const type = data.type;
670691
if (type && handlers[type]) handlers[type](data);
671692
if (handlers['*']) handlers['*'](data);
@@ -682,13 +703,173 @@ class SleapRTCDashboard {
682703
};
683704
}
684705

706+
// =========================================================================
707+
// E2E Encryption for Relay Transport
708+
// =========================================================================
709+
710+
async _e2eGenerateKeypair() {
711+
const keyPair = await crypto.subtle.generateKey(
712+
{ name: 'ECDH', namedCurve: 'P-256' },
713+
false, ['deriveBits']
714+
);
715+
const publicKeyRaw = await crypto.subtle.exportKey('raw', keyPair.publicKey);
716+
return { privateKey: keyPair.privateKey, publicKeyRaw };
717+
}
718+
719+
async _e2eDeriveKey(privateKey, peerPublicKeyRaw) {
720+
const peerPublicKey = await crypto.subtle.importKey(
721+
'raw', peerPublicKeyRaw,
722+
{ name: 'ECDH', namedCurve: 'P-256' },
723+
false, []
724+
);
725+
const sharedBits = await crypto.subtle.deriveBits(
726+
{ name: 'ECDH', public: peerPublicKey },
727+
privateKey, 256
728+
);
729+
const hkdfKey = await crypto.subtle.importKey(
730+
'raw', sharedBits, 'HKDF', false, ['deriveKey']
731+
);
732+
return crypto.subtle.deriveKey(
733+
{
734+
name: 'HKDF',
735+
hash: 'SHA-256',
736+
salt: new Uint8Array(0),
737+
info: new TextEncoder().encode('sleap-rtc-relay-e2e-v1'),
738+
},
739+
hkdfKey,
740+
{ name: 'AES-GCM', length: 256 },
741+
false, ['encrypt', 'decrypt']
742+
);
743+
}
744+
745+
async _e2eEncrypt(payload) {
746+
const nonce = crypto.getRandomValues(new Uint8Array(12));
747+
const plaintext = new TextEncoder().encode(JSON.stringify(payload));
748+
const ciphertext = new Uint8Array(await crypto.subtle.encrypt(
749+
{ name: 'AES-GCM', iv: nonce },
750+
this._e2eSharedKey, plaintext
751+
));
752+
return {
753+
nonce: btoa(String.fromCharCode(...nonce)),
754+
ciphertext: btoa(String.fromCharCode(...ciphertext)),
755+
};
756+
}
757+
758+
async _e2eDecrypt(nonceB64, ciphertextB64) {
759+
try {
760+
const nonceBin = atob(nonceB64);
761+
const nonce = new Uint8Array(nonceBin.length);
762+
for (let i = 0; i < nonceBin.length; i++) nonce[i] = nonceBin.charCodeAt(i);
763+
const ctBin = atob(ciphertextB64);
764+
const ct = new Uint8Array(ctBin.length);
765+
for (let i = 0; i < ctBin.length; i++) ct[i] = ctBin.charCodeAt(i);
766+
const plaintext = await crypto.subtle.decrypt(
767+
{ name: 'AES-GCM', iv: nonce },
768+
this._e2eSharedKey, ct
769+
);
770+
return JSON.parse(new TextDecoder().decode(plaintext));
771+
} catch (e) {
772+
console.warn('[E2E] Decryption failed:', e.message);
773+
return null;
774+
}
775+
}
776+
777+
async _e2eInitKeyExchange(peerId) {
778+
this._e2eReady = false;
779+
this._e2eSharedKey = null;
780+
this._e2eSessionId = crypto.randomUUID();
781+
782+
const { privateKey, publicKeyRaw } = await this._e2eGenerateKeypair();
783+
this._e2ePrivateKey = privateKey;
784+
785+
const pubBytes = new Uint8Array(publicKeyRaw);
786+
const pubB64 = btoa(String.fromCharCode(...pubBytes))
787+
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
788+
789+
const attempt = async () => {
790+
return new Promise((resolve, reject) => {
791+
const timeout = setTimeout(() => {
792+
reject(new Error('Key exchange timeout'));
793+
}, 5000);
794+
795+
const originalHandler = this._sjWorkerSSE?.raw?.onmessage;
796+
const wrappedHandler = async (event) => {
797+
let data;
798+
try { data = JSON.parse(event.data); } catch { return; }
799+
800+
if (data.type === 'key_exchange_response' &&
801+
data.session_id === this._e2eSessionId) {
802+
clearTimeout(timeout);
803+
804+
let workerPubB64 = data.public_key
805+
.replace(/-/g, '+').replace(/_/g, '/');
806+
while (workerPubB64.length % 4) workerPubB64 += '=';
807+
const workerPubBin = atob(workerPubB64);
808+
const workerPubBytes = new Uint8Array(workerPubBin.length);
809+
for (let i = 0; i < workerPubBin.length; i++)
810+
workerPubBytes[i] = workerPubBin.charCodeAt(i);
811+
812+
try {
813+
this._e2eSharedKey = await this._e2eDeriveKey(
814+
this._e2ePrivateKey, workerPubBytes.buffer
815+
);
816+
this._e2eReady = true;
817+
console.log(`[E2E] Key exchange complete (session ${this._e2eSessionId.slice(0, 8)}...)`);
818+
resolve(true);
819+
} catch (e) {
820+
reject(e);
821+
}
822+
}
823+
824+
if (originalHandler) originalHandler(event);
825+
};
826+
827+
if (this._sjWorkerSSE?.raw) {
828+
this._sjWorkerSSE.raw.onmessage = wrappedHandler;
829+
}
830+
831+
this.apiWorkerMessage(this._sjRoomId, peerId, {
832+
type: 'key_exchange',
833+
session_id: this._e2eSessionId,
834+
public_key: pubB64,
835+
}).catch(reject);
836+
});
837+
};
838+
839+
for (let i = 0; i < 2; i++) {
840+
try {
841+
await attempt();
842+
return true;
843+
} catch (e) {
844+
console.warn(`[E2E] Key exchange attempt ${i + 1} failed: ${e.message}`);
845+
if (i === 0) continue;
846+
}
847+
}
848+
849+
console.error('[E2E] Key exchange failed after 2 attempts');
850+
return false;
851+
}
852+
685853
/**
686854
* Forward an arbitrary message to a worker via the signaling server.
855+
* If E2E encryption is active, the message is encrypted before sending.
687856
*/
688857
async apiWorkerMessage(roomId, peerId, message) {
858+
let outMessage = message;
859+
860+
if (this._e2eReady && this._e2eSharedKey && message.type !== 'key_exchange') {
861+
const { nonce, ciphertext } = await this._e2eEncrypt(message);
862+
outMessage = {
863+
type: 'encrypted_relay',
864+
session_id: this._e2eSessionId,
865+
nonce,
866+
ciphertext,
867+
};
868+
}
869+
689870
return this.apiRequest('/api/worker/message', {
690871
method: 'POST',
691-
body: JSON.stringify({ room_id: roomId, peer_id: peerId, message }),
872+
body: JSON.stringify({ room_id: roomId, peer_id: peerId, message: outMessage }),
692873
});
693874
}
694875

@@ -2490,7 +2671,7 @@ class SleapRTCDashboard {
24902671
}
24912672
}
24922673

2493-
sjGoToInferenceStep() {
2674+
async sjGoToInferenceStep() {
24942675
// Hide all views
24952676
['sj-step1', 'sj-step2', 'sj-step3', 'sj-status'].forEach(id => {
24962677
document.getElementById(id)?.classList.add('hidden');
@@ -2525,6 +2706,17 @@ class SleapRTCDashboard {
25252706
.on('worker_path_ok', (data) => this._sjHandlePathOk(data))
25262707
.on('worker_path_error', (data) => this._sjHandlePathError(data));
25272708

2709+
// E2E key exchange with worker
2710+
const inferenceError = document.getElementById('sj-inference-error');
2711+
const e2eOk = await this._e2eInitKeyExchange(this._sjWorkerId);
2712+
if (!e2eOk) {
2713+
if (inferenceError) {
2714+
inferenceError.textContent = 'Could not establish secure connection with worker. The worker may need to be updated.';
2715+
inferenceError.classList.remove('hidden');
2716+
}
2717+
return;
2718+
}
2719+
25282720
// Init icons
25292721
const inferenceView = document.getElementById('sj-step-inference');
25302722
if (window.lucide && inferenceView) {
@@ -3435,6 +3627,18 @@ class SleapRTCDashboard {
34353627
.on('worker_path_error', (data) => this._sjHandlePathError(data))
34363628
.on('fs_check_videos_response', (data) => this._sjHandleVideoCheck(data));
34373629

3630+
// E2E key exchange with worker
3631+
const e2eOk = await this._e2eInitKeyExchange(this._sjWorkerId);
3632+
if (!e2eOk) {
3633+
document.getElementById('sj-browser-spinner')?.classList.add('hidden');
3634+
const errEl = document.getElementById('sj-browser-error');
3635+
if (errEl) {
3636+
errEl.textContent = 'Could not establish secure connection with worker. The worker may need to be updated.';
3637+
errEl.classList.remove('hidden');
3638+
}
3639+
return;
3640+
}
3641+
34383642
// Re-fetch worker data to get fresh metadata (mounts may change after reconnection)
34393643
await this.loadRoomWorkers(this._sjRoomId);
34403644
const workers = this.roomWorkers[this._sjRoomId]?.workers ?? [];

0 commit comments

Comments
 (0)