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.
- 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.pyand all assets (socket.io, fonts) are self-hosted. No CDN, no third-party origins.
docker compose up -d --buildOpen 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.
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 --buildsetup.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).
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.
Phones can't use localhost, so they need a secure context (HTTPS) over the
network. Two practical options:
- 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:5000with WebSocket upgrade headers enabled. See the notes at the bottom ofdocker-compose.yml. - A tunnel (cloudflared, ngrok, tailscale-funnel) that terminates HTTPS.
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 --buildThat 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_IPto 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.
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").
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(setKEYWAVE_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 thelocationthat proxies tokeywave: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.
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.
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
- Security model and reporting: SECURITY.md
- Release notes: CHANGELOG.md
- License: LICENSE
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 hashnpm package integrity (registry):
sha512-+pormrDZwjRw05U8ADK4JpHejo87+gBd+muRBB/ozztH5yhDLMDF4jHQWN3NQQAsu1zBNPWTG0ZwVI0CR29H0A==
# 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 isolationToolchain 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.


