From f425e0bb8e82af6e52c9686fd66f779c5dad8e0a Mon Sep 17 00:00:00 2001 From: Fredrik Johansson Date: Tue, 30 Jun 2026 18:48:59 +0200 Subject: [PATCH] fix: stop leaking TURN secret to browser clients WASTE_CONFIG.turnSecret was shipped in plaintext config.js and used to compute coturn HMAC credentials client-side in browser.ts. Anyone reading the PWA's JS could read the secret and mint unlimited long-lived TURN credentials, turning the relay into an open proxy. The anchor now mints short-lived (1h) credentials server-side via a new GET /turn-credentials endpoint (-turn-secret flag), mirroring what the daemon already does. The browser fetches credentials instead of holding the secret. Daemon mode was unaffected (already server-side). Docs updated to drop turnSecret from config.js examples, document the new nginx route, and instruct anyone with an old config.js to rotate the coturn secret since it was previously exposed. Co-Authored-By: Claude Sonnet 4.6 --- EXTENSIONS.md | 10 +++++++--- FUTURE.md | 4 +++- README.md | 30 ++++++++++++++++++++++++------ cmd/anchor/main.go | 29 +++++++++++++++++++++++++++++ web/src/adapter/browser.ts | 34 +++++++++++++++++++++------------- 5 files changed, 84 insertions(+), 23 deletions(-) diff --git a/EXTENSIONS.md b/EXTENSIONS.md index f670a57..e065952 100644 --- a/EXTENSIONS.md +++ b/EXTENSIONS.md @@ -151,9 +151,13 @@ entries from all applicable share roots, with relative `path` fields **Status:** implemented (browser mode + daemon mode) **Affects:** ICE server configuration only, no wire changes -The browser adapter reads `WASTE_CONFIG.turnURL` and `WASTE_CONFIG.turnSecret` -and adds a TURN server to the WebRTC `ICEServers` list. Credentials are -generated using HMAC-SHA1 of the username (coturn `use-auth-secret` scheme). +The browser adapter reads `WASTE_CONFIG.turnURL` and fetches short-lived +credentials from the anchor's `GET /turn-credentials` endpoint (derived from +`WASTE_CONFIG.signalURL`, or overridden via `WASTE_CONFIG.turnCredentialsURL`). +The anchor computes the credential using HMAC-SHA1 of the username (coturn +`use-auth-secret` scheme) — the shared secret itself is never sent to the +browser. Daemon mode does the equivalent computation locally, since the +daemon already holds `-turn-secret` server-side. YAW/2 §0 explicitly declines TURN ("No relay (TURN)"). This extension is opt-in via server configuration and does not affect peers that omit it. diff --git a/FUTURE.md b/FUTURE.md index d486cd8..d11f8d5 100644 --- a/FUTURE.md +++ b/FUTURE.md @@ -41,7 +41,9 @@ Solved by using WebRTC DataChannels via pion. ICE gathers host + server-reflexiv ### TURN relay ✅ (shipped) Both browser and daemon modes support TURN relay. -**Browser mode:** `iceServers()` in `browser.ts` reads `WASTE_CONFIG.turnURL` and `WASTE_CONFIG.turnSecret`, generates time-limited HMAC-SHA1 credentials (compatible with coturn `use-auth-secret`), and adds the TURN server to the ICE candidate list. The peer dot turns yellow for relayed connections (`candidate_type: relay`). +**Browser mode:** `iceServers()` in `browser.ts` reads `WASTE_CONFIG.turnURL` and fetches a time-limited credential from the anchor's `GET /turn-credentials` endpoint (HMAC-SHA1, compatible with coturn `use-auth-secret`) rather than holding the shared secret client-side. The peer dot turns yellow for relayed connections (`candidate_type: relay`). + +> **Security fix:** earlier this previously embedded `turnSecret` directly in `WASTE_CONFIG`, which let anyone reading the PWA's JS mint unlimited long-lived TURN credentials. The secret now lives only on the anchor (`-turn-secret` flag); the anchor mints short-lived credentials per-request instead. **Daemon mode:** `-turn-url` and `-turn-secret` flags on `cmd/daemon`. `turnICEServers()` in `internal/netmgr/manager.go` generates HMAC-SHA1 credentials and injects them into the ICE server list for every new peer connection. diff --git a/README.md b/README.md index 83238b4..97f2f76 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ This tells the browser where to connect for signaling. Without it the join form ### 3. Nginx Proxy Manager setup -Create one **Proxy Host** for your domain (e.g. `waste.example.com`) with TLS enabled. You need two locations: +Create one **Proxy Host** for your domain (e.g. `waste.example.com`) with TLS enabled. You need these locations: **Location 1 — WebSocket signaling (`/ws`)** - Location: `/ws` @@ -142,6 +142,12 @@ Create one **Proxy Host** for your domain (e.g. `waste.example.com`) with TLS en - Forward port: `8080` - Enable: WebSockets Support +**Location 1b — TURN credentials (`/turn-credentials`, only if using TURN — see [step 4](#4-turn-relay-optional-fixes-mobile--cgnat))** +- Location: `/turn-credentials` +- Forward hostname/IP: `127.0.0.1` +- Forward port: `8080` +- Plain HTTP, no WebSockets toggle needed + **Location 2 — Web UI (catch-all)** - Location: `/` - Choose "Serve Static Files" (or point to a local HTTP server serving `/var/www/waste-web`) @@ -162,6 +168,7 @@ Or use `serve-web.sh` which handles PID tracking and restart: The key requirements: - `/ws` → anchor process (WebSocket, keep-alive) +- `/turn-credentials` → anchor process (plain HTTP; only needed if using TURN) - `/*` → static file server (SPA fallback: return `index.html` for unknown paths) ### 4. TURN relay (optional, fixes mobile / CGNAT) @@ -200,21 +207,32 @@ systemctl enable coturn systemctl start coturn ``` -**Update `config.js`** to tell browsers about the TURN server: +**Start the anchor with the same secret**, so it can mint short-lived credentials on your behalf: + +```bash +./waste-anchor -bind 0.0.0.0:8080 -turn-secret YOUR_SECRET_HERE +``` + +This enables `GET /turn-credentials` on the anchor, which returns a fresh `{username, credential}` pair (1-hour TTL) computed from the shared secret — the secret itself never leaves the server. + +**Update `config.js`** to tell browsers about the TURN server (no secret here — only the public relay address): ```js window.WASTE_CONFIG = { signalURL: 'wss://your-domain.com/ws', turnURL: 'turn:your-domain.com:3478', - turnSecret: 'YOUR_SECRET_HERE', } ``` -The `use-auth-secret` mode generates short-lived TURN credentials from the shared secret — no user database required. The relay only sees opaque DTLS-encrypted blobs. +> **Security note:** earlier versions of this doc had you put `turnSecret` directly in `config.js`. Don't — anyone reading the PWA's JS bundle could read it and mint unlimited, long-lived TURN credentials, turning your relay into an open proxy for anyone. The browser now calls the anchor's `/turn-credentials` endpoint instead and only ever sees a credential that expires in an hour. If you have an old `config.js` with `turnSecret` set, remove it and rotate the coturn secret (`static-auth-secret` in `turnserver.conf` and the anchor's `-turn-secret` flag) since the old one was exposed. -> The browser adapter reads `turnURL` and `turnSecret` from `WASTE_CONFIG` and adds the TURN server to the WebRTC `ICEServers` list automatically. If not configured, STUN-only is used (works for most desktop/home NAT situations). +The browser adapter calls `signalURL` with `/ws` swapped for `/turn-credentials` to find the anchor's endpoint by default; set `turnCredentialsURL` explicitly in `WASTE_CONFIG` if the anchor is reachable at a different path. If `turnURL` is set but the credentials endpoint is unreachable, the browser falls back to STUN-only. -**Daemon mode TURN:** pass `-turn-url turn:your-domain.com:3478 -turn-secret YOUR_SECRET_HERE` when starting the daemon. The same coturn `use-auth-secret` HMAC-SHA1 scheme is used — no extra config required beyond what you set up for browser mode. +You'll also need nginx to route the new path to the anchor, alongside `/ws` (see [step 3](#3-nginx-proxy-manager-setup)): + +- `/turn-credentials` → anchor process (plain HTTP, no WebSocket upgrade needed) + +**Daemon mode TURN:** pass `-turn-url turn:your-domain.com:3478 -turn-secret YOUR_SECRET_HERE` when starting the daemon. This is unaffected by the above — the daemon computes credentials itself server-side and never exposes the secret, same as the anchor now does for browser mode. --- diff --git a/cmd/anchor/main.go b/cmd/anchor/main.go index 679fdf4..89dbfa4 100644 --- a/cmd/anchor/main.go +++ b/cmd/anchor/main.go @@ -6,12 +6,17 @@ package main import ( "context" "crypto/ed25519" + "crypto/hmac" "crypto/rand" + "crypto/sha1" "crypto/sha256" + "encoding/base64" "encoding/hex" + "encoding/json" "flag" "log" "net/http" + "strconv" "sync" "time" @@ -23,16 +28,40 @@ import ( func main() { bind := flag.String("bind", "0.0.0.0:17339", "address to listen on") + turnSecret := flag.String("turn-secret", "", "coturn use-auth-secret shared secret; enables GET /turn-credentials") flag.Parse() a := newAnchor() http.HandleFunc("/ws", a.handleWS) + if *turnSecret != "" { + http.HandleFunc("/turn-credentials", turnCredentialsHandler(*turnSecret)) + log.Printf("anchor: /turn-credentials enabled") + } log.Printf("anchor: listening on %s", *bind) if err := http.ListenAndServe(*bind, nil); err != nil { log.Fatalf("anchor: %v", err) } } +// turnCredentialsHandler mints short-lived coturn use-auth-secret credentials +// server-side, so the shared secret never reaches the browser. Mirrors the +// scheme in internal/netmgr.Manager.turnICEServers (daemon mode). +func turnCredentialsHandler(secret string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Content-Type", "application/json") + expiry := strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10) + mac := hmac.New(sha1.New, []byte(secret)) + mac.Write([]byte(expiry)) + credential := base64.StdEncoding.EncodeToString(mac.Sum(nil)) + json.NewEncoder(w).Encode(struct { + Username string `json:"username"` + Credential string `json:"credential"` + TTL int `json:"ttl"` + }{Username: expiry, Credential: credential, TTL: 3600}) + } +} + // ── Anchor ──────────────────────────────────────────────────────────────────── type client struct { diff --git a/web/src/adapter/browser.ts b/web/src/adapter/browser.ts index 78b94f5..dce0acf 100644 --- a/web/src/adapter/browser.ts +++ b/web/src/adapter/browser.ts @@ -16,23 +16,31 @@ const EKEY_PREFIX = 'yaw/2.1 ekey' const FS_TIMEOUT = 2000 const STUN = 'stun:stun.l.google.com:19302' -async function turnCredential(secret: string, username: string): Promise { - const key = await crypto.subtle.importKey( - 'raw', new TextEncoder().encode(secret), - { name: 'HMAC', hash: 'SHA-1' }, - false, ['sign'] - ) - const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(username)) - return btoa(String.fromCharCode(...new Uint8Array(sig))) +// TURN credentials are short-lived and minted server-side by the anchor's +// GET /turn-credentials endpoint (see cmd/anchor). The shared coturn secret +// never reaches the browser — only a time-limited username/credential pair. +async function fetchTurnCredentials(credentialsURL: string): Promise<{ username: string; credential: string } | null> { + try { + const res = await fetch(credentialsURL) + if (!res.ok) return null + const data = await res.json() + if (!data.username || !data.credential) return null + return { username: data.username, credential: data.credential } + } catch { + return null + } } async function iceServers(): Promise { - const cfg = (window as unknown as { WASTE_CONFIG?: { turnURL?: string; turnSecret?: string } }).WASTE_CONFIG + const cfg = (window as unknown as { WASTE_CONFIG?: { turnURL?: string; turnCredentialsURL?: string; signalURL?: string } }).WASTE_CONFIG const servers: RTCIceServer[] = [{ urls: STUN }] - if (cfg?.turnURL && cfg?.turnSecret) { - const user = Math.floor(Date.now() / 1000) + 3600 + ':waste' - const credential = await turnCredential(cfg.turnSecret, user) - servers.push({ urls: cfg.turnURL, username: user, credential }) + if (cfg?.turnURL) { + const credentialsURL = cfg.turnCredentialsURL + ?? cfg.signalURL?.replace(/^ws/, 'http').replace(/\/ws\/?$/, '/turn-credentials') + if (credentialsURL) { + const creds = await fetchTurnCredentials(credentialsURL) + if (creds) servers.push({ urls: cfg.turnURL, username: creds.username, credential: creds.credential }) + } } return servers }