Case study: waste-go

A modern reimagining of the classic WASTE protocol — encrypted P2P chat and file sharing over WebRTC, with a formal protocol spec and three client modes.

Context

In 2003 a programmer at Nullsoft quietly released WASTE — a small encrypted P2P tool for friend-group chat and file sharing. It spread fast, got pulled within 24 hours under legal pressure, leaked back out, and slowly faded. The idea was right: private, serverless, no account, no company in the middle. The implementation was its era — RSA + Blowfish, hand-rolled TCP, no NAT traversal.

Twenty years later the problem is more relevant than ever. Signal requires a phone number. Discord is a company with terms of service. SFTP works but you feel bad asking non-technical friends to use it. waste-go is a full reimplementation: same idea — small trusted groups, encrypted, no server in the data path — rebuilt on Go, WebRTC, Ed25519, and YAW/2, a protocol designed by a friend that replaces the original WASTE wire format with a modern, formally specified foundation.

Problem

Build a system that routes no user data through any server, works across NAT without requiring open ports, runs in a browser with no install, and uses real cryptography — not “encrypted” in the README-only sense. The signaling server (the anchor) must learn as little as possible: presence and timing, nothing else.

Approach

1. Separate signaling from data — completely

The anchor has exactly two jobs: WebSocket relay that forwards sealed blobs between peers by public key within a named network, and STUN for address discovery. That’s it. The anchor never sees plaintext SDP, never sees ICE candidates, never sees a network name (only a SHA-256 hash). Once two peers have exchanged SDP through the anchor, the WebRTC DataChannel between them is direct and encrypted — the anchor drops out entirely.

Signaling payloads are sealed end-to-end with crypto_box (X25519 + XSalsa20-Poly1305). The anchor relays opaque ciphertext it cannot open.

2. Identity binding — because WebRTC doesn’t let you use your own key

Browsers generate their own ephemeral TLS certificate per RTCPeerConnection. You can’t substitute your Ed25519 identity key — the browser controls that cert. So how do you prove the channel you just opened actually belongs to the peer whose key you trust?

Two steps. First, the SDP offer/answer (which contains the DTLS fingerprint) travels inside a crypto_box sealed to the recipient’s long-term key — a malicious anchor can’t forge or swap SDPs. Second, once the DataChannel is open, both peers send a hello containing an Ed25519 signature over "yaw/2 bind" || local_fingerprint || remote_fingerprint. This re-binds the live channel to both DTLS fingerprints under each peer’s long-term identity. A fingerprint mismatch or a wrong key closes the connection.

After hello verification the session is trusted. The anchor had no role in that trust.

3. Forward-secret signaling — the 2.1 upgrade

YAW/2.0 sealed signaling payloads with static X25519 keys derived from long-term Ed25519 identities. Correct, but not forward-secret: if your long-term keys leaked years later, recorded signaling traffic could be retroactively decrypted.

YAW/2.1, developed with my friend as an extension to his baseline, adds ephemeral X25519 keys per session. Before sending an offer, a peer generates a fresh keypair, broadcasts the public half in a signed ekey message, then seals the actual SDP under those ephemeral keys. The ephemeral secret is wiped when the session ends.

The upgrade needed to be backward-compatible. The solution: send ekey first, start a 2-second timer. If the other side replies with their own ekey, use ephemeral keys. If the timer fires first (a 2.0 peer that ignores unknown message types), fall back to static keys. Both sides interoperate; newer sessions get the stronger guarantee.

4. Three client modes, one IPC protocol

Daemon mode — a Go process running locally. Handles crypto, manages identity on disk, connects to the anchor. A local JSON API (newline-delimited JSON over TCP on port 17337, or WebSocket on 17338) lets any UI talk to it.

Browser mode — when the web UI loads from a non-localhost origin, it runs entirely in the browser. Ed25519, X25519, XSalsa20-Poly1305 via libsodium compiled to WebAssembly. Identity stored in localStorage. No install, no daemon — a friend visits a URL and they’re in.

Desktop app — a Wails shell that packages the React frontend and daemon logic into a single native binary. The webview connects to ws://127.0.0.1:17338 exactly as in browser-daemon mode, so the frontend code is unchanged.

All three modes speak the same IPC protocol. The UI layer (React) doesn’t know or care which mode it’s running in — it sends {"type":"send_message","room":"general","body":"hi"} and gets back events. This forced a clean separation between UI and backend that’s made extending both easier.

5. NAT traversal

WebRTC handles this via ICE. Host candidates (LAN addresses) and server-reflexive candidates (public address via STUN) are embedded in the SDP and forwarded through the anchor in the sealed offer/answer. Connectivity checks run peer-to-peer; the anchor isn’t involved.

This covers most home broadband. It breaks on symmetric NAT — common on mobile data — where both peers are behind NAT that assigns a different port per destination. TURN relay (coturn with use-auth-secret) handles this as an explicit opt-in. When configured, the browser adapter adds the TURN server to the ICE server list. When not configured, STUN-only is used. The protocol doesn’t assume relay exists.

6. File transfer and resume

Files ride a dedicated DataChannel (f:<xid>) so a large transfer never blocks the chat channel. Sender offers via file-offer with the full-file SHA-256. Receiver accepts, optionally with a resume_offset if a partial transfer exists. Sender streams 64KB chunks respecting backpressure. SHA-256 is verified before the file is renamed from .tmp to its final path.

Resume works via a .meta sidecar alongside each in-progress .tmp. On a new offer, the receiver scans for a sidecar matching the SHA-256 and replies with the existing byte count. Peers that don’t understand resume_offset ignore it and send from the start — the SHA-256 verify still works, partials get overwritten, the transfer completes. No capability negotiation required.

7. Invite system and trust chains

The base network model is open to anyone who knows the anchor URL and network name. An optional extension adds cryptographic membership gating: invites are Ed25519-signed by an existing peer over anchor || network || inviter. New peers carry the invite string in their hello; accepting peers verify the signature and check the inviter’s key against a per-network SQLite store.

This forms a chain of trust: Alice invites Bob, Bob’s key is stored, Bob can invite Carol, and Alice will accept Carol because Bob is a known peer. Networks that opt in to require_invite reject hellos with no valid signed invite.

8. Protocol discipline

YAW/2.0 is locked — the wire format won’t change. All extensions (file resume, signed invites, multi-share, TURN, forward-secret signaling) are additive: new fields older peers ignore, new IPC commands older daemons return errors for. The frozen baseline gives independent implementations a stable interop target and made the 2.1 upgrade straightforward to reason about — I knew exactly what I was adding and what I couldn’t touch.

What’s missing

  • Gossip relay — the protocol already reserves a hops field for message relay; implementing it would let peers bridge connections that couldn’t form a direct session, giving partial-mesh resilience without TURN.
  • Group file browsing in browser mode — daemon mode has full multi-share with folder navigation; browser mode only supports direct file push.
  • Short-id verification UI — the protocol spec calls for out-of-band keypair verification via a human-readable short ID; a built-in QR or voice-read flow would make this frictionless.
  • Post-quantum signaling — hybrid X25519+ML-KEM once libsodium support lands; DTLS already handles this at the transport layer in modern WebRTC.

Stack

LayerTech
DaemonGo
Desktop shellWails
Browser cryptolibsodium compiled to WASM
ProtocolYAW/2.1 (extension of YAW/2.0)
SignalingWebSocket anchor (Go) + coturn
PersistenceSQLite (message history, peer store)