feat(gamestream): video encryption exists on the wire, behind a knob until glass confirms it

GS competitive program WP7 — the largest remaining wire divergence from a Sunshine-class
host, and the last item the docs' "weaker than the native protocol" caveat rested on.

- RTSP DESCRIBE advertises SS_ENC_VIDEO (0x02) as encryptionSupported and NEVER as
  encryptionRequested: requiring encryption would refuse every client that doesn't do
  it. ANNOUNCE's x-ss-general.encryptionEnabled echo decides the session, and is
  honored only for a bit the host actually offered.

- Each finished datagram is AES-128-GCM-sealed under the /launch rikey and shipped as
  [iv 12][frameNumber u32 LE][tag 16] || ciphertext(blocksize) — the ENC_VIDEO_HEADER
  layout, whose 32 bytes sit OUTSIDE the FEC blocksize (the client already subtracted
  them from the packetSize it negotiated, so the datagram still fits its MTU).

- The order is FEC first, THEN encrypt per shard. That is the load-bearing property,
  and it has its own test: the client decrypts what it received and runs RS recovery
  over those plaintexts, so parity computed over ciphertext would recover nothing.
  Sealing is in-place into a buffer that reserved the prefix, so the pooled
  no-allocation path (WP1.3) survives; parity, which encode_into sizes exactly, costs
  one memcpy on the ~20 % of packets that are parity.

- The GCM nonce counter is PROCESS-global and monotonic, never reset. (key, nonce)
  reuse is the one catastrophic GCM failure, and a session-scoped counter would repeat
  the moment a KEYLESS /resume — which WP3 defines as keeping the current keys —
  started a fresh packetizer on the same rikey.

DEFAULT OFF, opt in with PUNKTFUNK_GS_ENCRYPT=1. This is the compat plane's video hot
path and a wire mistake there is a black screen for any client that opts in; I cannot
run a stock Moonlight client in this environment, so it ships dark and the WP0.3
on-glass pass flips the default. SS_ENC_CONTROL_V2 and SS_ENC_AUDIO stay unoffered:
control-v2 also re-frames RTSP itself, and the audio-GCM layout is not in the
sanctioned wire reference.

Also WP8's docs pass, now that the claims are false in the user's favour:
moonlight.md said the GameStream path "doesn't use the native protocol's
FEC/encryption extensions" — Moonlight-compatible FEC has shipped for months and the
host now adapts it to reported loss. Rewrote that (and the clients.md twin) to say
what Moonlight actually does and doesn't get, documented the bitrate as the wire
budget it became in WP2.1, and documented the three new knobs (PUNKTFUNK_FRAME_DRIVEN,
PUNKTFUNK_GS_ADAPT, PUNKTFUNK_GS_ENCRYPT) — check-docs-drift.sh gates that.

⚠ The drift gate also demanded PUNKTFUNK_IDD_ADAPTIVE be pruned from the undocumented
baseline: it is documented in configuration.md but was never pruned, so that ratchet is
red on main independently of this branch. Pruned here since the gate refuses to pass
otherwise.

