Skip to content

Commit 6baa7ed

Browse files
alicup29claude
andcommitted
Add E2E encryption for relay transport (ECDH P-256 + AES-256-GCM)
Encrypts all relay messages between dashboard and workers so the signaling server cannot read message payloads. Uses ephemeral ECDH P-256 key exchange + HKDF + AES-256-GCM with zero new dependencies. Python (worker): - New sleap_rtc/encryption/ module (ecdh.py, envelope.py) - mesh_coordinator: key exchange handler, decrypt incoming, encrypt outgoing - RelayChannel.send() encrypts job status/progress when E2E session active - Session key storage with 24h pruning JavaScript (dashboard): - Web Crypto API: ECDH P-256 key generation, HKDF, AES-GCM encrypt/decrypt - Key exchange initiated on "Next →" (sjEnterStep3 / sjGoToInferenceStep) - apiWorkerMessage() transparently encrypts when E2E is ready - apiFsList/apiJobSubmit rerouted through encrypted path - sseConnect() decrypts encrypted_relay events and re-dispatches Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent adb08fc commit 6baa7ed

File tree

8 files changed

+1144
-61
lines changed

8 files changed

+1144
-61
lines changed

dashboard/app.js

Lines changed: 269 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,12 @@ class SleapRTCDashboard {
147147
this.activeJobs = new Map(); // jobId → job state
148148
this._workerPollInterval = null;
149149

150+
// E2E encryption state (per-session, cleared on page refresh)
151+
this._e2eSessionId = null;
152+
this._e2ePrivateKey = null; // CryptoKey (P-256 ECDH)
153+
this._e2eSharedKey = null; // CryptoKey (AES-256-GCM)
154+
this._e2eReady = false;
155+
150156
this.init();
151157
}
152158

@@ -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,20 +703,236 @@ class SleapRTCDashboard {
682703
};
683704
}
684705

