diff --git a/bun.lock b/bun.lock index 5907a0b..5aae334 100644 --- a/bun.lock +++ b/bun.lock @@ -5,13 +5,13 @@ "name": "wormhole", "dependencies": { "@hpke/chacha20poly1305": "^1.7.1", + "@hpke/core": "^1.7.4", "@hpke/hybridkem-x-wing": "^0.6.1", "@noble/ciphers": "^1.3.0", "@noble/curves": "^1.9.0", "@sveltejs/adapter-node": "^5.3.1", "@types/streamsaver": "^2.0.5", "@types/ws": "^8.18.1", - "i": "^0.3.7", "polka": "^0.5.2", "streamsaver": "^2.0.6", "ts-mls": "^1.1.0", @@ -99,8 +99,6 @@ "@hpke/hybridkem-x-wing": ["@hpke/hybridkem-x-wing@0.6.1", "", { "dependencies": { "@hpke/common": "^1.8.1", "@hpke/dhkem-x25519": "^1.6.4", "mlkem": "^2.5.0" } }, "sha512-mNdGapyHPw9gEicUlBYlWGjOpWmQyC49dEqLm5QtGZOSjIVSjSTBX/Bq2VxXNTeNdsRYIpPOalTwYbop/+4Ykw=="], - "@hpke/ml-kem": ["@hpke/ml-kem@0.2.1", "", { "dependencies": { "@hpke/common": "^1.8.1", "mlkem": "^2.5.0" } }, "sha512-9Hmf2fO8W45/h2COdD8+RAaszI2dw9/Id0lwrJD7rEwFbNki7lxb1HyEnqUuHz9ipL2D3q0L6egFKZbhBwj/5A=="], - "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], @@ -295,8 +293,6 @@ "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], - "i": ["i@0.3.7", "", {}, "sha512-FYz4wlXgkQwIPqhzC5TdNMLSE5+GS1IIDJZY/1ZiEPCT2S3COUVZeT5OW4BmW4r5LHLQuOosSwsvnroG9GR59Q=="], - "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], "is-module": ["is-module@1.0.0", "", {}, "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g=="], @@ -409,6 +405,8 @@ "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + "yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="], + "zimmerframe": ["zimmerframe@1.1.2", "", {}, "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w=="], "@rollup/plugin-commonjs/is-reference": ["is-reference@1.2.1", "", { "dependencies": { "@types/estree": "*" } }, "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ=="], diff --git a/package.json b/package.json index c7b98a1..ff8ae57 100644 --- a/package.json +++ b/package.json @@ -25,13 +25,13 @@ }, "dependencies": { "@hpke/chacha20poly1305": "^1.7.1", + "@hpke/core": "^1.7.4", "@hpke/hybridkem-x-wing": "^0.6.1", "@noble/ciphers": "^1.3.0", "@noble/curves": "^1.9.0", "@sveltejs/adapter-node": "^5.3.1", "@types/streamsaver": "^2.0.5", "@types/ws": "^8.18.1", - "i": "^0.3.7", "polka": "^0.5.2", "streamsaver": "^2.0.6", "ts-mls": "^1.1.0", diff --git a/server.ts b/server.ts index 6e43a5c..3639724 100644 --- a/server.ts +++ b/server.ts @@ -2,7 +2,7 @@ import { handler } from './build/handler.js'; // Adjust path as needed import http from 'http'; import { WebSocketServer } from 'ws'; import polka from 'polka'; -import { confgiureWebsocketServer } from './server/websocketHandler.ts' +import { confgiureWebsocketServer } from './src/lib/server/websocketHandler.ts' const server = http.createServer(); const app = polka({ server }); diff --git a/src/components/LoadingSpinner.svelte b/src/components/LoadingSpinner.svelte index cfff3a4..3d39702 100644 --- a/src/components/LoadingSpinner.svelte +++ b/src/components/LoadingSpinner.svelte @@ -1,5 +1,13 @@ + + import { derived, writable, type Writable } from "svelte/store"; - import { WebsocketConnectionState, ws } from "../stores/websocketStore"; + import { WebsocketConnectionState, ws } from "$stores/websocketStore"; import { isRTCConnected, dataChannelReady, peer, keyExchangeDone, - } from "../utils/webrtcUtil"; + } from "$lib/webrtcUtil"; import { advertisedOffers, fileRequestIds, messages, receivedOffers, - } from "../stores/messageStore"; - import { WebRTCPacketType } from "../types/webrtc"; - import { RoomConnectionState, type Room } from "../types/websocket"; - import { MessageType } from "../types/message"; + } from "$stores/messageStore"; + import { WebRTCPacketType } from "$types/webrtc"; + import { RoomConnectionState, type Room } from "$types/websocket"; + import { MessageType } from "$types/message"; import { fade } from "svelte/transition"; - import { WebBuffer } from "../utils/buffer"; + import { WebBuffer } from "../lib/buffer"; let inputMessage: Writable = writable(""); let inputFile: Writable = writable(null); diff --git a/src/utils/buffer.ts b/src/lib/buffer.ts similarity index 100% rename from src/utils/buffer.ts rename to src/lib/buffer.ts diff --git a/src/lib/challenge.ts b/src/lib/challenge.ts new file mode 100644 index 0000000..dc71225 --- /dev/null +++ b/src/lib/challenge.ts @@ -0,0 +1,45 @@ +import { ws } from "$stores/websocketStore"; +import { WebSocketMessageType } from "$types/websocket"; +import { solveChallenge } from "./powUtil"; + +export async function doChallenge(additionalData: string = ""): Promise<{ + challenge: string; + nonce: string; +} | null> { + let roomChallenge: string | null = null; + + let challengePromise = new Promise((resolve) => { + let unsubscribe = ws.handleEvent( + WebSocketMessageType.CHALLENGE, + async (value) => { + unsubscribe(); + roomChallenge = value.challenge; + resolve( + await solveChallenge( + roomChallenge, + value.difficulty, + additionalData, + ), + ); + }, + ); + }); + + ws.send({ + type: WebSocketMessageType.REQUEST_CHALLENGE, + }); + + let challengeNonce = await challengePromise; + if (!challengeNonce) { + throw new Error("Could not solve challenge within max iterations"); + } + + if (!roomChallenge) { + throw new Error("No room challenge"); + } + + return { + challenge: roomChallenge, + nonce: challengeNonce, + }; +} \ No newline at end of file diff --git a/src/utils/liveMap.ts b/src/lib/liveMap.ts similarity index 100% rename from src/utils/liveMap.ts rename to src/lib/liveMap.ts diff --git a/src/lib/powUtil.ts b/src/lib/powUtil.ts new file mode 100644 index 0000000..53e40c9 --- /dev/null +++ b/src/lib/powUtil.ts @@ -0,0 +1,25 @@ +export async function hashStringSHA256(message: string): Promise { + const textEncoder = new TextEncoder(); + const data = textEncoder.encode(message); + + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + return Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join(''); +} + +export async function solveChallenge(challenge: string, difficulty: number, additionalData: string): Promise { + let nonce = 0; + let targetPrefix = '0'.repeat(difficulty); + let maxIterations = 1_000_000; + + while (nonce < maxIterations) { + let hash = await hashStringSHA256(`${additionalData}${challenge}${nonce}`); + if (hash.startsWith(targetPrefix)) { + return nonce.toString(); + } + + nonce++; + } + + return null; +} + diff --git a/server/websocketHandler.ts b/src/lib/server/websocketHandler.ts similarity index 72% rename from server/websocketHandler.ts rename to src/lib/server/websocketHandler.ts index 2d7fbd0..f4a7c3e 100644 --- a/server/websocketHandler.ts +++ b/src/lib/server/websocketHandler.ts @@ -1,16 +1,15 @@ import { WebSocketServer } from "ws"; -import { Socket, WebSocketMessageType, type WebSocketMessage } from "../src/types/websocket"; -import { LiveMap } from '../src/utils/liveMap.ts'; +import { Socket, WebSocketMessageType, type WebSocketMessage } from "../../types/websocket.ts"; +import { LiveMap } from '../liveMap.ts'; +import { hashStringSHA256 } from "../powUtil.ts"; const adjectives = ['swift', 'silent', 'hidden', 'clever', 'brave', 'sharp', 'shadow', 'crimson', 'bright', 'quiet', 'loud', 'happy', 'dark', 'evil', 'good', 'intelligent', 'lovely', 'mysterious', 'peaceful', 'powerful', 'pure', 'quiet', 'shiny', 'sleepy', 'strong', 'sweet', 'tall', 'warm', 'gentle', 'kind', 'nice', 'polite', 'rough', 'rude', 'scary', 'shy', 'silly', 'smart', 'strange', 'tough', 'ugly', 'vivid', 'wicked', 'wise', 'young', 'sleepy']; const nouns = ['fox', 'river', 'stone', 'cipher', 'link', 'comet', 'falcon', 'signal', 'anchor', 'spark', 'stone', 'comet', 'rocket', 'snake', 'snail', 'shark', 'elephant', 'cat', 'dog', 'whale', 'orca', 'cactus', 'flower', 'frog', 'toad', 'apple', 'strawberry', 'raspberry', 'lemon', 'bot', 'gopher', 'dinosaur', 'racoon', 'penguin', 'chameleon', 'atom', 'particle', 'witch', 'wizard', 'warlock', 'deer'] -enum ErrorCode { - ROOM_NOT_FOUND, -} - const errors = { MALFORMED_MESSAGE: "Invalid message", + INVALID_CHALLENGE: "Invalid challenge", + MISSING_DATA: "One or more required fields are missing", ROOM_NOT_FOUND: "Room does not exist", ROOM_FULL: "Room is full", UNKNOWN_MESSAGE_TYPE: "Unknown message type", @@ -106,13 +105,13 @@ async function joinRoom(roomId: string, socket: Socket, initial?: boolean): Prom // 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 + // give a 60 second grace period before deleting the room setTimeout(() => { if (rooms.get(roomId)?.length === 1) { console.log("Room is empty, deleting"); deleteRoom(roomId); } - }, 5000) + }, 60000) return; } @@ -132,6 +131,39 @@ async function joinRoom(roomId: string, socket: Socket, initial?: boolean): Prom return room; } +// How many leading zeros are required to be considered solved +// In my testing, 2 seems to be too easy, and 4 seems to be too hard, so I'm going with 3 +const CHALLENGE_DIFFICULTY = 3; +// challenges that have yet to be attached to a challenged request +const outstandingChallenges = new Map(); + +function generateChallenge(): string { + let challenge = Array.from(crypto.getRandomValues(new Uint8Array(32))).map(b => b.toString(16).padStart(2, '0')).join(''); + // provide 90 seconds to solve the challenge + outstandingChallenges.set(challenge, setTimeout(() => { + console.log("Challenge timed out:", challenge); + outstandingChallenges.delete(challenge); + }, 90000)); + + return challenge; +} + +async function validateChallenge(challenge: string, nonce: string, additionalData: string = ""): Promise { + if (!outstandingChallenges.has(challenge)) { + return false; + } + + let hash = await hashStringSHA256(`${additionalData}${challenge}${nonce}`); + let result = hash.startsWith('0'.repeat(CHALLENGE_DIFFICULTY)); + if (result) { + console.log("Challenge solved:", challenge); + clearTimeout(outstandingChallenges.get(challenge)!); + outstandingChallenges.delete(challenge); + } + + return result; +} + function leaveRoom(roomId: string, socket: Socket): ServerRoom | undefined { let room = rooms.get(roomId); console.log(room?.length); @@ -189,10 +221,22 @@ export function confgiureWebsocketServer(wss: WebSocketServer) { return; } + console.log("Received message:", message); + let room: ServerRoom | undefined = undefined; switch (message.type) { case WebSocketMessageType.CREATE_ROOM: + if (!message.nonce || !message.challenge) { + socket.send({ type: WebSocketMessageType.ERROR, data: errors.MISSING_DATA }); + return; + } + + if (!await validateChallenge(message.challenge, message.nonce)) { + socket.send({ type: WebSocketMessageType.ERROR, data: errors.INVALID_CHALLENGE }); + return; + } + // else, create a new room try { if (message.roomName) { @@ -212,8 +256,13 @@ export function confgiureWebsocketServer(wss: WebSocketServer) { } break; case WebSocketMessageType.JOIN_ROOM: - if (!message.roomId) { - socket.send({ type: WebSocketMessageType.ERROR, data: errors.MALFORMED_MESSAGE }); + if (!message.roomId || !message.nonce || !message.challenge) { + socket.send({ type: WebSocketMessageType.ERROR, data: errors.MISSING_DATA }); + return; + } + + if (!await validateChallenge(message.challenge, message.nonce, message.roomId)) { + socket.send({ type: WebSocketMessageType.ERROR, data: errors.INVALID_CHALLENGE }); return; } @@ -237,9 +286,27 @@ export function confgiureWebsocketServer(wss: WebSocketServer) { return; } - room = await leaveRoom(message.roomId, socket); + room = leaveRoom(message.roomId, socket); if (!room) return; + break; + case WebSocketMessageType.CHECK_ROOM_EXISTS: + if (!message.roomId || !message.nonce || !message.challenge) { + socket.send({ type: WebSocketMessageType.ERROR, data: errors.MISSING_DATA }); + return; + } + + if (!await validateChallenge(message.challenge, message.nonce, message.roomId)) { + socket.send({ type: WebSocketMessageType.ERROR, data: errors.INVALID_CHALLENGE }); + return; + } + + socket.send({ type: WebSocketMessageType.ROOM_STATUS, roomId: message.roomId, status: rooms.get(message.roomId) ? 'found' : 'not-found' }); + break; + case WebSocketMessageType.REQUEST_CHALLENGE: + let challenge = generateChallenge(); + + socket.send({ type: WebSocketMessageType.CHALLENGE, challenge, difficulty: CHALLENGE_DIFFICULTY }); break; case WebSocketMessageType.WEBRTC_OFFER: case WebSocketMessageType.WERTC_ANSWER: diff --git a/src/lib/webrtc.ts b/src/lib/webrtc.ts index cd92bcc..cf0bc16 100644 --- a/src/lib/webrtc.ts +++ b/src/lib/webrtc.ts @@ -1,7 +1,7 @@ -import { ws } from '../stores/websocketStore'; -import { WebSocketMessageType } from '../types/websocket'; -import { WebRTCPacketType, type WebRTCPeerCallbacks } from '../types/webrtc'; +import { ws } from '$stores/websocketStore'; +import { WebSocketMessageType } from '$types/websocket'; +import { WebRTCPacketType, type WebRTCPeerCallbacks } from '$types/webrtc'; import { browser } from '$app/environment'; import { createApplicationMessage, createCommit, createGroup, decodeMlsMessage, defaultCapabilities, defaultLifetime, emptyPskIndex, encodeMlsMessage, generateKeyPackage, getCiphersuiteFromName, getCiphersuiteImpl, joinGroup, processPrivateMessage, type CiphersuiteImpl, type ClientState, type Credential, type KeyPackage, type PrivateKeyPackage, type Proposal } from 'ts-mls'; diff --git a/src/utils/webrtcUtil.ts b/src/lib/webrtcUtil.ts similarity index 95% rename from src/utils/webrtcUtil.ts rename to src/lib/webrtcUtil.ts index 0d586d1..35ed64a 100644 --- a/src/utils/webrtcUtil.ts +++ b/src/lib/webrtcUtil.ts @@ -1,11 +1,11 @@ import { writable, get, type Writable } from "svelte/store"; import { WebRTCPeer } from "$lib/webrtc"; -import { CHUNK_SIZE, WebRTCPacketType } from "../types/webrtc"; -import { room } from "../stores/roomStore"; -import { RoomConnectionState, type Room } from "../types/websocket"; -import { advertisedOffers, fileRequestIds, messages, receivedOffers } from "../stores/messageStore"; -import { MessageType, type Message } from "../types/message"; -import { WebSocketMessageType, type WebSocketMessage } from "../types/websocket"; +import { WebRTCPacketType } from "$types/webrtc"; +import { room } from "$stores/roomStore"; +import { RoomConnectionState, type Room } from "$types/websocket"; +import { advertisedOffers, fileRequestIds, messages, receivedOffers } from "$stores/messageStore"; +import { MessageType, type Message } from "$types/message"; +import { WebSocketMessageType, type WebSocketMessage } from "$types/websocket"; import { WebBuffer } from "./buffer"; import { goto } from "$app/navigation"; @@ -186,8 +186,10 @@ const callbacks = { if (downloadStream === undefined) { window.addEventListener("pagehide", onPageHide); window.addEventListener("beforeunload", beforeUnload); + + // @ts-ignore downloadStream = window.streamSaver.createWriteStream(file.name, { size: Number(file.size) }); - downloadWriter = downloadStream.getWriter(); + downloadWriter = downloadStream!.getWriter(); } await downloadWriter!.write(new Uint8Array(messageData.read())); @@ -249,6 +251,7 @@ const callbacks = { }, }; + export async function handleMessage(event: MessageEvent) { console.log("Message received:", event.data, typeof event.data); const message: WebSocketMessage = JSON.parse(event.data); @@ -299,7 +302,7 @@ export async function handleMessage(event: MessageEvent) { } if (!get(peer)) { - console.error("Unknown message type:", message.type); + console.debug("Unhandled message type:", message.type); return; } @@ -322,7 +325,7 @@ export async function handleMessage(event: MessageEvent) { await get(peer)?.addIceCandidate(message.data.candidate); return; default: - console.warn( + console.debug( `Unknown message type: ${message.type} from ${get(room).id}`, ); } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index ec31067..6e2009d 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -2,8 +2,8 @@ import "../app.css"; import favicon from "$lib/assets/favicon.svg"; import { onDestroy, onMount } from "svelte"; - import { WebsocketConnectionState, ws } from "../stores/websocketStore"; - import { room } from "../stores/roomStore"; + import { WebsocketConnectionState, ws } from "$stores/websocketStore"; + import { room } from "$stores/roomStore"; onMount(() => { ws.connect(); @@ -35,6 +35,12 @@ crossorigin="anonymous" href="/fonts/InstrumentSans-VariableFont_wdth,wght.woff2" /> + {#if process.env.NODE_ENV !== "production"} + + + {/if} @@ -52,7 +58,7 @@ Connecting to server... + {:else if $roomLoading} Creating Room... @@ -232,7 +247,7 @@ -