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 }