feat: HDR Step-0 colour-metadata transport + security-audit hardening
ci / rust (push) Failing after 45s
apple / swift (push) Successful in 57s
ci / web (push) Successful in 39s
ci / docs-site (push) Successful in 38s
windows-host / package (push) Successful in 3m26s
android / android (push) Successful in 3m40s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m24s
deb / build-publish (push) Successful in 2m10s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m22s
decky / build-publish (push) Successful in 25s
ci / bench (push) Successful in 4m44s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 16s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m4s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m7s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 3m5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m45s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 30s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m37s
flatpak / build-publish (push) Successful in 4m17s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m30s
docker / deploy-docs (push) Successful in 23s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 7m53s

Two strands, entangled in punktfunk1.rs, committed together (one builds-green tree).

HDR pipeline Step 0 — glass-to-glass colour-metadata transport (docs/hdr-pipeline-plan.md):
- Protocol/ABI: ColorInfo on the Welcome + a 0xCE HdrMeta datagram carry the source colour
  space + HDR10 static mastering metadata (quic.rs, abi.rs connect_ex5 fixing caps=0).
- New platform-independent, unit-tested HDR static-metadata helpers (hdr.rs): chromaticities
  (1/50000), mastering luminance (0.0001 cd/m2), MaxCLL/MaxFALL in HDR10/ST.2086 units.
- Capture/encode hooks (capture.rs, encode.rs set_hdr_meta) + Linux client / probe plumbing.

Security-audit hardening — top 3 from docs/security-review.md, each adversarially verified:
- #1 [HIGH] Secret file permissions. The host key.pem/cert.pem and both trust stores are now
  written owner-only: 0600 + dir 0700 on Unix (mirrors mgmt_token), best-effort
  SYSTEM/Administrators/OWNER-only icacls DACL on Windows (%ProgramData% is Users-readable).
  Closes a local key-disclosure -> host-impersonation gap. New gamestream::{create_private_dir,
  write_secret_file} + a 0600 regression test.
- #2 [HIGH] Native SPAKE2 PIN is single-use. The PIN is consumed the moment the host sends its
  key-confirmation (which lets the client test its one guess), before reading the proof, so any
  completed attempt -- right OR wrong -- disarms the window. A wrong PIN isn't observable
  host-side (the client aborts before sending its proof), so consuming on first attempt is what
  delivers the documented "one online guess" instead of an unbounded brute-force of the static
  4-digit PIN. Test verifies single-use.
- #3 [MEDIUM] RTSP packetSize is bounded ([64,2048] in stream_config) and VideoPacketizer::new
  uses saturating .max(1), killing a PRE-AUTH div-by-zero/underflow panic of the video thread.
  Tests for {0,15,16,17} + out-of-range rejection.

