From 68bb6f1d2cd3a43dda42237bac07bd543c0bc574 Mon Sep 17 00:00:00 2001 From: Zoe <62722391+juls0730@users.noreply.github.com> Date: Fri, 5 Sep 2025 01:59:07 -0500 Subject: [PATCH] better E2E encryption, nicer UI, bug fixes, more --- server/websocketHandler.ts | 19 ++-- src/components/RTCMessage.svelte | 125 ++++++++++++++++++----- src/lib/webrtc.ts | 166 +++++++++++++++++-------------- src/routes/+page.svelte | 26 +++-- src/routes/[roomId]/+page.svelte | 36 +++++-- src/shared/keyConfig.ts | 6 ++ src/stores/roomStore.ts | 12 ++- src/stores/websocketStore.ts | 133 +++++++++++++++++++++++-- src/types/websocket.ts | 3 - src/utils/webrtcUtil.ts | 61 ++++-------- 10 files changed, 407 insertions(+), 180 deletions(-) create mode 100644 src/shared/keyConfig.ts diff --git a/server/websocketHandler.ts b/server/websocketHandler.ts index 504e02a..8f4dae9 100644 --- a/server/websocketHandler.ts +++ b/server/websocketHandler.ts @@ -45,6 +45,13 @@ async function joinRoom(roomId: string, socket: WebSocket) { // for some reason, when you filter the array when the length is 1 it stays at 1, but we *know* that if its 1 // then when this client disconnects, the room should be deleted since the room is empty if (room.length === 1) { + // give a 5 second grace period before deleting the room + setTimeout(() => { + if (rooms.get(roomId)?.length === 1) { + console.log("Room is empty, deleting"); + deleteRoom(roomId); + } + }, 5000) deleteRoom(roomId); return; } @@ -54,19 +61,9 @@ async function joinRoom(roomId: string, socket: WebSocket) { // TODO: consider letting rooms get larger than 2 clients if (room.length == 2) { - // A room key used to wrap the clients public keys during key exchange - let roomKey = await crypto.subtle.generateKey( - { - name: "AES-KW", - length: 256, - }, - true, - ["wrapKey", "unwrapKey"], - ) - let jsonWebKey = await crypto.subtle.exportKey("jwk", roomKey); room.forEach(async client => { // announce the room is ready, and tell each peer if they are the initiator - client.send(JSON.stringify({ type: SocketMessageType.ROOM_READY, data: { isInitiator: client !== socket, roomKey: { key: jsonWebKey } } })); + client.send(JSON.stringify({ type: SocketMessageType.ROOM_READY, data: { isInitiator: client !== socket } })); }); } diff --git a/src/components/RTCMessage.svelte b/src/components/RTCMessage.svelte index e76d25b..e526417 100644 --- a/src/components/RTCMessage.svelte +++ b/src/components/RTCMessage.svelte @@ -1,7 +1,7 @@ +

{$room?.id} - {$room?.connectionState} - {$webSocketConnected}

+ -{#if $room !== null && $connected === true && $connectionState === ConnectionState.CONNECTED} - {#if !$isRTCConnected} -

Waiting for peer to connect...

- {:else if !$dataChannelReady} -

Establishing data channel...

- {:else if !$keyExchangeDone} -

Establishing a secure connection with the peer...

- {:else} +{#if $room !== null && $webSocketConnected === true && $room.connectionState === ConnectionState.CONNECTED} +
+ {#if !$isRTCConnected || !$dataChannelReady || !$keyExchangeDone || !$canCloseLoadingOverlay} +
+ {#if !$isRTCConnected} +

Waiting for peer to connect...

+ {:else if !$dataChannelReady} +

Establishing data channel...

+ {:else if !$keyExchangeDone} +

Establishing a secure connection with the peer...

+ {:else} +

+ Successfully established a secure connection to + peer! +

+ {/if} +
+ {#if !$keyExchangeDone} + + + + + + {:else} + + + + {/if} +
+
+ {/if} {#each $messages as msg} -
-
+
+

{#if msg.initiator} You: {:else} Peer: {/if} -

- +

+

{#if msg.type === MessageType.TEXT} {msg.data} {:else} Unknown message type: {msg.type} {/if} - +

{/each}
@@ -98,19 +167,25 @@ bind:this={inputFileElement} class="absolute opacity-0 -top-[9999px] -left-[9999px]" /> -
+
e.key === "Enter" && sendMessage()} + disabled={!$isRTCConnected || + !$dataChannelReady || + !$keyExchangeDone} placeholder="Type your message..." - class="flex-grow p-2 rounded bg-gray-700 border border-gray-600 text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500" + class="flex-grow p-2 rounded bg-gray-700 border border-gray-600 text-gray-100 placeholder-gray-400 + focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed" />
- {/if} +
{/if} diff --git a/src/lib/webrtc.ts b/src/lib/webrtc.ts index cc5e2b9..b439eb9 100644 --- a/src/lib/webrtc.ts +++ b/src/lib/webrtc.ts @@ -1,7 +1,7 @@ import { get } from 'svelte/store'; -import { ws } from '../stores/websocketStore'; -import { roomKey } from '../utils/webrtcUtil'; +import { WebSocketMessageType, ws } from '../stores/websocketStore'; import { WebRTCPacketType, type KeyStore, type WebRTCPeerCallbacks } from '../types/webrtc'; +import { clientKeyConfig } from '../shared/keyConfig'; export class WebRTCPeer { private peer: RTCPeerConnection | null = null; @@ -28,13 +28,13 @@ export class WebRTCPeer { } private sendIceCandidate(candidate: RTCIceCandidate) { - get(ws).send(JSON.stringify({ - type: 'ice-candidate', + get(ws).send({ + type: WebSocketMessageType.WEBRTC_ICE_CANDIDATE, data: { roomId: this.roomId, candidate: candidate, }, - })) + }) } public async initialize() { @@ -98,7 +98,6 @@ export class WebRTCPeer { channel.onmessage = async (event: MessageEvent) => { console.log('data channel message:', event.data); - // event is binary data, we need to parse it, convert it into a WebRTCMessage, and then decrypt it if // necessary let data = new Uint8Array(event.data); @@ -116,32 +115,17 @@ export class WebRTCPeer { console.log("Received key exchange", data.buffer); - // let textDecoder = new TextDecoder(); - // let dataString = textDecoder.decode(data.buffer); + const textDecoder = new TextDecoder(); + const jsonKey = JSON.parse(textDecoder.decode(data)); - // console.log("Received key exchange", dataString); + console.log("Received key exchange", jsonKey); - // let json = JSON.parse(dataString); - - let unwrappingKey = get(roomKey); - if (!unwrappingKey.key) throw new Error("Room key not set"); - - this.keys.peersPublicKey = await window.crypto.subtle.unwrapKey( + this.keys.peersPublicKey = await window.crypto.subtle.importKey( "jwk", - data, - unwrappingKey.key, - { - name: "AES-KW", - length: 256, - }, - { - name: "RSA-OAEP", - modulusLength: 4096, - publicExponent: new Uint8Array([1, 0, 1]), - hash: "SHA-256", - }, + jsonKey, + clientKeyConfig, true, - ["encrypt"], + ["wrapKey"], ); // if our keys are not generated, start the reponding side of the key exchange @@ -155,7 +139,32 @@ export class WebRTCPeer { } if (encrypted) { - data = new Uint8Array(await this.decrypt(data.buffer)); + if (!this.keys.localKeys) { + throw new Error("Local keypair not generated"); + } + + // start at 0 since the header is already sliced off + let keyLength = data[0] << 8 | data[1]; + + let aeskey = await window.crypto.subtle.unwrapKey( + "raw", + data.subarray(2, 2 + keyLength), + this.keys.localKeys.privateKey, + clientKeyConfig, + { + name: "AES-GCM", + length: 256, + }, + true, + ["encrypt", "decrypt"], + ) + + let iv = data.subarray(2 + keyLength, 2 + keyLength + 16); + let encryptedData = data.subarray(2 + keyLength + 16); + + console.log("Decrypting message", encryptedData); + + data = new Uint8Array(await this.decrypt(encryptedData, aeskey, iv)); } let message = { @@ -187,13 +196,13 @@ export class WebRTCPeer { await this.peer.setLocalDescription(offer) - get(ws).send(JSON.stringify({ - type: 'offer', + get(ws).send({ + type: WebSocketMessageType.WEBRTC_OFFER, data: { roomId: this.roomId, sdp: offer, }, - })); + }); } catch (error) { console.info('Error creating offer:', error); // should trigger re-negotiation @@ -221,13 +230,13 @@ export class WebRTCPeer { console.log("Sending answer", answer); - get(ws).send(JSON.stringify({ - type: 'answer', + get(ws).send({ + type: WebSocketMessageType.WERTC_ANSWER, data: { roomId: this.roomId, sdp: answer, }, - })); + }); } catch (error) { console.error('Error creating answer:', error); @@ -248,20 +257,14 @@ export class WebRTCPeer { private async generateKeyPair() { console.log("Generating key pair"); + // this key pair is used for wrapping the unique AES-GCM key for each message const keyPair = await window.crypto.subtle.generateKey( - { - name: "RSA-OAEP", - modulusLength: 4096, - publicExponent: new Uint8Array([1, 0, 1]), - hash: "SHA-256", - }, + clientKeyConfig, true, - ["encrypt", "decrypt"], + ["wrapKey", "unwrapKey"], ); - if (keyPair instanceof CryptoKey) { - throw new Error("Key pair not generated"); - } + console.log("generated key pair", keyPair); this.keys.localKeys = keyPair; } @@ -271,50 +274,40 @@ export class WebRTCPeer { await this.generateKeyPair(); if (!this.keys.localKeys) throw new Error("Key pair not generated"); - let wrappingKey = get(roomKey); - if (!wrappingKey.key) throw new Error("Room key not set"); + console.log("exporting key", this.keys.localKeys.publicKey); + const exported = await window.crypto.subtle.exportKey("jwk", this.keys.localKeys.publicKey); - console.log("wrapping key", this.keys.localKeys.publicKey, wrappingKey.key); - const exported = await window.crypto.subtle.wrapKey( - "jwk", - this.keys.localKeys.publicKey, - wrappingKey.key, - { - name: "AES-KW", - length: 256, - }, - ); - - console.log("wrapping key exported", exported); - - const exportedKeyBuffer = exported; + // convert exported key to a string then pack that sting into an array buffer + const exportedKeyBuffer = new TextEncoder().encode(JSON.stringify(exported)); console.log("exported key buffer", exportedKeyBuffer); - this.send(exportedKeyBuffer, WebRTCPacketType.KEY_EXCHANGE); + this.send(exportedKeyBuffer.buffer, WebRTCPacketType.KEY_EXCHANGE); } - private async encrypt(data: ArrayBuffer): Promise { - if (!this.keys.peersPublicKey) throw new Error("Peer's public key not set"); - + private async encrypt(data: Uint8Array, key: CryptoKey, iv: Uint8Array): Promise { return await window.crypto.subtle.encrypt( { - name: "RSA-OAEP", + name: "AES-GCM", + length: 256, + iv, + tagLength: 128, }, - this.keys.peersPublicKey, + key, data, ); } - private async decrypt(data: ArrayBuffer): Promise { - if (!this.keys.localKeys) throw new Error("Local keypair not generated"); - + private async decrypt(data: Uint8Array, key: CryptoKey, iv: Uint8Array): Promise { return await window.crypto.subtle.decrypt( { - name: "RSA-OAEP", + name: "AES-GCM", + length: 256, + iv, + tagLength: 128, }, - this.keys.localKeys.privateKey, + key, data, ); } @@ -331,15 +324,36 @@ export class WebRTCPeer { if (this.keys.peersPublicKey && type != WebRTCPacketType.KEY_EXCHANGE) { console.log("Sending encrypted message", data); - let encryptedData = await this.encrypt(data); + let iv = window.crypto.getRandomValues(new Uint8Array(16)); + let key = await window.crypto.subtle.generateKey( + { + name: "AES-GCM", + length: 256, + }, + true, + ["encrypt", "decrypt"], + ) - console.log("Encrypted data", encryptedData); + let encryptedData = await this.encrypt(new Uint8Array(data), key, iv); + + let exportedKey = await window.crypto.subtle.wrapKey( + "raw", + key, + this.keys.peersPublicKey, + clientKeyConfig, + ) header |= 1 << 7; - let buf = new Uint8Array(encryptedData.byteLength + 1); + let buf = new Uint8Array(encryptedData.byteLength + 3 + exportedKey.byteLength + iv.byteLength); buf[0] = header; - buf.subarray(1).set(new Uint8Array(encryptedData)); + buf[1] = (exportedKey.byteLength >> 8) & 0xFF; + buf[2] = exportedKey.byteLength & 0xFF; + buf.subarray(3).set(new Uint8Array(exportedKey)); + buf.subarray(3 + exportedKey.byteLength).set(new Uint8Array(iv)); + buf.subarray(3 + exportedKey.byteLength + iv.byteLength).set(new Uint8Array(encryptedData)); + + console.log("Sending encrypted message", buf); this.dataChannel.send(buf.buffer); } else { diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 2aea9ec..77488d6 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,6 +1,10 @@