@@ -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 ?? [ ] ;
0 commit comments