Skip to content

cristiancmoises/keywave

Repository files navigation

Keywave

End-to-end encrypted, peer-to-peer chat and video. One small service, no accounts, no persistence. The server only relays the handshake; it never sees message or call content.

Keywave Screenshot Screenshot

Features

  • 1:1 and group (up to 6 peers, full mesh) text chat and video/audio calls, encrypted in the browser. Every pair has its own ephemeral keys.
  • Post-quantum hybrid key agreement (protocol v2): every pair derives its keys from ECDH P-256 and ML-KEM-1024 (FIPS 203, NIST category 5) combined through a transcript-bound HKDF — recorded traffic stays confidential unless both are broken. No classical-only fallback exists (fail closed, downgrade-resistant by construction). Validated against 80 official NIST ACVP vectors and cross-checked against two independent implementations (see AUDIT.md).
  • Works on desktop, Android, iPhone, and iPad with a responsive, touch-friendly layout, front/back camera switching, a screen wake lock during calls, and an installable PWA manifest.
  • Invite by link: a share sheet plus one-tap WhatsApp, Telegram, email, and SMS, or copy the link / room ID. Opening an invite link auto-joins the room.
  • Safety-number verification (named emoji + hex) to detect a man-in-the-middle — per pair, since each pair has its own keys.
  • Per-frame media encryption (AES-256-GCM over encoded frames) on Chromium (createEncodedStreams), Safari 15.4+, and Firefox 117+ (RTCRtpScriptTransform), negotiated per pair and falling back to DTLS-SRTP when one side lacks support. All frame crypto runs in a dedicated worker, off the main thread.
  • Signaling relay runs on gevent greenlets (one OS thread serves thousands of WebSocket connections) with a directed, per-pair relay — no broadcast.
  • Single self-contained service: the client is embedded in app.py and all assets (socket.io, fonts) are self-hosted. No CDN, no third-party origins.

Quick start (local)

docker compose up -d --build

Open http://localhost:5128. To try it on one machine, open it in two browser windows: create a room in the first, copy the room ID, and join with it in the second. localhost is a secure context, so the camera and Web Crypto work without TLS. Use a normal Chromium/Chrome or Firefox build — heavily privacy-hardened browsers (e.g. ungoogled-chromium, LibreWolf) can break WebRTC media and prevent the call from connecting.

Deploy for real (phones / different networks) in 3 steps

Calls between two different networks need a TURN relay, and phones need HTTPS. Both are bundled — run the helper, open the ports, start it:

./setup.sh                                              # writes .env: TURN secret + your public IP
# open UDP+TCP 3478, UDP 49160-49200 (TURN), and 80+443 if using the HTTPS profile
docker compose --profile turn --profile tls up -d --build

setup.sh generates the shared TURN secret and auto-detects your public IP. The tls profile runs Caddy, which fetches a Let's Encrypt certificate for your domain automatically (point its DNS at the host first). Already have a reverse proxy? Drop --profile tls and point your proxy at http://keywave:5000. See Running on a phone or over a network for the details and how to verify the relay.

Endpoints: GET /healthz (status + active room count) and GET /config (ICE servers for the client).

Connecting a peer