706+
// =========================================================================
707+
// E2E Encryption for Relay Transport
708+
// =========================================================================
709+
710+
/**
711+
* Generate an ephemeral ECDH P-256 keypair for E2E encryption.
712+
* @returns {Promise<{privateKey: CryptoKey, publicKeyRaw: ArrayBuffer}>}
713+
*/
714+
async _e2eGenerateKeypair() {
715+
const keyPair = await crypto.subtle.generateKey(
716+
{ name: 'ECDH', namedCurve: 'P-256' },
717+
false, // private key not extractable
718+
['deriveBits']
719+
);
720+
const publicKeyRaw = await crypto.subtle.exportKey('raw', keyPair.publicKey);
721+
return { privateKey: keyPair.privateKey, publicKeyRaw };
722+
}
723+
724+
/**
725+
* Derive an AES-256-GCM key from ECDH shared secret + HKDF.
726+
* Parameters must match Python side exactly.
727+
* @param {CryptoKey} privateKey - Our P-256 private key
728+
* @param {ArrayBuffer} peerPublicKeyRaw - Peer's raw public key (65 bytes)
729+
* @returns {Promise<CryptoKey>} AES-256-GCM key
730+
*/
731+
async _e2eDeriveKey(privateKey, peerPublicKeyRaw) {
732+
const peerPublicKey = await crypto.subtle.importKey(
733+
'raw', peerPublicKeyRaw,
734+
{ name: 'ECDH', namedCurve: 'P-256' },
735+
false, []
736+
);
737+
738+
// ECDH → shared secret
739+
const sharedBits = await crypto.subtle.deriveBits(
740+
{ name: 'ECDH', public: peerPublicKey },
741+
privateKey, 256
742+
);
743+
744+
// Import as HKDF key material
745+
const hkdfKey = await crypto.subtle.importKey(
746+
'raw', sharedBits, 'HKDF', false, ['deriveKey']
747+
);
748+
749+
// HKDF → AES-256-GCM key (must match Python: SHA-256, no salt, info="sleap-rtc-relay-e2e-v1")
750+
return crypto.subtle.deriveKey(
751+
{
752+
name: 'HKDF',
753+
hash: 'SHA-256',
754+
salt: new Uint8Array(0),
755+
info: new TextEncoder().encode('sleap-rtc-relay-e2e-v1'),
756+
},
757+
hkdfKey,
758+
{ name: 'AES-GCM', length: 256 },
759+
false, ['encrypt', 'decrypt']
760+
);
761+
}
762+
763+
/**
764+
* Encrypt a JSON payload with AES-256-GCM.
765+
* @param {object} payload - JSON-serializable object
766+
* @returns {Promise<{nonce: string, ciphertext: string}>} Base64-encoded nonce and ciphertext
767+
*/
768+
async _e2eEncrypt(payload) {
769+
const nonce = crypto.getRandomValues(new Uint8Array(12));
770+
const plaintext = new TextEncoder().encode(JSON.stringify(payload));
771+
const ciphertext = new Uint8Array(await crypto.subtle.encrypt(
772+
{ name: 'AES-GCM', iv: nonce },
773+
this._e2eSharedKey, plaintext
774+
));
775+
return {
776+
nonce: btoa(String.fromCharCode(...nonce)),
777+
ciphertext: btoa(String.fromCharCode(...ciphertext)),
778+
};
779+
}
780+
781+
/**
782+
* Decrypt an AES-256-GCM ciphertext back to a JSON object.
783+
* @param {string} nonceB64 - Base64-encoded nonce
784+
* @param {string} ciphertextB64 - Base64-encoded ciphertext
785+
* @returns {Promise<object|null>} Decrypted JSON object, or null on failure
786+
*/
787+
async _e2eDecrypt(nonceB64, ciphertextB64) {
788+
try {
789+
const nonceBin = atob(nonceB64);
790+
const nonce = new Uint8Array(nonceBin.length);
791+
for (let i = 0; i < nonceBin.length; i++) nonce[i] = nonceBin.charCodeAt(i);
792+
793+
const ctBin = atob(ciphertextB64);
794+
const ct = new Uint8Array(ctBin.length);
795+
for (let i = 0; i < ctBin.length; i++) ct[i] = ctBin.charCodeAt(i);
796+
797+
const plaintext = await crypto.subtle.decrypt(
798+
{ name: 'AES-GCM', iv: nonce },
799+
this._e2eSharedKey, ct
800+
);
801+
return JSON.parse(new TextDecoder().decode(plaintext));
802+
} catch (e) {
803+
console.warn('[E2E] Decryption failed:', e.message);
804+
return null;
805+
}
806+
}
807+
808+
/**
809+
* Initiate E2E key exchange with a worker. Called from sjEnterStep3() and
810+
* sjGoToInferenceStep() after opening the SSE connection.
811+
*
812+
* Sends our ephemeral public key, waits for the worker's response on SSE,
813+
* derives the shared AES key. Retries once on timeout (5s).
814+
*
815+
* @param {string} peerId - Worker peer ID
816+
* @returns {Promise<boolean>} True if key exchange succeeded
817+
*/
818+
async _e2eInitKeyExchange(peerId) {
819+
this._e2eReady = false;
820+
this._e2eSharedKey = null;
821+
this._e2eSessionId = crypto.randomUUID();
822+
823+
const { privateKey, publicKeyRaw } = await this._e2eGenerateKeypair();
824+
this._e2ePrivateKey = privateKey;
825+
826+
// Encode public key as URL-safe base64 (no padding)
827+
const pubBytes = new Uint8Array(publicKeyRaw);
828+
let pubB64 = btoa(String.fromCharCode(...pubBytes))
829+
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
830+
831+
const attempt = async () => {
832+
return new Promise((resolve, reject) => {
833+
const timeout = setTimeout(() => {
834+
reject(new Error('Key exchange timeout'));
835+
}, 5000);
836+
837+
// Listen for key_exchange_response on existing SSE
838+
const originalHandler = this._sjWorkerSSE?.raw?.onmessage;
839+
const wrappedHandler = async (event) => {
840+
let data;
841+
try { data = JSON.parse(event.data); } catch { return; }
842+
843+
if (data.type === 'key_exchange_response' &&
844+
data.session_id === this._e2eSessionId) {
845+
clearTimeout(timeout);
846+
847+
// Decode worker's public key
848+
let workerPubB64 = data.public_key
849+
.replace(/-/g, '+').replace(/_/g, '/');
850+
while (workerPubB64.length % 4) workerPubB64 += '=';
851+
const workerPubBin = atob(workerPubB64);
852+
const workerPubBytes = new Uint8Array(workerPubBin.length);
853+
for (let i = 0; i < workerPubBin.length; i++)
854+
workerPubBytes[i] = workerPubBin.charCodeAt(i);
855+
856+
// Derive shared key
857+
try {
858+
this._e2eSharedKey = await this._e2eDeriveKey(
859+
this._e2ePrivateKey, workerPubBytes.buffer
860+
);
861+
this._e2eReady = true;
862+
console.log(`[E2E] Key exchange complete (session ${this._e2eSessionId.slice(0, 8)}...)`);
863+
resolve(true);
864+
} catch (e) {
865+
reject(e);
866+
}
867+
}
868+
869+
// Also call original handler for other message types
870+
if (originalHandler) originalHandler(event);
871+
};
872+
873+
// Temporarily wrap the SSE handler to intercept key_exchange_response
874+
if (this._sjWorkerSSE?.raw) {
875+
this._sjWorkerSSE.raw.onmessage = wrappedHandler;
876+
}
877+
878+
// Send key exchange request
879+
this.apiWorkerMessage(this._sjRoomId, peerId, {
880+
type: 'key_exchange',
881+
session_id: this._e2eSessionId,
882+
public_key: pubB64,
883+
}).catch(reject);
884+
});
885+
};
886+
887+
// Try twice
888+
for (let i = 0; i < 2; i++) {
889+
try {
890+
await attempt();
891+
return true;
892+
} catch (e) {
893+
console.warn(`[E2E] Key exchange attempt ${i + 1} failed: ${e.message}`);
894+
if (i === 0) continue; // Retry once
895+
}
896+
}
897+
898+
console.error('[E2E] Key exchange failed after 2 attempts');
899+
return false;
900+
}
901+
685902
/**
686903
* Forward an arbitrary message to a worker via the signaling server.
904+
* If E2E encryption is active, the message is encrypted before sending.
687905
*/
688906
async apiWorkerMessage(roomId, peerId, message) {
907+
let outMessage = message;
908+
909+
// Encrypt if E2E is ready (but not the key_exchange itself)
910+
if (this._e2eReady && this._e2eSharedKey && message.type !== 'key_exchange') {
911+
const { nonce, ciphertext } = await this._e2eEncrypt(message);
912+
outMessage = {
913+
type: 'encrypted_relay',
914+
session_id: this._e2eSessionId,
915+
nonce,
916+
ciphertext,
917+
};
918+
}
919+
689920
return this.apiRequest('/api/worker/message', {
690921
method: 'POST',
691-
body: JSON.stringify({ room_id: roomId, peer_id: peerId, message }),
922+
body: JSON.stringify({ room_id: roomId, peer_id: peerId, message: outMessage }),
692923
});
693924
}
694925

