Skip to content

feat(rtcp): Tier 2 — SRTCP + randomised timing + BYE + rtcp-mux - #163

Merged
tinpotnick merged 3 commits into
babblevoice:mainfrom
16KnighT:feat/tier2-rtcp
Jul 22, 2026
Merged

feat(rtcp): Tier 2 — SRTCP + randomised timing + BYE + rtcp-mux#163
tinpotnick merged 3 commits into
babblevoice:mainfrom
16KnighT:feat/tier2-rtcp

Conversation

@16KnighT

@16KnighT 16KnighT commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Tier 2 RTCP

Builds on Tier 1 RTCP (#162) — this PR is stacked on feat/tier1-rtcp and 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 to main.

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 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::sync::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) × Tmin / 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).

Phase C — rtcp-mux (RFC 5761)

  • Carry RTCP over the RTP port/5-tuple when the peer negotiates rtcpmux, instead of the separate P+1 control port. Cleartext and SRTCP both work muxed; 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.rs): send to the RTP remote on rtp_sock (no +1) under mux; SRTCP encryption unchanged.
  • Inbound (recv_loop.rs): demux RTCP off the RTP port via is_muxed_rtcp (first byte 128..=191 + pkt[1] 200..=204 — classified pre-decrypt, since SRTP/SRTCP leave the header cleartext). 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 and cloned to both loops).
  • rtcp_loop refactored to argument-driven handle_rtcp/parse_and_fold so both loops share one code path; maybe_build_decrypt made pub.
  • JS layer: no functional wiring needed — openchannel/remote pass params.remote through transparently in local and proxy/node modes (lib/server.js, lib/node.js, lib/message.js are all JSON-passthrough with no field whitelist), so remote.rtcpmux already reaches the addon. Only the public JSDoc in index.js was added; consumers set it from the SDP a=rtcp-mux attribute.

Out of scope (deferred)

  • Peer-cert fingerprint verification (insecure_skip_verify remains) — a separate hardening task.

Tests / verification

  • Rust: SRTCP round-trip + tamper-rejection, interval-bounds, and rtcp-mux demux (recv_loop, 3 tests) unit tests. cargo test --lib 108 passing; clippy --all-targets + rustfmt clean.
  • JS: test/interface/projectrtprtcpsecure.js (two-channel DTLS bridge asserts stats.rtcp.out.valid === 1 both 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 into stats.rtcp.out, P+1 stays silent) in projectrtprtcp.js.
  • Full npm test: 149 passing / 6 pending / 0 failing.
  • Still owner-side manual: tcpdump against 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

@16KnighT 16KnighT changed the title feat(rtcp): Tier 2 — SRTCP + randomised report timing + BYE on close feat(rtcp): Tier 2 — SRTCP + randomised timing + BYE + rtcp-mux Jul 21, 2026
Nick Knight and others added 3 commits July 22, 2026 18:39
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
tinpotnick merged commit c6839d8 into babblevoice:main Jul 22, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants