├── .eslintrc.cjs ├── .gitignore ├── README.md ├── app ├── entry.client.tsx ├── entry.server.tsx ├── root.tsx ├── routes │ └── _index.tsx ├── tailwind.css └── utils │ ├── crypto.ts │ └── websocket.ts ├── package-lock.json ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── public ├── favicon.ico ├── logo-dark.png └── logo-light.png ├── server └── ws.js ├── tailwind.config.ts ├── tsconfig.json └── vite.config.ts /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** 2 | * This is intended to be a basic starting point for linting in your app. 3 | * It relies on recommended configs out of the box for simplicity, but you can 4 | * and should modify this configuration to best suit your team's needs. 5 | */ 6 | 7 | /** @type {import('eslint').Linter.Config} */ 8 | module.exports = { 9 | root: true, 10 | parserOptions: { 11 | ecmaVersion: "latest", 12 | sourceType: "module", 13 | ecmaFeatures: { 14 | jsx: true, 15 | }, 16 | }, 17 | env: { 18 | browser: true, 19 | commonjs: true, 20 | es6: true, 21 | }, 22 | ignorePatterns: ["!**/.server", "!**/.client"], 23 | 24 | // Base config 25 | extends: ["eslint:recommended"], 26 | 27 | overrides: [ 28 | // React 29 | { 30 | files: ["**/*.{js,jsx,ts,tsx}"], 31 | plugins: ["react", "jsx-a11y"], 32 | extends: [ 33 | "plugin:react/recommended", 34 | "plugin:react/jsx-runtime", 35 | "plugin:react-hooks/recommended", 36 | "plugin:jsx-a11y/recommended", 37 | ], 38 | settings: { 39 | react: { 40 | version: "detect", 41 | }, 42 | formComponents: ["Form"], 43 | linkComponents: [ 44 | { name: "Link", linkAttribute: "to" }, 45 | { name: "NavLink", linkAttribute: "to" }, 46 | ], 47 | "import/resolver": { 48 | typescript: {}, 49 | }, 50 | }, 51 | }, 52 | 53 | // Typescript 54 | { 55 | files: ["**/*.{ts,tsx}"], 56 | plugins: ["@typescript-eslint", "import"], 57 | parser: "@typescript-eslint/parser", 58 | settings: { 59 | "import/internal-regex": "^~/", 60 | "import/resolver": { 61 | node: { 62 | extensions: [".ts", ".tsx"], 63 | }, 64 | typescript: { 65 | alwaysTryTypes: true, 66 | }, 67 | }, 68 | }, 69 | extends: [ 70 | "plugin:@typescript-eslint/recommended", 71 | "plugin:import/recommended", 72 | "plugin:import/typescript", 73 | ], 74 | }, 75 | 76 | // Node 77 | { 78 | files: [".eslintrc.cjs"], 79 | env: { 80 | node: true, 81 | }, 82 | }, 83 | ], 84 | }; 85 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /build 5 | .env 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # E2EE Chat 2 | 3 | E2EE Chat is an end-to-end encrypted chat application built with Remix, WebSockets, and libsodium for encryption. This project demonstrates how to create a secure chat application where messages are encrypted on the client side and decrypted on the client side, ensuring that the server cannot read the messages. 4 | 5 | ## Features 6 | 7 | - End-to-end encryption using libsodium 8 | - Real-time messaging with WebSockets 9 | - Modern UI with DaisyUI 10 | 11 | ## Getting Started 12 | 13 | ### Prerequisites 14 | 15 | - Node.js (v14 or higher) 16 | - pnpm 17 | 18 | ### Installation 19 | 20 | 1. Clone the repository: 21 | 22 | ```sh 23 | git clone https://github.com/yourusername/e2e-chat.git 24 | cd e2e-chat 25 | ``` 26 | 27 | 2. Install dependencies: 28 | 29 | ```sh 30 | pnpm install 31 | ``` 32 | 33 | ### Running the Application 34 | 35 | 1. Start the WebSocket server: 36 | 37 | ```sh 38 | node server/ws.js 39 | ``` 40 | 41 | 2. Start the Remix development server: 42 | 43 | ```sh 44 | pnpm run dev 45 | ``` 46 | 47 | 3. Open your browser and navigate to `http://localhost:5173`. 48 | 49 | ### Project Structure 50 | 51 | - `app/routes/_index.tsx`: The main chat interface. 52 | - `app/utils/crypto.ts`: Utility functions for encryption and decryption using libsodium. 53 | - `app/utils/websocket.ts`: Utility function to connect to the WebSocket server. 54 | - `server/ws.js`: WebSocket server implementation. 55 | 56 | ### How It Works 57 | 58 | 1. When the client connects to the WebSocket server, it generates a key pair using `libsodium`. 59 | 2. Messages are encrypted on the client side using a shared key and sent to the server. 60 | 3. The server broadcasts the encrypted messages to all connected clients. 61 | 4. Clients decrypt the received messages using the shared key. 62 | -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * By default, Remix will handle hydrating your app on the client for you. 3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ 4 | * For more information, see https://remix.run/file-conventions/entry.client 5 | */ 6 | 7 | import { RemixBrowser } from "@remix-run/react"; 8 | import { startTransition, StrictMode } from "react"; 9 | import { hydrateRoot } from "react-dom/client"; 10 | 11 | startTransition(() => { 12 | hydrateRoot( 13 | document, 14 | 15 | 16 | 17 | ); 18 | }); 19 | -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * By default, Remix will handle generating the HTTP Response for you. 3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ 4 | * For more information, see https://remix.run/file-conventions/entry.server 5 | */ 6 | 7 | import { PassThrough } from "node:stream"; 8 | 9 | import type { AppLoadContext, EntryContext } from "@remix-run/node"; 10 | import { createReadableStreamFromReadable } from "@remix-run/node"; 11 | import { RemixServer } from "@remix-run/react"; 12 | import { isbot } from "isbot"; 13 | import { renderToPipeableStream } from "react-dom/server"; 14 | 15 | const ABORT_DELAY = 5_000; 16 | 17 | export default function handleRequest( 18 | request: Request, 19 | responseStatusCode: number, 20 | responseHeaders: Headers, 21 | remixContext: EntryContext, 22 | // This is ignored so we can keep it in the template for visibility. Feel 23 | // free to delete this parameter in your app if you're not using it! 24 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 25 | loadContext: AppLoadContext 26 | ) { 27 | return isbot(request.headers.get("user-agent") || "") 28 | ? handleBotRequest( 29 | request, 30 | responseStatusCode, 31 | responseHeaders, 32 | remixContext 33 | ) 34 | : handleBrowserRequest( 35 | request, 36 | responseStatusCode, 37 | responseHeaders, 38 | remixContext 39 | ); 40 | } 41 | 42 | function handleBotRequest( 43 | request: Request, 44 | responseStatusCode: number, 45 | responseHeaders: Headers, 46 | remixContext: EntryContext 47 | ) { 48 | return new Promise((resolve, reject) => { 49 | let shellRendered = false; 50 | const { pipe, abort } = renderToPipeableStream( 51 | , 56 | { 57 | onAllReady() { 58 | shellRendered = true; 59 | const body = new PassThrough(); 60 | const stream = createReadableStreamFromReadable(body); 61 | 62 | responseHeaders.set("Content-Type", "text/html"); 63 | 64 | resolve( 65 | new Response(stream, { 66 | headers: responseHeaders, 67 | status: responseStatusCode, 68 | }) 69 | ); 70 | 71 | pipe(body); 72 | }, 73 | onShellError(error: unknown) { 74 | reject(error); 75 | }, 76 | onError(error: unknown) { 77 | responseStatusCode = 500; 78 | // Log streaming rendering errors from inside the shell. Don't log 79 | // errors encountered during initial shell rendering since they'll 80 | // reject and get logged in handleDocumentRequest. 81 | if (shellRendered) { 82 | console.error(error); 83 | } 84 | }, 85 | } 86 | ); 87 | 88 | setTimeout(abort, ABORT_DELAY); 89 | }); 90 | } 91 | 92 | function handleBrowserRequest( 93 | request: Request, 94 | responseStatusCode: number, 95 | responseHeaders: Headers, 96 | remixContext: EntryContext 97 | ) { 98 | return new Promise((resolve, reject) => { 99 | let shellRendered = false; 100 | const { pipe, abort } = renderToPipeableStream( 101 | , 106 | { 107 | onShellReady() { 108 | shellRendered = true; 109 | const body = new PassThrough(); 110 | const stream = createReadableStreamFromReadable(body); 111 | 112 | responseHeaders.set("Content-Type", "text/html"); 113 | 114 | resolve( 115 | new Response(stream, { 116 | headers: responseHeaders, 117 | status: responseStatusCode, 118 | }) 119 | ); 120 | 121 | pipe(body); 122 | }, 123 | onShellError(error: unknown) { 124 | reject(error); 125 | }, 126 | onError(error: unknown) { 127 | responseStatusCode = 500; 128 | // Log streaming rendering errors from inside the shell. Don't log 129 | // errors encountered during initial shell rendering since they'll 130 | // reject and get logged in handleDocumentRequest. 131 | if (shellRendered) { 132 | console.error(error); 133 | } 134 | }, 135 | } 136 | ); 137 | 138 | setTimeout(abort, ABORT_DELAY); 139 | }); 140 | } 141 | -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Links, 3 | Meta, 4 | Outlet, 5 | Scripts, 6 | ScrollRestoration, 7 | } from "@remix-run/react"; 8 | import type { LinksFunction } from "@remix-run/node"; 9 | 10 | import "./tailwind.css"; 11 | 12 | export const links: LinksFunction = () => [ 13 | { rel: "preconnect", href: "https://fonts.googleapis.com" }, 14 | { 15 | rel: "preconnect", 16 | href: "https://fonts.gstatic.com", 17 | crossOrigin: "anonymous", 18 | }, 19 | { 20 | rel: "stylesheet", 21 | href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap", 22 | }, 23 | ]; 24 | 25 | export function Layout({ children }: { children: React.ReactNode }) { 26 | return ( 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | {children} 36 | 37 | 38 | 39 | 40 | ); 41 | } 42 | 43 | export default function App() { 44 | return ; 45 | } 46 | -------------------------------------------------------------------------------- /app/routes/_index.tsx: -------------------------------------------------------------------------------- 1 | import type { MetaFunction } from "@remix-run/node"; 2 | import { useEffect, useRef, useState } from "react"; 3 | import { decryptMessage, encryptMessage, generateKeyPair } from "~/utils/crypto"; 4 | import { connectWebSocket } from "~/utils/websocket"; 5 | 6 | export const meta: MetaFunction = () => { 7 | return [ 8 | { title: "E2EE Chat" }, 9 | ]; 10 | }; 11 | 12 | export default function Index() { 13 | const [messages, setMessage] = useState([]); 14 | const [input, setInput] = useState(""); 15 | const socket = useRef(null); 16 | 17 | useEffect(() => { 18 | const setupSocket = async () => { 19 | if (socket.current) return; 20 | 21 | const { publicKey, privateKey } = await generateKeyPair(); 22 | const sharedKey = new Uint8Array(32); // Use a consistent key for encryption and decryption 23 | 24 | const ws = connectWebSocket(async (data) => { 25 | console.log(`Received: ${JSON.stringify(data)}`); 26 | if (data.ciphertext) { 27 | const { ciphertext, nonce } = data; 28 | const decryptedMessage = await decryptMessage( 29 | new Uint8Array(Object.values(ciphertext)), 30 | new Uint8Array(Object.values(nonce)), 31 | sharedKey 32 | ); 33 | 34 | setMessage((prev) => [...prev, decryptedMessage]); 35 | } 36 | }); 37 | 38 | socket.current = ws; 39 | } 40 | 41 | setupSocket(); 42 | 43 | return () => { 44 | if (socket.current) { 45 | socket.current.close(); 46 | } 47 | }; 48 | }, []); 49 | 50 | const sendMessage = async () => { 51 | if(!socket.current) { 52 | return; 53 | } 54 | 55 | const sharedKey = new Uint8Array(32); 56 | const { ciphertext, nonce } = await encryptMessage(input, sharedKey); 57 | 58 | socket.current.send(JSON.stringify({ ciphertext, nonce })); 59 | setInput(''); 60 | } 61 | 62 | return ( 63 |
64 |
65 |
66 | {messages.map((msg, index) => ( 67 |
68 |
{msg}
69 |
70 | ))} 71 |
72 |
73 | 74 |
75 | setInput(e.target.value)} 79 | placeholder="Type your message..." 80 | /> 81 | 87 |
88 |
89 | ); 90 | } -------------------------------------------------------------------------------- /app/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | html, 6 | body { 7 | @apply bg-white dark:bg-gray-950; 8 | 9 | @media (prefers-color-scheme: dark) { 10 | color-scheme: dark; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /app/utils/crypto.ts: -------------------------------------------------------------------------------- 1 | import sodium from 'libsodium-wrappers'; 2 | import { c } from 'node_modules/vite/dist/node/types.d-aGj9QkWt'; 3 | 4 | export async function generateKeyPair() { 5 | await sodium.ready; 6 | return sodium.crypto_kx_keypair(); 7 | } 8 | 9 | export async function encryptMessage(message: string, key: Uint8Array) { 10 | const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES); 11 | const ciphertext = sodium.crypto_secretbox_easy(message, nonce, key); 12 | 13 | return { ciphertext, nonce }; 14 | } 15 | 16 | export async function decryptMessage(cipherText: Uint8Array, nonce: Uint8Array, key: Uint8Array) { 17 | const decrypted = sodium.crypto_secretbox_open_easy(cipherText, nonce, key); 18 | return new TextDecoder().decode(decrypted); 19 | } -------------------------------------------------------------------------------- /app/utils/websocket.ts: -------------------------------------------------------------------------------- 1 | export function connectWebSocket(onMessage: (data: unknown) => void) { 2 | const socket = new WebSocket('ws://localhost:8080'); 3 | 4 | socket.onopen = () => console.log("WebSocket connected!"); 5 | socket.onerror = (error) => console.error("WebSocket error:", error); 6 | 7 | socket.onmessage = (event) => { 8 | console.log("Received from server:", event.data); 9 | 10 | onMessage(JSON.parse(event.data)); 11 | } 12 | 13 | return socket; 14 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "e2e-chat", 3 | "private": true, 4 | "sideEffects": false, 5 | "type": "module", 6 | "scripts": { 7 | "build": "remix vite:build", 8 | "dev": "remix vite:dev", 9 | "lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .", 10 | "start": "remix-serve ./build/server/index.js", 11 | "typecheck": "tsc" 12 | }, 13 | "dependencies": { 14 | "@remix-run/node": "^2.15.2", 15 | "@remix-run/react": "^2.15.2", 16 | "@remix-run/serve": "^2.15.2", 17 | "isbot": "^4.1.0", 18 | "libsodium-wrappers": "^0.7.15", 19 | "react": "^18.2.0", 20 | "react-dom": "^18.2.0", 21 | "ws": "^8.18.0" 22 | }, 23 | "devDependencies": { 24 | "@remix-run/dev": "^2.15.2", 25 | "@types/libsodium-wrappers": "^0.7.14", 26 | "@types/react": "^18.2.20", 27 | "@types/react-dom": "^18.2.7", 28 | "@typescript-eslint/eslint-plugin": "^6.7.4", 29 | "@typescript-eslint/parser": "^6.7.4", 30 | "autoprefixer": "^10.4.19", 31 | "eslint": "^8.38.0", 32 | "eslint-import-resolver-typescript": "^3.6.1", 33 | "eslint-plugin-import": "^2.28.1", 34 | "eslint-plugin-jsx-a11y": "^6.7.1", 35 | "eslint-plugin-react": "^7.33.2", 36 | "eslint-plugin-react-hooks": "^4.6.0", 37 | "postcss": "^8.4.38", 38 | "tailwindcss": "^3.4.4", 39 | "typescript": "^5.1.6", 40 | "vite": "^5.1.0", 41 | "vite-tsconfig-paths": "^4.2.1" 42 | }, 43 | "engines": { 44 | "node": ">=20.0.0" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/revanp/e2ee-chat/c842f46ac139440afe8df03856478a3bcc46551a/public/favicon.ico -------------------------------------------------------------------------------- /public/logo-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/revanp/e2ee-chat/c842f46ac139440afe8df03856478a3bcc46551a/public/logo-dark.png -------------------------------------------------------------------------------- /public/logo-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/revanp/e2ee-chat/c842f46ac139440afe8df03856478a3bcc46551a/public/logo-light.png -------------------------------------------------------------------------------- /server/ws.js: -------------------------------------------------------------------------------- 1 | import { WebSocketServer } from "ws"; 2 | 3 | const wss = new WebSocketServer({ port: 8080 }); 4 | 5 | wss.on('connection', (ws) => { 6 | ws.on('message', (message) => { 7 | const data = JSON.parse(message); 8 | 9 | console.log('Received: ', data); 10 | 11 | wss.clients.forEach((client) => { 12 | if (client !== ws && client.readyState === WebSocket.OPEN) { 13 | client.send(JSON.stringify(data)); 14 | } 15 | }) 16 | }); 17 | 18 | ws.on('close', () => { 19 | console.log('Client disconnected'); 20 | }); 21 | }); 22 | 23 | console.log("WebSocket server running on ws://localhost:8080"); -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | export default { 4 | content: ["./app/**/{**,.client,.server}/**/*.{js,jsx,ts,tsx}"], 5 | theme: { 6 | extend: { 7 | fontFamily: { 8 | sans: [ 9 | "Inter", 10 | "ui-sans-serif", 11 | "system-ui", 12 | "sans-serif", 13 | "Apple Color Emoji", 14 | "Segoe UI Emoji", 15 | "Segoe UI Symbol", 16 | "Noto Color Emoji", 17 | ], 18 | }, 19 | }, 20 | }, 21 | plugins: [], 22 | } satisfies Config; 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "**/*.ts", 4 | "**/*.tsx", 5 | "**/.server/**/*.ts", 6 | "**/.server/**/*.tsx", 7 | "**/.client/**/*.ts", 8 | "**/.client/**/*.tsx" 9 | ], 10 | "compilerOptions": { 11 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 12 | "types": ["@remix-run/node", "vite/client"], 13 | "isolatedModules": true, 14 | "esModuleInterop": true, 15 | "jsx": "react-jsx", 16 | "module": "ESNext", 17 | "moduleResolution": "Bundler", 18 | "resolveJsonModule": true, 19 | "target": "ES2022", 20 | "strict": true, 21 | "allowJs": true, 22 | "skipLibCheck": true, 23 | "forceConsistentCasingInFileNames": true, 24 | "baseUrl": ".", 25 | "paths": { 26 | "~/*": ["./app/*"] 27 | }, 28 | 29 | // Vite takes care of building everything, not tsc. 30 | "noEmit": true 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { vitePlugin as remix } from "@remix-run/dev"; 2 | import { defineConfig } from "vite"; 3 | import tsconfigPaths from "vite-tsconfig-paths"; 4 | 5 | declare module "@remix-run/node" { 6 | interface Future { 7 | v3_singleFetch: true; 8 | } 9 | } 10 | 11 | export default defineConfig({ 12 | plugins: [ 13 | remix({ 14 | future: { 15 | v3_fetcherPersist: true, 16 | v3_relativeSplatPath: true, 17 | v3_throwAbortReason: true, 18 | v3_singleFetch: true, 19 | v3_lazyRouteDiscovery: true, 20 | }, 21 | }), 22 | tsconfigPaths(), 23 | ], 24 | }); 25 | --------------------------------------------------------------------------------