@@ -147,12 +147,6 @@ 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-
156150 this . init ( ) ;
157151 }
158152
@@ -668,25 +662,10 @@ class SleapRTCDashboard {
668662 const url = `${ CONFIG . RELAY_SERVER } /stream/${ encodeURIComponent ( channel ) } ` ;
669663 const es = new EventSource ( url ) ;
670664 const handlers = { } ;
671- const dashboard = this ;
672665
673- es . onmessage = async ( event ) => {
666+ es . onmessage = ( event ) => {
674667 let data ;
675668 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-
690669 const type = data . type ;
691670 if ( type && handlers [ type ] ) handlers [ type ] ( data ) ;
692671 if ( handlers [ '*' ] ) handlers [ '*' ] ( data ) ;
@@ -703,229 +682,18 @@ class SleapRTCDashboard {
703682 } ;
704683 }
705684
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-
902685 /**
903686 * Forward an arbitrary message to a worker via the signaling server.
904- * If E2E encryption is active, the message is encrypted before sending.
905687 */
906688 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-
920689 return this . apiRequest ( '/api/worker/message' , {
921690 method : 'POST' ,
922- body : JSON . stringify ( { room_id : roomId , peer_id : peerId , message : outMessage } ) ,
691+ body : JSON . stringify ( { room_id : roomId , peer_id : peerId , message } ) ,
923692 } ) ;
924693 }
925694
926695 /**
927696 * Request a directory listing from a worker's filesystem.
928- * When E2E is active, routes through apiWorkerMessage for encryption.
929697 */
930698 async apiFsList ( roomId , peerId , path , reqId , offset = 0 ) {
931699 if ( this . _e2eReady && this . _e2eSharedKey ) {
@@ -941,13 +709,17 @@ class SleapRTCDashboard {
941709
942710 /**
943711 * Submit a training job to a worker.
944- * When E2E is active, routes through apiWorkerMessage for encryption.
712+ * When E2E is active, generates job_id client-side and routes through
713+ * apiWorkerMessage. The dedicated endpoint can't be used because the
714+ * signaling server would see the config in plaintext.
945715 */
946716 async apiJobSubmit ( roomId , peerId , config ) {
947717 if ( this . _e2eReady && this . _e2eSharedKey ) {
948- return this . apiWorkerMessage ( roomId , peerId , {
949- type : 'job_assigned' , config,
718+ const jobId = `job_${ crypto . randomUUID ( ) . replace ( / - / g, '' ) . slice ( 0 , 8 ) } ` ;
719+ await this . apiWorkerMessage ( roomId , peerId , {
720+ type : 'job_assigned' , job_id : jobId , config,
950721 } ) ;
722+ return { job_id : jobId } ;
951723 }
952724 return this . apiRequest ( '/api/jobs/submit' , {
953725 method : 'POST' ,
@@ -2718,7 +2490,7 @@ class SleapRTCDashboard {
27182490 }
27192491 }
27202492
2721- async sjGoToInferenceStep ( ) {
2493+ sjGoToInferenceStep ( ) {
27222494 // Hide all views
27232495 [ 'sj-step1' , 'sj-step2' , 'sj-step3' , 'sj-status' ] . forEach ( id => {
27242496 document . getElementById ( id ) ?. classList . add ( 'hidden' ) ;
@@ -2753,17 +2525,6 @@ class SleapRTCDashboard {
27532525 . on ( 'worker_path_ok' , ( data ) => this . _sjHandlePathOk ( data ) )
27542526 . on ( 'worker_path_error' , ( data ) => this . _sjHandlePathError ( data ) ) ;
27552527
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-
27672528 // Init icons
27682529 const inferenceView = document . getElementById ( 'sj-step-inference' ) ;
27692530 if ( window . lucide && inferenceView ) {
@@ -3674,18 +3435,6 @@ class SleapRTCDashboard {
36743435 . on ( 'worker_path_error' , ( data ) => this . _sjHandlePathError ( data ) )
36753436 . on ( 'fs_check_videos_response' , ( data ) => this . _sjHandleVideoCheck ( data ) ) ;
36763437
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-
36893438 // Re-fetch worker data to get fresh metadata (mounts may change after reconnection)
36903439 await this . loadRoomWorkers ( this . _sjRoomId ) ;
36913440 const workers = this . roomWorkers [ this . _sjRoomId ] ?. workers ?? [ ] ;
0 commit comments