From 1b8ac362b61b688fbb445b1821fef55387209023 Mon Sep 17 00:00:00 2001 From: Zoe <62722391+juls0730@users.noreply.github.com> Date: Wed, 3 Sep 2025 17:36:21 -0500 Subject: [PATCH] encryption, code cleanup, nice types, bug fixes, and more --- README.md | 47 ++----- server/websocketHandler.ts | 99 ++++++-------- src/components/RTCMessage.svelte | 64 +++++++-- src/lib/webrtc.ts | 223 ++++++++++++++++++++++++++++--- src/routes/+page.svelte | 5 +- src/routes/[roomId]/+page.svelte | 11 +- src/stores/messageStore.ts | 4 + src/stores/roomStore.ts | 4 +- src/types/message.ts | 63 +++++++++ src/types/webrtc.ts | 27 ++++ src/types/websocket.ts | 99 ++++++++++++++ src/utils/webrtcUtil.ts | 81 ++++++++--- 12 files changed, 577 insertions(+), 150 deletions(-) create mode 100644 src/stores/messageStore.ts create mode 100644 src/types/message.ts create mode 100644 src/types/webrtc.ts create mode 100644 src/types/websocket.ts diff --git a/README.md b/README.md index 75842c4..099c585 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,17 @@ -# sv +# Wormhole +(needs a different name I think because I dont want to confuse it with wormhole.app) -Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). +A peer-to-peer encrypted file sharing app. -## Creating a project +## Features +- E2E communication +- P2P file sharing +- P2P chat -If you're seeing this, you've probably already done this step. Congrats! +Your data is peer-to-peer encrypted and only accessible to the people you share it with, it never touches any servers. -```sh -# create a new project in the current directory -npx sv create - -# create a new project in my-app -npx sv create my-app -``` - -## Developing - -Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: - -```sh -npm run dev - -# or start the server and open the app in a new browser tab -npm run dev -- --open -``` - -## Building - -To create a production version of your app: - -```sh -npm run build -``` - -You can preview the production build with `npm run preview`. - -> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. +## How to use +1. clone the repo +2. run `bun install` +3. run `bun run dev --host` (webrtc doesnt co-operate with localhost connections, so connect via 127.0.0.1) +4. open the browser at http://127.0.0.1:5173 \ No newline at end of file diff --git a/server/websocketHandler.ts b/server/websocketHandler.ts index a030b49..504e02a 100644 --- a/server/websocketHandler.ts +++ b/server/websocketHandler.ts @@ -1,44 +1,22 @@ import { WebSocketServer } from "ws"; import type { WebSocket } from "ws"; +import { SocketMessageType, type SocketMessage } from "../src/types/websocket"; // TODO: remove stale rooms somehow const rooms = new Map(); -enum MessageType { - // requests - CREATE_ROOM = 'create', - JOIN_ROOM = 'join', - - // responses - ROOM_CREATED = 'created', - ROOM_JOINED = 'joined', - ROOM_READY = 'ready', - - // webrtc - ICE_CANDIDATE = 'ice-candidate', - OFFER = 'offer', - ANSWER = 'answer', - - ERROR = 'error', -} - -type Message = { - type: MessageType; - data: any; -}; - -function createRoom(socket: WebSocket): string { +async function createRoom(socket: WebSocket): Promise { let roomId = Math.random().toString(36).substring(2, 10); rooms.set(roomId, []); - socket.send(JSON.stringify({ type: MessageType.ROOM_CREATED, data: roomId })); + socket.send(JSON.stringify({ type: SocketMessageType.ROOM_CREATED, data: roomId })); - joinRoom(roomId, socket); + await joinRoom(roomId, socket); return roomId; } -function joinRoom(roomId: string, socket: WebSocket) { +async function joinRoom(roomId: string, socket: WebSocket) { let room = rooms.get(roomId); console.log(room?.length); @@ -48,13 +26,13 @@ function joinRoom(roomId: string, socket: WebSocket) { } if (room.length == 2) { - socket.send(JSON.stringify({ type: MessageType.ERROR, data: 'Room is full' })); + socket.send(JSON.stringify({ type: SocketMessageType.ERROR, data: 'Room is full' })); return; } // notify all clients in the room of the new client, except the client itself room.forEach(client => { - client.send(JSON.stringify({ type: MessageType.JOIN_ROOM, data: roomId })); + client.send(JSON.stringify({ type: SocketMessageType.JOIN_ROOM, data: roomId })); }); room.push(socket); @@ -76,11 +54,23 @@ function joinRoom(roomId: string, socket: WebSocket) { // TODO: consider letting rooms get larger than 2 clients if (room.length == 2) { - room.forEach(client => { + // 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: MessageType.ROOM_READY, data: { isInitiator: client !== socket } })); + client.send(JSON.stringify({ type: SocketMessageType.ROOM_READY, data: { isInitiator: client !== socket, roomKey: { key: jsonWebKey } } })); }); } + + console.log("Room created:", roomId, room.length); } function deleteRoom(roomId: string) { @@ -90,57 +80,50 @@ function deleteRoom(roomId: string) { export function confgiureWebsocketServer(ws: WebSocketServer) { ws.on('connection', socket => { // Handle messages from the client - socket.on('message', event => { - let message; + socket.on('message', async event => { + let message: SocketMessage | undefined = undefined; if (event instanceof Buffer) { // Assuming JSON is sent as a string try { - const jsonObject = JSON.parse(Buffer.from(event).toString()); - // TODO: validate the message - message = jsonObject as Message; + message = JSON.parse(Buffer.from(event).toString()); } catch (e) { console.error("Error parsing JSON:", e); } } - if (!message) { + if (message === undefined) { console.log("Received non-JSON message:", event); // If the message is not JSON, send an error message - socket.send(JSON.stringify({ type: MessageType.ERROR, data: 'Invalid message' })); + socket.send(JSON.stringify({ type: SocketMessageType.ERROR, data: 'Invalid message' })); return; } - let { type } = message; - - // coerce type to a MessageType enum - type = type as MessageType; - - switch (type) { - case MessageType.CREATE_ROOM: + switch (message.type) { + case SocketMessageType.CREATE_ROOM: // else, create a new room - createRoom(socket); + await createRoom(socket); break; - case MessageType.JOIN_ROOM: + case SocketMessageType.JOIN_ROOM: // if join message has a roomId, join the room - if (!message.data) { - socket.send(JSON.stringify({ type: MessageType.ERROR, data: 'Invalid message' })); + if (!message.roomId) { + socket.send(JSON.stringify({ type: SocketMessageType.ERROR, data: 'Invalid message' })); return; } // if the user tries to join a room that doesnt exist, send an error message - if (rooms.get(message.data) == undefined) { - socket.send(JSON.stringify({ type: MessageType.ERROR, data: 'Invalid roomId' })); + if (rooms.get(message.roomId) == undefined) { + socket.send(JSON.stringify({ type: SocketMessageType.ERROR, data: 'Invalid roomId' })); return; } - joinRoom(message.data, socket); + await joinRoom(message.roomId, socket); // the client is now in the room and the peer knows about it - socket.send(JSON.stringify({ type: MessageType.ROOM_JOINED, data: null })); + socket.send(JSON.stringify({ type: SocketMessageType.ROOM_JOINED, roomId: message.roomId })); break; - case MessageType.OFFER: - case MessageType.ANSWER: - case MessageType.ICE_CANDIDATE: + case SocketMessageType.OFFER: + case SocketMessageType.ANSWER: + case SocketMessageType.ICE_CANDIDATE: // relay these messages to the other peers in the room const room = rooms.get(message.data.roomId); @@ -153,8 +136,8 @@ export function confgiureWebsocketServer(ws: WebSocketServer) { } break; default: - console.warn(`Unknown message type: ${type}`); - socket.send(JSON.stringify({ type: MessageType.ERROR, data: 'Unknown message type' })); + console.warn(`Unknown message type: ${message.type}`); + socket.send(JSON.stringify({ type: SocketMessageType.ERROR, data: 'Unknown message type' })); break; } }); diff --git a/src/components/RTCMessage.svelte b/src/components/RTCMessage.svelte index 3f59129..e76d25b 100644 --- a/src/components/RTCMessage.svelte +++ b/src/components/RTCMessage.svelte @@ -1,13 +1,17 @@ -{#if $room !== null && $connected === true} + +{#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} -
+
{#each $messages as msg} -

{msg}

+
+
+ {#if msg.initiator} + You: + {:else} + Peer: + {/if} +
+ + {#if msg.type === MessageType.TEXT} + {msg.data} + {:else} + Unknown message type: {msg.type} + {/if} + +
{/each}
void; - onMessage: (message: string | ArrayBuffer) => void; - onDataChannelOpen: () => void; - onNegotiationNeeded: () => void; - onError: (error: any) => void; -} +import { roomKey } from '../utils/webrtcUtil'; +import { WebRTCPacketType, type KeyStore, type WebRTCPeerCallbacks } from '../types/webrtc'; export class WebRTCPeer { private peer: RTCPeerConnection | null = null; @@ -15,10 +9,16 @@ export class WebRTCPeer { private isInitiator: boolean; private roomId: string; private callbacks: WebRTCPeerCallbacks; + private keys: KeyStore = { + localKeys: null, + peersPublicKey: null, + }; private iceServers = [ - { urls: 'stun:stun.l.google.com:19302' }, - { urls: 'stun:stun1.l.google.com:19302' }, + { urls: "stun:stun.l.google.com:19302" }, + { urls: "stun:stun.l.google.com:5349" }, + { urls: "stun:stun1.l.google.com:3478" }, + { urls: "stun:stun1.l.google.com:5349" }, ]; constructor(roomId: string, isInitiator: boolean, callbacks: WebRTCPeerCallbacks) { @@ -80,14 +80,90 @@ export class WebRTCPeer { } private setupDataChannelEvents(channel: RTCDataChannel) { - channel.onopen = () => { + channel.binaryType = "arraybuffer"; + + channel.onopen = async () => { console.log('data channel open'); this.callbacks.onDataChannelOpen(); + + try { + if (this.isInitiator) { + await this.startKeyExchange(); + } + } catch (e) { + console.error("Error starting key exchange:", e); + this.callbacks.onError(e); + } }; - channel.onmessage = (event) => { + channel.onmessage = async (event: MessageEvent) => { console.log('data channel message:', event.data); - this.callbacks.onMessage(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); + const encrypted = (data[0] >> 7) & 1; + const type = data[0] & 0b01111111; + data = data.slice(1); + + console.log("parsed data", data, encrypted, type); + + if (type == WebRTCPacketType.KEY_EXCHANGE) { + if (this.keys.peersPublicKey) { + console.error("Key exchange already done"); + return; + } + + console.log("Received key exchange", data.buffer); + + // let textDecoder = new TextDecoder(); + // let dataString = textDecoder.decode(data.buffer); + + // console.log("Received key exchange", dataString); + + // 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( + "jwk", + data, + unwrappingKey.key, + { + name: "AES-KW", + length: 256, + }, + { + name: "RSA-OAEP", + modulusLength: 4096, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["encrypt"], + ); + + // if our keys are not generated, start the reponding side of the key exchange + if (!this.keys.localKeys) { + await this.startKeyExchange(); + } + + // by this point, both peers should have exchanged their keys + this.callbacks.onKeyExchangeDone(); + return; + } + + if (encrypted) { + data = new Uint8Array(await this.decrypt(data.buffer)); + } + + let message = { + type: type as WebRTCPacketType, + data: data.buffer, + }; + + this.callbacks.onMessage(message); }; channel.onclose = () => { @@ -105,8 +181,11 @@ export class WebRTCPeer { if (!this.peer) throw new Error('Peer not initialized'); try { - const offer = await this.peer.createOffer(); - await this.peer.setLocalDescription(offer); + const offer = await this.peer.createOffer() + + console.log("Sending offer", offer); + + await this.peer.setLocalDescription(offer) get(ws).send(JSON.stringify({ type: 'offer', @@ -115,10 +194,9 @@ export class WebRTCPeer { sdp: offer, }, })); - } catch (error) { - console.error('Error creating offer:', error); - this.callbacks.onError(error); + console.info('Error creating offer:', error); + // should trigger re-negotiation } } @@ -141,6 +219,8 @@ export class WebRTCPeer { const answer = await this.peer.createAnswer(); await this.peer.setLocalDescription(answer); + console.log("Sending answer", answer); + get(ws).send(JSON.stringify({ type: 'answer', data: { @@ -166,9 +246,112 @@ export class WebRTCPeer { } } - public send(data: string | ArrayBuffer) { + private async generateKeyPair() { + console.log("Generating key pair"); + const keyPair = await window.crypto.subtle.generateKey( + { + name: "RSA-OAEP", + modulusLength: 4096, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["encrypt", "decrypt"], + ); + + if (keyPair instanceof CryptoKey) { + throw new Error("Key pair not generated"); + } + + this.keys.localKeys = keyPair; + } + + private async startKeyExchange() { + console.log("Starting key exchange"); + 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("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; + + console.log("exported key buffer", exportedKeyBuffer); + + this.send(exportedKeyBuffer, WebRTCPacketType.KEY_EXCHANGE); + } + + private async encrypt(data: ArrayBuffer): Promise { + if (!this.keys.peersPublicKey) throw new Error("Peer's public key not set"); + + return await window.crypto.subtle.encrypt( + { + name: "RSA-OAEP", + }, + this.keys.peersPublicKey, + data, + ); + } + + private async decrypt(data: ArrayBuffer): Promise { + if (!this.keys.localKeys) throw new Error("Local keypair not generated"); + + return await window.crypto.subtle.decrypt( + { + name: "RSA-OAEP", + }, + this.keys.localKeys.privateKey, + data, + ); + } + + public async send(data: ArrayBuffer, type: WebRTCPacketType) { + console.log("Sending message of type", type, "with data", data); + if (!this.dataChannel || this.dataChannel.readyState !== 'open') throw new Error('Data channel not initialized'); - this.dataChannel.send(data); + + console.log(this.keys) + let header = (type & 0x7F); + + // the key exchange is done, encrypt the message + if (this.keys.peersPublicKey && type != WebRTCPacketType.KEY_EXCHANGE) { + console.log("Sending encrypted message", data); + + let encryptedData = await this.encrypt(data); + + console.log("Encrypted data", encryptedData); + + header |= 1 << 7; + + let buf = new Uint8Array(encryptedData.byteLength + 1); + buf[0] = header; + buf.subarray(1).set(new Uint8Array(encryptedData)); + + this.dataChannel.send(buf.buffer); + } else { + console.log("Sending unencrypted message", data); + // the key exchange is not done yet, send the message unencrypted + + let buf = new Uint8Array(data.byteLength + 1); + buf[0] = header; + buf.subarray(1).set(new Uint8Array(data)); + + this.dataChannel.send(buf.buffer); + } } public close() { diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index aaddaec..2aea9ec 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,17 +1,20 @@