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
@@ -272,7 +272,20 @@ fn stream_config(map: &HashMap<String, String>) -> Option<StreamConfig> {
let parse_u = |k: &str| map.get(k).and_then(|s| s.trim().parse::<u32>().ok());
let width = parse_u("x-nv-video[0].clientViewportWd")?;
let height = parse_u("x-nv-video[0].clientViewportHt")?;
// packetSize is attacker-controlled and PRE-AUTH (the RTSP listener is unauthenticated). It sets
// the per-shard payload (`packet_size - 16`); a tiny value underflows / div-by-zeros the video
// thread, an absurd one amplifies per-shard allocation. Reject anything outside a sane range
// (real Moonlight uses ~1024) so a malformed ANNOUNCE fails here instead of panicking the stream.
const PACKET_SIZE_MIN: usize = 64;
const PACKET_SIZE_MAX: usize = 2048;
let packet_size = parse_u("x-nv-video[0].packetSize")? as usize;
if !(PACKET_SIZE_MIN..=PACKET_SIZE_MAX).contains(&packet_size) {
tracing::warn!(
packet_size,
"RTSP ANNOUNCE: out-of-range packetSize — rejecting"
);
return None;
}
let fps = parse_u("x-nv-video[0].maxFPS")
.filter(|&f| f > 0)
.unwrap_or(60);
@@ -424,6 +437,27 @@ mod tests {
assert!(stream_config(&map).is_none());
}
/// packetSize is attacker-controlled AND pre-auth (the RTSP listener is unauthenticated), so an
/// out-of-range value must be rejected here rather than panic the video thread (≤16 → div-by-zero
/// / underflow; absurd → allocation amplification). Sane values (real Moonlight ~1024) pass.
#[test]
fn announce_rejects_out_of_range_packet_size() {
for bad in ["0", "16", "63", "4096", "999999"] {
let map = announce(&[("x-nv-video[0].packetSize", bad)]);
assert!(
stream_config(&map).is_none(),
"out-of-range packetSize {bad} must be rejected"
);
}
for ok in ["64", "1024", "1392", "2048"] {
let map = announce(&[("x-nv-video[0].packetSize", ok)]);
assert!(
stream_config(&map).is_some(),
"in-range packetSize {ok} must be accepted"
);
}
}
/// Audio negotiation: numChannels/AudioQuality/packetDuration, with Moonlight defaults.
#[test]
fn announce_audio_params() {