Skip to content

vpuhoff/secure-chat

Repository files navigation

Secure Chat (Zero-Knowledge, E2E)

Minimal E2E-encrypted chat on Cloudflare Workers and Durable Objects. One invite link = one room. No accounts, no server-side decryption.

Why it exists: spin up a private chat with a single link; the server only relays encrypted data and never sees the key or message content. Suited for one-off conversations, support threads, consultations, or your own zero-knowledge chat on your domain. You can run it on Cloudflare, or self-host with Docker Compose and optionally expose it as a Tor hidden service (.onion) for access without clearnet.


Contents


How it works

  • Room ID is in the URL path (/room/<roomId>).
  • Secret key is in the URL hash (#...) and is never sent to the server.
  • On first open with #secret, the key is imported as non-extractable and stored in IndexedDB per room; the page then reloads without the hash.
  • Encryption and decryption happen only in the browser (AES-GCM).
  • The Durable Object only relays encrypted messages and stores the last 10 per room.
  • Users are anonymous and identified by a short clientId stored in the browser per room.

Flow:

  1. Open /new_chat — a room is created and an invite link is shown (room id + secret in the hash).
  2. Share the link. When opened, the browser saves the key in IndexedDB and reloads without #secret.
  3. Clients connect via WebSocket to /ws/<roomId>?client=<shortId> and exchange encrypted messages.
  4. New joiners receive the last 10 encrypted messages on connect and decrypt them locally.

Technical implementation details

Stack

  • Runtime: Cloudflare Workers (V8 isolates).
  • Room state: Durable Object ChatRoomDO (one instance per roomId via idFromName(roomId)).
  • Client: a single HTML document; CSS and JS are served from the same Worker (/app.css, /app.js); no separate build.

Routes and responses

Path Purpose
/ Returns API OK (stub).
/new_chat Chat page: create a room and generate invite link.
/room/<roomId> Chat page for an existing room.
/ws/<roomId>?client=<shortId> WebSocket into the room; client is the stable participant id (4–16 chars, a-z0-9_-).
/app.js, /app.css Client script and styles.
/healthz Liveness check (ok).
All other paths API OK.

Message protocol (WebSocket)

All frames are JSON. Envelope types:

  • message — chat message: from, iv, ciphertext, sentAt. Server adds from and sentAt, relays to others, and appends to history.
  • system — system events (join/leave, history cleared).
  • history — on connect: last up to 10 encrypted room messages.
  • clear_history — client command: delete history in the DO and broadcast history_cleared.
  • history_cleared — notification that history was cleared (and by whom).

Incoming data is validated on the server in parseEnvelope() (types and format; e.g. clientId via regex ^[a-z0-9_-]{4,16}$/i, roomId up to 64 chars).

Cryptography (client)

  • Algorithm: AES-GCM, 256-bit key (32 bytes).
  • Room key: 32 random bytes in Base64URL in the link hash; imported with crypto.subtle.importKey(..., { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']) — not extractable.
  • Per message: 12-byte random IV, encrypt plaintext; iv and ciphertext sent over WebSocket in Base64URL.
  • Key storage: IndexedDB, database secure_chat_keys, store room_keys, record key roomId; record holds CryptoKey, clientId, updatedAt.

Durable Object

  • Class: ChatRoomDO; Worker binding CHAT_ROOMS.
  • Storage: built-in key-value (this.ctx.storage); key "history" — array of up to 10 ChatMessageEnvelope items (latest messages).
  • Connections: Map<WebSocket, clientId>; on new WebSocket — send history, system “connected”, broadcast “joined”; on close/error — “left”.
  • Clear history: on clear_history command — storage.delete("history"), broadcast history_cleared and system “cleared room history”.

Security headers (all app responses)

  • Content-Security-Policy: default-src 'none'; script-src / style-src / connect-src limited to 'self' and wss: as needed; base-uri 'none', frame-ancestors 'none', object-src 'none', form-action 'none'; require-trusted-types-for 'script'.
  • Cache-Control: no-store.
  • Referrer-Policy: no-referrer.
  • X-Content-Type-Options: nosniff.
  • X-Frame-Options: DENY.
  • Cross-Origin-Opener-Policy: same-origin.
  • Cross-Origin-Resource-Policy: same-origin.
  • Permissions-Policy: camera, microphone, geolocation, etc. disabled.

Security

What is protected

  • Confidentiality of text: the server only sees encrypted iv + ciphertext; the key stays in the browser and in the URL hash (the hash is not sent to the server).
  • Zero-knowledge: the operator and Worker code cannot read messages without the key from the link.
  • Strict CSP and isolation: scripts and styles only from same origin, no embedding in iframes, reduced XSS and injection surface.

What is not (and where the risks are)

  • Single key per room: anyone with the link can read everything and trigger “Clear history” — history is cleared for everyone. No per-user keys or cryptographic “delete for everyone”.
  • No key rotation or forward secrecy: a leaked key gives access to all room history.
  • No contact verification: no way to compare key fingerprints (as in Signal/Keybase); trust is “whoever has the link”.
  • Metadata visible to the server: who connected when to which room, traffic volume. No metadata hiding.
  • Client code: encryption and key logic live in JS served from the same origin; integrity relies on HTTPS and CSP.

Pros and cons

Pros:

  • No accounts, sign-up, or email/phone binding.
  • Minimal trust in the server: it only relays ciphertext and stores the last 10 messages in encrypted form.
  • Simple crypto model (AES-GCM, one key per room) — easy to audit and learn from.
  • Single repo, single deploy (Worker + DO), no separate DB or backend.
  • Fork and deploy on your own domain (Cloudflare).

Cons:

  • One shared key per room; anyone with the link is a full participant and can clear history.
  • No key rotation, no forward secrecy.
  • Key is tied to one browser/profile; a new device means sharing the full link with #secret again.
  • No verification of “who exactly I added”.
  • Text only, last 10 messages; no media, editing, or history search.

Comparison with alternatives

Solution Advantages over Secure Chat Disadvantages vs Secure Chat
Signal Key rotation, contact verification, multi-device, rich features Requires phone number, central server, no self-hosted deploy
Matrix (Element) Federation, flexible rooms, many clients and devices More complex, needs account/server, more dependencies
Session / Briar Better metadata hiding (Tor/networks) Harder to use and deploy
Telegram (secret chat) Convenience, multi-device Trust in server, account binding, not zero-knowledge in the same sense
Keybase (historical) Identity, teams, key verification Product discontinued; idea was “teams + keys”

Secure Chat does not replace full-featured messengers; it targets a narrow use case: a private room from one link, no sign-up, with your own deployment.


Run locally

npm install
npm run dev

Open in browser: http://localhost:8787.


Docker and Tor (onion service)

Run the app with Docker Compose and optionally expose it as a Tor hidden service so it is reachable via a .onion URL. No Cloudflare account needed; clients can use Tor Browser and never touch clearnet.

Prerequisites: Docker and Docker Compose.

  1. Start the stack (app + Tor):

    docker compose up -d
  2. Clearnet: open http://localhost:8787 — usually available within a few seconds.

  3. Onion: get your .onion address (after Tor has started):

    docker compose exec tor cat /var/lib/tor/secure_chat/hostname

    Open http://<that>.onion in Tor Browser (or another Tor-capable client). Invite links will use the .onion host when opened in that context.

The clearnet URL is ready quickly. The .onion address can take about 1–3 minutes to become reachable: Tor must bootstrap (connect to the network and build circuits), then publish and propagate the hidden service descriptor. If the onion URL does not load at first, wait a minute or two and try again.

Details: The tor service forwards hidden service port 80 to secure-chat:8787. The onion key is stored in the tor-hidden-service volume so the same .onion address is kept across restarts.


Deploying on Cloudflare

This project is a Worker with a Durable Object; HTML/CSS/JS are served by the same Worker. The recommended way is to deploy as a Cloudflare Worker. Cloudflare Pages is aimed mainly at static sites and Functions; you cannot define Durable Objects inside a Pages project’s code — they are used via a separate Worker. Below: quick Worker deploy and Dashboard (including Workers & Pages) options.

1. Deploy with Wrangler (CLI)

You need a Cloudflare account and Wrangler set up.

npm install
npm run deploy

On first run, Wrangler will prompt to log in and attach the project. After deploy, the app is available at something like https://secure-chat.<your-subdomain>.workers.dev (or your custom domain if configured for the Worker in Cloudflare).

2. Deploy via Cloudflare Dashboard (Workers & Pages)

  1. Go to Cloudflare DashboardWorkers & Pages.
  2. CreateCreate Worker (or Create applicationWorker).
  3. Choose Connect to Git and attach the repository for this project.
  4. Configure the build:
    • Build command: npm ci && npm run deploy
    • Or use Build command: npm ci and, depending on the UI, set the build output to the result of wrangler deploy (e.g. “Use Wrangler” / “Worker build”). If the UI has an explicit “Deploy with Wrangler” or “Build with wrangler deploy” mode, use it and set the command to npm run deploy (after npm ci).
  5. Save and deploy. The Worker with Durable Object will be deployed; URL will be like https://secure-chat.<account>.workers.dev or your custom domain.

3. Cloudflare Pages (with a Worker that has Durable Objects)

This repo is a full Worker with a Durable Object, not a static site. Pages and logic are served by the Worker, so a “classic” deploy as Pages-only (static site) does not fit.

If you want to use Cloudflare Pages (e.g. custom domain via Pages or a single project under Pages):

  1. Deploy the Worker with Durable Object using one of the methods above (Wrangler or Workers & Pages with Git). That creates a Worker with the name from wrangler.json (e.g. secure-chat).
  2. In Pages, create a project:
    • Create projectConnect to Git and select the same repo.
    • In build settings, indicate this is not a static site but a Worker (if the UI has something like “Compatibility mode” / “Worker” for the build).
    • Or keep Pages as a minimal static stub and set a Custom domain to the same host that points to your Worker (via CNAME or Workers route).
  3. Binding Durable Object to Pages (if needed):
    The Durable Object is defined in this Worker’s code and deploys with it. You don’t need to “attach” the DO to Pages separately if you deploy this project as the Worker and use the Worker’s URL.
    If you have a separate Pages project (different frontend) and want to call this chat from it, that frontend would call the deployed Worker URL (e.g. https://secure-chat.<account>.workers.dev). The DO already runs inside that Worker.

Summary: for this repo, deploying as a Worker (option 1 or 2) is enough. Use Pages if you want a custom domain through Pages or a separate static frontend that talks to the deployed Worker by URL.


Limitations

  • Only the last 10 messages per room are stored.
  • One shared key per room — anyone with the link can read and use Clear history, removing history for everyone.
  • Key is tied to one browser/profile; a new device or browser needs the full link with #secret.
  • No key rotation, contact verification, or media — text-only MVP.

Notes

  • The chat UI is served only for /new_chat and /room/<roomId>. Other paths return API OK.
  • Without the secret (e.g. only /room/<id> with no hash), messages cannot be decrypted.
  • All app responses send the security headers listed above (CSP, COOP, CORP, etc.).

About

E2E-encrypted chat: Workers + Durable Objects. Self-host with Docker or run as .onion.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors