fix(security): remaining audit findings — mgmt admin gate, RTSP DoS bounds, FEC drop, ALPN, ct-compare
apple / swift (push) Successful in 56s
windows-host / package (push) Successful in 2m25s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m8s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m10s
android / android (push) Successful in 4m42s
ci / rust (push) Successful in 4m44s
ci / web (push) Successful in 30s
ci / docs-site (push) Successful in 35s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 57s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m0s
deb / build-publish (push) Successful in 2m10s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m43s
flatpak / build-publish (push) Successful in 3m59s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m28s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m13s

Addresses the lower-severity findings from docs/security-review.md (#4-#12). Each fix was
adversarially re-reviewed (5-agent pass); two review catches folded in (the Apple client's
GET /library cert path; an RTSP header-cap bypass + a spawn-panic counter leak).

- #4 [low] mgmt mTLS-paired-cert no longer grants full admin. A paired STREAMING cert authorizes
  only a read-only allowlist (GET /host,/compositors,/status,/clients,/native/clients,/library);
  every state-changing route and every PIN-exposing route (/pair, /native/pair) requires the
  operator's bearer token. New cert_auth_is_a_read_only_allowlist test. (/library kept on the
  allowlist — the native clients browse it cert-only; its mutations stay token-only.)
- #6 [low] RTSP pre-auth DoS bounds: a concurrent-connection cap (RAII slot guard), a per-read
  timeout (slow-loris), and Content-Length/header/message size caps — closing an unauthenticated
  slow-loris / memory-growth / thread-exhaustion vector on TCP 48010.
- #11 [info] A FEC reconstruction failure is now a counted drop (discard the block, keep the
  session) instead of being stream-fatal — a lossy link can't be torn down by one bad block.
- #10 [info] Fixed ALPN ("pkf1") on both native QUIC endpoints (defense-in-depth; a deliberate
  coordinated client+host upgrade — a new host rejects an ALPN-less old client).
- #8 [info] Constant-time GameStream pairing phase-4 hash compare (crypto::ct_eq).
- #7 [low] New VirtualDisplay::set_launch_command carries the launch command per-session on the
  GameStream path (no process-global env stomp under concurrent sessions); native path keeps the
  env under today's single-session model (documented; plumb per-session with concurrent sessions).
- #5 [low] Legacy GameStream GCM nonce reuse: documented as inherent to Nvidia's old-style control
  encryption (Apollo/Moonlight identical; key is client-known) — unfixable on the legacy wire; the
  real fix is V2 control-encryption negotiation. Code comment at control.rs.
