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