Create a room. The waiting screen shows an invite link and quick share buttons: a native share sheet (covering Signal, Session, Tox, Element, and anything else installed) plus one-tap WhatsApp, Telegram, email, and SMS, with "Copy" for the link and "Copy ID" for the room ID alone. The room ID travels in the URL fragment (#room=ID), which browsers never send to the server, and opening the link auto-joins the room. There is also an invite button (↗) in the call controls.

A room holds the number of peers you picked at creation (1:1 by default, or a group of 3-6) and is one-time use: anyone who has the link can take a free slot, so after connecting, verify the safety number with each peer (see SECURITY.md) to confirm no one is in the middle. Group calls are a full mesh — every participant uploads its media to every other participant — so they work best on strong connections; the client automatically steps the per-sender bitrate down as the room grows.

Running on a phone or over a network

Phones can't use localhost, so they need a secure context (HTTPS) over the network. Two practical options:

  1. Reverse proxy with a real certificate (recommended). Put Keywave behind Nginx Proxy Manager, Caddy, or Traefik with a domain and a Let's Encrypt certificate, pointed at http://keywave:5000 with WebSocket upgrade headers enabled. See the notes at the bottom of docker-compose.yml.
  2. A tunnel (cloudflared, ngrok, tailscale-funnel) that terminates HTTPS.

Making calls connect reliably (TURN)

Calls connect peer-to-peer using public STUN. Between two different real networks — two mobile carriers, corporate firewalls, or carrier-grade NAT — direct P2P often fails, which is why a call can work on localhost but not between a phone and a laptop on the internet. STUN-only is effectively localhost/LAN-only; the waiting screen warns when no relay is configured and the server logs a warning at startup. The fix is a TURN relay, and this build ships one you can turn on:

cp .env.example .env          # then edit .env
#   KEYWAVE_TURN_HOST=turn.example.com      (public host both peers can reach)
#   KEYWAVE_TURN_SECRET=<openssl rand -hex 32>
#   KEYWAVE_TURN_EXTERNAL_IP=<your public IP>   (on AWS/GCP/Oracle 1:1-NAT VPS)
docker compose --profile turn up -d --build

That starts a bundled coturn alongside Keywave. The app mints short-lived TURN credentials per client (coturn's use-auth-secret scheme) and serves the ICE configuration from GET /config, so no client edits are needed. The single .env value reaches both the app and coturn, so credentials always match; coturn refuses to start without a real secret.

Two deployment gotchas worth calling out:

  • Open the ports. UDP/TCP 3478 and UDP 49160–49200 must be reachable on your firewall / cloud security group.
  • Set the public IP on NAT'd clouds. On AWS/GCP/Oracle (and any 1:1-NAT host) the VM's NIC holds a private IP, so coturn would otherwise advertise an unroutable relay address and media silently fails. Set KEYWAVE_TURN_EXTERNAL_IP to the public IP (or <public>/<private>).

Already run a TURN server? Point Keywave at it instead with KEYWAVE_TURN_URL, KEYWAVE_TURN_USER, and KEYWAVE_TURN_PASS (a turns: URL on 443 works for clients behind HTTPS-only egress). To confirm relaying works end to end, set KEYWAVE_FORCE_RELAY=1 — it forces all media through TURN, so a call that still connects proves the relay is good.

Connections self-heal: trickle ICE candidates are queued (never dropped), a broken path triggers a bounded ICE-restart, and a dropped WebSocket reconnects and rebinds to the room within a grace window (KEYWAVE_GRACE_TTL, default 90s) instead of ending the call.

Configuration

Everything is optional and set via environment variables.

Variable Purpose Default
KEYWAVE_PORT Bind port inside the container 5000
KEYWAVE_ALLOWED_ORIGINS CORS allowlist (* or comma-separated) *
KEYWAVE_MAX_ROOMS Global room cap 5000
KEYWAVE_ROOM_TTL Seconds a half-open room lives before reaping 7200
KEYWAVE_GRACE_TTL Seconds a dropped peer's slot is held for reconnect 90
KEYWAVE_MAX_ROOM_PEERS Largest group size a room creator can pick (2-8) 6
KEYWAVE_CREATE_MAX / KEYWAVE_CREATE_WINDOW Room-creation rate limit (count / seconds) 10 / 60
KEYWAVE_MSG_MAX / KEYWAVE_MSG_WINDOW Relayed-message rate limit (count / seconds, scaled by room fan-out) 120 / 10
KEYWAVE_SIG_MAX / KEYWAVE_SIG_WINDOW Signaling rate limit per client (pubkey/kem/offer/answer/ice) 600 / 10
KEYWAVE_MAX_PAYLOAD Max bytes per relayed field 262144
KEYWAVE_STUN Comma-separated STUN URLs two public Google STUN
KEYWAVE_TURN_HOST Public host of the bundled coturn (enables minted TURN creds) (unset)
KEYWAVE_TURN_SECRET Shared secret matching coturn static-auth-secret (placeholder is ignored) (unset)
KEYWAVE_TURN_EXTERNAL_IP Public IP coturn advertises (required on 1:1-NAT clouds) (unset)
KEYWAVE_TURN_TLS Also advertise turns: on 443 (needs TLS coturn) (1 to enable) (off)
KEYWAVE_DOMAIN Domain for the optional Caddy tls profile (auto-HTTPS) (unset)
KEYWAVE_TURN_TTL Lifetime of a minted TURN credential (seconds) 86400
KEYWAVE_TURN_URL / _USER / _PASS External TURN with static credentials (unset)
KEYWAVE_FORCE_RELAY Force all media through TURN (1 to enable) (off)

Lock KEYWAVE_ALLOWED_ORIGINS to your real origin in production. ICE servers are served to the client at GET /config; configure TURN there via the variables above (see "Making calls connect reliably").

Troubleshooting

Stuck on "reconnecting"; logs show Invalid transport for session and transport=websocket … 400. Your reverse proxy is not forwarding the WebSocket upgrade, so Socket.IO can never leave long-polling and the session thrashes. The app itself supports WebSocket — this is purely a proxy setting. Fix it one of two ways:

  • Use the bundled Caddy, which forwards WebSocket automatically: docker compose --profile tls up -d (set KEYWAVE_DOMAIN). Caddy is verified to proxy the WebSocket transport correctly.
  • Or enable WebSocket on your existing proxy. Nginx Proxy Manager: turn on Websockets Support for the host. Plain nginx: add proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; on the location that proxies to keywave:5000.

Desktop connects but phones / mobile networks do not (TURN allocates then resets). Almost always the relay port range is closed in the firewall. coturn listens on 3478 for control but relays media on a separate UDP range, so if only 3478 is open the allocation succeeds and then media never flows (you see Connection reset by peer in docker logs keywave-turn). Open both: UDP+TCP 3478 and UDP 49160-49400, e.g. sudo ufw allow 3478/tcp && sudo ufw allow 3478/udp && sudo ufw allow 49160:49400/udp. Confirm the relay actually carries media by setting KEYWAVE_FORCE_RELAY=1 and placing a call between two phones — if that connects, TURN is correct. Also check KEYWAVE_TURN_EXTERNAL_IP is your public IP (on 1:1-NAT clouds), and note that testing through a VPN (e.g. Mullvad) can block the relay path — test on a direct connection first. On mobile use stock Chrome/Safari, not hardened browsers.

Behind a WAF / IPS (OpenAppSec, CrowdSec, ModSecurity) or Nginx Proxy Manager "Block Common Exploits" — mobile times out while desktop works. WebRTC signaling is high-volume and carries SDP, ICE candidates (IP:port strings), and base64 ciphertext — which these tools misread as scanning or injection. A stable WebSocket connection (desktop) upgrades once and the WAF can't see the frames, so it works; but a client that falls back to long-polling (common on mobile, or when WebSocket-over-HTTP/2 isn't supported) sends every message as an HTTP POST that the WAF inspects and the IPS rate-counts — so signaling gets 403'd mid-call or the IP gets banned, and the peer times out. Fixes:

  • WAF (OpenAppSec/ModSecurity): exclude the /socket.io/ path from inspection, or set that app/practice to Detect (log-only) mode.
  • IPS (CrowdSec): check for bans with cscli decisions list; whitelist the app's path or the reverse-proxy vhost so socket.io's request rate isn't seen as probing.
  • Nginx Proxy Manager: enable Websockets Support and turn off "Block Common Exploits" for the host; keep a long proxy_read_timeout.
  • Serving the site over HTTP/1.1 (or a proxy that does WebSocket-over-HTTP/2) keeps clients on WebSocket, which sidesteps per-message WAF/IPS inspection.

Mobile connects then reconnect-loops / times out while desktop is fine, and your domain has an IPv6 (AAAA) record. Phones use Happy Eyeballs and try IPv6 first; if your host serves the app/TURN only over IPv4 (IPv4-only firewall, KEYWAVE_TURN_HOST/KEYWAVE_TURN_EXTERNAL_IP set to an IPv4 address), mobile clients race onto a dead IPv6 path and stall. A common trap is a subdomain CNAME'd to an apex that itself has both A and AAAA — the subdomain inherits the apex's AAAA. Either serve everything over IPv6 too, or make the endpoint IPv4-only: replace the CNAME with a plain A record and confirm dig <host> AAAA +short is empty and dig <host> A +short is your IPv4.

keywave-caddy stuck in Created / won't start. Caddy needs ports 80 and 443; if either is busy (you already run a reverse proxy) it can't start. You don't need it — drop --profile tls and point your existing proxy at http://keywave:5000 with WebSocket upgrade enabled. Remove the stuck container with docker rm keywave-caddy.

Each person sees only their own camera. Use a normal Chromium/Chrome or Firefox. Privacy-hardened builds (ungoogled-chromium, LibreWolf) can break WebRTC media so the remote stream never arrives. Also, a single physical webcam can only be opened by one browser at a time — use two devices or two cameras.

How it works

Each pair of peers exchanges ephemeral ECDH P-256 public keys through the relay (directed, never broadcast), derives AES-256-GCM keys with HKDF, and encrypts chat messages and media frames in the browser; frame crypto runs in a worker via the standard RTCRtpScriptTransform (Safari/Firefox) or legacy createEncodedStreams (Chromium). Group rooms are a full mesh of such pairs — compromise of one pair never touches another. A safety number derived from each pair's shared secret and both public keys lets the two users confirm there is no man-in-the-middle on that pair. Full details, including the threat model and known limitations, are in SECURITY.md.

Project layout

app.py             Flask + Socket.IO relay, with the client embedded as HTML
static/            Self-hosted socket.io client and fonts (no CDN)
Dockerfile         python:3.12-slim, runs app.py behind your reverse proxy
docker-compose.yml keywave + opt-in coturn (turn) + opt-in Caddy HTTPS (tls)
setup.sh           Generates .env (TURN secret + public IP) for a VPS deploy
Caddyfile          Auto-HTTPS reverse proxy used by the `tls` profile
.env.example       All deployment variables, documented

Links

Post-quantum layer — reproducible build & verification

Protocol v2's ML-KEM-1024 ships as a vendored, unminified, hash-pinned single file: static/vendor/noble-post-quantum-0.6.1.js (30,875 bytes, SHA-256 3c8a77bc80c65be04e9cc6a9719722516886b713315ec9305efd41d683260e7f). The server refuses to boot if the file's hash mismatches, and the page pins the same hash via SRI. Rebuild it yourself:

npm install @noble/post-quantum@0.6.1 esbuild@0.25.5
cat > entry.js << 'EOF'
import { ml_kem1024 } from '@noble/post-quantum/ml-kem.js';
export { ml_kem1024 };
EOF
npx esbuild entry.js --bundle --format=iife --global-name=noblePQ \
  --outfile=noble-post-quantum-0.6.1.js
sha256sum noble-post-quantum-0.6.1.js   # must match the pinned hash

npm package integrity (registry): sha512-+pormrDZwjRw05U8ADK4JpHejo87+gBd+muRBB/ozztH5yhDLMDF4jHQWN3NQQAsu1zBNPWTG0ZwVI0CR29H0A==

Running the verification suite

# 1. Official NIST ACVP known-answer tests (80 vectors, ML-KEM-1024)
node kats/kat_mlkem.js       # ACVP JSON vectors committed alongside the runner

# 2. Hybrid handshake unit + adversarial tests on the SHIPPED client code
node test_hybrid.node.js     # 10 assertions incl. tampered-ct / MITM cases

# 3. Live signaling integration (boots the real server)
pip install -r requirements.txt python-socketio[client]
python3 test_signaling.py    # 20 assertions incl. kem relay isolation

Toolchain used for the recorded results: Node v22.22.2, Python 3.12.3, esbuild 0.25.5; differential references RustCrypto ml-kem 0.2.3 (rustc 1.96.1) and kyber-py 1.2.0. Full evidence map: AUDIT.md. Threat model: THREAT_MODEL.md. Design rationale (including why Evelin's discipline was adopted and mirim deliberately was not embedded): DESIGN.md.

Breaking change: protocol v2 is not compatible with keywave ≤ 1.x. Both call sides must run the same build; mixed versions refuse explicitly instead of downgrading.

About

Secure, ephemeral chat + video communication with no server‑side storage.

Topics

Resources

License

Security policy

Stars

3 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors