ci / web (push) Successful in 51s
ci / docs-site (push) Successful in 1m6s
apple / swift (push) Successful in 1m24s
decky / build-publish (push) Successful in 31s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 18s
ci / bench (push) Successful in 7m2s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 12s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 5m8s
deb / build-publish (push) Successful in 9m35s
arch / build-publish (push) Successful in 12m19s
deb / build-publish-host (push) Successful in 12m46s
android / android (push) Successful in 16m5s
windows-host / package (push) Successful in 16m34s
apple / screenshots (push) Successful in 6m28s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m11s
ci / rust (push) Successful in 27m9s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m19s
docker / deploy-docs (push) Successful in 27s
GameStream sessions outlived their client: the only complete teardowns were the explicit ones (RTSP TEARDOWN, nvhttp /cancel, mgmt DELETE /session), and the only automatic detector was a media-UDP send error — which needs an ICMP port-unreachable, so a true vanish (Wi-Fi drop, sleep, power off, crash) left video+audio encoding into the void forever, and even a plain Moonlight quit (which sends neither TEARDOWN nor /cancel) leaked the session. The stale state then cascaded: a lingering launch 503-blocked a different client under mode_conflict=reject, and streaming=true made a reconnect's PLAY take its "stream already running" branch — no new threads, old threads still aimed at the dead endpoint, the reconnect got no media. ENet already detects all of this — the control peer's reliable-ping timeout (or clean disconnect) fires Event::Disconnect within ~5-30 s — but the handler only reset input state. Wire the real teardown into it: * AppState::end_session — THE compat-plane session teardown: stops both media threads (their flags), clears launch + negotiated stream config; idempotent. /cancel and mgmt stop_session now share it. * control.rs Disconnect → end_session. Gated on the TRACKED session peer, and Connect only tracks a peer from the /launch owner's IP (the same source-IP bind the RTSP/media plane uses), so an unauthenticated LAN peer connect+disconnect can't end a live session, and a fast reconnect's stale-peer timeout can't kill its successor. * Client-unreachable UDP send errors now end the whole session via an OnSessionLost callback (built at PLAY) instead of stopping only the plane that noticed — audio no longer keeps streaming after video detects the dead client, and vice versa. Linux check/clippy/tests green (53 gamestream tests incl. the new end_session regression test). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
681 lines
32 KiB
Rust
681 lines
32 KiB
Rust
//! The GameStream control stream: an ENet host on UDP 47999. Moonlight connects this
|
||
//! BEFORE the video stream starts (`STAGE_CONTROL_STREAM_START` precedes
|
||
//! `STAGE_VIDEO_STREAM_START`), so it must be up or the whole connection aborts. It carries
|
||
//! input (mouse/keyboard/gamepad), keepalives, and QoS feedback.
|
||
//!
|
||
//! Sunshine-mode hosts (we advertise `state=SUNSHINE_SERVER_FREE`) make Moonlight encrypt the
|
||
//! control stream with AES-128-GCM under the `/launch` `rikey`, even though we negotiate no
|
||
//! media encryption. Wire framing (all little-endian):
|
||
//!
|
||
//! ```text
|
||
//! u16 encType = 0x0001 | u16 length | u32 seq | [16-byte GCM tag] | ciphertext
|
||
//! length = sizeof(seq) + 16 (tag) + plaintext
|
||
//! ```
|
||
//!
|
||
//! 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
|
||
//! 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.
|
||
//!
|
||
//! Runs on its own native thread for the host's lifetime.
|
||
|
||
use super::{AppState, CONTROL_PORT};
|
||
use crate::inject::gamepad::GamepadManager;
|
||
use anyhow::{anyhow, Context, Result};
|
||
use punktfunk_core::input::InputEvent;
|
||
use punktfunk_core::quic::HdrMeta;
|
||
use rusty_enet::{Event, Host, HostSettings, Packet, PeerID};
|
||
use std::net::UdpSocket;
|
||
use std::sync::mpsc::Sender;
|
||
use std::sync::Arc;
|
||
use std::time::Duration;
|
||
|
||
/// Bind the ENet control host on 47999 and service it forever on a dedicated thread.
|
||
pub fn spawn(state: Arc<AppState>) -> Result<()> {
|
||
let socket = UdpSocket::bind(("0.0.0.0", CONTROL_PORT)).context("bind control UDP")?;
|
||
socket
|
||
.set_nonblocking(true)
|
||
.context("control socket nonblocking")?;
|
||
let mut host = Host::new(
|
||
socket,
|
||
HostSettings {
|
||
peer_limit: 4,
|
||
// Moonlight connects with CTRL_CHANNEL_COUNT (0x30) channels and sends gamepad
|
||
// input on channel 0x10+n — a smaller limit silently discards controller input.
|
||
channel_limit: 0x30,
|
||
..Default::default()
|
||
},
|
||
)
|
||
.map_err(|e| anyhow!("ENet host init: {e:?}"))?;
|
||
tracing::info!(port = CONTROL_PORT, "ENet control listening");
|
||
|
||
std::thread::Builder::new()
|
||
.name("punktfunk-control".into())
|
||
.spawn(move || {
|
||
// GCM scheme detected from the first authenticating packet; reused thereafter.
|
||
let mut detected: Option<Scheme> = None;
|
||
// Consecutive control-decrypt failures for this peer — throttles the warn log so a
|
||
// junk-packet flood can't spam unbounded lines (security-review 2026-06-28 #10).
|
||
let mut decrypt_fails: u64 = 0;
|
||
// Decoded keyboard/mouse is forwarded to a dedicated host-lifetime injector thread —
|
||
// NEVER injected inline, so a slow Wayland/libei/SendInput call can't head-block ENet
|
||
// keepalive/retransmit servicing on this thread. The injector owns non-Send compositor
|
||
// state and lives on its own thread (see crate::inject::InjectorService); the held
|
||
// `inj_tx` clone keeps it alive for the control thread's lifetime.
|
||
let inj_tx = crate::inject::InjectorService::start().sender();
|
||
// Virtual gamepads (uinput). ONE monotonic host→client control sequence counter, shared
|
||
// by every outbound message (rumble + the HDR-mode signal): the GCM nonce is derived
|
||
// from `seq`, so a per-message-type counter would reuse (key, nonce) pairs across
|
||
// message types in the host direction.
|
||
let mut pads = GamepadManager::new();
|
||
// Pen/touch translator (SS_PEN/SS_TOUCH → virtual tablet / wire touch). Sent only
|
||
// by clients that saw our SS_FF_PEN_TOUCH_EVENTS feature flag (rtsp.rs).
|
||
let mut pointer = super::pen::GsPointer::new();
|
||
let mut host_seq: u32 = 0;
|
||
// One-shot latch for the HDR-mode control message (0x010e); re-armed on Disconnect.
|
||
let mut hdr_sent = false;
|
||
let mut peer: Option<PeerID> = None;
|
||
loop {
|
||
loop {
|
||
match host.service() {
|
||
Ok(Some(event)) => match event {
|
||
Event::Connect { peer: p, .. } => {
|
||
// Track this peer as THE session peer only if it comes from the
|
||
// `/launch` owner's IP (when captured — `None` falls back to
|
||
// trusting the connect, the pre-teardown behavior). The tracked
|
||
// peer's disconnect now ENDS the session, so an unauthenticated
|
||
// LAN peer that connects+disconnects on 47999 must not be able to
|
||
// steal the slot and tear a live session down. Same source-IP
|
||
// bind the RTSP/media plane uses (security-review #4).
|
||
let owner_ip = state.launch.lock().unwrap().and_then(|s| s.peer_ip);
|
||
let from = p.address().map(|a| a.ip());
|
||
if owner_ip.is_some() && from.is_some() && owner_ip != from {
|
||
tracing::warn!(
|
||
?from,
|
||
"control: peer connected from a non-owner IP — ignoring"
|
||
);
|
||
} else {
|
||
tracing::info!("control: client connected");
|
||
peer = Some(p.id());
|
||
}
|
||
}
|
||
Event::Disconnect { peer: p, .. } => {
|
||
// Gate on the TRACKED session peer: a stray probe peer (or the
|
||
// OLD peer's late timeout after a fast reconnect replaced it in
|
||
// the Connect arm) must neither clobber the live session's input
|
||
// state nor end its session.
|
||
if peer != Some(p.id()) {
|
||
tracing::debug!("control: non-session peer disconnected");
|
||
continue;
|
||
}
|
||
tracing::info!("control: client disconnected");
|
||
detected = None;
|
||
decrypt_fails = 0;
|
||
peer = None;
|
||
// Re-arm the HDR-mode signal for the next connection.
|
||
hdr_sent = false;
|
||
// Unplug the session's virtual pads + tablet (destroying the
|
||
// uinput pen releases any held tool/tip kernel-side).
|
||
pads = GamepadManager::new();
|
||
pointer = super::pen::GsPointer::new();
|
||
// The control stream is the session's liveness anchor — Moonlight
|
||
// holds it for the whole stream, and ENet detects a vanished peer
|
||
// via its reliable-ping timeout (~5–30 s), which ALSO lands here.
|
||
// End the session: without this, a client that disconnects without
|
||
// an explicit RTSP TEARDOWN / nvhttp `/cancel` (a network drop,
|
||
// sleep, crash — or just a plain Moonlight quit, which sends
|
||
// neither) left the media threads streaming at the dead endpoint
|
||
// forever (a UDP send only errors on an ICMP port-unreachable) and
|
||
// the stale launch/streaming state wedged every reconnect.
|
||
state.end_session("control stream disconnected");
|
||
}
|
||
Event::Receive {
|
||
channel_id, packet, ..
|
||
} => {
|
||
on_receive(
|
||
&state,
|
||
channel_id,
|
||
packet.data(),
|
||
&mut detected,
|
||
&mut decrypt_fails,
|
||
&inj_tx,
|
||
&mut pads,
|
||
&mut pointer,
|
||
);
|
||
}
|
||
},
|
||
Ok(None) => break,
|
||
Err(e) => {
|
||
tracing::warn!(error = ?e, "control: service error");
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
// Service the pads' force-feedback protocol every tick (games block inside
|
||
// 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
|
||
// 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
|
||
// collide. This is INHERENT to Nvidia's old-style
|
||
// GameStream control encryption (Apollo/moonlight-common-c are identical: only the V2
|
||
// 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).
|
||
if let (Some(pid), Some(scheme)) = (peer, detected) {
|
||
let key = state.launch.lock().unwrap().map(|s| s.gcm_key);
|
||
if let Some(key) = key {
|
||
let mut out: Vec<Vec<u8>> = Vec::new();
|
||
// One-shot HDR-mode signal (type 0x010e / Sunshine `IDX_HDR_MODE`) once the
|
||
// control stream is live. Stock Moonlight clients only flip the TV into HDR
|
||
// picture mode when they receive this async message — the video is already
|
||
// BT.2020 PQ, but without the cue the display stays in SDR mode (the exact
|
||
// symptom aurora-tv PR #53 worked around client-side). Sent before rumble so
|
||
// the client sees HDR as early as possible.
|
||
if !hdr_sent {
|
||
// `state.stream` is populated by the RTSP ANNOUNCE, which precedes the
|
||
// control stream — but guard the race: only commit the one-shot decision
|
||
// once a config exists, so a not-yet-set stream can't latch us into never
|
||
// signaling HDR. A non-HDR session latches too (it never needs the msg).
|
||
if let Some(hdr) = state.stream.lock().unwrap().map(|s| s.hdr) {
|
||
if hdr {
|
||
let pt =
|
||
hdr_mode_plaintext(true, &pf_frame::hdr::generic_hdr10());
|
||
out.push(encrypt_control(&key, &scheme, host_seq, &pt));
|
||
host_seq = host_seq.wrapping_add(1);
|
||
tracing::info!(
|
||
"control: signaled HDR mode ON to client (0x010e)"
|
||
);
|
||
}
|
||
hdr_sent = true;
|
||
}
|
||
}
|
||
pads.pump_rumble(|index, low, high| {
|
||
let pt = super::gamepad::rumble_plaintext(index, low, high);
|
||
out.push(encrypt_control(&key, &scheme, host_seq, &pt));
|
||
host_seq = host_seq.wrapping_add(1);
|
||
});
|
||
for wire in out {
|
||
if let Err(e) = host.peer_mut(pid).send(0, &Packet::reliable(&wire[..]))
|
||
{
|
||
tracing::warn!(error = ?e, "control send failed");
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
// No client/scheme yet: still answer FF uploads so games don't block.
|
||
pads.pump_rumble(|_, _, _| {});
|
||
}
|
||
// ENet needs frequent servicing for handshake/keepalive/retransmit.
|
||
std::thread::sleep(Duration::from_millis(2));
|
||
}
|
||
})
|
||
.context("spawn control thread")?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Decode the lost-frame range from an invalidate-reference-frames (0x0301) control message: two
|
||
/// little-endian `i64` (firstFrame, lastFrame) after the 4-byte `[u16 type][u16 length]` header,
|
||
/// matching Sunshine/Apollo's `IDX_INVALIDATE_REF_FRAMES`. Returns `None` when the body is too
|
||
/// short or the range is nonsensical, in which case the caller falls back to a full IDR.
|
||
fn decode_rfi_range(pt: &[u8]) -> Option<(i64, i64)> {
|
||
if pt.len() < 20 {
|
||
return None;
|
||
}
|
||
let first = i64::from_le_bytes(pt[4..12].try_into().ok()?);
|
||
let last = i64::from_le_bytes(pt[12..20].try_into().ok()?);
|
||
(first >= 0 && last >= first).then_some((first, last))
|
||
}
|
||
|
||
/// Handle one received control packet: decrypt it (learning the GCM scheme on the first one),
|
||
/// decode any input event, and inject it into the host session.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn on_receive(
|
||
state: &AppState,
|
||
_channel_id: u8,
|
||
d: &[u8],
|
||
detected: &mut Option<Scheme>,
|
||
decrypt_fails: &mut u64,
|
||
inj_tx: &Sender<InputEvent>,
|
||
pads: &mut GamepadManager,
|
||
pointer: &mut super::pen::GsPointer,
|
||
) {
|
||
let Some(key) = state.launch.lock().unwrap().map(|s| s.gcm_key) else {
|
||
return; // control traffic before /launch — no key yet
|
||
};
|
||
// Encrypted control packets begin with u16 LE encType = 0x0001 and an 8-byte header.
|
||
if d.len() < 8 || d[0] != 0x01 || d[1] != 0x00 {
|
||
return;
|
||
}
|
||
|
||
let pt = match decrypt_control(&key, d, detected) {
|
||
Some((scheme, pt)) => {
|
||
if detected.is_none() {
|
||
tracing::info!(?scheme, "control: GCM scheme locked in");
|
||
}
|
||
*detected = Some(scheme);
|
||
*decrypt_fails = 0;
|
||
pt
|
||
}
|
||
None => {
|
||
// Throttle: a junk-packet flood must not spam one warn line per packet. Log the first
|
||
// failure, then only at exponentially-spaced counts (1, 2, 4, 8, …).
|
||
*decrypt_fails += 1;
|
||
if decrypt_fails.is_power_of_two() {
|
||
tracing::warn!(
|
||
len = d.len(),
|
||
fails = *decrypt_fails,
|
||
"control: GCM decrypt failed"
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
};
|
||
|
||
// Recovery requests after loss. Invalidate-reference-frames (0x0301, Gen7) carries the lost
|
||
// frame range (two LE i64 after the [type][len] header, like Sunshine/Apollo's
|
||
// IDX_INVALIDATE_REF_FRAMES) — route it to the encoder, which invalidates those refs instead of
|
||
// a full IDR when it can (NVENC RFI). Request-IDR (0x0302 / 0x0305) and a malformed 0x0301 force
|
||
// a keyframe. The video thread drains rfi_range/force_idr and resyncs without a multi-second stall.
|
||
if pt.len() >= 2 {
|
||
let inner = u16::from_le_bytes([pt[0], pt[1]]);
|
||
if inner == 0x0301 {
|
||
if let Some((first, last)) = decode_rfi_range(&pt) {
|
||
*state.rfi_range.lock().unwrap() = Some((first, last));
|
||
tracing::debug!(first, last, "control: RFI request → invalidate ref frames");
|
||
} else {
|
||
state
|
||
.force_idr
|
||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||
tracing::debug!("control: RFI request (no range) → keyframe");
|
||
}
|
||
return;
|
||
}
|
||
if matches!(inner, 0x0302 | 0x0305) {
|
||
state
|
||
.force_idr
|
||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||
tracing::debug!(
|
||
ty = %format_args!("{inner:#06x}"),
|
||
"control: IDR request → keyframe"
|
||
);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Controller events go to the uinput virtual pads (created on demand per the mask).
|
||
if let Some(gp) = super::gamepad::decode(&pt) {
|
||
pads.handle(&gp);
|
||
return;
|
||
}
|
||
|
||
// Pen/touch extension events (Moonlight sends them only after seeing our feature flag):
|
||
// pen drives this session's virtual tablet; touch forwards as ordinary wire touches.
|
||
if let Some(p) = super::input::decode_pointer(&pt) {
|
||
pointer.apply(&p, |ev| {
|
||
let _ = inj_tx.send(ev);
|
||
});
|
||
return;
|
||
} else if super::input::is_pointer_magic(&pt) {
|
||
// A pointer magic that failed the body parse — a layout mismatch against this
|
||
// client, exactly what an on-glass "touch/pen does nothing" needs surfaced. The
|
||
// first few dump their bytes so the mismatch is diagnosable from the log alone.
|
||
static HEX_DUMPS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
||
if HEX_DUMPS.fetch_add(1, std::sync::atomic::Ordering::Relaxed) < 5 {
|
||
let hex: String = pt.iter().map(|b| format!("{b:02x}")).collect();
|
||
tracing::warn!(
|
||
len = pt.len(),
|
||
bytes = %hex,
|
||
"gamestream: SS_TOUCH/SS_PEN packet failed to decode (malformed/unexpected layout)"
|
||
);
|
||
} else {
|
||
tracing::warn!(
|
||
len = pt.len(),
|
||
"gamestream: SS_TOUCH/SS_PEN packet failed to decode (malformed/unexpected layout)"
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
|
||
let events = super::input::decode(&pt);
|
||
if events.is_empty() {
|
||
return; // keepalive / QoS / unhandled input kind
|
||
}
|
||
|
||
// Forward to the dedicated injector thread (it opens the backend on the first event and
|
||
// coalesces redundant motion). A closed channel means the injector thread died at startup —
|
||
// input is lossy, so drop silently rather than spam.
|
||
for ev in events {
|
||
let _ = inj_tx.send(ev);
|
||
}
|
||
}
|
||
|
||
/// How a control packet's nonce is built — Moonlight picks one based on the negotiated flags.
|
||
#[derive(Clone, Copy, Debug)]
|
||
enum NonceKind {
|
||
/// `SS_ENC_CONTROL_V2`: 12-byte nonce, `seq` in [0..4], marker bytes at [10..12].
|
||
V2 { seq_be: bool, marker: [u8; 2] },
|
||
/// Legacy: 16-byte nonce, only `iv[0] = seq & 0xff` (the rest zero).
|
||
LegacyLowByte,
|
||
/// Legacy variant: 16-byte nonce, full `seq` in [0..4] (the rest zero).
|
||
Legacy16Seq { seq_be: bool },
|
||
}
|
||
|
||
impl NonceKind {
|
||
fn nonce(&self, seq: u32) -> Vec<u8> {
|
||
let seq_bytes = |be: bool| {
|
||
if be {
|
||
seq.to_be_bytes()
|
||
} else {
|
||
seq.to_le_bytes()
|
||
}
|
||
};
|
||
match *self {
|
||
NonceKind::V2 { seq_be, marker } => {
|
||
let mut iv = vec![0u8; 12];
|
||
iv[0..4].copy_from_slice(&seq_bytes(seq_be));
|
||
iv[10] = marker[0];
|
||
iv[11] = marker[1];
|
||
iv
|
||
}
|
||
NonceKind::LegacyLowByte => {
|
||
let mut iv = vec![0u8; 16];
|
||
iv[0] = (seq & 0xff) as u8;
|
||
iv
|
||
}
|
||
NonceKind::Legacy16Seq { seq_be } => {
|
||
let mut iv = vec![0u8; 16];
|
||
iv[0..4].copy_from_slice(&seq_bytes(seq_be));
|
||
iv
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The byte-exact GCM scheme that opened a control packet. Determined empirically once per
|
||
/// connection (AES-GCM gives no partial credit, so an authenticating combination is proof).
|
||
#[derive(Clone, Copy, Debug)]
|
||
struct Scheme {
|
||
/// `gcm_key` is byte-reversed before use (defensive; Sunshine's net effect is forward).
|
||
key_rev: bool,
|
||
nonce: NonceKind,
|
||
/// GCM tag sits before the ciphertext (vs after).
|
||
tag_first: bool,
|
||
aad: Aad,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug)]
|
||
enum Aad {
|
||
None,
|
||
/// The 4-byte cleartext header prefix (encType + length), `d[0..4]`.
|
||
Header4,
|
||
}
|
||
|
||
impl Scheme {
|
||
fn key(&self, base: &[u8; 16]) -> [u8; 16] {
|
||
let mut k = *base;
|
||
if self.key_rev {
|
||
k.reverse();
|
||
}
|
||
k
|
||
}
|
||
}
|
||
|
||
/// Open an encrypted control packet `d` (8-byte cleartext header + `[tag?][ciphertext]`). If
|
||
/// `detected` is set only that scheme is tried (fast path); otherwise the full cross-product
|
||
/// of plausible schemes (nonce construction × key byte-order × tag position × AAD) is swept
|
||
/// and the combination whose GCM tag authenticates is returned.
|
||
fn decrypt_control(
|
||
key: &[u8; 16],
|
||
d: &[u8],
|
||
detected: &Option<Scheme>,
|
||
) -> Option<(Scheme, Vec<u8>)> {
|
||
let seq = u32::from_le_bytes([d[4], d[5], d[6], d[7]]);
|
||
let payload = &d[8..];
|
||
if payload.len() < 16 {
|
||
return None;
|
||
}
|
||
|
||
let attempt = |s: Scheme| -> Option<Vec<u8>> {
|
||
// aes-gcm wants `ciphertext || tag`; reassemble from whichever wire order this is.
|
||
let (ct, tag) = if s.tag_first {
|
||
(&payload[16..], &payload[..16])
|
||
} else {
|
||
(
|
||
&payload[..payload.len() - 16],
|
||
&payload[payload.len() - 16..],
|
||
)
|
||
};
|
||
let mut ct_tag = Vec::with_capacity(ct.len() + 16);
|
||
ct_tag.extend_from_slice(ct);
|
||
ct_tag.extend_from_slice(tag);
|
||
let aad: &[u8] = match s.aad {
|
||
Aad::None => &[],
|
||
Aad::Header4 => &d[0..4],
|
||
};
|
||
gcm_open(&s.key(key), &s.nonce.nonce(seq), &ct_tag, aad)
|
||
};
|
||
|
||
if let Some(s) = *detected {
|
||
return attempt(s).map(|pt| (s, pt));
|
||
}
|
||
|
||
// Candidate nonce constructions, most-likely first.
|
||
const MARKERS: [[u8; 2]; 3] = [*b"CC", *b"HC", *b"CH"];
|
||
let mut kinds: Vec<NonceKind> = vec![NonceKind::LegacyLowByte];
|
||
for seq_be in [false, true] {
|
||
for marker in MARKERS {
|
||
kinds.push(NonceKind::V2 { seq_be, marker });
|
||
}
|
||
kinds.push(NonceKind::Legacy16Seq { seq_be });
|
||
}
|
||
|
||
for &nonce in &kinds {
|
||
for key_rev in [false, true] {
|
||
for tag_first in [true, false] {
|
||
for aad in [Aad::None, Aad::Header4] {
|
||
let s = Scheme {
|
||
key_rev,
|
||
nonce,
|
||
tag_first,
|
||
aad,
|
||
};
|
||
if let Some(pt) = attempt(s) {
|
||
return Some((s, pt));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
/// Serialize an [`HdrMeta`] into Moonlight's `SS_HDR_METADATA` control-message layout: 26 bytes,
|
||
/// all **little-endian** (moonlight-common-c parses it with `BYTE_ORDER_LITTLE`), primaries in
|
||
/// **R, G, B** order — note [`HdrMeta`]/ST.2086 stores them G, B, R, so we reorder. Luminance is
|
||
/// re-scaled to the wire units the client reads: `maxDisplayLuminance`/`maxFullFrameLuminance` in
|
||
/// whole nits, `minDisplayLuminance` in 1/10000-nit, content light levels already in nits. There is
|
||
/// no separate full-frame luminance in our metadata, so it mirrors the mastering peak.
|
||
fn ss_hdr_metadata(m: &HdrMeta) -> [u8; 26] {
|
||
let max_display_nits = (m.max_display_mastering_luminance / 10_000).min(u16::MAX as u32) as u16;
|
||
let min_display = m.min_display_mastering_luminance.min(u16::MAX as u32) as u16;
|
||
let mut b = [0u8; 26];
|
||
let mut o = 0;
|
||
let mut put = |v: u16| {
|
||
b[o..o + 2].copy_from_slice(&v.to_le_bytes());
|
||
o += 2;
|
||
};
|
||
// displayPrimaries[3] in R, G, B order (HdrMeta is G, B, R).
|
||
for p in [
|
||
m.display_primaries[2],
|
||
m.display_primaries[0],
|
||
m.display_primaries[1],
|
||
] {
|
||
put(p[0]);
|
||
put(p[1]);
|
||
}
|
||
put(m.white_point[0]);
|
||
put(m.white_point[1]);
|
||
put(max_display_nits); // maxDisplayLuminance (nits)
|
||
put(min_display); // minDisplayLuminance (1/10000 nit)
|
||
put(m.max_cll); // maxContentLightLevel (nits)
|
||
put(m.max_fall); // maxFrameAverageLightLevel (nits)
|
||
put(max_display_nits); // maxFullFrameLuminance (nits) — no separate value; mirror the peak
|
||
debug_assert_eq!(o, 26);
|
||
b
|
||
}
|
||
|
||
/// Build the host→client HDR-mode control plaintext (type `0x010e` / Sunshine `IDX_HDR_MODE`):
|
||
/// `[u16 type][u16 length][u8 enabled][SS_HDR_METADATA]`, all little-endian, `length` counting the
|
||
/// enable byte + metadata (mirrors [`super::gamepad::rumble_plaintext`]). Moonlight flips the
|
||
/// TV/decoder into HDR picture mode on `enabled != 0` (`ConnListenerSetHdrMode`). We advertise a
|
||
/// Sunshine server, so the client (`IS_SUNSHINE()`) reads the full 26-byte metadata block.
|
||
fn hdr_mode_plaintext(enabled: bool, m: &HdrMeta) -> Vec<u8> {
|
||
let meta = ss_hdr_metadata(m);
|
||
let mut pt = Vec::with_capacity(4 + 1 + meta.len());
|
||
pt.extend_from_slice(&0x010eu16.to_le_bytes()); // type
|
||
pt.extend_from_slice(&((1 + meta.len()) as u16).to_le_bytes()); // length = enable + metadata
|
||
pt.push(enabled as u8);
|
||
pt.extend_from_slice(&meta);
|
||
pt
|
||
}
|
||
|
||
/// Seal a host→client control message, mirroring the client's `detected` scheme with the
|
||
/// direction flipped: V2 nonces use marker `H?` (host-originated) instead of `C?`; legacy
|
||
/// nonces keep their construction with our own independent `seq` counter. Wire layout matches
|
||
/// what the client sends us: `[0x0001][length][seq][tag|ct per scheme.tag_first]`.
|
||
fn encrypt_control(key: &[u8; 16], scheme: &Scheme, seq: u32, pt: &[u8]) -> Vec<u8> {
|
||
let nonce_kind = match scheme.nonce {
|
||
NonceKind::V2 { seq_be, marker } => NonceKind::V2 {
|
||
seq_be,
|
||
marker: [b'H', marker[1]],
|
||
},
|
||
other => other,
|
||
};
|
||
let length = (4 + 16 + pt.len()) as u16;
|
||
let mut wire = Vec::with_capacity(8 + 16 + pt.len());
|
||
wire.extend_from_slice(&0x0001u16.to_le_bytes());
|
||
wire.extend_from_slice(&length.to_le_bytes());
|
||
wire.extend_from_slice(&seq.to_le_bytes());
|
||
let aad: Vec<u8> = match scheme.aad {
|
||
Aad::None => Vec::new(),
|
||
Aad::Header4 => wire[0..4].to_vec(),
|
||
};
|
||
let ct_tag = gcm_seal(&scheme.key(key), &nonce_kind.nonce(seq), pt, &aad);
|
||
let (ct, tag) = ct_tag.split_at(ct_tag.len() - 16);
|
||
if scheme.tag_first {
|
||
wire.extend_from_slice(tag);
|
||
wire.extend_from_slice(ct);
|
||
} else {
|
||
wire.extend_from_slice(ct);
|
||
wire.extend_from_slice(tag);
|
||
}
|
||
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> {
|
||
use aes_gcm::aead::consts::{U12, U16};
|
||
use aes_gcm::aead::generic_array::GenericArray;
|
||
use aes_gcm::aead::{Aead, KeyInit, Payload};
|
||
use aes_gcm::{aes::Aes128, AesGcm};
|
||
|
||
let p = Payload { msg: pt, aad };
|
||
match nonce.len() {
|
||
12 => AesGcm::<Aes128, U12>::new_from_slice(key)
|
||
.unwrap()
|
||
.encrypt(GenericArray::from_slice(nonce), p)
|
||
.expect("GCM seal"),
|
||
16 => AesGcm::<Aes128, U16>::new_from_slice(key)
|
||
.unwrap()
|
||
.encrypt(GenericArray::from_slice(nonce), p)
|
||
.expect("GCM seal"),
|
||
_ => unreachable!("nonce length"),
|
||
}
|
||
}
|
||
|
||
/// 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>> {
|
||
use aes_gcm::aead::consts::{U12, U16};
|
||
use aes_gcm::aead::generic_array::GenericArray;
|
||
use aes_gcm::aead::{Aead, KeyInit, Payload};
|
||
use aes_gcm::{aes::Aes128, AesGcm};
|
||
|
||
let p = Payload { msg: ct_tag, aad };
|
||
match nonce.len() {
|
||
12 => AesGcm::<Aes128, U12>::new_from_slice(key)
|
||
.ok()?
|
||
.decrypt(GenericArray::from_slice(nonce), p)
|
||
.ok(),
|
||
16 => AesGcm::<Aes128, U16>::new_from_slice(key)
|
||
.ok()?
|
||
.decrypt(GenericArray::from_slice(nonce), p)
|
||
.ok(),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::decode_rfi_range;
|
||
|
||
/// Build a 0x0301 invalidate-ref-frames plaintext: `[type LE][len LE][firstFrame i64 LE][last i64 LE]`.
|
||
fn rfi_msg(first: i64, last: i64) -> Vec<u8> {
|
||
let mut v = vec![0x01, 0x03, 0x10, 0x00]; // type 0x0301, length 16
|
||
v.extend_from_slice(&first.to_le_bytes());
|
||
v.extend_from_slice(&last.to_le_bytes());
|
||
v
|
||
}
|
||
|
||
#[test]
|
||
fn decodes_a_valid_rfi_range() {
|
||
assert_eq!(decode_rfi_range(&rfi_msg(40, 47)), Some((40, 47)));
|
||
assert_eq!(decode_rfi_range(&rfi_msg(5, 5)), Some((5, 5))); // single frame
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_short_or_nonsensical_ranges() {
|
||
assert_eq!(decode_rfi_range(&[0x01, 0x03, 0x00, 0x00]), None); // header only, no body
|
||
assert_eq!(decode_rfi_range(&rfi_msg(-1, 9)), None); // negative first
|
||
assert_eq!(decode_rfi_range(&rfi_msg(9, 4)), None); // last < first
|
||
}
|
||
|
||
/// The HDR-mode plaintext must match what moonlight-common-c parses: `[u16 type=0x010e]
|
||
/// [u16 length=27][u8 enable][SS_HDR_METADATA]`, 31 bytes, all little-endian, primaries R,G,B.
|
||
#[test]
|
||
fn hdr_mode_plaintext_wire_layout() {
|
||
let pt = super::hdr_mode_plaintext(true, &pf_frame::hdr::generic_hdr10());
|
||
assert_eq!(pt.len(), 31); // 4 header + 1 enable + 26 metadata
|
||
assert_eq!(&pt[0..2], &0x010eu16.to_le_bytes()); // type
|
||
assert_eq!(&pt[2..4], &27u16.to_le_bytes()); // length = enable + metadata
|
||
assert_eq!(pt[4], 1); // enabled
|
||
// Metadata starts at byte 5, R primary first (HdrMeta stores G,B,R; wire is R,G,B).
|
||
assert_eq!(&pt[5..7], &35400u16.to_le_bytes()); // red.x
|
||
assert_eq!(&pt[7..9], &14600u16.to_le_bytes()); // red.y
|
||
assert_eq!(&pt[9..11], &8500u16.to_le_bytes()); // green.x
|
||
assert_eq!(&pt[13..15], &6550u16.to_le_bytes()); // blue.x
|
||
assert_eq!(&pt[17..19], &15635u16.to_le_bytes()); // whitePoint.x
|
||
assert_eq!(&pt[21..23], &1000u16.to_le_bytes()); // maxDisplayLuminance (nits)
|
||
assert_eq!(&pt[23..25], &1u16.to_le_bytes()); // minDisplayLuminance (1/10000 nit)
|
||
assert_eq!(&pt[25..27], &1000u16.to_le_bytes()); // maxContentLightLevel (MaxCLL)
|
||
assert_eq!(&pt[27..29], &400u16.to_le_bytes()); // maxFrameAverageLightLevel (MaxFALL)
|
||
assert_eq!(&pt[29..31], &1000u16.to_le_bytes()); // maxFullFrameLuminance mirrors the peak
|
||
}
|
||
|
||
#[test]
|
||
fn hdr_mode_plaintext_disabled_still_well_formed() {
|
||
let pt = super::hdr_mode_plaintext(false, &pf_frame::hdr::generic_hdr10());
|
||
assert_eq!(pt.len(), 31);
|
||
assert_eq!(&pt[2..4], &27u16.to_le_bytes());
|
||
assert_eq!(pt[4], 0); // disabled
|
||
}
|
||
}
|