The control channel stops reusing a nonce, and the media ports stop trusting whoever knocks first #417

Merged
enricobuehler merged 5 commits from worktree-gamestream-security into main 2026-08-27 16:44:46 +00:00
11 changed files with 728 additions and 128 deletions
+23 -24
View File
@@ -11,6 +11,19 @@
//! the `/launch` `rikey` with a per-packet IV `BE32(rikeyid + seq)` (PKCS7 padding, RTP
//! header left in the clear).
//!
//! **CBC here is unauthenticated, and that is the protocol, not a gap to close.** An attacker who
//! can inject at the audio port can flip ciphertext bits and the client will decrypt and play the
//! result, because there is no tag to reject it. The instinct is to reach for `SS_ENC_AUDIO`
//! (0x04) as the authenticated answer — but per the sanctioned wire reference `SS_ENC_AUDIO`
//! **selects exactly this mode**: "if SS_ENC_AUDIO: AES-128-CBC encrypt the PKCS7-padded Opus
//! frame with IV = BE32(avRiKeyId + seq)", noted there as "CBC, not GCM. No auth tag appended
//! (unlike video/control GCM)", and negotiated by `x-nv-general.featureFlags` bit 0x20 rather
//! than the `encryptionSupported` mask. So GameStream has no authenticated audio mode to
//! advertise: offering the flag would change nothing on the wire, and adding a tag would be a
//! private extension no client can decode. (We encrypt unconditionally rather than on the flag,
//! because modern Moonlight decrypts unconditionally — see above.) A session that needs
//! authenticated audio needs the native punktfunk/1 plane, whose audio is AES-GCM.
//!
//! Surround sessions additionally carry Sunshine-style audio FEC: every aligned block of 4
//! data packets is followed by 2 ReedSolomon parity packets (`packetType = 127`, an
//! `AUDIO_FEC_HEADER` after the RTP header). FEC is opportunistic on the client — in-order
@@ -220,6 +233,8 @@ pub fn start(
audio_cap: AudioCapSlot,
on_lost: super::OnSessionLost,
owner_ip: Option<std::net::IpAddr>,
// This session's ping payload — the other half of the endpoint guard beside `owner_ip`.
av_ping: [u8; super::AV_PING_LEN],
// Bumped as this thread's LAST act — the teardown-complete signal `/resume`'s media
// restart waits on (see `AppState::media_exited`).
media_exited: Arc<std::sync::atomic::AtomicU64>,
@@ -229,7 +244,7 @@ pub fn start(
.spawn(move || {
tracing::info!(?params, "audio stream starting");
if let Err(e) = run(
&running, &gcm_key, rikeyid, params, &audio_cap, &on_lost, owner_ip,
&running, &gcm_key, rikeyid, params, &audio_cap, &on_lost, owner_ip, &av_ping,
) {
tracing::error!(error = %format!("{e:#}"), "audio stream failed");
}
@@ -253,6 +268,7 @@ pub fn start(
_audio_cap: AudioCapSlot,
_on_lost: super::OnSessionLost,
_owner_ip: Option<std::net::IpAddr>,
_av_ping: [u8; super::AV_PING_LEN],
media_exited: std::sync::Arc<std::sync::atomic::AtomicU64>,
) {
tracing::error!("GameStream audio requires Linux (PipeWire) or Windows (WASAPI) + libopus");
@@ -261,6 +277,7 @@ pub fn start(
}
#[cfg(any(target_os = "linux", target_os = "windows"))]
#[allow(clippy::too_many_arguments)] // one call site (`start`), which carries the same allow
fn run(
running: &AtomicBool,
gcm_key: &[u8; 16],
@@ -269,36 +286,18 @@ fn run(
audio_cap: &std::sync::Mutex<Option<Box<dyn AudioCapturer>>>,
on_lost: &super::OnSessionLost,
owner_ip: Option<std::net::IpAddr>,
av_ping: &[u8; super::AV_PING_LEN],
) -> Result<()> {
let sock = UdpSocket::bind(("0.0.0.0", AUDIO_PORT)).context("bind audio UDP")?;
// Grow SO_SNDBUF/RCVBUF; the opt-in DSCP/QoS tag happens after connect below (Windows
// qWAVE derives the flow from the connected 5-tuple).
punktfunk_core::transport::grow_socket_buffers(&sock);
// The client pings the audio port (~every 500ms) so we learn where to send.
sock.set_read_timeout(Some(Duration::from_secs(10)))?;
tracing::debug!(port = AUDIO_PORT, "audio: awaiting client ping");
let mut probe = [0u8; 256];
// Same owner-IP bind as the video plane (LaunchSession::peer_ip): only the launching peer's
// pings are honored, so an off-path LAN peer cannot capture the audio endpoint (a DoS here, as
// audio payload is AES-CBC under `rikey`). `None` keeps the pre-owner behavior.
// security-review 2026-08-15 finding 1.
let client = {
let deadline = std::time::Instant::now() + Duration::from_secs(10);
loop {
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
if remaining.is_zero() {
anyhow::bail!("audio: no client ping from the launch owner within 10s");
}
sock.set_read_timeout(Some(remaining))?;
let (_, src) = sock
.recv_from(&mut probe)
.context("audio: no client ping within 10s")?;
if owner_ip.is_some_and(|ip| ip != src.ip()) {
continue;
}
break src;
}
};
// Same guard as the video plane, through the same shared helper: owner IP plus this session's
// ping payload. Capturing the audio endpoint is a DoS rather than a disclosure (the payload is
// AES-CBC under `rikey`), but it is the same race and deserves the same answer.
let client = super::learn_client_endpoint(&sock, "audio", owner_ip, av_ping)?;
sock.connect(client)
.context("connect client audio endpoint")?;
// Opt-in DSCP/QoS-tag this as the audio class (PUNKTFUNK_DSCP=1); the guard keeps the
@@ -14,8 +14,10 @@
//!
//! The GCM nonce depends on what Moonlight negotiated (`encryptControlMessage` in
//! moonlight-common-c). For `SS_ENC_CONTROL_V2` it is a 12-byte nonce with `seq` (LE) in bytes
//! [0..4] and `b"CC"` (client→host) at [10..12]. For the legacy path — which we hit, since we
//! advertise no encryption — it is a 16-byte nonce with only `iv[0] = seq & 0xff` and the rest
//! [0..4] and `b"CC"` (client→host) at [10..12]. That is the path a stock client takes now that
//! `SS_ENC_CONTROL_V2` is offered by default. The legacy path — a client that declines it, or a
//! host set to `PUNKTFUNK_GS_ENCRYPT=video`/`0` — is a 16-byte nonce with only
//! `iv[0] = seq & 0xff` and the rest
//! zero. The tag is prepended to the ciphertext; there is no AAD; the key is the forward
//! `hex::decode(rikey)`. We auto-detect the exact scheme via [`decrypt_control`] on the first
//! packet that authenticates, since GCM gives no partial credit.
@@ -696,7 +698,8 @@ fn spawn(state: Arc<AppState>) -> Result<Running> {
// EVIOCSFF until answered) and relay mixed rumble levels to the client.
//
// SECURITY NOTE (audit #5, legacy GCM nonce reuse): on the LEGACY control scheme
// (`NonceKind::Legacy*`, which we hit because we advertise no encryption) the nonce is
// (`NonceKind::Legacy*`, which we hit unless the client negotiated
// `SS_ENC_CONTROL_V2` — see `rtsp::SS_ENC_CONTROL_V2`) the nonce is
// just the per-direction `seq` (`iv[0]=seq&0xff`, rest zero) with NO direction byte —
// so host control messages (this `host_seq`, shared by rumble + the HDR-mode signal)
// and client input (its own seq) share the same (key, nonce) space when their seqs
@@ -705,8 +708,10 @@ fn spawn(state: Arc<AppState>) -> Result<Running> {
// scheme adds `iv[10..12] = 'H','C'` to separate the host direction). It can't be fixed
// on the legacy wire without breaking Moonlight; the GCM key is the client-supplied
// `rikey` (so only a passive eavesdropper who missed the HTTPS /launch is the
// adversary). The real fix is V2 control-encryption negotiation; for untrusted networks
// use the native punktfunk/1 plane (correct per-direction nonces + seq-as-AAD).
// adversary). The real fix is V2 control-encryption negotiation, which this host now
// offers — a client that echoes `SS_ENC_CONTROL_V2` lands on `NonceKind::V2` and this
// note stops applying to it. For untrusted networks use the native punktfunk/1 plane
// (correct per-direction nonces + seq-as-AAD).
if let (Some(pid), Some(scheme)) = (peer, detected) {
let key = state.launch.lock().unwrap().map(|s| s.gcm_key);
// Remember it for the teardown message (see `last_key`).
@@ -1211,8 +1216,9 @@ fn encrypt_control(key: &[u8; 16], scheme: &Scheme, seq: u32, pt: &[u8]) -> Vec<
wire
}
/// AES-128-GCM seal (companion to [`gcm_open`]); returns `ciphertext || tag`.
fn gcm_seal(key: &[u8; 16], nonce: &[u8], pt: &[u8], aad: &[u8]) -> Vec<u8> {
/// AES-128-GCM seal (companion to [`gcm_open`]); returns `ciphertext || tag`. Shared with the
/// RTSP plane, which seals its own messages under the same session key.
pub(super) fn gcm_seal(key: &[u8; 16], nonce: &[u8], pt: &[u8], aad: &[u8]) -> Vec<u8> {
use aes_gcm::aead::consts::{U12, U16};
use aes_gcm::aead::{Aead, KeyInit, Payload};
use aes_gcm::{aes::Aes128, AesGcm};
@@ -1234,7 +1240,7 @@ fn gcm_seal(key: &[u8; 16], nonce: &[u8], pt: &[u8], aad: &[u8]) -> Vec<u8> {
/// AES-128-GCM open with a 12- or 16-byte nonce and explicit AAD. Returns the plaintext iff
/// the tag authenticates. `ct_tag` is `ciphertext || tag` (aes-gcm's expected order).
fn gcm_open(key: &[u8; 16], nonce: &[u8], ct_tag: &[u8], aad: &[u8]) -> Option<Vec<u8>> {
pub(super) fn gcm_open(key: &[u8; 16], nonce: &[u8], ct_tag: &[u8], aad: &[u8]) -> Option<Vec<u8>> {
use aes_gcm::aead::consts::{U12, U16};
use aes_gcm::aead::{Aead, KeyInit, Payload};
use aes_gcm::{aes::Aes128, AesGcm};
+190
View File
@@ -59,6 +59,105 @@ pub const VIDEO_PORT: u16 = 47998;
pub const CONTROL_PORT: u16 = 47999;
pub const AUDIO_PORT: u16 = 48000;
/// Length of the per-session A/V ping payload. The SETUP response carries it hex-encoded, so
/// these 8 bytes are the 16 characters a client echoes back.
#[cfg(feature = "gamestream")]
pub const AV_PING_LEN: usize = 8;
/// How long [`learn_client_endpoint`] holds out for a datagram that actually carries the session's
/// ping payload once an unverified one is already in hand.
#[cfg(feature = "gamestream")]
const AV_PING_GRACE: std::time::Duration = std::time::Duration::from_secs(2);
/// How long a media plane waits for its client to show up at all.
#[cfg(feature = "gamestream")]
const AV_PING_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
/// Decide whether a datagram carries this session's ping payload.
///
/// Accepts either encoding as a **prefix**: the SETUP header's ASCII hex, or its decoded bytes —
/// and trailing bytes are fine, because a modern client wraps the payload in an `SS_PING`
/// structure with a sequence number. Constant-time, so a peer can't probe the expected value a
/// byte at a time by timing our answer.
#[cfg(feature = "gamestream")]
fn ping_matches(datagram: &[u8], expect: &[u8; AV_PING_LEN]) -> bool {
let hex = hex::encode(expect);
let ascii =
datagram.len() >= hex.len() && crypto::ct_eq(&datagram[..hex.len()], hex.as_bytes());
let raw = datagram.len() >= expect.len() && crypto::ct_eq(&datagram[..expect.len()], expect);
ascii || raw
}
/// Learn a media stream's client UDP endpoint from the first datagram that proves it belongs to
/// this session. Two guards, weakest to strongest:
///
/// * **Source IP** — only the launch owner's datagrams are considered (security-review 2026-08-15
/// finding 1). On its own this leaves the endpoint to whoever sends first from that address: a
/// NAT neighbour sharing the owner's public IP, or an on-path peer spoofing it.
/// * **Ping payload** — the per-session secret minted at `/launch`, handed out in the SETUP
/// response, and echoed by the client as its first datagram. A racer who never saw it cannot
/// produce it.
///
/// The payload check **prefers** rather than **requires**, and that is deliberate. The sanctioned
/// wire reference says the client echoes the payload, and that modern clients send it inside an
/// `SS_PING` structure *with a sequence number* — but it gives neither that structure's layout nor
/// whether the payload crosses as the header's ASCII or as its decoded bytes. [`ping_matches`]
/// accepts every shape those unknowns allow, yet a hard gate resting on an unverified layout would
/// black-screen every session it guessed wrong about, and compatibility is this plane's whole
/// reason to exist. So an unverified datagram is held as a fallback, adopted only if the grace
/// window passes with nothing better, and logged with the bytes that did arrive — one real session
/// settles the question, and the fallback can go.
#[cfg(feature = "gamestream")]
pub fn learn_client_endpoint(
sock: &UdpSocket,
label: &str,
owner_ip: Option<IpAddr>,
expect: &[u8; AV_PING_LEN],
) -> Result<std::net::SocketAddr> {
let start = std::time::Instant::now();
let deadline = start + AV_PING_TIMEOUT;
let mut probe = [0u8; 256];
// The first owner datagram that did NOT carry the payload, kept with a copy of its opening
// bytes — `probe` is overwritten by every later datagram, and those bytes are the whole point
// of the warning below.
let mut fallback: Option<(std::net::SocketAddr, Vec<u8>)> = None;
let mut grace = deadline;
loop {
let remaining = grace.saturating_duration_since(std::time::Instant::now());
if remaining.is_zero() {
break;
}
sock.set_read_timeout(Some(remaining))?;
// Any read error ends the wait and falls through to the decision below — a timeout here is
// the grace window expiring, not a failure, once a fallback is in hand.
let Ok((n, src)) = sock.recv_from(&mut probe) else {
break;
};
if owner_ip.is_some_and(|ip| ip != src.ip()) {
continue;
}
if ping_matches(&probe[..n], expect) {
tracing::info!(%src, "{label}: client endpoint learned (ping payload verified)");
return Ok(src);
}
if fallback.is_none() {
fallback = Some((src, probe[..n.min(32)].to_vec()));
grace = (std::time::Instant::now() + AV_PING_GRACE).min(deadline);
}
}
match fallback {
Some((src, head)) => {
tracing::warn!(
%src,
bytes = %hex::encode(&head),
"{label}: first datagram did not carry this session's ping payload — adopting it anyway (source-IP-bound only). Report these bytes: they pin the wire encoding."
);
Ok(src)
}
None => anyhow::bail!("{label}: no client ping from the launch owner within 10s"),
}
}
/// Advertised host version. Major ≥ 7 tells Moonlight to use SHA-256 for pairing.
pub const APP_VERSION: &str = "7.1.431.-1";
pub const GFE_VERSION: &str = "3.23.0.74";
@@ -230,6 +329,14 @@ pub struct AppState {
pub(crate) control_gate: control::Gate,
/// The active launch session (set by `/launch`, consumed by RTSP/media).
pub launch: std::sync::Mutex<Option<LaunchSession>>,
/// This session's A/V ping payload ([`AV_PING_LEN`] bytes, big-endian in these 8) — minted
/// fresh by `/launch` and `/resume`, handed to the client in the SETUP response, and echoed
/// back by it as the first datagram on each media port. It is what lets the media planes tell
/// their client apart from anything else arriving at the port from the same address; see
/// [`learn_client_endpoint`]. Not in [`LaunchSession`]: the client does not supply it, we mint
/// it, and it re-mints on resume while that struct's keys may not.
#[cfg(feature = "gamestream")]
pub av_ping: std::sync::atomic::AtomicU64,
/// Negotiated video config from RTSP ANNOUNCE (consumed by the stream on PLAY).
#[cfg(feature = "gamestream")]
pub stream: std::sync::Mutex<Option<stream::StreamConfig>>,
@@ -344,6 +451,27 @@ impl AppState {
self.end_session(reason)
}
/// Mint a fresh A/V ping payload for a session that is beginning (`/launch`) or re-beginning
/// (`/resume`), and return it. Must happen before the client's RTSP SETUP, which is what hands
/// it out.
#[cfg(feature = "gamestream")]
pub fn mint_av_ping(&self) -> [u8; AV_PING_LEN] {
let payload = crypto::random::<AV_PING_LEN>();
self.av_ping.store(
u64::from_be_bytes(payload),
std::sync::atomic::Ordering::SeqCst,
);
payload
}
/// This session's A/V ping payload — what SETUP advertises and the media planes expect back.
#[cfg(feature = "gamestream")]
pub fn av_ping_payload(&self) -> [u8; AV_PING_LEN] {
self.av_ping
.load(std::sync::atomic::Ordering::SeqCst)
.to_be_bytes()
}
/// Fresh control-plane state: no active session; the pairing allow-list is loaded from
/// disk (pairings persist across restarts). `stats` is the shared recorder handed to both the
/// mgmt API and the streaming loops. (The native-only build's variant is below — same state
@@ -361,6 +489,7 @@ impl AppState {
paired: std::sync::Mutex::new(load_paired()),
control_gate: control::Gate::new(),
launch: std::sync::Mutex::new(None),
av_ping: std::sync::atomic::AtomicU64::new(0),
stream: std::sync::Mutex::new(None),
audio_params: std::sync::Mutex::new(audio::AudioParams::default()),
streaming: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
@@ -1000,6 +1129,21 @@ mod session_tests {
}
}
/// Mint and read must agree on byte order — if they did not, every session would advertise
/// one payload in its SETUP response and expect another at the media ports, and no client
/// would ever be recognised. A resume must also not reuse the old value: it re-mints
/// precisely because the previous one may have been seen on the (plaintext) wire.
#[cfg(feature = "gamestream")]
#[test]
fn av_ping_mint_round_trips_and_changes() {
let state = test_state();
let first = state.mint_av_ping();
assert_eq!(first, state.av_ping_payload(), "advertised != expected");
let second = state.mint_av_ping();
assert_ne!(first, second, "a resume must not reuse the payload");
assert_eq!(second, state.av_ping_payload());
}
/// `end_session` is THE compat-plane teardown: one call must clear the whole session — both
/// media-thread flags, the launch, and the negotiated stream config — and be idempotent.
/// Guards the ENet-Disconnect / client-unreachable paths that previously stopped nothing
@@ -1074,6 +1218,52 @@ mod session_tests {
}
}
#[cfg(all(test, feature = "gamestream"))]
mod av_ping_tests {
use super::{ping_matches, AV_PING_LEN};
const P: [u8; AV_PING_LEN] = [0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77];
/// The wire reference does not say whether the client echoes the SETUP header's ASCII or its
/// decoded bytes, and says the modern form wraps the payload in a structure carrying a
/// sequence number. Every shape those unknowns allow has to be recognised, or a correct client
/// gets its endpoint refused.
#[test]
fn both_encodings_match_with_or_without_a_trailing_sequence() {
let hex = b"0011223344556677";
let raw = &P[..];
assert!(ping_matches(hex, &P), "ASCII hex, exactly");
assert!(ping_matches(raw, &P), "decoded bytes, exactly");
// …and each with an SS_PING-style sequence number appended.
assert!(
ping_matches(&[&hex[..], &[0, 0, 0, 1]].concat(), &P),
"hex + seq"
);
assert!(
ping_matches(&[raw, &[0, 0, 0, 1]].concat(), &P),
"raw + seq"
);
}
/// The point of the payload: a datagram that does not carry it is not this session's client.
#[test]
fn anything_else_does_not_match() {
assert!(!ping_matches(b"", &P), "empty");
assert!(!ping_matches(b"PING", &P), "the legacy fixed ping");
assert!(!ping_matches(b"001122334455667", &P), "one hex char short");
assert!(!ping_matches(&P[..7], &P), "one raw byte short");
assert!(
!ping_matches(b"0011223344556678", &P),
"last hex char wrong"
);
let mut near = P;
near[7] ^= 1;
assert!(!ping_matches(&near, &P), "last raw byte wrong");
// The old fixed constant, now that every session mints its own.
assert!(!ping_matches(b"0011223344556677", &[0xAB; AV_PING_LEN]));
}
}
#[cfg(all(test, unix))]
mod tests {
use std::os::unix::fs::PermissionsExt;
@@ -267,6 +267,10 @@ async fn h_launch(
// waiting out its reconnect window — is reprieved by the stream thread, which resolves
// the title anyway and so needs no second library scan here.
st.quit.store(false, std::sync::atomic::Ordering::SeqCst);
// Fresh A/V ping payload for this session, before the client's RTSP SETUP asks for it:
// it is what the media planes use to tell this client's first datagram apart from any
// other arriving at the port from the same address.
st.mint_av_ping();
*st.launch.lock().unwrap() = Some(session);
tracing::info!(
w = session.width,
@@ -378,6 +382,10 @@ async fn h_resume(
session.peer_ip = Some(a.ip());
}
}
// A resume is a new connection with a new RTSP handshake and new media threads, so it gets a
// new ping payload too — the old one may have been observed on the wire (RTSP is plaintext
// until `SS_ENC_CONTROL_V2`), and nothing that learns an endpoint after this point has seen it.
st.mint_av_ping();
xml(session_url_xml(&st, "resume"))
}
@@ -25,6 +25,26 @@ use tokio::sync::Notify;
/// own PIN; security-review 2026-06-28 #1). `getservercert` parks until a PIN arrives.
/// Max pairing handshakes parked in [`PinGate::take`] at once (each holds a slot for up to
/// 300s), bounding a pre-auth waiter flood. Real pairing is one operator-driven client at a time.
///
/// **On brute-forcing the 4-digit PIN** (audited 2026-08-27, apollo-comparison #96): 10⁴ is a
/// small space, but nothing here is guessing at it, because **a network peer has no way to submit
/// a PIN**. Submission is `POST /api/v1/pair/pin` on the bearer-authenticated management API and
/// nowhere else, so there is no oracle to hammer and no attempt counter worth adding — a
/// per-attempt cap would bound the *operator's* typos, not an attacker. A wrong PIN fails the
/// ceremony and costs the attacker a fresh client handshake *and* a fresh operator submission,
/// which is not a loop anyone can automate from the network.
///
/// What that leaves is not brute force but **capture**: the PIN slot is global and bound to no
/// particular handshake, so a peer parked at the right moment can take the PIN the operator typed
/// for someone else. That is the real residual, it is already narrowed twice — [`PinGate::submit`]
/// refuses while more than one handshake is parked, and an unconsumed PIN expires rather than
/// waiting to authenticate whoever knocks next — and the full fix is to key the gate by
/// `uniqueid` (which also needs the management API to say *which* device is asking, so the
/// operator answers a named prompt rather than a bare one).
///
/// The cap below does leak one bit to an unauthenticated peer — a refused `getservercert` tells it
/// that `MAX_PARKED_WAITERS` handshakes are already parked. That is inherent to having a cap, and
/// the alternative (an unbounded pre-auth park) is the worse trade.
const MAX_PARKED_WAITERS: usize = 4;
pub struct PinGate {
+413 -61
View File
@@ -6,9 +6,13 @@
//!
//! Runs on its own native thread (control-plane setup, not the per-frame hot path), one
//! thread per connection. DESCRIBE offers `SS_ENC_VIDEO` (per-shard AES-128-GCM video, WP7 —
//! on by default, never REQUIRED, `PUNKTFUNK_GS_ENCRYPT=0` opts out); audio is AES-CBC and the
//! ENet control stream AES-GCM regardless, and `SS_ENC_CONTROL_V2`/`SS_ENC_AUDIO` are not
//! offered (see [`EncOffer`]).
//! on by default, never REQUIRED, `PUNKTFUNK_GS_ENCRYPT=0` opts out) and `SS_ENC_CONTROL_V2`
//! — which gives the ENet control stream a per-direction nonce and lets the client seal RTSP
//! itself (`PUNKTFUNK_GS_ENCRYPT=video` drops just this one). Audio is AES-CBC regardless and
//! `SS_ENC_AUDIO` is still not offered (its layout is absent from the wire reference). See
//! [`EncOffer`].
//!
//! A sealed connection is recognised, not negotiated: see [`ENCRYPTED_MESSAGE_TYPE_BIT`].
use super::audio;
use super::stream::{self, StreamConfig};
@@ -18,13 +22,10 @@ use anyhow::{Context, Result};
use std::collections::HashMap;
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
/// Opaque per-session payload the client echoes as its first UDP datagram (port-learning).
const PING_PAYLOAD: &str = "0011223344556677";
// The RTSP listener is UNAUTHENTICATED (no TLS/pairing) and one-thread-per-connection, so bound
// every attacker-controllable dimension to deny a pre-auth slow-loris / memory-growth DoS: a hard
// cap on concurrent connections, a per-read timeout so a stalled peer can't pin a thread, a
@@ -103,19 +104,46 @@ fn handle_conn(mut stream: TcpStream, state: Arc<AppState>) -> Result<()> {
let _ = stream.set_read_timeout(Some(RTSP_READ_TIMEOUT));
let deadline = Instant::now() + RTSP_REQUEST_DEADLINE;
let mut buf: Vec<u8> = Vec::new();
// Which framing this connection speaks is the client's choice, and the first byte settles it:
// a sealed message opens with `typeAndLength`, whose MSB is [`ENCRYPTED_MESSAGE_TYPE_BIT`],
// while a plaintext one opens with an ASCII method name. We answer in kind, so there is no
// negotiation to get wrong and no state to keep.
if !fill_at_least(&mut stream, &mut buf, 1, deadline)? {
return Ok(()); // peer closed without sending anything
}
let sealed = buf[0] & 0x80 != 0;
// Sealed RTSP is keyed by the same `/launch` rikey as everything else, so it cannot precede a
// launch. Refusing here (rather than mis-parsing) keeps the failure legible.
let key = state.launch.lock().unwrap().map(|s| s.gcm_key);
let key = match (sealed, key) {
(false, _) => None,
(true, Some(k)) => Some(k),
(true, None) => {
anyhow::bail!("sealed RTSP message arrived with no launch session to key it")
}
};
// GameStream RTSP is one request per TCP connection: moonlight-common-c reads the
// response until EOF, so we answer one message and close the connection (which signals
// the end of the response). Session state lives in `AppState`, not the connection.
if let Some(req) = read_message(&mut stream, &mut buf, deadline)? {
let req = match key {
Some(k) => read_sealed_message(&mut stream, &mut buf, deadline, &k)?,
None => read_message(&mut stream, &mut buf, deadline)?,
};
if let Some(req) = req {
tracing::debug!(
method = %req.method,
cseq = %req.cseq,
sealed,
headers = %req.head.replace("\r\n", " | "),
body = %req.body.replace("\r\n", " | "),
"RTSP request"
);
let resp = handle_request(&req, &state, peer);
stream.write_all(resp.as_bytes()).context("RTSP write")?;
let out = match key {
Some(k) => seal_response(&k, resp.as_bytes()),
None => resp.into_bytes(),
};
stream.write_all(&out).context("RTSP write")?;
stream.flush().ok();
// Close (FIN after the flushed response) so the client detects end-of-response.
let _ = stream.shutdown(std::net::Shutdown::Both);
@@ -223,6 +251,18 @@ fn handle_request(req: &Request, state: &Arc<AppState>, peer: Option<SocketAddr>
Some(&describe_sdp()),
),
"SETUP" => {
// Gated like its siblings ANNOUNCE and PLAY, and for a sharper reason since the ping
// payload became a secret: this response is where the payload is handed out, so an
// ungated SETUP would let any peer that can reach 48010 simply *ask* for the value the
// media planes verify — and then win the endpoint race it is meant to lose. Real
// clients SETUP from the same address they launched from, so this costs them nothing.
if authorized_launch(state, peer).is_none() {
tracing::warn!(
?peer,
"RTSP SETUP — refused: not the paired `/launch` owner"
);
return response_status("401 Unauthorized", &req.cseq, &[], None);
}
let (port, extra_key) = match stream_type(&req.uri) {
Some("audio") => (AUDIO_PORT, "X-SS-Ping-Payload"),
Some("video") => (VIDEO_PORT, "X-SS-Ping-Payload"),
@@ -230,12 +270,15 @@ fn handle_request(req: &Request, state: &Arc<AppState>, peer: Option<SocketAddr>
_ => return response_status("404 Not Found", &req.cseq, &[], None),
};
let transport = format!("server_port={port}");
// This session's payload, minted at `/launch`. The client echoes it as its first
// datagram on each media port, which is how those planes recognise it.
let payload = hex::encode(state.av_ping_payload());
response(
&req.cseq,
&[
("Session", "DEADBEEFCAFE;timeout = 90"),
("Transport", &transport),
(extra_key, PING_PAYLOAD),
(extra_key, &payload),
],
None,
)
@@ -310,6 +353,7 @@ fn handle_request(req: &Request, state: &Arc<AppState>, peer: Option<SocketAddr>
quit: state.quit.clone(),
fingerprint: ls.owner_fp.map(hex::encode),
owner_ip: ls.peer_ip,
av_ping: state.av_ping_payload(),
on_game_exit: {
let st = state.clone();
Arc::new(move || {
@@ -337,6 +381,9 @@ fn handle_request(req: &Request, state: &Arc<AppState>, peer: Option<SocketAddr>
// Same owner-IP bind as the video plane: only the launching peer's pings are
// honored at the audio endpoint. security-review 2026-08-15 finding 1.
ls.peer_ip,
// ...and the same ping payload, which is what tells this client's first
// datagram apart from anything else arriving from that address.
state.av_ping_payload(),
state.media_exited.clone(),
);
}
@@ -370,37 +417,53 @@ fn handle_request(req: &Request, state: &Arc<AppState>, peer: Option<SocketAddr>
const SS_FF_PEN_TOUCH_EVENTS: u32 = 0x01;
/// `SS_ENC_VIDEO` — the per-shard AES-128-GCM video mode (moonlight-common-c
/// `Limelight-internal.h`; the siblings are `SS_ENC_CONTROL_V2` 0x01 and `SS_ENC_AUDIO` 0x04,
/// neither of which this host offers yet: control-v2 also re-frames RTSP itself, and the
/// audio-GCM layout is not in the sanctioned wire reference).
/// `Limelight-internal.h`). `SS_ENC_AUDIO` 0x04 is still not offered: the audio-GCM layout is
/// not in the sanctioned wire reference.
const SS_ENC_VIDEO: u32 = 0x02;
/// `SS_ENC_CONTROL_V2` — the V2 control-encryption scheme. What it buys is a **direction byte**
/// in the GCM nonce: `[10..12]` = `b"CC"` client→host, `b"HC"` host→client. The legacy scheme has
/// no such separation — its nonce is just the sender's own `seq` — so host messages and client
/// input share one (key, nonce) space and collide whenever their independent counters cross.
/// That is the single catastrophic AES-GCM failure, and this flag is its documented fix.
///
/// Enabling it also lets the client seal RTSP itself; see [`read_sealed_message`].
const SS_ENC_CONTROL_V2: u32 = 0x01;
/// How this host offers video encryption (WP7), from `PUNKTFUNK_GS_ENCRYPT`.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum EncOffer {
/// `0` — advertise nothing, the plaintext wire this plane sent before WP7. The escape
/// hatch for a client that turns out to mis-negotiate, and for measuring the seal's cost.
Off,
/// **The default.** Advertise as SUPPORTED and let the client decide — what a
/// Sunshine-class host does. Verified on glass 2026-08-27 (.173 → Moonlight 6.x on macOS,
/// RTX 4090, 2560x1440@240 HEVC Main10): the client opts in of its own accord even on a
/// LAN, and decodes the sealed stream in hardware with zero errors.
/// **The default.** Advertise `SS_ENC_VIDEO` and `SS_ENC_CONTROL_V2` as SUPPORTED and let
/// the client decide — what a Sunshine-class host does. Both verified on glass 2026-08-27
/// (.173 → Moonlight on macOS, RTX 4090, 2560x1440@240 HEVC Main10 HDR): the client opts into
/// both of its own accord even on a LAN, decodes the sealed video in hardware, and the host
/// logs the control scheme it settled on as `V2 { marker: "CC" }` — per-direction nonces, so
/// the legacy scheme's (key, nonce) reuse is retired for that session.
Supported,
/// `require` — additionally list it as REQUESTED, which forces any client that supports
/// it to enable it. **The on-glass test lever**: with `Supported` alone a LAN session may
/// negotiate plaintext and never exercise a single sealed packet, so a green test would
/// prove nothing about the path it was meant to validate. Not a shipping mode — a client
/// that cannot do video encryption has nowhere to go from here.
/// `video` — video encryption only, dropping `SS_ENC_CONTROL_V2` back to the legacy control
/// scheme. The granular way out: `Off` would also throw away video encryption, and this plane
/// serves a spread of client builds of which exactly one has been tested against the V2 offer.
VideoOnly,
/// `require` — additionally list everything offered as REQUESTED, which forces any client
/// that supports it to enable it. **The on-glass test lever**: with `Supported` alone a LAN
/// session may negotiate plaintext and never exercise a single sealed packet, so a green test
/// would prove nothing about the path it was meant to validate. Not a shipping mode — a
/// client that cannot do the offered encryption has nowhere to go from here.
Required,
}
/// Whether — and how hard — this host offers video encryption (WP7). **On by default** since
/// the 2026-08-27 on-glass pass: a stock Moonlight client negotiates `SS_ENC_VIDEO` by itself
/// (even on a LAN, where it was not obvious it would) and decodes the sealed stream in
/// hardware, and FEC still recovers through the seal at 5 % injected wire loss — 27 s with
/// zero keyframe re-requests. `PUNKTFUNK_GS_ENCRYPT=0` is the escape hatch back to the
/// plaintext wire; `require` additionally REQUESTS it (the test lever that forces the
/// negotiation when a client would otherwise decline).
/// Whether — and how hard — this host offers encryption. **On by default** since the
/// 2026-08-27 on-glass pass: a stock Moonlight client negotiates `SS_ENC_VIDEO` by itself (even
/// on a LAN, where it was not obvious it would) and decodes the sealed stream in hardware, and
/// FEC still recovers through the seal at 5 % injected wire loss — 27 s with zero keyframe
/// re-requests. `SS_ENC_CONTROL_V2` joined the default the same way, in a second on-glass pass
/// later that day. `PUNKTFUNK_GS_ENCRYPT=0` is the escape hatch back to the plaintext wire,
/// `video` keeps video encryption but drops the control offer, and `require` additionally
/// REQUESTS both (the test lever that forces the negotiation when a client would otherwise
/// decline).
fn gs_video_encryption_offer() -> EncOffer {
static ON: std::sync::OnceLock<EncOffer> = std::sync::OnceLock::new();
*ON.get_or_init(|| {
@@ -409,6 +472,7 @@ fn gs_video_encryption_offer() -> EncOffer {
.map(str::trim)
{
Ok("0") | Ok("off") | Ok("false") | Ok("no") => EncOffer::Off,
Ok("video") | Ok("video-only") => EncOffer::VideoOnly,
Ok("require") | Ok("required") => EncOffer::Required,
// Unset, `1`, `supported`, or anything unrecognized: the default offer.
_ => EncOffer::Supported,
@@ -419,13 +483,148 @@ fn gs_video_encryption_offer() -> EncOffer {
/// The `(encryptionSupported, encryptionRequested)` masks an offer advertises — pure, so the
/// advertisement is unit-testable without touching the process-global env.
fn enc_flags(offer: EncOffer) -> (u32, u32) {
// REQUESTED is only ever non-zero for the `require` test lever: requiring encryption would
// refuse every client that cannot do it.
match offer {
EncOffer::Off => (0, 0),
EncOffer::Supported => (SS_ENC_VIDEO, 0),
EncOffer::Required => (SS_ENC_VIDEO, SS_ENC_VIDEO),
EncOffer::VideoOnly => (SS_ENC_VIDEO, 0),
EncOffer::Supported => (SS_ENC_VIDEO | SS_ENC_CONTROL_V2, 0),
EncOffer::Required => (
SS_ENC_VIDEO | SS_ENC_CONTROL_V2,
SS_ENC_VIDEO | SS_ENC_CONTROL_V2,
),
}
}
/// `ENCRYPTED_MESSAGE_TYPE_BIT` — the MSB of `typeAndLength`, set on every sealed RTSP message.
///
/// It is also what makes the two framings **self-distinguishing**, which is why this host needs
/// no negotiation state for them: a plaintext RTSP message opens with an ASCII method name
/// (`OPTIONS`, `DESCRIBE`, `PLAY`), whose first byte is always below 0x80. So the client picks the
/// framing per connection and we answer in whatever we were asked in — no `corever` threshold to
/// guess (the sanctioned reference names that field but not its value), and no way for the two
/// sides to disagree.
const ENCRYPTED_MESSAGE_TYPE_BIT: u32 = 0x8000_0000;
/// `encrypted_rtsp_header_t`: `u32 typeAndLength | u32 sequenceNumber | u8 tag[16]`, big-endian,
/// followed by `length` bytes of ciphertext.
const ENC_RTSP_HEADER: usize = 24;
/// Host→client RTSP sequence numbers. PROCESS-global and monotonic, never reset — the same rule
/// WP7 established for the video counter, and for a sharper reason here: GameStream RTSP is one
/// message per TCP connection, so a per-connection counter would restart at 0 for every one of a
/// session's seven messages and reuse (key, nonce) six times over.
static RTSP_HOST_SEQ: AtomicU32 = AtomicU32::new(0);
/// The 12-byte GCM nonce for a sealed RTSP message (NIST SP800-38D 8.2.1): `sequenceNumber`
/// big-endian in `[0..4]`, `[10]` the originating direction (`b'C'` client, `b'H'` host), `[11]`
/// the channel (`b'R'` for RTSP). The direction byte is the entire point — it is what keeps each
/// side's counter in its own nonce space.
fn rtsp_nonce(seq: u32, direction: u8) -> [u8; 12] {
let mut iv = [0u8; 12];
iv[0..4].copy_from_slice(&seq.to_be_bytes());
iv[10] = direction;
iv[11] = b'R';
iv
}
/// Build one sealed frame: `encrypted_rtsp_header_t` followed by the ciphertext. Pure, and
/// direction-parameterised so a test can build the client's side of the wire too.
fn seal_frame(key: &[u8; 16], seq: u32, direction: u8, pt: &[u8]) -> Vec<u8> {
let ct_tag = super::control::gcm_seal(key, &rtsp_nonce(seq, direction), pt, &[]);
let (ct, tag) = ct_tag.split_at(ct_tag.len() - 16);
let mut wire = Vec::with_capacity(ENC_RTSP_HEADER + ct.len());
wire.extend_from_slice(&(ENCRYPTED_MESSAGE_TYPE_BIT | ct.len() as u32).to_be_bytes());
wire.extend_from_slice(&seq.to_be_bytes());
wire.extend_from_slice(tag);
wire.extend_from_slice(ct);
wire
}
/// Frame + seal one RTSP response for a client that sealed its request.
fn seal_response(key: &[u8; 16], pt: &[u8]) -> Vec<u8> {
seal_frame(key, RTSP_HOST_SEQ.fetch_add(1, Ordering::Relaxed), b'H', pt)
}
/// Open one COMPLETE sealed frame and parse the RTSP message inside it. Split out of
/// [`read_sealed_message`] so the half that can be wrong about the wire is reachable without a
/// socket. `frame` must be exactly the header plus its declared payload.
fn open_sealed_frame(key: &[u8; 16], frame: &[u8]) -> Result<Request> {
anyhow::ensure!(
frame.len() >= ENC_RTSP_HEADER,
"sealed RTSP frame too short"
);
let seq = u32::from_be_bytes([frame[4], frame[5], frame[6], frame[7]]);
// Wire order is tag-first; `aes-gcm` wants `ciphertext || tag`.
let mut ct_tag = frame[ENC_RTSP_HEADER..].to_vec();
ct_tag.extend_from_slice(&frame[8..ENC_RTSP_HEADER]);
let pt = super::control::gcm_open(key, &rtsp_nonce(seq, b'C'), &ct_tag, &[])
.context("sealed RTSP message failed to authenticate")?;
// Inside the seal is an ordinary RTSP message. Its end is already known from the frame
// length, so unlike the plaintext path there is no Content-Length to trust.
let Some(end) = find_subslice(&pt, b"\r\n\r\n") else {
anyhow::bail!("sealed RTSP message has no header terminator");
};
if end > MAX_RTSP_HEADER {
anyhow::bail!("RTSP headers exceed limit");
}
let head = std::str::from_utf8(&pt[..end]).context("RTSP header utf8")?;
let body = String::from_utf8_lossy(&pt[end + 4..]).into_owned();
Ok(parse_request(head, body))
}
/// Read until `buf` holds at least `want` bytes. `Ok(false)` = the peer closed first.
fn fill_at_least(
stream: &mut TcpStream,
buf: &mut Vec<u8>,
want: usize,
deadline: Instant,
) -> Result<bool> {
while buf.len() < want {
if Instant::now() >= deadline {
anyhow::bail!("RTSP request deadline exceeded");
}
let mut tmp = [0u8; 8192];
let n = stream.read(&mut tmp).context("RTSP read")?;
if n == 0 {
return Ok(false);
}
buf.extend_from_slice(&tmp[..n]);
if buf.len() > MAX_RTSP_MSG {
anyhow::bail!("RTSP message exceeds limit");
}
}
Ok(true)
}
/// Read one **sealed** RTSP message and return the request inside it. Decrypt input is
/// `tag || ciphertext` on the wire, which is reassembled into the `ciphertext || tag` order
/// `aes-gcm` expects.
fn read_sealed_message(
stream: &mut TcpStream,
buf: &mut Vec<u8>,
deadline: Instant,
key: &[u8; 16],
) -> Result<Option<Request>> {
if !fill_at_least(stream, buf, ENC_RTSP_HEADER, deadline)? {
return Ok(None);
}
let type_and_length = u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]);
let len = (type_and_length & !ENCRYPTED_MESSAGE_TYPE_BIT) as usize;
// This length is attacker-controlled and reaches us before a single byte has authenticated,
// so it is bounded by the same budget the plaintext path uses rather than trusted enough to
// reserve against. (`fill_at_least` also caps, but refusing here avoids the read entirely.)
if len > MAX_RTSP_MSG {
anyhow::bail!("sealed RTSP payload of {len} bytes exceeds limit");
}
if !fill_at_least(stream, buf, ENC_RTSP_HEADER + len, deadline)? {
anyhow::bail!("sealed RTSP message truncated");
}
let req = open_sealed_frame(key, &buf[..ENC_RTSP_HEADER + len])?;
buf.drain(..ENC_RTSP_HEADER + len);
Ok(Some(req))
}
/// Host capability SDP returned by DESCRIBE. Advertises HEVC + AV1, the surround configs, and
/// — when offered — `SS_ENC_VIDEO` as SUPPORTED but never REQUESTED: a stock client that
/// wants plaintext (or predates the negotiation) must keep working exactly as before.
@@ -600,13 +799,30 @@ fn stream_config(map: &HashMap<String, String>) -> Option<StreamConfig> {
);
hdr = false;
}
// The client's requested CSC (moonlight-common-c SdpGenerator.c: `encoderCscMode =
// (colorspace << 1) | fullRange` — colorspace 0=Rec601, 1=Rec709, 2=Rec2020). Moonlight
// renderers configure their YUV→RGB from this REQUESTED value (not the bitstream VUI), so a
// host that encodes something else shifts the client's colours. INSTRUMENTATION ONLY for
// now: we always encode BT.709 limited for SDR (the IDD VideoConverter / VUI-driven NVENC)
// and BT.2020 PQ for HDR — log what clients actually ask for so honoring `encoderCscMode`
// can be scoped from field data rather than guessed. (Absent on very old clients.)
// The client's requested CSC (`encoderCscMode = (colorspace << 1) | fullRange` — colorspace
// 0=Rec601, 1=Rec709, 2=Rec2020). We encode BT.709 limited for SDR and BT.2020 PQ for HDR,
// and this value is read but not honored. Scoped 2026-08-27 from a live session rather than
// guessed, because the earlier note here overstated what is known:
//
// * **In an HDR session the request cannot be honored at all.** HDR10 *is* BT.2020 + PQ; a
// client asking for Rec709 while negotiating `dynamicRangeMode=1` has asked for two
// incompatible things, and the HDR half is the one that carries the grade. Field data says
// this is the common case, not a corner: a stock Moonlight client on an HDR session sends
// `csc=3` (Rec709 **full**) while streaming BT.2020 PQ. So the old code warned on every
// HDR session about something it could never act on.
// * **Whether an ignored request actually shifts colours is UNVERIFIED.** The claim was that
// Moonlight renders from this value rather than the bitstream VUI. The sanctioned wire
// reference does not say that — it lists `encoderCscMode` among the keys a host parses and
// nothing more — and we emit a correct, explicit VUI (`videoSignalTypePresentFlag` +
// `colourDescriptionPresentFlag`), which a VUI-driven renderer would follow. Settling it
// needs a capture or a look at a screen, not more reading.
// * **Honoring it is not "just plumbing".** `videoFullRangeFlag` is hardcoded to 0 in every
// encoder backend and the capture-side CSC is fixed to match, so an SDR client asking for
// full range needs a per-session colour request threaded from here through the capture CSC
// into each backend's VUI — code the NATIVE plane shares and currently gets right.
//
// So: warn only where the request is both honorable in principle and unmet, and say what is
// actually known. (Absent on very old clients.)
if let Some(csc) = parse_u("x-nv-video[0].encoderCscMode") {
let (space, range) = (
match csc >> 1 {
@@ -630,14 +846,24 @@ fn stream_config(map: &HashMap<String, String>) -> Option<StreamConfig> {
range,
"GameStream client requested CSC — matches ours"
);
} else if hdr {
// Not actionable: the session is HDR, so the colour space is settled by HDR10.
tracing::debug!(
csc,
requested = format!("{space} {range}"),
encoding = ours,
"GameStream client requested a CSC that HDR overrides — HDR10 is BT.2020 PQ by \
definition, and the stream's VUI says so"
);
} else {
tracing::warn!(
csc,
requested = format!("{space} {range}"),
encoding = ours,
"GameStream client requested a CSC we don't encode — Moonlight renders by its \
REQUEST, so its colours will be shifted (honoring encoderCscMode is a known \
follow-up; report this log line)"
"GameStream client requested an SDR CSC we don't encode — we signal what we \
encode in the VUI, so a VUI-driven client is still correct; a client that \
renders from its own request would see shifted colours (unverified — honoring \
the request needs the CSC + VUI threaded per session)"
);
}
}
@@ -657,13 +883,25 @@ fn stream_config(map: &HashMap<String, String>) -> Option<StreamConfig> {
.filter(|n| (1..=32).contains(n))
.unwrap_or(1);
// The encryption bitmask the client CHOSE, echoed back from what DESCRIBE advertised
// (WP7). Honor only the VIDEO bit, and only when we offered it — a client cannot turn on
// a mode the host never advertised, whatever it echoes.
let encrypt_video = gs_video_encryption_offer() != EncOffer::Off
&& parse_u("x-ss-general.encryptionEnabled").unwrap_or(0) & SS_ENC_VIDEO != 0;
// (WP7). Honor a bit only when we actually offered it — a client cannot turn on a mode the
// host never advertised, whatever it echoes — so the echo is masked by the offer rather than
// merely checked against `Off`.
let (offered, _) = enc_flags(gs_video_encryption_offer());
let enabled = parse_u("x-ss-general.encryptionEnabled").unwrap_or(0) & offered;
let encrypt_video = enabled & SS_ENC_VIDEO != 0;
if encrypt_video {
tracing::info!("RTSP ANNOUNCE: client enabled SS_ENC_VIDEO — sealing every video shard");
}
// Nothing to store for the control bit: both planes it governs recognise the sealed form
// from the wire itself — the ENet stream detects the V2 nonce on the first packet that
// authenticates, and RTSP detects the sealed framing from its leading MSB. Worth saying out
// loud all the same, because it is what retires the legacy scheme's (key, nonce) reuse for
// this session.
if enabled & SS_ENC_CONTROL_V2 != 0 {
tracing::info!(
"RTSP ANNOUNCE: client enabled SS_ENC_CONTROL_V2 — control nonces are per-direction"
);
}
Some(StreamConfig {
width,
height,
@@ -915,26 +1153,138 @@ mod tests {
assert_eq!(ap.channels, 2);
}
/// WP7 advertisement: OFF advertises nothing (the shipping default, so a stock client
/// negotiates the plaintext wire exactly as it always has); `Supported` offers video
/// encryption without ever REQUIRING it; only the `require` test lever sets requested —
/// which is the whole reason it exists (a client that is merely *allowed* to encrypt may
/// decline on a LAN, and then an on-glass test proves nothing).
/// The advertisement ladder: `Off` offers nothing (the plaintext wire this plane shipped
/// before WP7); `video` offers video encryption alone; `Supported` — the default — adds
/// `SS_ENC_CONTROL_V2`; and only the `require` test lever ever sets REQUESTED, which is the
/// whole reason it exists (a client that is merely *allowed* to encrypt may decline on a LAN,
/// and then an on-glass test proves nothing).
#[test]
fn encryption_is_offered_but_never_required_in_shipping_modes() {
assert_eq!(enc_flags(EncOffer::Off), (0, 0));
assert_eq!(enc_flags(EncOffer::Supported), (SS_ENC_VIDEO, 0));
assert_eq!(
enc_flags(EncOffer::VideoOnly),
(SS_ENC_VIDEO, 0),
"the granular way out keeps video encryption"
);
assert_eq!(
enc_flags(EncOffer::Supported),
(SS_ENC_VIDEO | SS_ENC_CONTROL_V2, 0),
"the default offers both, and requires neither"
);
assert_eq!(
enc_flags(EncOffer::Required),
(SS_ENC_VIDEO, SS_ENC_VIDEO),
(
SS_ENC_VIDEO | SS_ENC_CONTROL_V2,
SS_ENC_VIDEO | SS_ENC_CONTROL_V2
),
"the test lever must also REQUEST it, or a client may decline"
);
// Whatever the mode, the host never advertises a bit it cannot serve.
for offer in [EncOffer::Off, EncOffer::Supported, EncOffer::Required] {
// Whatever the mode, the host never advertises a bit it cannot serve — `SS_ENC_AUDIO`
// (0x04) most of all, whose layout is not in the sanctioned reference.
let servable = SS_ENC_VIDEO | SS_ENC_CONTROL_V2;
for offer in [
EncOffer::Off,
EncOffer::VideoOnly,
EncOffer::Supported,
EncOffer::Required,
] {
let (sup, req) = enc_flags(offer);
assert_eq!(sup & !SS_ENC_VIDEO, 0, "no unimplemented bits offered");
assert_eq!(sup & !servable, 0, "no unimplemented bits offered");
assert_eq!(req & !sup, 0, "never request what isn't supported");
}
// The default carries the control offer — that is what retires the legacy nonce reuse
// for a stock client, and a default that quietly lost the bit would be a silent
// regression rather than a visible one.
assert_ne!(
enc_flags(EncOffer::Supported).0 & SS_ENC_CONTROL_V2,
0,
"the default must offer control-v2"
);
}
/// The sealed-RTSP round trip, and the property the whole framing rests on: a sealed message
/// is recognisable from its first byte, and a plaintext one is never mistaken for it.
#[test]
fn sealed_rtsp_round_trips_and_is_self_distinguishing() {
let key = [0x5Au8; 16];
let msg = b"OPTIONS rtsp://x RTSP/1.0\r\nCSeq: 1\r\n\r\n";
let wire = seal_response(&key, msg);
assert!(wire.len() > ENC_RTSP_HEADER);
// A sealed frame announces itself in the MSB; RTSP methods are ASCII and never do.
assert!(wire[0] & 0x80 != 0, "sealed frames set the type bit");
for method in [
"OPTIONS", "DESCRIBE", "SETUP", "ANNOUNCE", "PLAY", "TEARDOWN",
] {
assert!(
method.as_bytes()[0] & 0x80 == 0,
"{method} must not look sealed"
);
}
let type_and_length = u32::from_be_bytes([wire[0], wire[1], wire[2], wire[3]]);
let len = (type_and_length & !ENCRYPTED_MESSAGE_TYPE_BIT) as usize;
assert_eq!(
len,
wire.len() - ENC_RTSP_HEADER,
"length covers the payload"
);
let seq = u32::from_be_bytes([wire[4], wire[5], wire[6], wire[7]]);
// Decrypt the way a client does: wire order is tag-first, `aes-gcm` wants tag last.
let mut ct_tag = wire[ENC_RTSP_HEADER..].to_vec();
ct_tag.extend_from_slice(&wire[8..ENC_RTSP_HEADER]);
let pt = super::super::control::gcm_open(&key, &rtsp_nonce(seq, b'H'), &ct_tag, &[])
.expect("host-sealed RTSP must open under the H/R nonce");
assert_eq!(pt, msg);
// The direction byte is the point: the client's nonce must NOT open a host message.
assert!(
super::super::control::gcm_open(&key, &rtsp_nonce(seq, b'C'), &ct_tag, &[]).is_none(),
"a host message must not authenticate under the client direction"
);
}
/// The receive path, end to end: a frame sealed the way a client seals one is opened and
/// parsed back into the request it carried — headers, CSeq and body intact. This is the half
/// that decides whether a sealed session works at all.
#[test]
fn sealed_rtsp_request_is_opened_and_parsed() {
let key = [0xC3u8; 16];
let msg = b"ANNOUNCE rtsp://x RTSP/1.0\r\nCSeq: 6\r\nContent-length: 7\r\n\r\nv=0\r\na=b";
let frame = seal_frame(&key, 42, b'C', msg);
let req = open_sealed_frame(&key, &frame).expect("a client-sealed frame must open");
assert_eq!(req.method, "ANNOUNCE");
assert_eq!(req.cseq, "6");
assert_eq!(req.body, "v=0\r\na=b");
// A frame sealed in the HOST direction must not open as a client request — the direction
// byte is what separates the two nonce spaces.
let host_framed = seal_frame(&key, 42, b'H', msg);
assert!(
open_sealed_frame(&key, &host_framed).is_err(),
"host-direction frame must not authenticate as a client request"
);
// Neither may a tampered one: GCM is what makes this different from the CBC audio path.
let mut flipped = frame.clone();
let last = flipped.len() - 1;
flipped[last] ^= 0x01;
assert!(
open_sealed_frame(&key, &flipped).is_err(),
"a flipped ciphertext byte must be rejected, not decrypted"
);
// …and a wrong key must not open it either.
assert!(open_sealed_frame(&[0u8; 16], &frame).is_err(), "wrong key");
}
/// Every sealed host message must use a fresh sequence number. RTSP is one message per TCP
/// connection, so a counter that reset per connection would repeat (key, nonce) across a
/// session's seven messages — the one catastrophic AES-GCM failure.
#[test]
fn host_rtsp_sequence_never_repeats() {
let key = [1u8; 16];
let seq_of = |w: &[u8]| u32::from_be_bytes([w[4], w[5], w[6], w[7]]);
let a = seq_of(&seal_response(&key, b"a\r\n\r\n"));
let b = seal_response(&key, b"b\r\n\r\n");
let c = seal_response(&key, b"c\r\n\r\n");
assert!(seq_of(&b) > a, "sequence must advance");
assert!(seq_of(&c) > seq_of(&b), "sequence must advance");
}
/// The DESCRIBE SDP carries the codec indicators and all six Opus configs, normal
@@ -943,12 +1293,14 @@ mod tests {
#[test]
fn describe_advertises_codecs_and_surround() {
let sdp = describe_sdp();
// The default build OFFERS video encryption (verified on glass) but never requires it,
// so a client that wants plaintext still gets exactly the wire it always got.
assert!(sdp.contains(&format!(
"a=x-ss-general.encryptionSupported:{SS_ENC_VIDEO}"
)));
assert!(sdp.contains("a=x-ss-general.encryptionRequested:0"));
// The default build OFFERS video encryption AND control-v2 (both verified on glass) but
// requires neither, so a client that wants the plaintext wire still gets exactly the wire
// it always got. Asserted against `enc_flags` rather than a literal so this tracks the
// default instead of restating it — the env can still steer it (`PUNKTFUNK_GS_ENCRYPT`),
// which is why this reads the offer rather than assuming one.
let (supported, requested) = enc_flags(gs_video_encryption_offer());
assert!(sdp.contains(&format!("a=x-ss-general.encryptionSupported:{supported}")));
assert!(sdp.contains(&format!("a=x-ss-general.encryptionRequested:{requested}")));
assert!(
sdp.contains("sprop-parameter-sets=AAAAAU"),
"HEVC indicator"
@@ -1,6 +1,10 @@
//! The `/serverinfo` capability/status XML Moonlight GETs before pairing and each launch.
use super::{Host, APP_VERSION, GFE_VERSION, SERVER_CODEC_MODE_SUPPORT};
use super::{Host, APP_VERSION, GFE_VERSION, SCM_HEVC, SERVER_CODEC_MODE_SUPPORT};
/// The HEVC luma-pixel ceiling GFE advertises, which Moonlight compares the mode it wants
/// against. Emitted only when the codec mask actually offers HEVC.
const MAX_LUMA_PIXELS_HEVC: u64 = 1_869_449_984;
/// Build the `<root status_code="200">…</root>` serverinfo document. `https` selects the
/// paired-HTTPS variant (real MAC); `paired` is whether the HTTPS peer presented a client cert
@@ -32,6 +36,16 @@ pub fn serverinfo_xml(host: &Host, https: bool, paired: bool, current_game: u32)
"SUNSHINE_SERVER_FREE"
};
let codec_mode_support = codec_mode_support();
// Follow the mask rather than stating this unconditionally: a host whose probe dropped HEVC
// (a software-encoder host does H.264 and nothing else) used to advertise capacity for HEVC in
// the same document that said it had no HEVC. Harmless while clients gate on the mask, but two
// advertisements that contradict each other stay harmless only by luck. `0` is the
// unambiguous "no HEVC capacity" answer in the field's own terms.
let max_luma_hevc = if codec_mode_support & SCM_HEVC != 0 {
MAX_LUMA_PIXELS_HEVC
} else {
0
};
format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<root status_code="200">
@@ -41,7 +55,7 @@ pub fn serverinfo_xml(host: &Host, https: bool, paired: bool, current_game: u32)
<uniqueid>{uniqueid}</uniqueid>
<HttpsPort>{https_port}</HttpsPort>
<ExternalPort>{http_port}</ExternalPort>
<MaxLumaPixelsHEVC>1869449984</MaxLumaPixelsHEVC>
<MaxLumaPixelsHEVC>{max_luma_hevc}</MaxLumaPixelsHEVC>
<mac>{mac}</mac>
<LocalIP>{local_ip}</LocalIP>
<ServerCodecModeSupport>{codec_mode_support}</ServerCodecModeSupport>
@@ -125,10 +139,8 @@ fn base_codec_mode_support() -> u32 {
// Deliberately a local gate rather than delegating wholesale to `host_wire_caps()`: that would
// be the drift-proof shape, but on Windows it re-runs the DXGI adapter enumeration several
// times per `/serverinfo` GET (the probe helpers each sample it), and this endpoint is polled.
// The software case is a plain config read, so it costs nothing here. (Follow-up worth doing:
// the static `MaxLumaPixelsHEVC` in the XML above still advertises an HEVC limit even when the
// mask drops HEVC — harmless, since Moonlight gates on the mask, but it is a second and now
// inconsistent advertisement.)
// The software case is a plain config read, so it costs nothing here. (`MaxLumaPixelsHEVC`
// in the XML above now follows this mask, so the two can no longer disagree.)
if matches!(
pf_host_config::config().encoder_pref.as_str(),
"software" | "sw" | "openh264"
+7 -22
View File
@@ -75,6 +75,9 @@ pub struct GameLifetime {
/// handed the (plaintext) video stream. `None` keeps the pre-owner behavior. security-review
/// 2026-08-15 finding 1.
pub owner_ip: Option<std::net::IpAddr>,
/// This session's A/V ping payload ([`super::AppState::av_ping`]) — the other half of the
/// endpoint guard beside `owner_ip`, and the half a peer sharing that address cannot forge.
pub av_ping: [u8; super::AV_PING_LEN],
/// Ends the whole session, deliberately — the action for "the launched game exited".
pub on_game_exit: super::OnSessionLost,
}
@@ -217,28 +220,10 @@ fn run(
port = VIDEO_PORT,
"video: awaiting client ping to learn endpoint"
);
let mut probe = [0u8; 256];
// Bind only to the launch owner's source IP (LaunchSession::peer_ip), the same owner the
// RTSP/ENet planes enforce. Video is plaintext by design, so without this an off-path LAN peer
// trickling UDP at this port wins the endpoint race in `recv_from` and is handed the desktop.
// `None` keeps the pre-owner behavior. security-review 2026-08-15 finding 1.
let client = {
let deadline = std::time::Instant::now() + Duration::from_secs(10);
loop {
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
if remaining.is_zero() {
anyhow::bail!("video: no client ping from the launch owner within 10s");
}
sock.set_read_timeout(Some(remaining))?;
let (_, src) = sock
.recv_from(&mut probe)
.context("video: no client ping within 10s")?;
if life.owner_ip.is_some_and(|ip| ip != src.ip()) {
continue;
}
break src;
}
};
// Bound to the launch owner's source IP AND to this session's ping payload — see
// `super::learn_client_endpoint`, which both media planes share so they cannot drift on what
// counts as their client.
let client = super::learn_client_endpoint(&sock, "video", life.owner_ip, &life.av_ping)?;
sock.connect(client)
.context("connect client video endpoint")?;
// Opt-in DSCP/QoS-tag this as the video class (PUNKTFUNK_DSCP=1); the guard keeps the
+29 -2
View File
@@ -95,8 +95,35 @@ pub(crate) async fn serve_https(
}
}
/// Requests + signature-checks the client cert but accepts any (the pairing handshake is
/// the real proof). Pinning to the paired set is a hardening follow-up.
/// Requests the client cert and **verifies its `CertificateVerify` signature**, but does not
/// judge the certificate itself. Authorization happens immediately after the handshake, against
/// the pinned allow-list (`nvhttp::peer_is_paired`).
///
/// **This is the design, not an unfinished pin.** (Reviewed 2026-08-27; the comment here used to
/// call pinning "a hardening follow-up", which read as debt.) Three things decide it:
///
/// * **A TLS handshake cannot know the route.** It completes before a single byte of the request
/// line is parsed, so "pin the post-pair routes, accept-any on the pairing routes" is not
/// expressible here — it would take a second listener on a second port, and the protocol fixes
/// the ports.
/// * **Some HTTPS traffic must come from unpaired peers.** `/serverinfo` over 47984 answers
/// `PairStatus=0` precisely so a client can discover it needs to pair; refusing the handshake
/// would remove the entry point to pairing. The management API shares this verifier and goes
/// further, admitting *certless* browsers (`mandatory: false`) that authenticate by bearer
/// token instead.
/// * **Deferring the check costs nothing cryptographically.** The signature verification below is
/// real — webpki's, or [`accept_legacy_moonlight_cert`]'s equivalent RSA check for the pre-v3
/// certificates Moonlight presents — so a peer reaching a handler has *proved possession* of the
/// private key for the certificate it presented. `peer_is_paired` then pins the SHA-256 of that
/// same certificate before any state-changing work happens, and every route but `/serverinfo`
/// goes through it. Rejecting an unpinned peer with an HTTP error rather than a TLS alert is a
/// difference in *when*, not in *what is proven*.
///
/// What would genuinely be a hole is accepting the certificate without checking the signature —
/// then anyone could replay a paired client's certificate, which is public, and pass the
/// fingerprint gate without its key. That is why [`verify_tls12_signature`] /
/// [`verify_tls13_signature`] below must keep returning a real verdict, and why the legacy
/// fallback re-verifies rather than waving the certificate through.
#[derive(Debug)]
struct AcceptAnyClientCert {
provider: Arc<CryptoProvider>,
+1 -1
View File
@@ -246,7 +246,7 @@ notes for context.
|---|---|---|
| `PUNKTFUNK_FRAME_DRIVEN` | `1` *(default)* · `0` | Wake the encoder when the capture actually delivers a frame, instead of sampling on a fixed tick. On by default on both protocols (a capture backend without an arrival signal keeps the tick regardless); `0` restores the tick everywhere. The tick costs about half a frame interval of latency per frame, so leave this on unless you are bisecting a cadence problem. |
| `PUNKTFUNK_GS_ADAPT` | `1` *(default)* · `0` | GameStream/Moonlight only: let the host act on the packet loss Moonlight reports — raising error correction as loss appears, winding it back when the link is clean, and easing the bitrate off under sustained loss (recovering as it settles). `0` pins error correction and bitrate at their configured values for the whole session. |
| `PUNKTFUNK_GS_ENCRYPT` | `1` *(default)* · `0` | GameStream/Moonlight only: offer per-packet video encryption (`SS_ENC_VIDEO`) to clients that support it. **On by default** — the host offers it and the client decides; Moonlight generally accepts, and error correction still recovers lost packets normally. `0` turns the offer off (the plaintext video wire earlier versions sent). Audio and the control channel are encrypted either way. |
| `PUNKTFUNK_GS_ENCRYPT` | `1` *(default)* · `video` · `0` | GameStream/Moonlight only: offer per-packet video encryption (`SS_ENC_VIDEO`) and the V2 control-encryption scheme (`SS_ENC_CONTROL_V2`) to clients that support them. **On by default** — the host offers, the client decides; Moonlight generally accepts both, and error correction still recovers lost packets normally. V2 gives the control channel a per-direction nonce, which the older scheme lacks. `video` offers video encryption only, leaving the control channel on the older scheme; `0` turns both offers off (the plaintext video wire earlier versions sent). Audio and the control channel are encrypted either way. |
| `PUNKTFUNK_GSO` | `1` · `0` | UDP segmentation offload on the send path (coalesce a frame's packets into kernel super-buffers) — cuts send CPU ~30%, but its line-rate packet trains can cost delivered throughput on constrained links (measured on a 2.5GbE hop). The default differs by platform. **Windows: on by default** (Send Offload — the lever that gets past ~1 Gbps, since Windows otherwise does one send call per packet); set `0` if a constrained link shows lost throughput. It also latches itself off for the rest of the run the first time the OS/NIC/path rejects an offloaded send. **Linux: off by default** until send pacing spaces the super-buffers; set `1` to opt in (auto-falls back to `sendmmsg` on kernels/paths without support). |
| `PUNKTFUNK_SPLIT_ENCODE` | `0`/`disable` · `1`/`auto` · `2` · `3` | NVENC N-way split-encode for very high pixel rates (5K@240). `auto` picks automatically above ~1 Gpix/s. H.264 never splits (not applicable per the SDK); on HEVC a *forced* split disables sub-frame readback (mutually unsupported) — set `0` to choose sub-frame instead. |
| `PUNKTFUNK_NVENC_SUBFRAME` | `0` · `1` | NVENC sub-frame (slice-level) readback for lower latency on sync sessions. Default: on where the GPU supports it (Linux direct NVENC). `0` = never; `1` = force. On HEVC it yields to a forced split-encode (the SDK documents the pair unsupported). |
+5 -4
View File
@@ -42,9 +42,8 @@ the secure native-only host):
(Bare `serve` is the secure native-only default and stock Moonlight clients can't connect to it; the
native plane is always on, and `--gamestream` adds the Moonlight-compat surface.) Video, audio and
input are all encrypted on this path, but GameStream still *pairs* over plain HTTP and its control
channel uses the older GameStream scheme rather than the native protocol's, so enable it on a
**trusted LAN**. See [Running as a Service](/docs/running-as-a-service) for the bundled
input are all encrypted on this path, but GameStream still *pairs* over plain HTTP, so enable it on
a **trusted LAN**. See [Running as a Service](/docs/running-as-a-service) for the bundled
unit. The host advertises itself on the network, so Moonlight usually finds it on its own.
## 2. Add the host in Moonlight
@@ -120,7 +119,9 @@ That **Desktop** entry is the operator base list. An `apps.json` in the host's c
configure — and it is why a marginal Wi-Fi link degrades rather than stuttering.
- **Your video is encrypted.** The host offers per-packet video encryption and Moonlight turns it
on by itself, so the stream is encrypted end to end — audio and the control channel always were.
Nothing to configure.
The host also offers the GameStream protocol's newer control-encryption scheme, which Moonlight
likewise turns on by itself; it gives each direction of the control channel its own nonce, which
the older scheme does not. Nothing to configure.
- Moonlight uses the GameStream protocol, so it doesn't get Punktfunk's native-protocol
extensions — no client-side speed test, no jumbo frames. Error correction and encryption are
*not* on that list any more: both are in every stream. On a good link the two protocols feel the