diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e03af6f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 juls0730 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index c23824c..d245068 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,11 @@ -# Wormhole -(needs a different name I think because I dont want to confuse it with wormhole.app) +# Noctis -A peer-to-peer encrypted file sharing app. +Noctis /ˈnɑktɪs/ *adjective* of the night + +A peer-to-peer end-to-end encrypted chat app. ## Features -- E2E communication +- E2EE communication - P2P file sharing - P2P chat @@ -18,4 +19,4 @@ wasm bindings for AWS' mls-rs library. 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 +4. open the browser at http://127.0.0.1:5173 diff --git a/server/websocketHandler.ts b/server/websocketHandler.ts index 46b869d..2d7fbd0 100644 --- a/server/websocketHandler.ts +++ b/server/websocketHandler.ts @@ -2,6 +2,20 @@ import { WebSocketServer } from "ws"; import { Socket, WebSocketMessageType, type WebSocketMessage } from "../src/types/websocket"; import { LiveMap } from '../src/utils/liveMap.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", + ROOM_NOT_FOUND: "Room does not exist", + ROOM_FULL: "Room is full", + UNKNOWN_MESSAGE_TYPE: "Unknown message type", +} + export class ServerRoom { private clients: Socket[] = []; @@ -38,16 +52,28 @@ export class ServerRoom { } } +function generateRoomName(): string { + const adj = adjectives[Math.floor(Math.random() * adjectives.length)]; + const noun = nouns[Math.floor(Math.random() * nouns.length)]; + return `${adj}-${noun}`; +} + const rooms = new LiveMap(); -async function createRoom(socket: Socket): Promise { - let roomId = Math.random().toString(36).substring(2, 10); +async function createRoom(socket: Socket, roomName?: string): Promise { + if (!roomName) { + roomName = generateRoomName(); + } + + const num = Math.floor(Math.random() * 900) + 100; + const roomId = `${roomName}-${num}`; + let room = rooms.set(roomId, new ServerRoom()); socket.send({ type: WebSocketMessageType.ROOM_CREATED, data: room.key }); try { - await joinRoom(room.key, socket); + await joinRoom(room.key, socket, true); } catch (e: any) { throw e; } @@ -55,18 +81,18 @@ async function createRoom(socket: Socket): Promise { return roomId; } -async function joinRoom(roomId: string, socket: Socket): Promise { +async function joinRoom(roomId: string, socket: Socket, initial?: boolean): Promise { let room = rooms.get(roomId); console.log(room?.length); // should be unreachable if (!room) { - socket.send({ type: WebSocketMessageType.ERROR, data: `Room ${roomId} does not exist` }); + socket.send({ type: WebSocketMessageType.ERROR, data: errors.ROOM_NOT_FOUND }); return undefined; } if (room.length == 2) { - socket.send({ type: WebSocketMessageType.ERROR, data: "Room is full" }); + socket.send({ type: WebSocketMessageType.ERROR, data: errors.ROOM_FULL }); return undefined; } @@ -93,6 +119,9 @@ async function joinRoom(roomId: string, socket: Socket): Promise client.ws !== ev.target)); }); + if (!initial) { + socket.send({ type: WebSocketMessageType.ROOM_JOINED, roomId: roomId, participants: room.length }); + } // TODO: consider letting rooms get larger than 2 clients if (room.length == 2) { room.forEachClient(client => client.send({ type: WebSocketMessageType.ROOM_READY, data: { isInitiator: client !== socket } })); @@ -109,7 +138,7 @@ function leaveRoom(roomId: string, socket: Socket): ServerRoom | undefined { // should be unreachable if (!room) { - socket.send({ type: WebSocketMessageType.ERROR, data: `Room ${roomId} does not exist` }); + socket.send({ type: WebSocketMessageType.ERROR, data: errors.ROOM_NOT_FOUND }); return undefined; } @@ -156,7 +185,7 @@ export function confgiureWebsocketServer(wss: WebSocketServer) { if (message === undefined) { console.log("Received non-JSON message:", event); // If the message is not JSON, send an error message - socket.send({ type: WebSocketMessageType.ERROR, data: 'Invalid message' }); + socket.send({ type: WebSocketMessageType.ERROR, data: errors.MALFORMED_MESSAGE }); return; } @@ -166,7 +195,17 @@ export function confgiureWebsocketServer(wss: WebSocketServer) { case WebSocketMessageType.CREATE_ROOM: // else, create a new room try { - await createRoom(socket); + if (message.roomName) { + // sanitize the room name + message.roomName = message.roomName.toLowerCase() + .replace(/\s+/g, '-') // Replace spaces with - + .replace(/[^\w-]+/g, '') // Remove all non-word chars + .replace(/--+/g, '-') // Replace multiple - with single - + .replace(/^-+/, '') // Trim - from start of text + .replace(/-+$/, ''); // Trim - from end of text + } + + await createRoom(socket, message.roomName); } catch (e: any) { socket.send({ type: WebSocketMessageType.ERROR, data: e.message }); throw e; @@ -174,29 +213,27 @@ export function confgiureWebsocketServer(wss: WebSocketServer) { break; case WebSocketMessageType.JOIN_ROOM: if (!message.roomId) { - socket.send({ type: WebSocketMessageType.ERROR, data: 'Invalid message' }); + socket.send({ type: WebSocketMessageType.ERROR, data: errors.MALFORMED_MESSAGE }); return; } if (rooms.get(message.roomId) == undefined) { - socket.send({ type: WebSocketMessageType.ERROR, data: 'Invalid roomId' }); + socket.send({ type: WebSocketMessageType.ERROR, data: errors.ROOM_NOT_FOUND }); return; } room = await joinRoom(message.roomId, socket); if (!room) return; - // the client is now in the room and the peer knows about it - socket.send({ type: WebSocketMessageType.ROOM_JOINED, roomId: message.roomId, participants: room.length }); break; case WebSocketMessageType.LEAVE_ROOM: if (!message.roomId) { - socket.send({ type: WebSocketMessageType.ERROR, data: 'Invalid message' }); + socket.send({ type: WebSocketMessageType.ERROR, data: errors.MALFORMED_MESSAGE }); return; } if (rooms.get(message.roomId) == undefined) { - socket.send({ type: WebSocketMessageType.ERROR, data: 'Invalid roomId' }); + socket.send({ type: WebSocketMessageType.ERROR, data: errors.ROOM_NOT_FOUND }); return; } @@ -220,7 +257,7 @@ export function confgiureWebsocketServer(wss: WebSocketServer) { break; default: console.warn(`Unknown message type: ${message.type}`); - socket.send({ type: WebSocketMessageType.ERROR, data: 'Unknown message type' }); + socket.send({ type: WebSocketMessageType.ERROR, data: errors.UNKNOWN_MESSAGE_TYPE }); break; } }); diff --git a/src/app.css b/src/app.css index 90a3140..7ae9a80 100644 --- a/src/app.css +++ b/src/app.css @@ -1,9 +1,51 @@ @import 'tailwindcss'; -body, html { - @apply bg-neutral-950 text-white font-sans min-h-screen; +@font-face { + font-family: "Instrument Sans"; + src: url("/fonts/InstrumentSans-VariableFont_wdth,wght.woff2") format("woff2"); + font-display: swap; +} + +:root { + --font-sans: "Instrument Sans", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + --test: #00060d; +} + +@theme { + --color-accent: #00e0b8; + --color-surface: #0f1626; + --color-paragraph: #e0e0e0; + --color-paragraph-muted: #8a94a6; + --color-primary: #00060d; +} + +body, +html { + @apply bg-primary font-sans min-h-screen text-paragraph; +} + +h1, +h2, +h3 { + color: #FFF; + line-height: 1.2; + margin-bottom: 1rem; +} + +h1 { + font-size: 3rem; +} + +h2 { + font-size: 2.25rem; + text-align: center; +} + +h3 { + font-size: 1.25rem; + font-weight: 600; } a { - @apply text-pink-600 underline hover:no-underline; + @apply text-accent underline hover:no-underline; } \ No newline at end of file diff --git a/src/components/LoadingSpinner.svelte b/src/components/LoadingSpinner.svelte new file mode 100644 index 0000000..cfff3a4 --- /dev/null +++ b/src/components/LoadingSpinner.svelte @@ -0,0 +1,20 @@ + + + + diff --git a/src/components/RTCMessage.svelte b/src/components/RTCMessage.svelte index b80fd0e..ffae5b5 100644 --- a/src/components/RTCMessage.svelte +++ b/src/components/RTCMessage.svelte @@ -1,7 +1,6 @@ + @@ -15,4 +43,23 @@ > -{@render children?.()} +
+
+
+ Noctis. +
+ +
+
+ +
+ {@render children?.()} +
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 2813a68..207c199 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,62 +1,265 @@ -
-

