Skip to content

Security & Authentication

bilbycast-relay is stateless and zero-knowledge — it forwards encrypted bytes between edges without ever being able to read them — but it still has security-relevant surfaces that operators need to configure correctly. Layers 1–5 below cover the forwarding planes; Layer 6 covers the optional viewer-distribution role, which is a different surface with a different threat model and only exists on -distribution builds.

bilbycast-relay sits between two edge nodes that are typically behind NAT. It pairs them by tunnel UUID and forwards packets. The edges use a shared 32-byte ChaCha20-Poly1305 key (distributed by the manager) to encrypt every payload before it touches the relay, so a compromised relay leaks only ciphertext, packet sizes, and timing. Attackers we defend against: an attacker who can run a malicious relay, an attacker on the network between edge and relay, an attacker who tries to bind to a tunnel UUID they don’t own, and an attacker who tries to call the relay’s REST API to enumerate or disrupt other tenants’ tunnels.

On the QUIC carrier, the transport from any edge to the relay is QUIC, which mandates TLS 1.3. ALPN is enforced — the relay only accepts the bilbycast-relay protocol identifier, which prevents anyone speaking a different ALPN from completing the handshake even if they reach the QUIC port.

The native-UDP carrier (plain UDP, used for native SRT/RIST and bond legs) has no transport TLS — its confidentiality rests entirely on Layer 2 below. That is by design: the carrier exists precisely to avoid QUIC’s per-packet overhead for inner protocols that already encrypt and recover their own traffic, and the relay never sees anything but Layer-2 ciphertext on it either.

The relay generates a self-signed cert at startup if none is configured (tls_cert_path / tls_key_path in the relay config). Edges connecting to a self-signed relay must explicitly opt in (accept_self_signed_cert: true plus BILBYCAST_ALLOW_INSECURE=1) — the same safety guard as the manager. For production, supply a real cert.

Edges can also pin the relay’s cert via cert_fingerprint (SHA-256), which validates the exact cert without trusting any CA store.

This is the crucial layer. The relay is zero-knowledge by design: every payload is encrypted by the source edge with ChaCha20-Poly1305 (AEAD) using a 32-byte key (tunnel_encryption_key) generated by the manager and distributed to both edges out of band. The relay sees only:

  • The tunnel UUID (used to route to the peer)
  • The ciphertext + 16-byte authentication tag
  • A 12-byte nonce
  • Packet sizes and timing

It cannot read the plaintext, modify it without breaking the auth tag, or replay packets across tunnels (the nonce + key combination is per-tunnel).

Per-packet overhead: 28 bytes (12-byte nonce + 16-byte Poly1305 tag).

Transport Framing
TCP [4-byte BE length][nonce + ciphertext + tag] per encrypted record
UDP Tunnel ID prefix + (nonce + ciphertext + tag) — payload encrypted before the tunnel ID is prepended

This means the relay can de-multiplex by tunnel UUID without ever having to decrypt anything.

The end-to-end encryption protects the payload, but it doesn’t on its own prevent an attacker from binding to a tunnel UUID they don’t own and exhausting relay resources. To close that gap, the relay supports optional per-tunnel bind authentication managed by the manager:

  1. The manager generates a 32-byte secret per tunnel (tunnel_bind_secret).
  2. The manager sends an authorize_tunnel command to the relay, providing the tunnel UUID and a precomputed HMAC-SHA256 token derived from the secret.
  3. The manager distributes the secret to both edges.
  4. When an edge binds to the tunnel, it computes the same HMAC and includes it in the TunnelBind message as bind_token.
  5. The relay compares the bind token to its stored authorisation with constant-time comparison (so timing attacks can’t recover the secret bit by bit).
  6. Mismatched or missing tokens cause the bind to be rejected with a TunnelDown notification, which surfaces to the manager as an event.

To revoke an authorisation, the manager sends revoke_tunnel — subsequent binds with the old token are rejected.

require_bind_auth defaults to false. In that mode a tunnel UUID for which no authorize_tunnel has been pushed accepts an unauthenticated bind — originally for backwards compatibility with older managers, and still the posture of a relay run without a manager, a tunnel whose authorize push failed, or the window after a relay restart before the manager re-pushes (the authorisation table is in memory, and edges re-register every ~5 s). Tunnels that do carry a pushed authorize_tunnel still require the exact HMAC even in permissive mode.

The consequence differs per plane, and this is the part worth reading twice.

  • On the QUIC carrier, an unauthenticated TunnelBind lets a stranger join a tunnel.
  • On the native plain-UDP carrier, the rendezvous latch turns a source address into a send target — so an unauthenticated Register can move a slot and redirect live media to the sender. That is media hijack, not merely eavesdropping on ciphertext. Both halves of the shipped defaults put a zero-config relay in this posture: require_bind_auth is false and udp_relay_enabled is true, so a config that simply omits udp_relay_enabled is still exposed.

Two mitigations ship, and neither is a substitute for bind auth:

  • A live slot is held down. A Register from a different source IP is refused for 12 seconds after that slot’s last forwarded media datagram, so a flowing session cannot be stolen. Same-IP moves are always allowed — a NAT port rebind, a socket rotation and an edge restart on the same host are all legitimate and all keep the IP — and a slot that has never carried media stays last-writer-wins.
  • The relay says so out loud. A relay in this posture emits a Warning relay_native_plane_unauthenticated event at startup, which lands on the manager’s Events page. A relay is headless; journald is not where an operator looks.

