feat(rtcp): Tier 2 — SRTCP + randomised timing + BYE + rtcp-mux - #163
Merged
Conversation
Builds on Tier 1 RTCP. All gated on the existing DTLS-SRTP path; plaintext behaviour is unchanged for non-secure (plain SIP) channels. Phase A — SRTCP (RFC 3711): - Encrypt outbound RTCP with encrypt_rtcp on secure channels, gated on state.srtp_encrypt exactly like the RTP send path (rtcp_tx.rs). - Decrypt inbound RTCP in rtcp_loop.rs using its own SRTCP context, fed the DTLS keying material over a tokio watch channel from poll_dtls_handshake (the loop is spawned before the handshake and can't see state.srtp_decrypt). - Share local/remote key selection via local_srtp_params/remote_srtp_params in dtls_session.rs, reused by the tick (RTP) and the RTCP loop (SRTCP). - Delete the dead srtp_ctx.rs stub (NotYetImplemented, unused). Phase B — RFC 3550 §6.2 timing + BYE: - Replace the fixed 250-tick interval with a randomised one ([0.5,1.5)xTmin/1.21828, first report at half-interval) using a per-channel xorshift RNG seeded from time XOR ssrc — no rand crate. Full multiparty reconsideration is deliberately out of scope for a point-to-point server. - Emit an RTCP BYE (SRTCP-encrypted when secure) on close: the Local close path (actor.rs) and the mixer idle-removal path (close_idle_members made async). Tests: SRTCP round-trip + tamper-rejection and interval-bounds unit tests; new JS integration test projectrtprtcpsecure.js (two-channel DTLS bridge) and a BYE (PT 203) test in projectrtprtcp.js. cargo test --lib 105 pass, clippy + fmt clean; full npm test 148 passing / 6 pending / 0 failing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Carry RTCP over the RTP port/5-tuple when the peer negotiates rtcp-mux, instead of the separate P+1 control port. Cleartext and SRTCP both work over the muxed port; classic split-port (SIP softphone) behaviour is unchanged. - Negotiation: `rtcpmux` on RemoteConfig, parsed at both JS entry points (`remote()` and `openchannel`), latched to `state.rtcpmux`. - Outbound (rtcp_tx): send to the RTP remote on `rtp_sock` (no port+1) under mux; SRTCP encryption unchanged. - Inbound (recv_loop): demux RTCP off the RTP port via `is_muxed_rtcp` (first byte 128..=191 + pkt[1] 200..=204 — pre-decrypt, header stays cleartext under SRTP/SRTCP). Reuses a shared `rtcp_loop::handle_rtcp` and its own lazily-built SRTCP decrypt context, fed by the same watch key channel (now created before the recv_loop spawn, cloned to both). - rtcp_loop refactored to argument-driven `handle_rtcp`/`parse_and_fold` so both loops share one path; `maybe_build_decrypt` made pub. - JS layer already passes `params.remote` through transparently (local + proxy/node); only the public JSDoc for `remote.rtcpmux` was added. Tests: 3 Rust demux unit tests (`recv_loop`); JS mux test in projectrtprtcp.js (RTCP on the RTP port, muxed RR folds into stats.rtcp.out, P+1 stays silent). cargo test --lib 108 pass, clippy --all-targets + rustfmt clean, full npm test 149 passing / 6 pending. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… stats shape babblevoice#162 landed the reviewed Close-stats shape (lowercase keys, boolean `valid`, `null` rttms, `in.valid`). The Tier 2 tests were authored against the pre-review Tier 1 shape, so update the new mux/BYE/SRTCP assertions to match now that babblevoice#162 is in main: - out.valid: `1` → `true`; in.cumulativeLost/out.fractionLost/... → lowercase - rttms: was `-1` sentinel, now `null` until the peer echoes an SR — the secure test relaxes to null-or-number since RTT needs a second exchange that isn't guaranteed within the window - refresh stale "~5 s / tick 250" timing comments for the randomised interval Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
tinpotnick
force-pushed
the
feat/tier2-rtcp
branch
from
July 22, 2026 18:02
18c5d0b to
7ddb5e1
Compare
tinpotnick
approved these changes
Jul 22, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Tier 2 RTCP
Builds on Tier 1 RTCP (#162) — this PR is stacked on
feat/tier1-rtcpand should merge after #162. Until #162 lands, the diff here also shows the Tier 1 commit; it collapses to the Tier 2-only diff once #162 is merged tomain.Everything secure is gated on the existing DTLS-SRTP path. Plaintext RTCP behaviour is unchanged for non-secure (plain SIP) channels — the secure branches only activate once a channel has completed a DTLS handshake.
Phase A — SRTCP (RFC 3711)
encrypt_rtcpon secure channels, gated onstate.srtp_encryptexactly like the RTP send path (rtcp_tx.rs).rtcp_loop.rsusing its own SRTCP context, fed the DTLS keying material over atokio::sync::watchchannel frompoll_dtls_handshake(the loop is spawned before the handshake and can't seestate.srtp_decrypt).local_srtp_params/remote_srtp_paramsindtls_session.rs, reused by the tick (RTP) and the RTCP loop (SRTCP).srtp_ctx.rsstub (NotYetImplemented, unused).Phase B — RFC 3550 §6.2 timing + BYE
[0.5,1.5) × Tmin / 1.21828, first report at half-interval) using a per-channel xorshift RNG seeded from time XOR ssrc — norandcrate. Full multiparty reconsideration is deliberately out of scope for a point-to-point server.actor.rs) and the mixer idle-removal path (close_idle_membersmade async).Phase C — rtcp-mux (RFC 5761)
rtcpmux, instead of the separate P+1 control port. Cleartext and SRTCP both work muxed; classic split-port (SIP softphone) behaviour is unchanged.rtcpmuxonRemoteConfig, parsed at both JS entry points (remote()andopenchannel), latched tostate.rtcpmux.rtcp_tx.rs): send to the RTP remote onrtp_sock(no+1) under mux; SRTCP encryption unchanged.recv_loop.rs): demux RTCP off the RTP port viais_muxed_rtcp(first byte128..=191+pkt[1]200..=204— classified pre-decrypt, since SRTP/SRTCP leave the header cleartext). Reuses a sharedrtcp_loop::handle_rtcpand its own lazily-built SRTCP decrypt context, fed by the same watch key channel (now created before the recv_loop spawn and cloned to both loops).rtcp_looprefactored to argument-drivenhandle_rtcp/parse_and_foldso both loops share one code path;maybe_build_decryptmadepub.openchannel/remotepassparams.remotethrough transparently in local and proxy/node modes (lib/server.js,lib/node.js,lib/message.jsare all JSON-passthrough with no field whitelist), soremote.rtcpmuxalready reaches the addon. Only the public JSDoc inindex.jswas added; consumers set it from the SDPa=rtcp-muxattribute.Out of scope (deferred)
insecure_skip_verifyremains) — a separate hardening task.Tests / verification
recv_loop, 3 tests) unit tests.cargo test --lib108 passing; clippy--all-targets+ rustfmt clean.test/interface/projectrtprtcpsecure.js(two-channel DTLS bridge assertsstats.rtcp.out.valid === 1both ways — the end-to-end SRTCP proof); a BYE (PT 203) test and an rtcp-mux test (RTCP on the RTP port, muxed RR folds intostats.rtcp.out, P+1 stays silent) inprojectrtprtcp.js.npm test: 149 passing / 6 pending / 0 failing.tcpdumpagainst a real secure/muxing peer — expect opaque SRTCP + a BYE on hangup, and (for mux) RTCP on the RTP port with nothing on P+1.🤖 Generated with Claude Code