fmt + clippy -D warnings clean; full workspace test suite green (93 host tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-21 09:07:59 +00:00
parent 22a9ce4229
commit 3526517eb1
26 changed files with 1916 additions and 77 deletions
+78 -17
View File
@@ -27,9 +27,9 @@ use punktfunk_core::config::{CompositorPref, FecConfig, FecScheme, GamepadPref,
use punktfunk_core::input::{InputEvent, InputKind};
use punktfunk_core::packet::{FLAG_PIC, FLAG_PROBE, FLAG_SOF};
use punktfunk_core::quic::{
endpoint, io, ClockEcho, ClockProbe, Hello, LossReport, PairChallenge, PairProof, PairRequest,
PairResult, ProbeRequest, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe, Start,
Welcome,
endpoint, io, ClockEcho, ClockProbe, ColorInfo, Hello, LossReport, PairChallenge, PairProof,
PairRequest, PairResult, ProbeRequest, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe,
Start, Welcome,
};
use punktfunk_core::transport::UdpTransport;
use punktfunk_core::Session;
@@ -418,6 +418,17 @@ async fn pair_ceremony(
)
.await?;
// SINGLE-USE PIN: we've now sent the host key-confirmation, which lets the client TEST this one
// guess (a right PIN → its proof will match; a wrong PIN → the client detects the mismatch and
// aborts *without* sending its proof). So consume the PIN HERE — before reading the proof —
// regardless of the outcome: an attacker gets EXACTLY ONE online guess (the documented guarantee),
// not an unbounded brute-force of the 4-digit space against a static, never-rotating PIN. A
// malformed request that errored at `pake.finish` above never reached here, so it doesn't burn the
// window (no DoS from garbage). The operator re-arms (web console / restart) for the next device —
// including after a successful pair; the protocol gives no reliable host-observable "wrong PIN"
// signal to scope this to failures only (the client just disconnects).
np.disarm();
let proof = tokio::time::timeout(PAIRING_TIMEOUT, io::read_msg(&mut recv))
.await
.map_err(|_| anyhow!("pairing timed out waiting for the client's confirmation"))??;
@@ -640,6 +651,16 @@ async fn serve_session(
gamepad,
bitrate_kbps,
bit_depth,
// Colour signalling the client configures its decoder/presenter from. A negotiated
// 10-bit session is our HDR path (BT.2020 PQ — what the NVENC HEVC VUI emits from a
// 10-bit capture format); 8-bit stays BT.709 SDR. The mastering metadata (ST.2086 +
// CLL) rides the 0xCE datagram below. (A future step can refine this to the capturer's
// actual monitor HDR state and announce a mid-stream flip.)
color: if bit_depth >= 10 {
ColorInfo::HDR10_BT2020_PQ
} else {
ColorInfo::SDR_BT709
},
};
io::write_msg(&mut send, &welcome.encode()).await?;
@@ -842,6 +863,17 @@ async fn serve_session(
None
};
// HDR static metadata (ST.2086 mastering + CEA-861.3 content light level), host → client, sent
// once at session start when an HDR session was negotiated, as a generic HDR10 baseline. The
// virtual-source stream loop then sends the source display's REAL mastering metadata (Windows
// GetDesc1) as soon as capture starts and re-sends it on keyframes; the client applies the
// latest it receives. This baseline covers the synthetic source and the pre-capture gap.
if welcome.color.is_hdr() {
let meta = crate::hdr::generic_hdr10();
let _ = conn.send_datagram(punktfunk_core::quic::encode_hdr_meta_datagram(&meta).into());
tracing::info!("sent HDR10 static metadata (0xCE; generic baseline)");
}
// Test hook (synthetic source only): a scripted feedback burst on the host→client
// planes — rumble (0xCA) + DualSense HID-output (0xCD) — so loopback tests can assert
// the client's feedback path without a real game writing output reports to a real pad.
@@ -882,6 +914,7 @@ async fn serve_session(
let bit_depth = welcome.bit_depth; // resolved encode bit depth (8, or 10 when negotiated)
let stop_stream = stop.clone();
let fec_target_dp = fec_target.clone(); // data-plane handle to the adaptive-FEC target
let conn_stream = conn.clone(); // for sending the source's real HDR metadata (0xCE) mid-stream
let result: Result<()> = async {
tokio::task::spawn_blocking(move || -> Result<()> {
// Wait briefly for the client to hole-punch our data port, then stream to its OBSERVED
@@ -935,6 +968,7 @@ async fn serve_session(
probe_rx,
probe_result_tx,
fec_target_dp,
conn_stream,
)
}
}
@@ -2041,6 +2075,7 @@ fn virtual_stream(
probe_rx: std::sync::mpsc::Receiver<ProbeRequest>,
probe_result_tx: tokio::sync::mpsc::UnboundedSender<ProbeResult>,
fec_target: Arc<AtomicU8>,
conn: quinn::Connection,
) -> Result<()> {
// This thread runs the capture+encode loop (single-process: Linux / synthetic / NO_WGC DDA) — or
// tail-calls the relay below. Elevate it so a CPU-heavy game can't deschedule our GPU submission.
@@ -2064,6 +2099,7 @@ fn virtual_stream(
probe_rx,
probe_result_tx,
fec_target,
conn,
);
}
tracing::info!(
@@ -2150,6 +2186,8 @@ fn virtual_stream(
let mut cur_mode = mode;
const MAX_CAPTURE_REBUILDS: u32 = 5;
let mut capture_rebuilds: u32 = 0;
// Last HDR mastering metadata we forwarded — re-sent as 0xCE on change/keyframe (see below).
let mut last_hdr_meta: Option<punktfunk_core::quic::HdrMeta> = None;
while !stop.load(Ordering::SeqCst) && std::time::Instant::now() < deadline {
// Mid-stream session switch (the box flipped Gaming↔Desktop): rebuild the WHOLE backend in
// place — a different compositor at the SAME client mode — keeping the Session + send thread
@@ -2285,6 +2323,16 @@ fn virtual_stream(
next = std::time::Instant::now();
}
}
// The source's static HDR mastering metadata (Windows GetDesc1; None on Linux/SDR) is the
// single source of truth: hand it to the encoder (in-band SEI on keyframes) and, when it
// changes, to the client (0xCE). Re-sent on each keyframe below so a dropped best-effort
// datagram converges within a GOP.
let hdr_meta = capturer.hdr_meta();
enc.set_hdr_meta(hdr_meta);
let mut resend_meta = hdr_meta != last_hdr_meta;
if resend_meta {
last_hdr_meta = hdr_meta;
}
let capture_ns = now_ns();
enc.submit(&frame).context("encoder submit")?;
// The deadline for this frame's packets (the next frame's due time); the send thread paces
@@ -2297,6 +2345,15 @@ fn virtual_stream(
} else {
FLAG_PIC as u32
};
// Re-send the HDR mastering metadata (0xCE) on each keyframe (a decoder-resync point) and
// whenever it changed, so a client that dropped the best-effort datagram re-converges.
if let Some(m) = last_hdr_meta {
if au.keyframe || resend_meta {
let _ = conn
.send_datagram(punktfunk_core::quic::encode_hdr_meta_datagram(&m).into());
resend_meta = false;
}
}
let encode_us = (now_ns().saturating_sub(capture_ns) / 1000) as u32;
let msg = FrameMsg {
data: au.data,
@@ -2368,6 +2425,9 @@ fn virtual_stream_relay(
probe_rx: std::sync::mpsc::Receiver<ProbeRequest>,
probe_result_tx: tokio::sync::mpsc::UnboundedSender<ProbeResult>,
fec_target: Arc<AtomicU8>,
// The SYSTEM-host relay path doesn't yet send the source mastering metadata as 0xCE — the
// helper's in-band SEI carries it (Windows follow-up). Held for that future wiring.
_conn: quinn::Connection,
) -> Result<()> {
use crate::capture::dxgi::WinCaptureTarget;
use crate::capture::wgc_relay::HelperRelay;
@@ -3329,15 +3389,7 @@ mod tests {
refresh_hz: 60,
};
// 1: wrong PIN → Crypto, nothing stored.
let err = NativeClient::pair("127.0.0.1", 19778, identity, "0000", "imposter", timeout)
.unwrap_err();
assert!(
matches!(err, punktfunk_core::PunktfunkError::Crypto),
"{err:?}"
);
// 2: anonymous session on a pairing-required host → rejected (connect fails).
// 1: anonymous session on a pairing-required host → rejected (independent of the PIN window).
assert!(
NativeClient::connect(
"127.0.0.1",
@@ -3356,16 +3408,14 @@ mod tests {
"anonymous session must be rejected"
);
// 3: correct PIN → paired, host fingerprint returned. Space past the pairing
// cooldown that the wrong-PIN attempt above just triggered (a real retry is slower).
std::thread::sleep(PAIRING_COOLDOWN + std::time::Duration::from_millis(200));
// 2: correct PIN → paired, host fingerprint returned. The ONE online attempt CONSUMES the
// arming window (single-use), verified by step 4.
let host_fp =
NativeClient::pair("127.0.0.1", 19778, identity, "4321", "test-client", timeout)
.expect("pairing with the right PIN");
assert!(test_paired_path().exists());
let _ = std::fs::remove_file(test_paired_path()); // already loaded; tidy /tmp
// 4: the paired identity gets a session — pinned to the ceremony's fingerprint.
// 3: the paired identity gets a session — pinned to the ceremony's fingerprint.
let client = NativeClient::connect(
"127.0.0.1",
19778,
@@ -3387,6 +3437,17 @@ mod tests {
assert_ne!(client.resolved_gamepad, GamepadPref::Auto);
drop(client);
// 4: SINGLE-USE PIN — the completed ceremony in step 2 consumed the arming window, so a
// second pairing attempt (even with the CORRECT PIN) is now rejected. This is the documented
// "one online guess" guarantee: an attacker can't brute-force the static 4-digit PIN. (The
// operator re-arms via the console / restart for the next device.)
std::thread::sleep(PAIRING_COOLDOWN + std::time::Duration::from_millis(200));
assert!(
NativeClient::pair("127.0.0.1", 19778, identity, "4321", "too-late", timeout).is_err(),
"the PIN window must be single-use (one online guess)"
);
let _ = std::fs::remove_file(test_paired_path()); // tidy /tmp
host.join().unwrap().unwrap();
}
}