To close it, either:

  • Set require_bind_auth: true — but only on a relay driven by a manager that pushes bind secrets. Strict mode fails closed on both planes for every tunnel with no pushed authorisation, so on a manager-less relay it refuses every bind and takes the relay off air.
  • Or disable the native plane with udp_relay_enabled: false (--no-udp-relay on the command line), if you do not carry native SRT/RIST or bond legs over this relay.

The relay exposes a small REST API for stats and topology inspection:

Endpoint Auth required (when api_token is set)
GET /health No — always public
GET /metrics Yes
GET /api/v1/tunnels Yes
GET /api/v1/udp-sessions Yes
GET /api/v1/edges Yes
GET /api/v1/stats Yes
DELETE /api/v1/tunnels/{id} Fail-closed403 unless api_token is set
DELETE /api/v1/udp-sessions/{id} Fail-closed403 unless api_token is set

The read-only GET endpoints stay open-by-default for backwards compatibility when api_token is unset. The two DELETE teardown routes are the exception: they are fail-closed, refusing with 403 unless api_token is configured, so a destructive route is never reachable unauthenticated.

To enable token auth, set api_token in the relay config to a 32–128 character string:

api_token = "f3a6b8c1d4e7..."

Clients must then send Authorization: Bearer <token> on every request to a non-/health endpoint. The token is checked with constant-time comparison.

If api_token is unset, all endpoints are open and the relay logs a startup warning. This is permitted for development and isolated networks but not recommended for anything reachable from the public internet.

The relay can optionally connect outbound to a bilbycast-manager via the same WebSocket protocol used by edges. The auth model is identical:

  • Initial registration uses a short-lived token issued by the manager.
  • The manager mints a permanent node_secret that the relay stores in its config.
  • Subsequent reconnects authenticate with the secret.
  • The relay enforces wss:// and supports accept_self_signed_cert (gated by BILBYCAST_ALLOW_INSECURE=1) and cert pinning (cert_fingerprint).

This connection is the channel the manager uses to call authorize_tunnel, revoke_tunnel, disconnect_edge, and close_tunnel.

Layer 6 — Viewer distribution (-distribution builds only)

Section titled “Layer 6 — Viewer distribution (-distribution builds only)”

The optional viewer-distribution role adds a browser-facing HTTP listener (default :4485). It is a separate surface from the REST API on :4480 and from the QUIC and native-UDP data planes, it is not covered by api_token, and it carries /whep/{stream}, /whip/{stream}, /watch/{stream}, /origin/{stream}/{file} and /distribution/health. A plain forwarder build does not have it at all.

Two independent gates, and they do not cover the same thing:

Gate Default Covers
require_ingest_token true The write surfaces — the WHIP offer and the edge’s PUT /origin/{stream}/{file}
require_viewer_token false WHEP onlyGET /origin/{stream}/{file} is unauthenticated in every mode

The ungated origin GET is deliberate: it is the CDN-facing half, and a CDN pulls it with no credential of the relay’s. It also means that for any stream also running the LL-HLS tier, the viewer gate is bypassable by fetching /origin/{stream}/index.m3u8. Restrict the listener at the network or reverse-proxy layer if that matters. See Viewer Distribution — Access control.

Tokens are minted by the manager, scoped (viewer or ingest) to a single stream, and expiring; the relay validates them statelessly with no database and no revocation path. Front the listener with TLS. Browsers require a secure context anyway, and without a TLS terminator the ?token= form of a viewer credential crosses the wire in clear and lands in the proxy’s default access log.

One mitigation worth knowing about: the WHEP endpoint is ICE-Lite and does not implement RFC 7675 consent freshness, so media is pinned to the address that completed the DTLS handshake and datagrams from any other address are dropped on ingress before the WebRTC stack sees them. A spoofed STUN nomination therefore mints no peer-reflexive candidate and — decisively — draws no reply, which closes a UDP-reflector amplification path. The same pin fires on a legitimate viewer whose public IP changes mid-session (Wi-Fi to cellular, a CGNAT rotation), who sees the player go black until they reload; that case raises a Warning event once per session so it is diagnosable rather than invisible.

For production deployments:

  • Provide a real TLS cert for the relay (tls_cert_path / tls_key_path in the relay config). Don’t rely on the self-signed fallback.
  • Set api_token in the relay config to a long random value.
  • Configure the manager to issue authorize_tunnel for every tunnel, then set require_bind_auth: true in the relay config so an un-authorised tunnel is refused rather than admitted. Do this only on a manager-driven relay — strict mode fails closed on both planes. If you do not carry native SRT/RIST or bond legs over this relay, udp_relay_enabled: false removes the plane where an unauthenticated bind can move live media.
  • Distribute tunnel_encryption_key only via the manager, never out-of-band by hand.
  • On edge configs, prefer cert_fingerprint over accept_self_signed_cert.
  • Run the relay behind a firewall that only exposes the QUIC port (default 4433), the native-UDP carrier port (default 4434, if you use native SRT/RIST or bond legs over relay), and the REST API port to the systems that need them.
  • Monitor the relay’s event stream for bind-rejection events (category tunnel, message “Tunnel bind rejected: invalid token”) and for the structured relay_dos_suspect DoS identifier raised when a source trips the per-IP connection or per-connection tunnel-bind caps — repeated hits indicate either misconfiguration or an active attack.
Logged Not logged
Connection lifecycle (edge connect/disconnect, tunnel bind/unbind) Tunnel ciphertext or any decrypted payload
Bind authentication failures Tunnel encryption keys or bind secrets
Push status updates from manager commands Edge-to-edge media content
Stats and bandwidth counters Specific source/destination IPs of the encapsulated traffic
TLS handshake errors Anything that would let an attacker correlate observed bytes back to a flow