I built elm.chat around a narrow idea: a conversation should not automatically become a permanent server-side asset.
It is not a replacement for a contact network or a claim that metadata can disappear. It is a disposable room for one live conversation. The interesting engineering question was how to make that room reliable without giving the server a readable or persistent transcript.
The current answer is:
- React and Web Crypto in the browser
- one Cloudflare Worker for the app and API
- one Durable Object per room
- one WebSocket per participant
- encrypted content relayed, but not persisted, by the Durable Object
The complete project is AGPL-3.0 source.
One coordination boundary per room
A room is a natural Durable Object boundary. One object owns:
- room policy and lifecycle
- participant presence
- creator capabilities
- single-use invite state
- WebSocket relay
- expiry and destruction
The Worker creates room metadata, addresses the object by room ID, and routes subsequent API and WebSocket traffic to it.
The object stores the room policy, status, creator token, and invite records. It does not store message envelopes, encrypted file chunks, or a transcript.
That distinction matters. “Encrypted at rest” would still make the server a durable archive. elm.chat instead asks currently connected clients to hold the conversation in memory.
The room secret stays in the fragment
Room creation starts in the browser:
- Generate a random 256-bit room secret.
- Create room metadata through the Worker.
- Navigate to
/c/:roomId#<room_secret>. - Derive the room key locally with HKDF-SHA-256.
The fragment is not included in normal HTTP requests, so the Worker receives the room ID but not the room secret. A strict Referrer-Policy: no-referrer reduces accidental capability leakage to other origins.
The browser uses the derived AES-GCM-256 key for message and file content:
- each message gets a fresh random 96-bit nonce
- files are divided into 64 KiB chunks
- each file chunk is encrypted independently
The relay receives ciphertext envelopes and routing information, not plaintext.
One WebSocket carries the live room
After joining, a client uses one WebSocket for:
- presence
- encrypted messages
- encrypted file chunks
- transcript-sync requests and responses
- participant removal
- destruction events
- keepalive traffic
WebSocket attachments are the source of truth for live membership. They survive Durable Object hibernation, so the object can wake and continue targeted routing without relying on a process-local participant map.
When a new participant joins, the server does not load a transcript. The client requests one from peers that are already connected. A peer sends its current encrypted history through the relay, with a hard cap on the number of synced messages.
If nobody connected still has an item, it is gone. That is intentional.
Why I did not use direct WebRTC
Peer-to-peer sounds like the obvious privacy choice, but the transport tradeoff is more complicated.
Direct WebRTC ICE negotiation can reveal participant IP addresses to other people in the room. Connectivity can also fail on mobile carriers, symmetric NAT, and restrictive networks unless the application adds a TURN relay.
I chose a Durable Object relay because:
- room members never connect directly
- participants do not learn one another’s IP addresses
- the same transport works across restrictive networks
- there is no separate STUN/TURN service to operate
This does not eliminate metadata.
Cloudflare still sees each connection’s IP address, timing, sizes, and presence. An observer can infer activity bursts even without decrypting content. The architecture moves content trust away from the server while accepting that the relay remains a metadata observer.
That is the actual claim—not “no trace.”
One-time membership
The room creator holds a creator capability in local browser storage and can:
- issue single-use invites
- revoke invites
- remove participants
- destroy the room
A non-creator needs a valid invite to join. The invite is consumed by the first new session that uses it. That same session can reconnect after a reload, but another session cannot reuse the link.
This makes an elm.chat URL a handoff for one participant, not a permanent public room address.
What the current design does not solve
End-to-end encryption and ephemerality do not make a system invulnerable.
The current build does not solve:
- screenshots or copied plaintext
- compromised devices or browser extensions
- malicious recipients
- traffic analysis
- denial of service
- strong anonymous routing
- verified human identity
- forward secrecy beyond the shared room key
The clients generate ephemeral identity keys, but those keys are not yet used to authenticate messages. Replay and duplicate protection are also unfinished. The project has also not had an independent security audit.
Those are not footnotes. They define where the current system should and should not be trusted.
Run it or review it
The repository includes:
- the architecture
- the threat model
- a one-click Cloudflare deployment path
- manual Wrangler deployment instructions
- contributor guidance and scoped issues
You can try the live room, inspect the source, or read the current security status and limitations.
The feedback I am most interested in is on:
- the encrypted-relay versus WebRTC tradeoff
- one Durable Object per room as the coordination boundary
- client-supplied transcript sync after object hibernation
- the message-authentication path that should come next
If you find a security issue, please use the repository’s private reporting instructions rather than publishing an exploitable detail.
Top comments (0)