├── .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 |
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 |
--------------------------------------------------------------------------------