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