- #9 [info] GameStream plain-HTTP pairing: documented (inherent to GFE compat; use punktfunk/1).
- #12 [low] Web global NODE_TLS_REJECT_UNAUTHORIZED: fix designed (undici dispatcher scoped to the
  loopback mgmt fetch) but DEFERRED — needs `bun add undici` in the web build env; reverted to keep
  the web working. Latent-only (the loopback mgmt fetch is the console's only outbound TLS).

fmt + clippy -D warnings clean; 94 host + core tests green; no C-ABI/OpenAPI drift. (The HDR
Steps 1-2 client work in the tree is the user's parallel WIP — deliberately NOT included here.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-21 09:50:24 +00:00
parent 551012bb43
commit 3c55ec37fa
12 changed files with 273 additions and 28 deletions
+19 -4
View File
@@ -296,8 +296,9 @@ impl Reassembler {
stats: &StatsCounters,
) -> Result<Option<Frame>> {
// On a lossy datagram link a malformed or non-video packet is dropped, never
// fatal: it must not abort `poll_frame`. Only a genuine FEC reconstruction
// failure propagates as an error.
// fatal: it must not abort `poll_frame`. A FEC reconstruction failure (corrupt or
// incompatible shards that passed the header checks) likewise drops the block rather
// than killing the whole session — the stream recovers at the next keyframe/RFI.
if pkt.len() < HEADER_LEN {
StatsCounters::add(&stats.packets_dropped, 1);
return Ok(None);
@@ -407,8 +408,22 @@ impl Reassembler {
.iter()
.filter(|s| s.is_some())
.count();
let recovered =
coder.reconstruct(block.data_shards, block.recovery_shards, &mut block.shards)?;
let recovered = match coder.reconstruct(
block.data_shards,
block.recovery_shards,
&mut block.shards,
) {
Ok(r) => r,
Err(_) => {
// Corrupt/incompatible shards that slipped past the header checks: discard this
// block (mark done so later shards for it are ignored) and keep the session
// alive — a lossy link must not be torn down by one unrecoverable block; the
// frame stays incomplete and the client recovers at the next keyframe/RFI.
block.done = true;
StatsCounters::add(&stats.packets_dropped, 1);
return Ok(None);
}
};
block.done = true;
StatsCounters::add(
&stats.fec_recovered_shards,
+11 -2
View File
@@ -1490,6 +1490,12 @@ pub mod endpoint {
server_from_der(cert_der, key_der, addr)
}
/// Fixed ALPN for the punktfunk/1 QUIC handshake. Pinning it rejects a cross-protocol peer at the
/// TLS layer (defense-in-depth) and makes the wire protocol explicit. Both ends set the SAME value;
/// a host with ALPN configured rejects a client that offers none, so client + host must be updated
/// together (acceptable while the protocol/ABI is still evolving).
const QUIC_ALPN: &[u8] = b"pkf1";
fn server_from_der(
cert_der: rustls::pki_types::CertificateDer<'static>,
key_der: rustls::pki_types::PrivateKeyDer<'static>,
@@ -1500,10 +1506,11 @@ pub mod endpoint {
// identity is fingerprinted post-handshake (pairing / --require-pairing checks);
// one that presents none still connects (and is rejected at the app layer when
// pairing is required).
let rustls_cfg = rustls::ServerConfig::builder()
let mut rustls_cfg = rustls::ServerConfig::builder()
.with_client_cert_verifier(Arc::new(AcceptAnyClientCert))
.with_single_cert(vec![cert_der], key_der)
.map_err(|e| anyhow_result::Error::msg(format!("server config: {e}")))?;
rustls_cfg.alpn_protocols = vec![QUIC_ALPN.to_vec()];
let quic_cfg = quinn::crypto::rustls::QuicServerConfig::try_from(rustls_cfg)
.map_err(|e| anyhow_result::Error::msg(format!("quic server config: {e}")))?;
let mut server_config = quinn::ServerConfig::with_crypto(Arc::new(quic_cfg));
@@ -1580,7 +1587,7 @@ pub mod endpoint {
pin,
observed: observed.clone(),
}));
let rustls_cfg = match identity {
let mut rustls_cfg = match identity {
None => builder.with_no_client_auth(),
Some((cert_pem, key_pem)) => {
use rustls::pki_types::pem::PemObject;
@@ -1596,6 +1603,8 @@ pub mod endpoint {
.map_err(|e| anyhow_result::Error::msg(format!("client auth: {e}")))?
}
};
// Must match the server's ALPN ([`QUIC_ALPN`]) or the handshake is rejected.
rustls_cfg.alpn_protocols = vec![QUIC_ALPN.to_vec()];
let quic_cfg = quinn::crypto::rustls::QuicClientConfig::try_from(rustls_cfg)
.map_err(|e| anyhow_result::Error::msg(format!("quic client config: {e}")))?;
let mut client_cfg = quinn::ClientConfig::new(Arc::new(quic_cfg));
@@ -103,6 +103,18 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
}
// 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 rumble (this `rumble_seq`) 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 {
@@ -25,6 +25,12 @@ pub fn sha256(parts: &[&[u8]]) -> [u8; 32] {
h.finalize().into()
}
/// Constant-time byte-slice equality — no early exit, so a timing side-channel can't probe the
/// expected value byte-by-byte. Returns false on a length mismatch (the length isn't secret here).
pub fn ct_eq(a: &[u8], b: &[u8]) -> bool {
a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
/// The PIN-derived AES-128 key: `SHA-256(salt || pin)[..16]` (salt first, PIN as ASCII).
pub fn pin_key(salt: &[u8; 16], pin: &str) -> [u8; 16] {
let d = sha256(&[salt, pin.as_bytes()]);
@@ -224,7 +224,8 @@ impl Pairing {
let client_secret = &data[..16];
let client_sig = &data[16..];
let expected = crypto::sha256(&[&s.server_challenge, &s.client_cert_sig, client_secret]);
let hash_ok = expected[..] == s.client_hash[..];
// Constant-time compare so a timing side-channel can't probe the expected hash.
let hash_ok = crypto::ct_eq(&expected, &s.client_hash);
let sig_ok = verify256(&s.client_pubkey, client_secret, client_sig).is_ok();
if hash_ok && sig_ok {
{
+51 -1
View File
@@ -15,12 +15,34 @@ use anyhow::{Context, Result};
use std::collections::HashMap;
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::atomic::Ordering;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
/// 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, and
// size caps on the request headers + body (real GameStream RTSP messages are a few hundred bytes).
const MAX_RTSP_CONNS: usize = 8;
const RTSP_READ_TIMEOUT: Duration = Duration::from_secs(15);
const MAX_RTSP_HEADER: usize = 16 * 1024;
const MAX_RTSP_BODY: usize = 64 * 1024;
const MAX_RTSP_MSG: usize = 128 * 1024;
/// Live RTSP connection count, so a flood can't spawn unbounded threads. Decremented by [`ConnGuard`].
static RTSP_ACTIVE: AtomicUsize = AtomicUsize::new(0);
/// Decrements [`RTSP_ACTIVE`] when a connection thread exits (normally OR on panic).
struct ConnGuard;
impl Drop for ConnGuard {
fn drop(&mut self) {
RTSP_ACTIVE.fetch_sub(1, Ordering::Relaxed);
}
}
/// Bind 48010 and accept RTSP connections on a dedicated thread.
pub fn spawn(state: Arc<AppState>) -> Result<()> {
let listener = TcpListener::bind(("0.0.0.0", RTSP_PORT))
@@ -32,8 +54,19 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
for conn in listener.incoming() {
match conn {
Ok(stream) => {
// Reserve a slot; over the cap, drop the connection (close) without a thread.
if RTSP_ACTIVE.fetch_add(1, Ordering::Relaxed) >= MAX_RTSP_CONNS {
RTSP_ACTIVE.fetch_sub(1, Ordering::Relaxed);
tracing::warn!("RTSP: too many concurrent connections — dropping");
continue; // `stream` drops → connection closed
}
// Construct the slot guard BEFORE spawning and move it into the worker, so the
// slot is released even if `thread::spawn` itself panics (OS thread-limit) —
// the closure (and its captured guard) is dropped during the unwind.
let guard = ConnGuard;
let st = state.clone();
std::thread::spawn(move || {
let _guard = guard; // releases the slot on exit/panic
if let Err(e) = handle_conn(stream, st) {
tracing::warn!(error = %format!("{e:#}"), "RTSP connection ended");
}
@@ -57,6 +90,8 @@ struct Request {
fn handle_conn(mut stream: TcpStream, state: Arc<AppState>) -> Result<()> {
let peer = stream.peer_addr().ok();
// A per-read timeout so a stalled/slow-loris peer can't pin this thread indefinitely.
let _ = stream.set_read_timeout(Some(RTSP_READ_TIMEOUT));
let mut buf: Vec<u8> = Vec::new();
// 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
@@ -82,10 +117,19 @@ fn handle_conn(mut stream: TcpStream, state: Arc<AppState>) -> Result<()> {
fn read_message(stream: &mut TcpStream, buf: &mut Vec<u8>) -> Result<Option<Request>> {
loop {
if let Some(end) = find_subslice(buf, b"\r\n\r\n") {
// Cap the header section even when the terminator IS present (a single oversized header
// block that fits a `\r\n\r\n` would otherwise skip the no-terminator cap below).
if end > MAX_RTSP_HEADER {
anyhow::bail!("RTSP headers exceed limit");
}
let head = std::str::from_utf8(&buf[..end]).context("RTSP header utf8")?;
let content_len = header_value(head, "content-length")
.and_then(|v| v.trim().parse::<usize>().ok())
.unwrap_or(0);
// Reject an absurd Content-Length before waiting to buffer it (allocation amplification).
if content_len > MAX_RTSP_BODY {
anyhow::bail!("RTSP Content-Length {content_len} exceeds limit");
}
let total = end + 4 + content_len;
if buf.len() < total {
// headers complete but body still arriving — read more
@@ -95,6 +139,9 @@ fn read_message(stream: &mut TcpStream, buf: &mut Vec<u8>) -> Result<Option<Requ
buf.drain(..total);
return Ok(Some(parse_request(&head, body)));
}
} else if buf.len() > MAX_RTSP_HEADER {
// No header terminator within the cap — a slow-loris dribbling headers forever.
anyhow::bail!("RTSP headers exceed limit");
}
let mut tmp = [0u8; 8192];
let n = stream.read(&mut tmp).context("RTSP read")?;
@@ -102,6 +149,9 @@ fn read_message(stream: &mut TcpStream, buf: &mut Vec<u8>) -> Result<Option<Requ
return Ok(None); // peer closed
}
buf.extend_from_slice(&tmp[..n]);
if buf.len() > MAX_RTSP_MSG {
anyhow::bail!("RTSP message exceeds limit");
}
}
}
@@ -104,16 +104,11 @@ fn run(
// output is released when this capturer drops at stream end (RAII via its keepalive).
if std::env::var("PUNKTFUNK_VIDEO_SOURCE").as_deref() == Ok("virtual") {
// The launched app picks the compositor (e.g. gamescope for game entries) and the
// nested command; env vars remain manual overrides / fallbacks.
// nested command.
let compositor = app
.and_then(|a| a.compositor)
.map(Ok)
.unwrap_or_else(|| crate::vdisplay::detect().context("detect compositor"))?;
if let Some(cmd) = app.and_then(|a| a.cmd.as_deref()) {
// The gamescope backend reads the nested command from this env var; setting it
// per-launch is safe (one stream session at a time).
std::env::set_var("PUNKTFUNK_GAMESCOPE_APP", cmd);
}
tracing::info!(
?compositor,
app = ?app.map(|a| &a.title),
@@ -122,6 +117,9 @@ fn run(
"video source: virtual display (native client resolution)"
);
let mut vd = crate::vdisplay::open(compositor).context("open virtual display")?;
// Carry the resolved launch command on the backend instance (per-session) rather than a
// process-global env var, so concurrent sessions can't stomp each other's launch target.
vd.set_launch_command(app.and_then(|a| a.cmd.clone()));
let vout = vd
.create(punktfunk_core::Mode {
width: cfg.width,
+110 -4
View File
@@ -23,7 +23,7 @@ use crate::gamestream::{
use anyhow::{Context, Result};
use axum::{
extract::{Path, Request, State},
http::{header, StatusCode},
http::{header, Method, StatusCode},
middleware::{self, Next},
response::{IntoResponse, Response},
routing::get,
@@ -461,10 +461,15 @@ async fn require_auth(State(st): State<Arc<MgmtState>>, req: Request, next: Next
return next.run(req).await; // liveness probe is always open
}
// A paired native client authenticates by its mTLS certificate — the same identity + trust the
// QUIC data plane uses — so it never needs a bearer token. The fingerprint is attached by
// `serve_https` from the verified peer cert; we authorize it iff it's in the paired store.
// QUIC data plane uses. But "paired to STREAM" is not "paired to ADMINISTER": a streaming cert
// authorizes only the safe, read-only status routes, NOT state-changing or pairing-administration
// routes (which would let one paired client unpair others, read/arm the pairing PIN, stop
// sessions, or edit the library). Everything outside the allowlist requires the operator's bearer
// token. The fingerprint is attached by `serve_https` from the verified peer cert.
if let Some(PeerCertFingerprint(Some(fp))) = req.extensions().get::<PeerCertFingerprint>() {
if st.native.as_ref().is_some_and(|n| n.is_paired(fp)) {
if cert_may_access(req.method(), req.uri().path())
&& st.native.as_ref().is_some_and(|n| n.is_paired(fp))
{
return next.run(req).await;
}
}
@@ -487,6 +492,27 @@ async fn require_auth(State(st): State<Arc<MgmtState>>, req: Request, next: Next
}
}
/// Which routes a paired *streaming* cert (mTLS, no bearer token) may reach: a small allowlist of
/// safe, read-only status routes only. Deny-by-default — every state-changing route and every route
/// that exposes a pairing PIN or the pending-approval queue requires the operator's bearer token, so
/// a streaming client can't administer the host (unpair others, arm/read the PIN, stop sessions,
/// edit the library). `/health` is handled separately (always open).
fn cert_may_access(method: &Method, path: &str) -> bool {
method == Method::GET
&& matches!(
path,
"/api/v1/host"
| "/api/v1/compositors"
| "/api/v1/status"
| "/api/v1/clients"
| "/api/v1/native/clients"
// The native clients browse the game library with their cert (no bearer token); the
// library MUTATIONS (POST/PUT/DELETE /library/custom) stay token-only via the exact
// GET-path match above.
| "/api/v1/library"
)
}
/// Compare SHA-256 digests instead of the strings — constant-time with respect to the
/// secret without pulling in a ct-eq dependency.
fn token_eq(presented: &str, expected: &str) -> bool {
@@ -1274,6 +1300,86 @@ mod tests {
axum::http::Request::get(path).body(Body::empty()).unwrap()
}
/// Send a request authenticated ONLY by a paired streaming cert (the `PeerCertFingerprint`
/// `serve_https` would attach) — no bearer header — so `require_auth`'s cert branch decides.
async fn send_cert(app: &Router, mut req: axum::http::Request<Body>, fp: &str) -> StatusCode {
req.extensions_mut()
.insert(PeerCertFingerprint(Some(fp.to_string())));
app.clone().oneshot(req).await.expect("infallible").status()
}
/// A paired *streaming* cert (mTLS, no bearer) authorizes only the read-only allowlist; every
/// state-changing or PIN-exposing route still requires the operator's bearer token (audit #4).
#[tokio::test]
async fn cert_auth_is_a_read_only_allowlist() {
let np = Arc::new(
crate::native_pairing::NativePairing::load_with(
Some(
std::env::temp_dir().join(format!("pf-mgmt-cert-{}.json", std::process::id())),
),
None,
false,
)
.unwrap(),
);
let fp = "deadbeefcafe";
np.add("streaming-client", fp).unwrap();
let app = test_app_native(test_state(), np);
// Allowlisted read-only GETs → the cert authorizes them (not 401).
for p in [
"/api/v1/host",
"/api/v1/status",
"/api/v1/compositors",
"/api/v1/clients",
"/api/v1/native/clients",
"/api/v1/library",
] {
assert_ne!(
send_cert(&app, get_req(p), fp).await,
StatusCode::UNAUTHORIZED,
"a paired streaming cert should authorize GET {p}"
);
}
// PIN-exposing GET + state-changing routes → token-only (cert rejected without a bearer).
assert_eq!(
send_cert(&app, get_req("/api/v1/native/pair"), fp).await,
StatusCode::UNAUTHORIZED,
"GET /native/pair exposes the PIN → must require the bearer token"
);
assert_eq!(
send_cert(
&app,
post_json(
"/api/v1/native/pair/arm",
serde_json::json!({"ttl_secs": 60})
),
fp,
)
.await,
StatusCode::UNAUTHORIZED,
"arming pairing must require the bearer token"
);
assert_eq!(
send_cert(
&app,
axum::http::Request::delete("/api/v1/native/clients/deadbeefcafe")
.body(Body::empty())
.unwrap(),
fp,
)
.await,
StatusCode::UNAUTHORIZED,
"unpair (DELETE) must require the bearer token"
);
// An UNPAIRED cert is rejected even on an allowlisted path.
assert_eq!(
send_cert(&app, get_req("/api/v1/status"), "not-paired").await,
StatusCode::UNAUTHORIZED,
"an unpaired cert must be rejected"
);
}
#[tokio::test]
async fn health_is_open_and_versioned() {
let app = test_app(test_state(), None);
+6 -4
View File
@@ -564,10 +564,12 @@ async fn serve_session(
};
// Resolve a requested library launch (the client sends only the store-qualified id;
// we look it up in OUR library so a client can't inject a command). Set the gamescope
// backend's app env var, exactly as the GameStream /launch path does — safe per-session
// (one session at a time). Only the bare-spawn gamescope path reads it; on a shared
// desktop (kwin/mutter/wlroots) or an attach-to-existing session it's a harmless no-op.
// we look it up in OUR library so a client can't inject a command). The bare-spawn gamescope
// backend picks this up via the `PUNKTFUNK_GAMESCOPE_APP` env fallback in `spawn` (on a shared
// desktop / attach-to-existing session it's a harmless no-op). This is the process-global env
// path — safe under today's ONE-session-at-a-time model; when concurrent native sessions land
// (`what's left` §3), resolve the command into the per-session VirtualDisplay via
// `set_launch_command` (as the GameStream path now does) so sessions can't stomp each other.
if let Some(id) = hello.launch.as_deref() {
match crate::library::launch_command(id) {
Some(cmd) => {
+5
View File
@@ -50,6 +50,11 @@ pub trait VirtualDisplay: Send {
/// Create a virtual output of the given mode. Teardown is RAII: drop the returned
/// [`VirtualOutput`]'s `keepalive`.
fn create(&mut self, mode: Mode) -> Result<VirtualOutput>;
/// Set the per-session command this display should launch into its nested output (the resolved
/// app/game). Carried on the backend instance — NOT a process-global env var — so concurrent
/// sessions can't stomp each other's launch target. Default: no-op (backends that attach to an
/// existing session / don't spawn a nested command ignore it; only gamescope's spawn path uses it).
fn set_launch_command(&mut self, _cmd: Option<String>) {}
}
/// Compositors punktfunk knows how to drive (plan §6).
@@ -25,7 +25,12 @@ use std::time::{Duration, Instant};
/// * `PUNKTFUNK_GAMESCOPE_NODE=<id|auto>` — ATTACH to an already-running gamescope (capture +
/// inject, no lifecycle ownership).
/// * else — SPAWN a bare headless gamescope sized to the mode, running `PUNKTFUNK_GAMESCOPE_APP`.
pub struct GamescopeDisplay;
#[derive(Default)]
pub struct GamescopeDisplay {
/// The resolved per-session launch command (set via [`VirtualDisplay::set_launch_command`]); the
/// bare-spawn path runs it instead of reading the process-global `PUNKTFUNK_GAMESCOPE_APP`.
cmd: Option<String>,
}
/// A running host-managed session (its transient systemd --user unit) + the mode it was launched at.
struct SessionState {
@@ -79,7 +84,7 @@ static STEAMOS_TOOK_OVER: std::sync::Mutex<bool> = std::sync::Mutex::new(false);
impl GamescopeDisplay {
pub fn new() -> Result<Self> {
Ok(GamescopeDisplay)
Ok(GamescopeDisplay::default())
}
}
@@ -88,6 +93,10 @@ impl VirtualDisplay for GamescopeDisplay {
"gamescope"
}
fn set_launch_command(&mut self, cmd: Option<String>) {
self.cmd = cmd;
}
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
// Host-managed gamescope-session-plus at the CLIENT's mode (the Bazzite path): launch the
// full Steam-Deck-UI session headless at the client's resolution + refresh — so games SEE
@@ -121,7 +130,12 @@ impl VirtualDisplay for GamescopeDisplay {
});
}
check_gamescope_version(); // diagnostic only — warns on known-deadlock-prone versions
let proc = GamescopeProc(spawn(mode.width, mode.height, mode.refresh_hz.max(1))?);
let proc = GamescopeProc(spawn(
mode.width,
mode.height,
mode.refresh_hz.max(1),
self.cmd.as_deref(),
)?);
// gamescope creates its PipeWire node a moment after start; poll for it (the proc is held
// alive meanwhile, and killed if we give up).
let node_id = wait_for_node(Duration::from_secs(15)).ok_or_else(|| {
@@ -626,9 +640,17 @@ pub const EI_SOCKET_FILE: &str = "/tmp/punktfunk-gamescope-ei";
/// stdout/stderr go to `/tmp/punktfunk-gamescope.log`. The app is launched through a tiny shell
/// wrapper that relays gamescope's `LIBEI_SOCKET` (set for its children) to [`EI_SOCKET_FILE`]
/// so the input injector can connect to gamescope's EIS server from outside.
fn spawn(w: u32, h: u32, hz: u32) -> Result<Child> {
let app =
std::env::var("PUNKTFUNK_GAMESCOPE_APP").unwrap_or_else(|_| "sleep infinity".to_string());
fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>) -> Result<Child> {
// A non-empty per-session command (set via `set_launch_command`) wins; else the
// `PUNKTFUNK_GAMESCOPE_APP` env var (the documented manual fallback); else a no-op that keeps
// gamescope alive. Each level is taken only if non-empty, so a blank per-session cmd transparently
// falls through to the env (matching the pre-fix behaviour).
let app = cmd
.map(str::to_string)
.filter(|s| !s.trim().is_empty())
.or_else(|| std::env::var("PUNKTFUNK_GAMESCOPE_APP").ok())
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| "sleep infinity".to_string());
let _ = std::fs::remove_file(EI_SOCKET_FILE); // stale socket path from a previous session
let mut cmd = Command::new("gamescope");
cmd.args(["--backend", "headless"])
+19
View File
@@ -2,6 +2,25 @@
Whole-project audit by a 10-surface multi-agent review; every finding adversarially verified (reachability, attacker-control, existing mitigation). **10 surfaces · 20 raw findings → 18 confirmed/partial, 2 refuted.** Threat model: a malicious network client (pre- and post-pairing) is the primary adversary; also an on-path MITM and a local unprivileged user (the host is privileged).
## Remediation status (2026-06-21)
All 12 confirmed findings have been addressed — fixed, or documented where a fix isn't safely possible:
| # | Sev | Status |
|---|---|---|
| #1 | high | **FIXED** (3526517) — secret files 0600 + dir 0700 / Windows icacls DACL |
| #2 | high | **FIXED** (3526517) — single-use SPAKE2 PIN (consumed at the host key-confirmation) |
| #3 | med | **FIXED** (3526517) — RTSP packetSize bounded + saturating packetizer math |
| #4 | low | **FIXED** — mgmt mTLS-cert auth restricted to a read-only allowlist; admin/state-changing routes require the bearer token |
| #5 | low | **DOCUMENTED (won't-fix on legacy)** — legacy GameStream GCM nonce reuse is inherent to Nvidia's old-style control encryption (Apollo/Moonlight identical); the GCM key is client-known. Real fix = V2 control-encryption negotiation; use punktfunk/1 for untrusted nets. Code comment at `control.rs` rumble loop. |
| #6 | low | **FIXED** — RTSP Content-Length/header caps + per-read timeout + concurrent-connection cap |
| #7 | low | **FIXED (GameStream) / DOCUMENTED (native)** — new `VirtualDisplay::set_launch_command` carries the launch command per-session (GameStream); native path keeps the env (safe under today's single-session model; plumb per-session with concurrent sessions) |
| #8 | info | **FIXED** — constant-time GameStream phase-4 hash compare (`crypto::ct_eq`) |
| #9 | info | **DOCUMENTED** — GameStream pairing over plain HTTP is inherent to GFE compat; steer untrusted networks to the SPAKE2 native plane |
| #10 | info | **FIXED** — fixed ALPN (`pkf1`) on both QUIC endpoints (coordinated client+host upgrade) |
| #11 | info | **FIXED** — FEC reconstruction failure is now a counted drop, not stream-fatal |
| #12 | low | **DEFERRED (fix ready, reverted)** — the scoped-dispatcher fix (undici `Agent` on `proxyRequest`'s `fetch` option) is designed and the mechanism verified sound (h3 honors the fetch option), but it needs `undici` added as a web dependency (`bun add undici` + lockfile regen), which requires the web build env — not available here. Reverted to keep the web build/proxy working. Latent-only: the loopback mgmt fetch is the web console's ONLY outbound TLS, so the global env weakens nothing today. Apply with: `cd web && bun add undici`, then scope `rejectUnauthorized:false` to the mgmt fetch and drop the global env. |
## Executive summary
Overall the punktfunk host is a security-conscious codebase with a strong cryptographic and wire-parsing core: the FEC/reassembler path bounds every attacker-controlled length field before allocation, AES-GCM is used correctly with per-direction nonce separation and seq-as-AAD on the native plane, and the native trust model (SPAKE2 PIN binding both cert fingerprints, fingerprint pinning that still verifies the real TLS handshake signature) is genuinely sound. The most serious real defects are (1) local secret-disclosure of the host's master private key (key.pem) — written with no restrictive mode/ACL while the far-less-sensitive mgmt token is carefully 0600 — which on Windows (%ProgramData% default Users-read ACL, LocalSystem service) is a near-certain cross-privilege host-impersonation primitive, and (2) the native SPAKE2 PIN ceremony permitting unlimited online guesses against a static, non-rotating 4-digit PIN (no disarm-on-failure, no lockout), which contradicts the documented "one online guess" guarantee and lets a pre-auth LAN attacker brute-force pairing of a fully-trusted rogue client in a few hours against the default standalone/CLI flow. Dominant themes: file-permission hygiene on secrets is inconsistent (the secure pattern exists but is applied selectively), pairing throttling relies on a single global rate-limit rather than attempt-bounding, and authorization is overbroad (any streaming-paired cert is also a full mgmt admin). The remaining findings are a contained pre-auth RTSP video-thread DoS (unbounded packetSize and Content-Length), a legacy GameStream control-stream GCM nonce-reuse that is muted by modern V2 negotiation and being key-gated, and several defense-in-depth nits (non-constant-time GameStream hash compare, no QUIC ALPN, cross-session env-var launch confusion, global NODE_TLS_REJECT_UNAUTHORIZED). No memory-unsafety or RCE was found on attacker wire bytes; panics are safe Rust and isolated by panic=unwind. Net: a solid foundation whose highest-leverage fixes are tightening secret file permissions and making the PIN single-use/lockout-bounded.