The control channel stops reusing a nonce, and the media ports stop trusting whoever knocks first #417
@@ -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 Reed–Solomon 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};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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). |
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user