695926
/**
696927
* Request a directory listing from a worker's filesystem.
928+
* When E2E is active, routes through apiWorkerMessage for encryption.
697929
*/
698930
async apiFsList(roomId, peerId, path, reqId, offset = 0) {
931+
if (this._e2eReady && this._e2eSharedKey) {
932+
return this.apiWorkerMessage(roomId, peerId, {
933+
type: 'fs_list_req', path, req_id: reqId, offset,
934+
});
935+
}
699936
return this.apiRequest('/api/fs/list', {
700937
method: 'POST',
701938
body: JSON.stringify({ room_id: roomId, peer_id: peerId, path, req_id: reqId, offset }),
@@ -704,8 +941,14 @@ class SleapRTCDashboard {
704941

705942
/**
706943
* Submit a training job to a worker.
944+
* When E2E is active, routes through apiWorkerMessage for encryption.
707945
*/
708946
async apiJobSubmit(roomId, peerId, config) {
947+
if (this._e2eReady && this._e2eSharedKey) {
948+
return this.apiWorkerMessage(roomId, peerId, {
949+
type: 'job_assigned', config,
950+
});
951+
}
709952
return this.apiRequest('/api/jobs/submit', {
710953
method: 'POST',
711954
body: JSON.stringify({ room_id: roomId, peer_id: peerId, config }),
@@ -2475,7 +2718,7 @@ class SleapRTCDashboard {
24752718
}
24762719
}
24772720

2478-
sjGoToInferenceStep() {
2721+
async sjGoToInferenceStep() {
24792722
// Hide all views
24802723
['sj-step1', 'sj-step2', 'sj-step3', 'sj-status'].forEach(id => {
24812724
document.getElementById(id)?.classList.add('hidden');
@@ -2510,6 +2753,17 @@ class SleapRTCDashboard {
25102753
.on('worker_path_ok', (data) => this._sjHandlePathOk(data))
25112754
.on('worker_path_error', (data) => this._sjHandlePathError(data));
25122755

2756+
// E2E key exchange with worker
2757+
const inferenceError = document.getElementById('sj-inference-error');
2758+
const e2eOk = await this._e2eInitKeyExchange(this._sjWorkerId);
2759+
if (!e2eOk) {
2760+
if (inferenceError) {
2761+
inferenceError.textContent = 'Could not establish secure connection with worker. The worker may need to be updated.';
2762+
inferenceError.classList.remove('hidden');
2763+
}
2764+
return;
2765+
}
2766+
25132767
// Init icons
25142768
const inferenceView = document.getElementById('sj-step-inference');
25152769
if (window.lucide && inferenceView) {
@@ -3420,6 +3674,18 @@ class SleapRTCDashboard {
34203674
.on('worker_path_error', (data) => this._sjHandlePathError(data))
34213675
.on('fs_check_videos_response', (data) => this._sjHandleVideoCheck(data));
34223676

3677+
// E2E key exchange with worker
3678+
const e2eOk = await this._e2eInitKeyExchange(this._sjWorkerId);
3679+
if (!e2eOk) {
3680+
document.getElementById('sj-browser-spinner')?.classList.add('hidden');
3681+
const errEl = document.getElementById('sj-browser-error');
3682+
if (errEl) {
3683+
errEl.textContent = 'Could not establish secure connection with worker. The worker may need to be updated.';
3684+
errEl.classList.remove('hidden');
3685+
}
3686+
return;
3687+
}
3688+
34233689
// Re-fetch worker data to get fresh metadata (mounts may change after reconnection)
34243690
await this.loadRoomWorkers(this._sjRoomId);
34253691
const workers = this.roomWorkers[this._sjRoomId]?.workers ?? [];

sleap_rtc/encryption/__init__.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""E2E encryption for the relay transport path.
2+
3+
Provides ECDH P-256 key exchange and AES-256-GCM message encryption so that
4+
relay messages between dashboard/sleap-app clients and workers are encrypted
5+
end-to-end. The signaling server can route messages but cannot read payloads.
6+
"""
7+
8+
from sleap_rtc.encryption.ecdh import (
9+
decrypt,
10+
derive_shared_key,
11+
encrypt,
12+
generate_keypair,
13+
public_key_from_b64,
14+
public_key_to_b64,
15+
)
16+
from sleap_rtc.encryption.envelope import ENCRYPTED_RELAY_TYPE, unwrap, wrap
17+
18+
__all__ = [
19+
"ENCRYPTED_RELAY_TYPE",
20+
"decrypt",
21+
"derive_shared_key",
22+
"encrypt",
23+
"generate_keypair",
24+
"public_key_from_b64",
25+
"public_key_to_b64",
26+
"unwrap",
27+
"wrap",
28+
]

0 commit comments

Comments
 (0)