├── .gitignore ├── README.md ├── e2eechat.mp4 ├── index.css ├── index.html ├── index.js ├── worker.mjs └── wrangler.toml /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cf-e2eechat 2 | 3 | End-to-end encrypted chat demo using [Cloudflare Workers](https://developers.cloudflare.com/workers) and [Durable Objects](https://developers.cloudflare.com/workers/runtime-apis/durable-objects/). 4 | 5 | Live demo: https://e2eechat.migrant.workers.dev/ 6 | 7 | Screencast: [e2eechat.mp4](e2eechat.mp4) 8 | 9 | ## Usage 10 | 11 | 1. Two users start chatting over an insecure channel which might be eavesdropping on the conversation. 12 | 2. Both parties open https://e2eechat.migrant.workers.dev/ and copy their public key. 13 | 3. They exchange keys over the existing channel and enter the other party's key in e2eechat. 14 | 4. Once both users join the shared channel, they can start messaging each other with end-to-end encryption. 15 | 16 | ## Inner workings 17 | 18 | * [worker.mjs](worker.mjs) runs as a Cloudflare Worker to serve static contents (index.*) and delegates websocket requests to a Durable Object "`Channel`". 19 | * When [index.html](index.html) is loaded, [index.js](index.js) generates an ephemeral ECC key pair on the client side. The public key is hex-encoded and shown to the user for sharing. 20 | * After the user enters the other party's public key, a websocket connection is established at `/api/channel/{channel-id}`. The channel id is deterministically generated from both public keys. 21 | * When the other user joins the channel which is handled by the same Durable Object instance, both clients are ready for E2EE messaging. 22 | * Using a secret derived from the sender's private key and the recipient's public key, each message is AES-encrypted on the client side, relayed by the shared Durable Object on the server side, and eventually decrypted by the recipient on the other end. 23 | 24 | ## Limitations 25 | 26 | * A channel allows two users only. It requires a more sophisticated E2EE protocol to support group chat. 27 | * For simplicity's sake, this demo does not prevent denial-of-service, message replay, and message tampering attacks. 28 | * No attempt is made to reconnect to a broken websocket session. -------------------------------------------------------------------------------- /e2eechat.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zmxv/cf-e2eechat/b7935db25525bda4cbd69de275724e7949887d86/e2eechat.mp4 -------------------------------------------------------------------------------- /index.css: -------------------------------------------------------------------------------- 1 | body { 2 | line-height: 150%; 3 | padding: 8px; 4 | margin: 0; 5 | background-color: #000; 6 | font-family: monospace; 7 | font-size: 13px; 8 | } 9 | 10 | #log { 11 | color: #999; 12 | } 13 | 14 | #input { 15 | font-family: monospace; 16 | font-size: 13px; 17 | background-color: transparent; 18 | color: #fff; 19 | margin: 0; 20 | padding: 0; 21 | outline: none; 22 | border: none; 23 | flex-grow: 1; 24 | } 25 | 26 | .console { 27 | display: flex; 28 | flex-direction: row; 29 | } 30 | 31 | .prompt, .debug { 32 | color: #666; 33 | } 34 | 35 | .pub-key, .local { 36 | color: #adff2f; 37 | } 38 | 39 | .ext-key, .remote { 40 | color: #daa520; 41 | } 42 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | E2EE Chat 6 | 7 | 8 | 9 |
10 |
11 | > 12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // Key exchange parameters. 2 | const keyAlgo = "ECDH"; 3 | const namedCurve = "P-256"; 4 | 5 | // Message encryption parameters. 6 | const encAlgo = "AES-GCM"; 7 | const encAlgoLen = 256; 8 | const encIVLen = 12; 9 | 10 | // Encoder and decoder functions. 11 | const arrayToHex = a => a.reduce((r, i) => r + ("0" + i.toString(16)).slice(-2), ""); 12 | const hexToArray = h => new Uint8Array(h.match(/[0-9a-f]{2}/g).map(s => parseInt(s, 16))); 13 | const uint8Array = a => new Uint8Array(a); 14 | const encodeText = t => new TextEncoder().encode(t); 15 | const decodeText = a => new TextDecoder().decode(a); 16 | 17 | // EC and AES utility functions. 18 | const generateIV = () => crypto.getRandomValues(new Uint8Array(encIVLen)); 19 | const generateKey = () => crypto.subtle.generateKey({name: keyAlgo, namedCurve}, true, ["deriveKey"]); 20 | const importPubKey = key => crypto.subtle.importKey("raw", key, {name: keyAlgo, namedCurve}, false, []); 21 | const deriveKey = (pvt, pub) => crypto.subtle.deriveKey({name: keyAlgo, public: pub}, pvt, {name: encAlgo, length: encAlgoLen}, false, ["encrypt", "decrypt"]); 22 | const encryptAES = (raw, iv, secret) => crypto.subtle.encrypt({name: encAlgo, iv}, secret, raw); 23 | const decryptAES = (cipher, iv, secret) => crypto.subtle.decrypt({name: encAlgo, iv}, secret, cipher); 24 | const exportKey = key => crypto.subtle.exportKey("raw", key).then(uint8Array); 25 | const encryptText = async (text, secret, _) => (_ = generateIV(), arrayToHex(_) + arrayToHex(uint8Array(await encryptAES(encodeText(text), _, secret)))); 26 | const decryptText = async (hex, secret) => decodeText(await decryptAES(hexToArray(hex.substr(encIVLen*2)), hexToArray(hex.substr(0, encIVLen*2)), secret)); 27 | 28 | // Global state. 29 | const g = { 30 | logEl: document.getElementById("log"), 31 | inputEl: document.getElementById("input"), 32 | state: "", 33 | ws: null, 34 | pvtKey: null, 35 | pubKey: null, 36 | extKey: null, 37 | secret: null, 38 | setExtKey: async key => (g.extKey = key, g.secret = await deriveKey(g.pvtKey, await importPubKey(key))), 39 | }; 40 | 41 | // Add a line to the bottom. 42 | function log(line, cls) { 43 | const div = document.createElement("div"); 44 | div.innerText = line; 45 | div.className = cls || ""; 46 | g.logEl.appendChild(div); 47 | scrollTo(0, document.body.clientHeight); 48 | } 49 | 50 | // Establish a WebSocket connection for E2EE communication. 51 | function joinChannel(channel) { 52 | const ws = new WebSocket(`${location.protocol.endsWith("s:") ? "wss" : "ws"}://${location.host}/api/channel/${channel}`); 53 | ws.addEventListener("open", _ => { 54 | g.state = "open"; 55 | log("Channel is opened"); 56 | }); 57 | ws.addEventListener("message", async ev => { 58 | try { 59 | const j = JSON.parse(ev.data); 60 | switch (j.op) { 61 | case 1: 62 | if (g.state === "ready") { 63 | log("The other party has left"); 64 | g.ws.close(); 65 | } else { 66 | g.state = "wait"; 67 | log("Waiting for the other party to join"); 68 | } 69 | break; 70 | case 2: 71 | g.state = "ready"; 72 | log("Ready for e2ee communication"); 73 | break; 74 | case 3: 75 | log(j.data, "debug"); 76 | log(await decryptText(j.data, g.secret), "remote"); 77 | break; 78 | default: 79 | throw Error(); 80 | } 81 | } catch (_) { 82 | log("Received invalid message"); 83 | } 84 | }); 85 | ws.addEventListener("close", _ => { 86 | g.state = "close"; 87 | log("Channel is closed"); 88 | }); 89 | ws.addEventListener("error", _ => { 90 | g.state = "fail"; 91 | log("Channel is broken"); 92 | }); 93 | g.ws = ws; 94 | }; 95 | 96 | // Parse the other party's public key and join a channel. 97 | async function parseExtKey(h) { 98 | // For simplicity, only uncompressed public keys are accepted. 99 | if (/^04[0-9a-f]{128}$/.test(h)) { 100 | await g.setExtKey(hexToArray(h)); 101 | log(h, "ext-key"); 102 | 103 | g.state = "join"; 104 | // Generate a shared channel id by XOR'ing the x-coordinates of two public keys. 105 | const a = []; 106 | for (let i = 1; i <= 32; i++) { 107 | a.push(g.pubKey[i] ^ g.extKey[i]); 108 | } 109 | const channel = arrayToHex(a); 110 | log("Joining channel:"); 111 | log(channel, "debug"); 112 | joinChannel(channel); 113 | } 114 | } 115 | 116 | // Send encrypted message via WebSocket. 117 | async function sendText(text) { 118 | const data = await encryptText(text, g.secret); 119 | log(data, "debug"); 120 | log(text, "local"); 121 | g.ws.send(JSON.stringify({op: 3, data})); 122 | } 123 | 124 | // Process user input based on the current state. 125 | function enterLine(input) { 126 | const line = input.value.trim(); 127 | if (line) { 128 | switch (g.state) { 129 | case "init": 130 | parseExtKey(line); 131 | break; 132 | case "ready": 133 | sendText(line); 134 | break; 135 | } 136 | } 137 | input.value = ""; 138 | } 139 | 140 | async function start() { 141 | g.inputEl.addEventListener("keypress", ev => ev.key === "Enter" && enterLine(ev.currentTarget)); 142 | log("E2EE Chat"); 143 | 144 | // Generate an ephemeral key pair. 145 | const kp = await generateKey(); 146 | g.pvtKey = kp.privateKey; 147 | g.pubKey = await exportKey(kp.publicKey); 148 | log("Share your ephemeral public key with the other party:"); 149 | log(arrayToHex(g.pubKey), "pub-key"); 150 | 151 | g.state = "init"; 152 | log("Enter the other party's public key below:"); 153 | } 154 | 155 | start(); -------------------------------------------------------------------------------- /worker.mjs: -------------------------------------------------------------------------------- 1 | import index_html from "./index.html"; 2 | import index_css from "./index.css"; 3 | import index_js from "./index.js"; 4 | 5 | export default { 6 | async fetch(req, env) { 7 | const url = new URL(req.url); 8 | const path = url.pathname; 9 | // Serve static content. 10 | if (path === "/") { 11 | return new Response(index_html, {headers: {"content-type": "text/html"}}); 12 | } else if (path === "/index.css") { 13 | return new Response(index_css, {headers: {"content-type": "text/css"}}); 14 | } else if (path === "/index.js") { 15 | return new Response(index_js, {headers: {"content-type": "text/javascript"}}); 16 | } else if (path.startsWith("/api/channel/")) { 17 | // Delegate websocket requests to durable object. 18 | const seg = path.split("/"); 19 | if (seg.length === 4 && seg[3].match(/^[0-9a-f]{64}$/)) { 20 | const channel = env.channels.get(env.channels.idFromName(seg[3])); 21 | return channel.fetch(req.url, req); 22 | } 23 | } 24 | return new Response("not found", {status: 404}); 25 | } 26 | } 27 | 28 | const messageLimit = 4096; 29 | 30 | // Durable object for message relay. 31 | export class Channel { 32 | constructor(state, env) { 33 | // Store nothing persistently. 34 | this.sessions = []; 35 | } 36 | 37 | async fetch(req) { 38 | if (req.headers.get("Upgrade") !== "websocket") { 39 | return new Response("websocket only", {status: 400}); 40 | } 41 | // Each channel is limited to two client sessions. 42 | // A different E2EE protocol is needed to support group chats. 43 | if (this.sessions.length >= 2) { 44 | return new Response("already in use", {status: 403}); 45 | } 46 | const pair = new WebSocketPair(); 47 | const ws = pair[1]; 48 | ws.accept(); 49 | this.sessions.push(ws); 50 | 51 | ws.addEventListener("message", async msg => { 52 | const data = msg.data; 53 | if (typeof data === "string" && data.length && data.length <= messageLimit) { 54 | try { 55 | // Relay encrypted messages. 56 | const j = JSON.parse(data); 57 | if (j.op === 3) { 58 | const s = this.sessions.find(s => s !== ws); 59 | if (s) { 60 | s.send(data); 61 | } 62 | } 63 | } catch (_) { 64 | } 65 | } 66 | }); 67 | 68 | ws.addEventListener("close", _ => { 69 | this.sessions = this.sessions.filter(s => s !== ws); 70 | this.clientChanged(); 71 | }); 72 | 73 | this.clientChanged(); 74 | 75 | return new Response(null, {status: 101, webSocket: pair[0]}); 76 | } 77 | 78 | clientChanged() { 79 | // Broadcast the number of active clients in the channel. 80 | const payload = JSON.stringify({op: this.sessions.length}); 81 | this.sessions.forEach(s => s.send(payload)); 82 | } 83 | } -------------------------------------------------------------------------------- /wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "e2eechat" 2 | compatibility_date = "2022-05-03" 3 | main = "./worker.mjs" 4 | 5 | [build.upload] 6 | format = "modules" 7 | rules = [{type = "Data", globs = ["*.html", "*.css", "*.js"]}] 8 | 9 | [durable_objects] 10 | bindings = [ 11 | { name = "channels", class_name = "Channel" } 12 | ] 13 | 14 | [[migrations]] 15 | tag = "1" 16 | new_classes = ["Channel"] 17 | --------------------------------------------------------------------------------