Gates: Linux container fmt + clippy --all-targets -D warnings (non-vacuous) +
send_pacing 14/14 + gamestream 89/89 (new: encrypted round-trip incl. tamper + wrong
key + IV uniqueness, and RS recovery THROUGH encryption); Windows .133 clippy
nvenc,amf-qsv,qsv clean + 14/14 + 88/88; check-docs-drift.sh and check-docs-links.sh
both clean.
This commit is contained in:
2026-08-27 16:15:20 +02:00
parent 247832014a
commit 47d9a7d2fa
8 changed files with 307 additions and 29 deletions
@@ -1032,6 +1032,7 @@ mod session_tests {
min_fec: 0,
hdr: false,
slices: 1, // the no-request default — hardware decoders get single-slice AUs
encrypt_video: false,
});
}
+38 -3
View File
@@ -292,6 +292,9 @@ fn handle_request(req: &Request, state: &Arc<AppState>, peer: Option<SocketAddr>
state.force_idr.clone(),
state.rfi_range.clone(),
state.loss_stats.clone(),
// The rikey reaches the video plane only when SS_ENC_VIDEO was
// negotiated (WP7) — no reason for it to travel otherwise.
cfg.encrypt_video.then_some(ls.gcm_key),
state.video_cap.clone(),
state.stats.clone(),
on_lost.clone(),
@@ -363,8 +366,25 @@ fn handle_request(req: &Request, state: &Arc<AppState>, peer: Option<SocketAddr>
/// of synthesizing mouse input client-side.
const SS_FF_PEN_TOUCH_EVENTS: u32 = 0x01;
/// Host capability SDP returned by DESCRIBE. Advertises HEVC + AV1 and no encryption
/// (plaintext streams for now; P1.5 adds the negotiated AES paths).
/// `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).
const SS_ENC_VIDEO: u32 = 0x02;
/// Whether this host OFFERS video encryption (WP7). **Opt-in** (`PUNKTFUNK_GS_ENCRYPT=1`)
/// until the on-glass pass confirms a stock client negotiates and decodes it: the sealed path
/// is the compat plane's video hot path, and a wire mistake there is a total black screen for
/// any client that opts in — where the cost of shipping it dark is only that nobody gets the
/// benefit yet. Flip the default once WP0.3 has run it against real Moonlight.
fn gs_video_encryption_offered() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var("PUNKTFUNK_GS_ENCRYPT").as_deref() == Ok("1"))
}
/// 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.
fn describe_sdp() -> String {
// Pen/touch events are advertised only where we can actually inject them (Linux with
// uinput — the same gate as HOST_CAP_PEN; design/pen-tablet-input.md §4). Elsewhere the
@@ -376,9 +396,15 @@ fn describe_sdp() -> String {
0
};
// Line-oriented a=key:value, matching what moonlight-common-c scans for.
let supported = if gs_video_encryption_offered() {
SS_ENC_VIDEO
} else {
0
};
let mut lines: Vec<String> = vec![
format!("a=x-ss-general.featureFlags:{feature_flags}"),
"a=x-ss-general.encryptionSupported:0".into(),
format!("a=x-ss-general.encryptionSupported:{supported}"),
// Never REQUESTED: requiring encryption would refuse every client that doesn't do it.
"a=x-ss-general.encryptionRequested:0".into(),
"sprop-parameter-sets=AAAAAU".into(), // HEVC capability indicator
"a=rtpmap:98 AV1/90000".into(), // AV1 capability indicator
@@ -589,6 +615,14 @@ fn stream_config(map: &HashMap<String, String>) -> Option<StreamConfig> {
let slices = parse_u("x-nv-video[0].videoEncoderSlicesPerFrame")
.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_offered()
&& parse_u("x-ss-general.encryptionEnabled").unwrap_or(0) & SS_ENC_VIDEO != 0;
if encrypt_video {
tracing::info!("RTSP ANNOUNCE: client enabled SS_ENC_VIDEO — sealing every video shard");
}
Some(StreamConfig {
width,
height,
@@ -599,6 +633,7 @@ fn stream_config(map: &HashMap<String, String>) -> Option<StreamConfig> {
min_fec,
hdr,
slices,
encrypt_video,
})
}
+26 -1
View File
@@ -34,6 +34,10 @@ pub struct StreamConfig {
/// (Amlogic — Chromecast with Google TV) wedge the whole device on multi-slice AUs they
/// never asked for (the 0.17.0 4-slice-default field regression). Absent ⇒ 1.
pub slices: u32,
/// The client echoed `SS_ENC_VIDEO` in its ANNOUNCE `x-ss-general.encryptionEnabled`, so
/// every video shard is AES-128-GCM-sealed under the `/launch` rikey (WP7). Only ever true
/// when the host advertised the bit in the first place — see `gs_video_encryption_offered`.
pub encrypt_video: bool,
}
/// A pooled capturer plus the three properties reuse must match on — its HDR-ness, its
@@ -86,6 +90,10 @@ pub fn start(
force_idr: Arc<AtomicBool>,
rfi_range: RfiSlot,
loss: Arc<super::GsLossStats>,
// The session rikey, ONLY when `cfg.encrypt_video` (WP7). Deliberately its own parameter
// rather than a `StreamConfig` field: that struct is `Debug`-logged at stream start, and a
// session key has no business in the log.
gcm_key: Option<[u8; 16]>,
video_cap: CapturerSlot,
stats: Arc<crate::stats_recorder::StatsRecorder>,
on_lost: super::OnSessionLost,
@@ -142,6 +150,7 @@ pub fn start(
&force_idr,
&rfi_range,
&loss,
gcm_key,
&video_cap,
&stats,
&on_lost,
@@ -180,6 +189,8 @@ fn run(
force_idr: &AtomicBool,
rfi_range: &std::sync::Mutex<Option<(i64, i64)>>,
loss: &super::GsLossStats,
// The session rikey when SS_ENC_VIDEO was negotiated — see `start`.
gcm_key: Option<[u8; 16]>,
video_cap: &std::sync::Mutex<Option<PooledCapturer>>,
// Shared stats recorder for the web-console capture/graph. Threaded into `stream_body` (the
// encode loop); per-frame sample emission is wired by a later pass.
@@ -531,6 +542,7 @@ fn run(
force_idr,
rfi_range,
loss,
gcm_key,
stats,
&client_label,
on_lost,
@@ -623,6 +635,7 @@ fn run(
force_idr,
rfi_range,
loss,
gcm_key,
stats,
&client_label,
on_lost,
@@ -1253,6 +1266,8 @@ fn stream_body(
// Client-reported loss counters (control 0x0201) — read as deltas by the 1 Hz adaptation
// step below (WP2.2-2.4: adaptive FEC percent + bitrate de-rating under the wire budget).
loss: &super::GsLossStats,
// The session rikey when SS_ENC_VIDEO was negotiated (WP7) — see `start`.
gcm_key: Option<[u8; 16]>,
// Shared stats recorder. The encode loop reads `stats.is_armed()` per frame to decide whether
// to accumulate the per-stage split, then emits a `StatsSample` at its 1 s aggregation boundary.
stats: &Arc<crate::stats_recorder::StatsRecorder>,
@@ -1347,7 +1362,17 @@ fn stream_body(
// Both sites that swap `enc` re-bind `frame` with it, so this is always
// `(frame.format, frame.width, frame.height)` right after one.
let mut enc_src = (frame.format, frame.width, frame.height);
let pk = VideoPacketizer::new(cfg.packet_size, fec_pct, cfg.min_fec);
let mut pk = VideoPacketizer::new(cfg.packet_size, fec_pct, cfg.min_fec);
// SS_ENC_VIDEO (WP7): seal every shard under the session's rikey when the client
// negotiated it. `cfg.encrypt_video` is already gated on the host having OFFERED the bit.
if cfg.encrypt_video {
match gcm_key {
Some(key) => pk.set_encryption_key(key),
// Can't happen (a session exists by RTSP PLAY), and streaming PLAINTEXT to a client
// expecting ciphertext is a black screen — refuse instead.
None => anyhow::bail!("SS_ENC_VIDEO negotiated but the session key is gone"),
}
}
// Pace at the client's negotiated frame rate, re-encoding the last captured frame when the
// compositor produced no new one. Compositors only emit frames on damage, so a static or
+219 -14
View File
@@ -12,8 +12,13 @@
//! `flags` byte isn't valid — so the NV header fields RS must reproduce (streamPacketIndex,
//! frameIndex, flags, multiFec*) are written into the data shards **before** encoding, and only
//! the transport fields (RTP header/seq/timestamp + fecInfo) are stamped **after**, matching
//! Sunshine `stream.cpp`. `pct = 0` falls back to data-shards-only. Plaintext (AES-GCM video
//! encryption is negotiated off for now).
//! Sunshine `stream.cpp`. `pct = 0` falls back to data-shards-only.
//!
//! ENCRYPTION (`SS_ENC_VIDEO`, WP7 — negotiated per session, see `rtsp::describe_sdp`): when the
//! client enables it, each finished datagram is AES-128-GCM-sealed under the `/launch` rikey and
//! shipped as `[iv 12][frameNumber u32 LE][tag 16] || ciphertext(blocksize)`. The order is
//! **FEC first, then encrypt per shard** — the client decrypts each shard it received and runs RS
//! recovery over those plaintexts, so parity over ciphertext would recover nothing.
//!
//! Buffers are POOLED (GS competitive program WP1.3): every datagram the paced sender finishes
//! with comes back through [`VideoPacketizer::recycle`], so a steady-state frame allocates
@@ -25,6 +30,13 @@ use punktfunk_core::fec::{ErasureCoder, Gf8Coder};
/// RTP `header` byte: version 2 (0x80) | extension (0x10) — Moonlight keys on the extension.
const RTP_HEADER_BYTE: u8 = 0x80 | 0x10;
/// `ENC_VIDEO_HEADER` / `video_packet_enc_prefix_t` — the 32-byte WIRE PREFIX in front of an
/// encrypted shard (`SS_ENC_VIDEO`, WP7): `[iv 12][frameNumber u32 LE][tag 16]`. Sixteen-byte
/// multiple by design, so the FEC blocksize behind it stays 16-aligned. It sits OUTSIDE the
/// FEC blocksize: the on-wire datagram is `prefix || ciphertext(blocksize)`, and the client
/// subtracted `sizeof(ENC_VIDEO_HEADER)` from the `packetSize` it negotiated, so the datagram
/// still fits the MTU it sized for.
const ENC_PREFIX: usize = 32;
const FLAG_PIC: u8 = 0x1;
const FLAG_EOF: u8 = 0x2;
const FLAG_SOF: u8 = 0x4;
@@ -64,6 +76,9 @@ pub struct VideoPacketizer {
/// Persistent GF(2⁸) coder so its `(k, m)` Cauchy-matrix cache survives across frames
/// (plan Phase 1.4) — a stream's block shape only moves with frame size.
coder: Gf8Coder,
/// `SS_ENC_VIDEO` session key (the `/launch` rikey) when the client negotiated video
/// encryption, else `None` (the plaintext wire this plane has always sent). WP7.
enc_key: Option<[u8; 16]>,
/// Spent datagram buffers handed back by the sender ([`recycle`](Self::recycle)); capped at
/// [`POOL_MAX`]. `pop` + zero-fill replaces the per-shard allocation of the old shape.
pool: Vec<Vec<u8>>,
@@ -74,6 +89,23 @@ pub struct VideoPacketizer {
parity_scratch: Vec<Vec<u8>>,
}
/// The `SS_ENC_VIDEO` nonce counter (WP7): PROCESS-global and monotonic, never reset. The
/// nonce is `counter_le[8] || 0,0,0 || 'V'`, and (key, nonce) reuse is the one catastrophic
/// GCM failure — a session-scoped counter would repeat the moment a KEYLESS `/resume`
/// (WP3: keeps the current keys) started a fresh packetizer on the same rikey. A u64 at
/// 120k packets/s outlives the hardware by millions of years, so monotonic-forever is both
/// the simplest and the only structurally safe choice.
static IV_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// The next `ENC_VIDEO_HEADER` nonce: `counter_le[8] || 0,0,0 || 'V'` (0x56).
fn next_iv() -> [u8; 12] {
let n = IV_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let mut iv = [0u8; 12];
iv[..8].copy_from_slice(&n.to_le_bytes());
iv[11] = b'V';
iv
}
impl VideoPacketizer {
pub fn new(packet_size: usize, fec_percentage: u8, min_fec: u8) -> Self {
VideoPacketizer {
@@ -89,12 +121,19 @@ impl VideoPacketizer {
frame_index: 0,
seq: 0,
coder: Gf8Coder::default(),
enc_key: None,
pool: Vec::new(),
data_scratch: Vec::new(),
parity_scratch: Vec::new(),
}
}
/// Turn on `SS_ENC_VIDEO` per-shard encryption with the session's rikey (WP7). Called once,
/// before the first frame, when the client's ANNOUNCE echoed the VIDEO bit.
pub fn set_encryption_key(&mut self, key: [u8; 16]) {
self.enc_key = Some(key);
}
/// Retarget the FEC overhead percent mid-stream (adaptive FEC, WP2.3). Safe per frame: the
/// block geometry is derived from `fec_percentage` fresh on every `packetize_into` call, and
/// the client derives each block's parity count from the per-packet `fecInfo` wire percent —
@@ -161,6 +200,14 @@ impl VideoPacketizer {
let pps = self.payload_per_shard;
let blocksize = SHARD_HEADER + pps; // = packet_size + 16
let pct = self.fec_percentage;
// With SS_ENC_VIDEO the datagram carries a 32-byte prefix in front of the shard, so
// every buffer reserves it and the shard itself is written at `off`. `off = 0` is the
// plaintext wire, byte-for-byte what this plane always sent.
let off = if self.enc_key.is_some() {
ENC_PREFIX
} else {
0
};
// frame payload = 8-byte short frame header + the AU bitstream. The header is built
// once; each payload byte is copied exactly once, straight into its datagram below —
@@ -202,7 +249,10 @@ impl VideoPacketizer {
for i in 0..k {
let global = first + i;
let seq = block_seq_base + i as u32;
let mut buf = self.take_buf(blocksize);
let mut buf = self.take_buf(off + blocksize);
// The shard proper: every offset below is relative to it, so the layout reads
// identically whether or not an encryption prefix precedes it.
let shard = &mut buf[off..];
let mut flags = FLAG_PIC;
if global == 0 {
flags |= FLAG_SOF;
@@ -210,11 +260,11 @@ impl VideoPacketizer {
if global == total_data - 1 {
flags |= FLAG_EOF;
}
buf[16..20].copy_from_slice(&(seq << 8).to_le_bytes()); // streamPacketIndex
buf[20..24].copy_from_slice(&frame_index.to_le_bytes()); // frameIndex
buf[24] = flags;
buf[26] = MULTI_FEC_FLAGS;
buf[27] = multi_fec_blocks;
shard[16..20].copy_from_slice(&(seq << 8).to_le_bytes()); // streamPacketIndex
shard[20..24].copy_from_slice(&frame_index.to_le_bytes()); // frameIndex
shard[24] = flags;
shard[26] = MULTI_FEC_FLAGS;
shard[27] = multi_fec_blocks;
// This shard covers frame-payload bytes [ps, pe): the 8-byte header first, then
// the AU. Only shard 0 can straddle the header/AU boundary (FRAME_HEADER < pps).
let ps = global * pps;
@@ -222,13 +272,13 @@ impl VideoPacketizer {
let mut w = SHARD_HEADER;
if ps < FRAME_HEADER {
let h_end = pe.min(FRAME_HEADER);
buf[w..w + (h_end - ps)].copy_from_slice(&header[ps..h_end]);
shard[w..w + (h_end - ps)].copy_from_slice(&header[ps..h_end]);
w += h_end - ps;
}
if pe > FRAME_HEADER {
let a_start = ps.max(FRAME_HEADER) - FRAME_HEADER;
let a_end = pe - FRAME_HEADER;
buf[w..w + (a_end - a_start)].copy_from_slice(&au[a_start..a_end]);
shard[w..w + (a_end - a_start)].copy_from_slice(&au[a_start..a_end]);
}
self.data_scratch.push(buf);
}
@@ -248,7 +298,11 @@ impl VideoPacketizer {
let b = self.pool.pop().unwrap_or_default();
self.parity_scratch.push(b);
}
let refs: Vec<&[u8]> = self.data_scratch.iter().map(|s| s.as_slice()).collect();
// Parity covers the PLAINTEXT shards (`[off..]`), which is the whole reason the
// order is FEC-then-encrypt: the client decrypts each shard it received and
// runs RS recovery over those plaintexts, so parity computed over ciphertext
// would recover nothing.
let refs: Vec<&[u8]> = self.data_scratch.iter().map(|s| &s[off..]).collect();
if self
.coder
.encode_into(&refs, m, &mut self.parity_scratch)
@@ -264,35 +318,94 @@ impl VideoPacketizer {
// flags/streamPacketIndex bytes, so a recovered data shard's RS-reconstructed
// NV header stays valid.
self.seq = block_seq_base + k as u32;
let key = self.enc_key;
for (i, mut buf) in self.data_scratch.drain(..).enumerate() {
let seq = block_seq_base + i as u32;
finalize(
&mut buf,
&mut buf[off..],
seq,
timestamp_90k,
frame_index,
multi_fec_blocks,
fec_info(k, i, wire_pct),
);
seal_shard(&mut buf, key, frame_index);
out.push(buf);
}
for (j, mut buf) in self.parity_scratch.drain(..).enumerate() {
// Moved out for the loop's `self.take_buf`/`self.pool` use, then restored so the
// scratch Vec keeps its allocation across frames.
let mut parity = std::mem::take(&mut self.parity_scratch);
for (j, mut par) in parity.drain(..).enumerate() {
let seq = self.seq;
self.seq = self.seq.wrapping_add(1);
finalize(
&mut buf,
&mut par,
seq,
timestamp_90k,
frame_index,
multi_fec_blocks,
fec_info(k, k + j, wire_pct),
);
// `encode_into` sizes parity to the shard length exactly, so an encrypted
// session moves it into a prefixed buffer (one memcpy on the ~20 % of packets
// that are parity) and returns the scratch buffer to the pool.
let mut buf = if off == 0 {
par
} else {
let mut b = self.take_buf(off + blocksize);
b[off..].copy_from_slice(&par);
self.pool.push(par);
b
};
seal_shard(&mut buf, key, frame_index);
out.push(buf);
}
self.parity_scratch = parity;
}
}
}
/// Seal one finished datagram for `SS_ENC_VIDEO` (WP7): AES-128-GCM over the WHOLE plaintext
/// shard at `[ENC_PREFIX..]`, in place (GCM is a stream cipher — ciphertext length equals
/// plaintext length), then the `[iv 12][frameNumber u32 LE][tag 16]` prefix in front of it.
/// **No AAD** — the prefix is not authenticated, matching the format a stock Moonlight client
/// decrypts. `key = None` is the plaintext wire and returns the buffer untouched.
///
/// A GCM failure can only mean a mis-sized buffer (a programming error, not input-driven), so
/// it is logged once-per-occurrence and the shard is dropped rather than sent as readable
/// plaintext under an encrypted negotiation — the client discards a shard it can't
/// authenticate, and FEC covers the gap.
fn seal_shard(buf: &mut Vec<u8>, key: Option<[u8; 16]>, frame_index: u32) {
use aes_gcm::aead::consts::U12;
use aes_gcm::aead::{AeadInOut, KeyInit};
use aes_gcm::{aes::Aes128, AesGcm};
let Some(key) = key else { return };
let iv = next_iv();
let cipher = match AesGcm::<Aes128, U12>::new_from_slice(&key) {
Ok(c) => c,
Err(_) => {
buf.clear();
return;
}
};
let tag = match cipher.encrypt_inout_detached(
(&iv).into(),
&[],
aes_gcm::aead::inout::InOutBuf::from(&mut buf[ENC_PREFIX..]),
) {
Ok(t) => t,
Err(e) => {
tracing::warn!(error = ?e, "gamestream: video shard seal failed — dropping it");
buf.clear();
return;
}
};
buf[..12].copy_from_slice(&iv);
buf[12..16].copy_from_slice(&frame_index.to_le_bytes());
buf[16..ENC_PREFIX].copy_from_slice(&tag);
}
/// `fecInfo` (u32, little-endian): `dataShards<<22 | fecIndex<<12 | fecPercentage<<4`.
fn fec_info(k: usize, fec_index: usize, pct: usize) -> u32 {
((k as u32) << 22) | ((fec_index as u32) << 12) | ((pct as u32) << 4)
@@ -593,6 +706,98 @@ mod tests {
assert_eq!(recover_au(&lossy), None);
}
/// Decrypt one `SS_ENC_VIDEO` datagram the way a client does: AES-128-GCM over
/// `[ENC_PREFIX..]` with the literal IV from the prefix, no AAD, tag at `[16..32]`.
/// `None` = the tag did not authenticate.
fn client_open(key: &[u8; 16], dg: &[u8]) -> Option<Vec<u8>> {
use aes_gcm::aead::consts::U12;
use aes_gcm::aead::{Aead, KeyInit, Payload};
use aes_gcm::{aes::Aes128, AesGcm};
let iv: [u8; 12] = dg[..12].try_into().ok()?;
// aes-gcm's `decrypt` wants `ciphertext || tag`, which the wire deliberately does not
// carry adjacently — so re-join them exactly as a client's own decrypt call does.
let mut ct_tag = dg[ENC_PREFIX..].to_vec();
ct_tag.extend_from_slice(&dg[16..ENC_PREFIX]);
AesGcm::<Aes128, U12>::new_from_slice(key)
.ok()?
.decrypt(
(&iv).into(),
Payload {
msg: &ct_tag,
aad: &[],
},
)
.ok()
}
/// WP7 round trip: an encrypted session's datagrams carry the 32-byte prefix, decrypt under
/// the session key to EXACTLY the plaintext the unencrypted path would have sent, and carry
/// the frame index in the prefix. The IV is never repeated.
#[test]
fn encrypted_shards_decrypt_to_the_plaintext_wire() {
let key = [0xA5u8; 16];
let au = synthetic_au(9_000, 0x1234);
let mut plain = VideoPacketizer::new(1392, 20, 2);
let mut enc = VideoPacketizer::new(1392, 20, 2);
enc.set_encryption_key(key);
let want = plain.packetize(&au, FrameType::Idr, 7777, Some(3));
let got = enc.packetize(&au, FrameType::Idr, 7777, Some(3));
assert_eq!(got.len(), want.len(), "same shard count either way");
let mut ivs = std::collections::HashSet::new();
for (sealed, expect) in got.iter().zip(&want) {
assert_eq!(
sealed.len(),
ENC_PREFIX + expect.len(),
"prefix sits OUTSIDE the FEC blocksize"
);
// frameNumber rides the prefix in the clear.
assert_eq!(
u32::from_le_bytes(sealed[12..16].try_into().unwrap()),
3,
"prefix frameNumber"
);
assert!(ivs.insert(sealed[..12].to_vec()), "an IV was REUSED");
assert_eq!(sealed[11], b'V', "IV marker byte");
let opened = client_open(&key, sealed).expect("tag authenticates");
assert_eq!(&opened, expect, "decrypts to the plaintext wire image");
}
// A tampered byte must not authenticate (it IS a GCM seal, not obfuscation).
let mut tampered = got[0].clone();
let last = tampered.len() - 1;
tampered[last] ^= 0xFF;
assert!(client_open(&key, &tampered).is_none(), "tamper detected");
// The wrong key does not open it either.
assert!(client_open(&[0x00; 16], &got[0]).is_none(), "wrong key");
}
/// The load-bearing property of the FEC-then-encrypt ORDER: a client that decrypts what it
/// received can RS-recover a shard it never got. Parity computed over ciphertext would
/// recover nothing here.
#[test]
fn encrypted_stream_still_recovers_a_lost_shard() {
let key = [0x3Cu8; 16];
let au = synthetic_au(4_000, 0xFEED);
let mut pk = VideoPacketizer::new(1392, 50, 1);
pk.set_encryption_key(key);
let sealed = pk.packetize(&au, FrameType::Idr, 0, Some(0));
// Decrypt everything the "client" received — dropping data shard 1 in flight.
let mut received: Vec<Option<Vec<u8>>> = sealed
.iter()
.map(|dg| client_open(&key, dg))
.collect::<Option<Vec<_>>>()
.expect("all tags authenticate")
.into_iter()
.map(Some)
.collect();
received[1] = None;
assert_eq!(
recover_au(&received).as_deref(),
Some(&au[..]),
"RS recovery over the DECRYPTED shards restores the AU"
);
}
/// The pooled path is BYTE-IDENTICAL to a fresh packetizer: recycled buffers (stale bytes
/// included) must never leak into the wire image. This is the WP1.3 regression lock.
#[test]
+3 -2
View File
@@ -39,8 +39,9 @@ Open the app, pick your host, [pair](/docs/pairing) once, and stream. It builds
Punktfunk also speaks the **GameStream** protocol, so any [Moonlight](https://moonlight-stream.org/)
client — a browser, a smart TV, an old phone, a games console — connects with no punktfunk-specific
software; it's the catch-all where no native Punktfunk app exists. See
[Connect with Moonlight](/docs/moonlight). It doesn't use the native protocol's FEC/encryption
extensions, but on a healthy LAN that rarely matters.
[Connect with Moonlight](/docs/moonlight). It gets error correction and host-side adaptation to a
lossy link like the native clients do; what it doesn't get are the native protocol's extensions
(client-side speed test, jumbo frames, the newer control encryption).
## Linux desktop client (GTK4)
+5 -2
View File
@@ -129,7 +129,7 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t
| Setting | Values | Meaning |
|---|---|---|
| `PUNKTFUNK_FEC_PCT` | `0``90` (percent) | **Pins** forward-error-correction redundancy and turns adaptive FEC **off**. Leave it unset on the native protocol: the host normally sizes recovery to the loss the client reports (a 150 % band, starting at 10 %; once a session has seen real loss the quiet-time floor is 5 % rather than 1 %, and a couple of clean minutes earn 1 % back), so pinning a number can leave a lossy link *worse* off than letting it adapt. Set it only when a fixed, known overhead matters — a measurement or a speed test; `0` disables FEC entirely. Under the wire-budget bitrate (see [Bitrate](#bitrate)) the pinned percent is still carved out of the budget, it just never moves. On the GameStream/Moonlight plane it is a plain override of that plane's fixed 20 %. |
| `PUNKTFUNK_FEC_PCT` | `0``90` (percent) | **Pins** forward-error-correction redundancy and turns adaptive FEC **off**. Leave it unset on the native protocol: the host normally sizes recovery to the loss the client reports (a 150 % band, starting at 10 %; once a session has seen real loss the quiet-time floor is 5 % rather than 1 %, and a couple of clean minutes earn 1 % back), so pinning a number can leave a lossy link *worse* off than letting it adapt. Set it only when a fixed, known overhead matters — a measurement or a speed test; `0` disables FEC entirely. Under the wire-budget bitrate (see [Bitrate](#bitrate)) the pinned percent is still carved out of the budget, it just never moves. On the GameStream/Moonlight plane it sets that plane's STARTING percent and, like on the native protocol, pins it — leave it unset and the host adapts there too, from the loss Moonlight reports. |
| `PUNKTFUNK_10BIT` | `1` · `0` *(default on)* | Allow 10-bit (HEVC Main10 / AV1 10-bit) sessions at all; `0` forces every session to 8-bit SDR. Which hosts can actually deliver it, and the client half of the switch, are on [HDR](/docs/hdr). |
| `PUNKTFUNK_444` | `1` · `0` *(default on)* | Host **policy gate** for full chroma 4:4:4 — sharper text and thin lines, no chroma loss. **On by default**; `0` forces every session to 4:2:0. It only ever *allows*: the client's own 4:4:4 setting (default off) is the real per-session switch, and the codec, capture-path and GPU gates behind it are on [Client settings → Full chroma](/docs/client-settings#video). Which GPUs and which clients can actually do it is in the [support matrix](/docs/support-matrix#encoders); how it interacts with HDR is on [HDR](/docs/hdr). **punktfunk/1 native only** — Moonlight stays 4:2:0. |
| `PUNKTFUNK_CHACHA20` | `1` · `0` *(default on)* | ChaCha20-Poly1305 session encryption for clients without hardware AES (old ARM TVs, e.g. webOS), lifting their ~100 Mbps software-AES decrypt ceiling. **On by default** on the host; a session uses it only when the client requests it — everyone else stays on AES-GCM. Purely a performance choice (both ciphers are full-strength); set `0` to force AES-GCM for all sessions. |
@@ -244,6 +244,9 @@ notes for context.
| Setting | Values | Meaning |
|---|---|---|
| `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` · unset *(default)* | GameStream/Moonlight only, **experimental**: offer per-packet video encryption (`SS_ENC_VIDEO`) to clients that support it. Off by default pending on-glass verification with real Moonlight clients — a client that opts in and can't decode gets no picture at all. Audio and the control channel are encrypted regardless of this setting. |
| `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). |
@@ -312,7 +315,7 @@ exposes it as `--max-concurrent N` (see the [Host CLI](/docs/host-cli) reference
- Client and host **negotiate the codec**: **HEVC (H.265)** by default, **AV1** for clients that
support it, and **H.264** when the session runs on the GPU-less software encoder.
- The native protocol adds forward error correction for lossy links — see `PUNKTFUNK_FEC_PCT` above.
- Both protocols add forward error correction for lossy links, and both adapt it to the loss the client reports — see `PUNKTFUNK_FEC_PCT` above.
## Settings documented elsewhere
+15 -5
View File
@@ -88,7 +88,8 @@ The device then appears under **Paired devices**, and Moonlight remembers the ho
Moonlight lists **Desktop** plus the games the host found installed (Steam, Epic, GOG, Xbox), with
cover art — the same [library](/docs/game-library) the native clients show. Pick one and start
streaming. The host creates a virtual display at the resolution and frame rate Moonlight requests
streaming. While a session of yours is running, Moonlight offers **Resume** and **Quit** for it —
resuming re-attaches to the session you left (only the device that started it sees this). The host creates a virtual display at the resolution and frame rate Moonlight requests
(set these in Moonlight's settings), encodes it on the GPU, and streams it. Mouse, keyboard, and
controllers flow back to the host — and a Moonlight client that sends pen events, an iPad's Apple
Pencil included, drives the same host-side tablet a native client would, with pressure and tilt
@@ -107,10 +108,19 @@ That **Desktop** entry is the operator base list. An `apps.json` in the host's c
10-bit BT.2020 PQ. If the toggle is there, turn it on and pick HEVC or AV1 — H.264 stays SDR.
Setting `PUNKTFUNK_10BIT=0` in [`host.env`](/docs/configuration) withdraws the offer entirely; it
is on by default.
- **Bitrate:** start moderate and raise it. For very high bitrates, the [native
clients](/docs/clients) have a built-in speed test; with Moonlight, set the bitrate manually.
- Moonlight uses the GameStream protocol, not Punktfunk's native FEC/encryption extensions. On a
solid LAN this is fine; on a lossy link a [native client](/docs/clients) holds up better.
- **Bitrate:** the number you set is a **wire budget**, not an encoder setting — the host fits the
video *plus* its error-correction inside it, so "20 Mbps" means about 20 Mbps on the wire. Start
moderate and raise it. For very high bitrates, the [native clients](/docs/clients) have a
built-in speed test; with Moonlight, set the bitrate manually.
- **The host adapts to your link.** Moonlight reports packet loss back to the host, and Punktfunk
acts on it: error correction is added when loss appears and wound back down when the link is
clean, and sustained loss also eases the bitrate off (recovering as things settle). Nothing to
configure — and it is why a marginal Wi-Fi link degrades rather than stuttering.
- Moonlight uses the GameStream protocol, so it doesn't get Punktfunk's native-protocol
extensions — no client-side speed test, no jumbo frames, and its control channel uses the older
GameStream encryption. Video error correction is *not* on that list: Moonlight-compatible FEC is
in every stream. On a good link the two protocols feel the same; a [native
client](/docs/clients) still has more headroom on a bad one.
- Comparing Moonlight's performance overlay with a Punktfunk client's stats HUD? The numbers
measure different slices of the pipeline — see [Understanding the Stats Overlay](/docs/stats)
for a line-by-line comparison matrix before drawing conclusions.
@@ -109,7 +109,6 @@ PUNKTFUNK_FLAG_PROBE
PUNKTFUNK_FLAG_SOF
PUNKTFUNK_FORCE_GAMEPAD_UI
PUNKTFUNK_FORCE_SHM
PUNKTFUNK_FRAME_DRIVEN
PUNKTFUNK_FRAME_LATENCY
PUNKTFUNK_GAMESCOPE_BIND
PUNKTFUNK_GAMESCOPE_WSI
@@ -147,7 +146,6 @@ PUNKTFUNK_HOST_CAP_PEN
PUNKTFUNK_HOST_CAP_TEXT_INPUT
PUNKTFUNK_HOST_TIMING_MAGIC
PUNKTFUNK_HW_FAULT
PUNKTFUNK_IDD_ADAPTIVE
PUNKTFUNK_INBOUND_REQ_FLAG
PUNKTFUNK_INPUT_DEBUG
PUNKTFUNK_INPUT_MAGIC