Welcome to Wormhole!

+
+
+

Your Private, Peer-to-Peer Chat Room

+

+ End-to-end encrypted. Peer-to-peer. No servers. No sign-ups. Just + chat. +

- {#if $webSocketConnected} - - {:else} -

Connecting to server...

- {/if} +
+ +
+ + +
+ or +
+
+
+ - {#if $room.id && browser} -

Room created!

-

Share this link with your friend:

- {location.origin}/{$room.id} - {/if} +
+
+

How It Works

+
+
+
+ 1 +
+

Create a Room

+

+ Click the button above to create a random room instantly, no + personal info required. +

+
+
+
+ 2 +
+

Share the Link

+

+ You'll get a unique link to your private room. Share this + link with anyone you want to chat with securely. +

+
+
+
+ 3 +
+

Chat Privately

+

+ Once they join, your messages are sent directly between your + devices, encrypted from end to end. Hidden from everyone + else. +

+
+
+
+
- - +
+
+

Security by Design

+
+
+
+ +
+

End-to-End Encrypted

+

+ Only you and the people in your room can read the messages. + Your data is encrypted before its sent using the Message + Layer Security (MLS) protocol. +

+
+
+
+ +
+

Truly Peer-to-Peer

+

+ Your messages are sent directly from your device to the + recipient's. They never pass through a central server. +

+
+
+
+ +
+

No Data Stored

+

+ We don't have accounts, and we don't store your messages. + Once you close the tab, the conversation is gone forever. +

+
+
+
+
+ +
+
+

+ © {new Date().getFullYear()} Noctis - MIT License +
+ Made with + + by + zoeissleeping +

+
+
+ + diff --git a/src/routes/[roomId]/+page.svelte b/src/routes/[roomId]/+page.svelte index fe276c9..9c38539 100644 --- a/src/routes/[roomId]/+page.svelte +++ b/src/routes/[roomId]/+page.svelte @@ -1,58 +1,121 @@ -
+
{#if $error} -

Hm. Something went wrong: {$error.toLocaleLowerCase()}

- {:else if $room.connectionState !== ConnectionState.CONNECTED && $room.connectionState !== ConnectionState.RECONNECTING} -

Connecting to server...

- {:else} - +

+ Something went wrong: {$error.toLocaleLowerCase()} +

+

+ click here to go back to the homepage +

+ {/if} + + {#if !$error} + {#if isHost} + {#if !$room.RTCConnectionReady} +

+ Your secure room is ready. +

+

+ Share the link below to invite someone to chat directly with + you. Once they join, you will be connected automatically. +

+ +
+ {roomLink} + +
+ {:else} + + {/if} + {:else if awaitingJoinConfirmation} +

+ You're invited to chat. +

+
+ + +
+ {:else} + + {/if} {/if}
diff --git a/src/stores/roomStore.ts b/src/stores/roomStore.ts index b3b9dd3..d0f5fcc 100644 --- a/src/stores/roomStore.ts +++ b/src/stores/roomStore.ts @@ -1,16 +1,19 @@ import { writable, type Writable } from 'svelte/store'; -import { ConnectionState } from '../types/websocket'; +import { RoomConnectionState } from '../types/websocket'; import { browser } from '$app/environment'; export interface Room { id: string | null; + host: boolean | null; + RTCConnectionReady: boolean; participants: number; - connectionState: ConnectionState; + connectionState: RoomConnectionState; } export const room: Writable = writable({ id: null, + host: null, + RTCConnectionReady: false, participants: 0, - connectionState: ConnectionState.DISCONNECTED, - key: null, + connectionState: RoomConnectionState.DISCONNECTED, }); \ No newline at end of file diff --git a/src/stores/websocketStore.ts b/src/stores/websocketStore.ts index c95f48b..c97940b 100644 --- a/src/stores/websocketStore.ts +++ b/src/stores/websocketStore.ts @@ -1,55 +1,105 @@ -import { get, writable } from 'svelte/store'; +import { get, writable, type Readable, type Writable } from 'svelte/store'; import { browser } from '$app/environment'; -import { room } from './roomStore'; -import { ConnectionState, Socket, WebSocketMessageType } from '../types/websocket'; +import { Socket, type WebSocketMessage } from '../types/websocket'; +import { handleMessage } from '../utils/webrtcUtil'; -let socket: Socket | null = null; -export const webSocketConnected = writable(false); - -function createSocket(): Socket { - if (!browser) { - // this only occurs on the server, which we dont care about because its not a client that can actually connect to the websocket server - // @ts-ignore - return null; - } - - if (socket) { - return socket; - } - - const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; - socket = new Socket(new WebSocket(`${protocol}//${location.host}/`)); - - socket.addEventListener('open', () => { - webSocketConnected.set(true); - console.log('Connected to websocket server'); - }); - - socket.addEventListener('close', () => { - // TODO: massively rework the reconnection logic, currently it only works if one client disconnects, if the - // TODO: other client disconnects after the other client has diconnected at least once, everything explodes - if (get(webSocketConnected) && get(room)?.connectionState === ConnectionState.CONNECTED) { - room.update((room) => ({ ...room, connectionState: ConnectionState.RECONNECTING })); - - setTimeout(() => { - ws.set(createSocket()); - - // attempt to rejoin the room if we were previously connected - get(ws).addEventListener('open', () => { - let oldRoomId = get(room)?.id; - if (oldRoomId) { - get(ws).send({ type: WebSocketMessageType.JOIN_ROOM, roomId: oldRoomId }); - room.update((room) => ({ ...room, connectionState: ConnectionState.CONNECTED })); - } - }); - }, 1000); - } - webSocketConnected.set(false); - socket = null; - console.log('Disconnected from websocket server, reconnecting...'); - }); - - return socket; +export enum WebsocketConnectionState { + DISCONNECTED, + CONNECTING, + CONNECTED, + RECONNECTING } -export const ws = writable(createSocket()); +interface WebSocketStoreValue { + status: WebsocketConnectionState; + socket: Socket | null; +} + +export type MessageHandler = (event: MessageEvent) => void; + +interface WebSocketStore extends Readable { + connect: () => void; + disconnect: () => void; + send: (message: WebSocketMessage) => void; +} + +// TODO: handle reconnection logic to room elsewhere (not implemented here) +function createWebSocketStore(messageHandler: MessageHandler): WebSocketStore { + const { subscribe, set, update } = writable({ status: WebsocketConnectionState.DISCONNECTED, socket: null }); + + let reconnectTimeout: NodeJS.Timeout | null = null; + let reconnectAttempts = 0; + + const send = (message: WebSocketMessage) => { + let currentState = get({ subscribe }); + if (currentState.socket?.readyState === WebSocket.OPEN) { + currentState.socket.send(message); + } else { + console.error("Socket not connected"); + } + }; + + const disconnect = () => { + let currentState = get({ subscribe }); + if (currentState.socket) { + currentState.socket.close(); + set({ status: WebsocketConnectionState.DISCONNECTED, socket: null }); + } + }; + + const connect = () => { + if (!browser) { + return; + } + + const currentState = get({ subscribe }); + if (currentState.socket || currentState.status === WebsocketConnectionState.CONNECTING) { + // already connected/connecting + return; + } + + update(s => ({ ...s, status: WebsocketConnectionState.CONNECTING })); + + const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const socket = new Socket(new WebSocket(`${protocol}//${location.host}/`)); + + socket.addEventListener('open', () => { + console.log('Connected to websocket server'); + reconnectAttempts = 0; + update(s => ({ ...s, status: WebsocketConnectionState.CONNECTED, socket })); + }); + + socket.addEventListener('message', messageHandler); + + socket.addEventListener('close', () => { + console.log('Disconnected from websocket server,'); + update(s => ({ ...s, socket: null })); + + // exponential backoff + const timeout = Math.min(Math.pow(2, reconnectAttempts) * 1000, 30000); + reconnectAttempts++; + + console.log(`Reconnecting in ${timeout / 1000}s...`); + update(s => ({ ...s, status: WebsocketConnectionState.RECONNECTING })); + + reconnectTimeout = setTimeout(() => { + connect(); + }, timeout); + }); + + socket.addEventListener('error', () => { + console.error('Error connecting to websocket server'); + socket.close(); + // close will trigger a reconnect + }); + }; + + return { + subscribe, + connect, + disconnect, + send, + }; +} + +export const ws = createWebSocketStore(handleMessage); \ No newline at end of file diff --git a/src/types/websocket.ts b/src/types/websocket.ts index 9090bb4..378c24d 100644 --- a/src/types/websocket.ts +++ b/src/types/websocket.ts @@ -1,4 +1,4 @@ -export enum ConnectionState { +export enum RoomConnectionState { CONNECTING, RECONNECTING, CONNECTED, @@ -8,7 +8,7 @@ export enum ConnectionState { export interface Room { id: string | null; participants: number; - connectionState: ConnectionState; + connectionState: RoomConnectionState; } export enum WebSocketMessageType { @@ -51,6 +51,7 @@ interface ErrorMessage { interface CreateRoomMessage { type: WebSocketMessageType.CREATE_ROOM; + roomName?: string; } interface JoinRoomMessage { @@ -129,11 +130,16 @@ export class Socket { console.log("WebSocket opened"); }); + this.addEventListener = this.ws.addEventListener.bind(this.ws); this.removeEventListener = this.ws.removeEventListener.bind(this.ws); this.close = this.ws.close.bind(this.ws); } + get readyState(): number { + return this.ws.readyState; + } + public send(message: WebSocketMessage) { console.log("Sending message:", message); diff --git a/src/utils/webrtcUtil.ts b/src/utils/webrtcUtil.ts index 9f2ce67..0d586d1 100644 --- a/src/utils/webrtcUtil.ts +++ b/src/utils/webrtcUtil.ts @@ -2,11 +2,12 @@ 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 { ConnectionState, type Room } from "../types/websocket"; +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"; export const error: Writable = writable(null); export let peer: Writable = writable(null); @@ -255,7 +256,8 @@ export async function handleMessage(event: MessageEvent) { switch (message.type) { case WebSocketMessageType.ROOM_CREATED: console.log("Room created:", message.data); - room.update((room) => ({ ...room, id: message.data, connectionState: ConnectionState.CONNECTED, participants: 1 })); + room.set({ id: message.data, host: true, RTCConnectionReady: false, connectionState: RoomConnectionState.CONNECTED, participants: 1 }); + goto(`/${message.data}`); return; case WebSocketMessageType.JOIN_ROOM: console.log("new client joined room"); @@ -263,7 +265,8 @@ export async function handleMessage(event: MessageEvent) { return; case WebSocketMessageType.ROOM_JOINED: // TODO: if a client disconnects, we need to resync the room state - room.update((room) => ({ ...room, connectionState: ConnectionState.CONNECTED, participants: message.participants })); + + room.set({ host: false, id: message.roomId, RTCConnectionReady: false, connectionState: RoomConnectionState.CONNECTED, participants: message.participants }); console.log("Joined room"); return; case WebSocketMessageType.ROOM_LEFT: @@ -282,6 +285,8 @@ export async function handleMessage(event: MessageEvent) { return; } + room.update(r => ({ ...r, RTCConnectionReady: true })); + console.log("Creating peer"); peer.set(new WebRTCPeer( roomId, diff --git a/static/fonts/InstrumentSans-VariableFont_wdth,wght.woff2 b/static/fonts/InstrumentSans-VariableFont_wdth,wght.woff2 new file mode 100644 index 0000000..02203d6 Binary files /dev/null and b/static/fonts/InstrumentSans-VariableFont_wdth,wght.woff2 differ