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
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:
@@ -133,6 +133,15 @@ pub trait Capturer: Send {
|
||||
/// the default is a no-op (synthetic sources are produced on demand). Set `true` for the
|
||||
/// duration of a stream, `false` when it ends.
|
||||
fn set_active(&self, _active: bool) {}
|
||||
|
||||
/// The source's static HDR mastering metadata (SMPTE ST.2086 + content light level), when the
|
||||
/// capturer can read it from the output (Windows `IDXGIOutput6::GetDesc1`). `None` = unknown /
|
||||
/// SDR / a backend that doesn't expose it (the default — Linux capture has no HDR path yet).
|
||||
/// The stream loop forwards this to the encoder (in-band SEI) and the client (`0xCE` datagram),
|
||||
/// so the two stay a single source of truth. May change mid-session if the source is regraded.
|
||||
fn hdr_meta(&self) -> Option<punktfunk_core::quic::HdrMeta> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// A deterministic moving test pattern (BGRx). Lets the spike exercise the encode → file →
|
||||
|
||||
@@ -41,7 +41,7 @@ use windows::Win32::Graphics::Dxgi::Common::{
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::{
|
||||
CreateDXGIFactory1, IDXGIAdapter1, IDXGIDevice, IDXGIDevice1, IDXGIFactory1, IDXGIOutput1,
|
||||
IDXGIOutput5, IDXGIOutputDuplication, IDXGIResource, DXGI_ERROR_ACCESS_LOST,
|
||||
IDXGIOutput5, IDXGIOutput6, IDXGIOutputDuplication, IDXGIResource, DXGI_ERROR_ACCESS_LOST,
|
||||
DXGI_ERROR_DEVICE_REMOVED, DXGI_ERROR_DEVICE_RESET, DXGI_ERROR_INVALID_CALL,
|
||||
DXGI_ERROR_MODE_CHANGE_IN_PROGRESS, DXGI_ERROR_WAIT_TIMEOUT, DXGI_OUTDUPL_DESC,
|
||||
DXGI_OUTDUPL_FRAME_INFO, DXGI_OUTDUPL_POINTER_SHAPE_INFO,
|
||||
@@ -129,6 +129,33 @@ pub(crate) unsafe fn find_output(gdi_name: &str) -> Result<(IDXGIAdapter1, IDXGI
|
||||
bail!("no DXGI output named {gdi_name} (gone after ACCESS_LOST?)")
|
||||
}
|
||||
|
||||
/// Read the source display's static HDR mastering metadata via `IDXGIOutput6::GetDesc1` (the
|
||||
/// monitor IS the "mastering display" for a desktop capture, exactly as Sunshine/Apollo treat it).
|
||||
/// GetDesc1 exposes the colour primaries, white point, and min/max mastering luminance but NOT a
|
||||
/// content light level, so MaxCLL/MaxFALL are left `0` (unknown — the display tone-maps from the
|
||||
/// mastering luminance). `None` if the output can't be cast to `IDXGIOutput6` or the call fails.
|
||||
unsafe fn read_output_hdr_meta(output: &IDXGIOutput1) -> Option<punktfunk_core::quic::HdrMeta> {
|
||||
let out6: IDXGIOutput6 = output.cast().ok()?;
|
||||
let d = out6.GetDesc1().ok()?;
|
||||
let m = crate::hdr::hdr_meta_from_display(
|
||||
(d.RedPrimary[0], d.RedPrimary[1]),
|
||||
(d.GreenPrimary[0], d.GreenPrimary[1]),
|
||||
(d.BluePrimary[0], d.BluePrimary[1]),
|
||||
(d.WhitePoint[0], d.WhitePoint[1]),
|
||||
d.MaxLuminance,
|
||||
d.MinLuminance,
|
||||
0, // MaxCLL: GetDesc1 has no content light level (Apollo zeroes it)
|
||||
0, // MaxFALL
|
||||
);
|
||||
tracing::info!(
|
||||
max_nits = d.MaxLuminance,
|
||||
min_nits = d.MinLuminance,
|
||||
max_full_frame_nits = d.MaxFullFrameLuminance,
|
||||
"read source display HDR mastering metadata (GetDesc1)"
|
||||
);
|
||||
Some(m)
|
||||
}
|
||||
|
||||
/// Create a fresh D3D11 device + context on a specific adapter (driver_type UNKNOWN with an explicit
|
||||
/// adapter). Used at open and on every ACCESS_LOST: a device created on one desktop cannot sustain a
|
||||
/// duplication on a *different* desktop (perpetual ACCESS_LOST), so the secure-desktop switch needs a
|
||||
@@ -1900,6 +1927,10 @@ pub struct DuplCapturer {
|
||||
/// produce a BT.2020 PQ 10-bit (`R10G10B10A2`) frame for NVENC. Toggling HDR fires ACCESS_LOST →
|
||||
/// `recreate_dupl` re-detects the format, so this tracks the *current* duplication.
|
||||
hdr_fp16: bool,
|
||||
/// The source display's static HDR mastering metadata (ST.2086 + content light level), read from
|
||||
/// `IDXGIOutput6::GetDesc1` whenever the duplication is HDR (`hdr_fp16`). The stream loop forwards
|
||||
/// it to the encoder (in-band SEI) and the client (0xCE). `None` when SDR or the read failed.
|
||||
hdr_meta: Option<punktfunk_core::quic::HdrMeta>,
|
||||
/// FP16 copy of the duplication surface (RT|SRV): the cursor composites onto it and the converter
|
||||
/// samples it. Reallocated on device/size change.
|
||||
fp16_src: Option<ID3D11Texture2D>,
|
||||
@@ -2129,6 +2160,14 @@ impl DuplCapturer {
|
||||
let gpu_mode = std::env::var("PUNKTFUNK_ENCODER")
|
||||
.map(|v| matches!(v.to_ascii_lowercase().as_str(), "nvenc" | "hw" | "nvidia"))
|
||||
.unwrap_or(false);
|
||||
// Read the source display's HDR mastering metadata while we still hold `output` (it is
|
||||
// moved into the struct below). Only meaningful for an HDR (FP16) duplication.
|
||||
let is_hdr_init = dd.ModeDesc.Format == DXGI_FORMAT_R16G16B16A16_FLOAT;
|
||||
let hdr_meta_init = if is_hdr_init {
|
||||
read_output_hdr_meta(&output)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
tracing::info!(
|
||||
"DXGI duplication: {}x{}@{} on {} ({}) dxgi_format={} (87=BGRA8 24=R10G10B10A2 10=R16G16B16A16_FLOAT)",
|
||||
width,
|
||||
@@ -2165,7 +2204,8 @@ impl DuplCapturer {
|
||||
gpu_copy: None,
|
||||
last_present: None,
|
||||
want_hdr,
|
||||
hdr_fp16: dd.ModeDesc.Format == DXGI_FORMAT_R16G16B16A16_FLOAT,
|
||||
hdr_fp16: is_hdr_init,
|
||||
hdr_meta: hdr_meta_init,
|
||||
fp16_src: None,
|
||||
fp16_srv: None,
|
||||
hdr10_out: None,
|
||||
@@ -2661,6 +2701,12 @@ impl DuplCapturer {
|
||||
// Re-detect HDR and drop the HDR textures/converter (old device). Toggling HDR on or
|
||||
// off is exactly this path: the duplication comes back as FP16 (HDR) or BGRA8.
|
||||
self.hdr_fp16 = dd.ModeDesc.Format == DXGI_FORMAT_R16G16B16A16_FLOAT;
|
||||
// Re-read the source mastering metadata for the (possibly new) HDR output, or clear it on SDR.
|
||||
self.hdr_meta = if self.hdr_fp16 {
|
||||
read_output_hdr_meta(&self.output)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.fp16_src = None;
|
||||
self.fp16_srv = None;
|
||||
self.hdr10_out = None;
|
||||
@@ -3084,6 +3130,15 @@ fn now_ns() -> u64 {
|
||||
}
|
||||
|
||||
impl Capturer for DuplCapturer {
|
||||
fn hdr_meta(&self) -> Option<punktfunk_core::quic::HdrMeta> {
|
||||
// Only when the duplication is actually HDR (FP16); cleared to None on an SDR rebuild.
|
||||
if self.hdr_fp16 {
|
||||
self.hdr_meta
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn next_frame(&mut self) -> Result<CapturedFrame> {
|
||||
// Generous: a secure-desktop switch can take several seconds to settle (re-resolve + recreate
|
||||
// the duplication up to 12 s). Better a few seconds of frozen-last-frame than dropping the stream.
|
||||
|
||||
@@ -127,6 +127,11 @@ pub struct WgcCapturer {
|
||||
first_frame: bool,
|
||||
|
||||
hdr: bool,
|
||||
/// The source display's static HDR mastering metadata (ST.2086 + content light level), read from
|
||||
/// `IDXGIOutput6::GetDesc1` at open when the output is HDR. Forwarded to the encoder (in-band SEI)
|
||||
/// and the client (0xCE) by the stream loop. `None` when SDR. (The helper relay path also encodes,
|
||||
/// so this is what gives the secure/normal-desktop HDR stream its mastering SEI.)
|
||||
hdr_meta: Option<punktfunk_core::quic::HdrMeta>,
|
||||
hdr_conv: Option<HdrConverter>,
|
||||
fp16_src: Option<ID3D11Texture2D>,
|
||||
fp16_srv: Option<ID3D11ShaderResourceView>,
|
||||
@@ -213,12 +218,31 @@ impl WgcCapturer {
|
||||
let hmonitor = od.Monitor;
|
||||
|
||||
// HDR iff the output's colour space is BT.2020 PQ (G2084) — matches the DDA FP16 detection.
|
||||
let hdr = output
|
||||
// From the same desc, read the source display's mastering metadata (ST.2086) when HDR.
|
||||
let desc1 = output
|
||||
.cast::<IDXGIOutput6>()
|
||||
.ok()
|
||||
.and_then(|o6| o6.GetDesc1().ok())
|
||||
.and_then(|o6| o6.GetDesc1().ok());
|
||||
let hdr = desc1
|
||||
.as_ref()
|
||||
.map(|d1| d1.ColorSpace == DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020)
|
||||
.unwrap_or(false);
|
||||
let hdr_meta = if hdr {
|
||||
desc1.as_ref().map(|d| {
|
||||
crate::hdr::hdr_meta_from_display(
|
||||
(d.RedPrimary[0], d.RedPrimary[1]),
|
||||
(d.GreenPrimary[0], d.GreenPrimary[1]),
|
||||
(d.BluePrimary[0], d.BluePrimary[1]),
|
||||
(d.WhitePoint[0], d.WhitePoint[1]),
|
||||
d.MaxLuminance,
|
||||
d.MinLuminance,
|
||||
0, // MaxCLL: GetDesc1 has no content light level (Apollo zeroes it)
|
||||
0, // MaxFALL
|
||||
)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Wrap our D3D11 device as a WinRT IDirect3DDevice so the frame pool allocates on it (the
|
||||
// pool textures land on our device → CopyResource + NVENC are same-device, no readback).
|
||||
@@ -326,6 +350,7 @@ impl WgcCapturer {
|
||||
timeout_ms,
|
||||
first_frame: true,
|
||||
hdr,
|
||||
hdr_meta,
|
||||
hdr_conv: None,
|
||||
fp16_src: None,
|
||||
fp16_srv: None,
|
||||
@@ -680,6 +705,10 @@ impl WgcCapturer {
|
||||
}
|
||||
|
||||
impl Capturer for WgcCapturer {
|
||||
fn hdr_meta(&self) -> Option<punktfunk_core::quic::HdrMeta> {
|
||||
self.hdr_meta
|
||||
}
|
||||
|
||||
fn next_frame(&mut self) -> Result<CapturedFrame> {
|
||||
let overall = Instant::now() + Duration::from_secs(20);
|
||||
loop {
|
||||
|
||||
@@ -57,6 +57,12 @@ pub trait Encoder: Send {
|
||||
/// Force the next submitted frame to be an IDR keyframe (e.g. after a client
|
||||
/// reference-frame-invalidation request). Default: no-op.
|
||||
fn request_keyframe(&mut self) {}
|
||||
/// Set the source's static HDR mastering metadata (from the capturer). An HDR encoder emits it
|
||||
/// as in-band SEI (`mastering_display_colour_volume` + `content_light_level_info`) on each
|
||||
/// keyframe so any decoder — including stock Moonlight — tone-maps from the source's real grade.
|
||||
/// Default: no-op (SDR encoders / libavcodec paths that don't attach it yet). Cheap to call
|
||||
/// every frame; only the direct-NVENC path consumes it.
|
||||
fn set_hdr_meta(&mut self, _meta: Option<punktfunk_core::quic::HdrMeta>) {}
|
||||
/// Invalidate a contiguous range of previously-encoded reference frames (client frame numbers,
|
||||
/// as reported in a loss-recovery request) so the encoder re-references an older still-valid
|
||||
/// frame instead of emitting a full IDR. Returns `true` if a real reference invalidation was
|
||||
|
||||
@@ -58,6 +58,11 @@ pub struct NvencD3d11Encoder {
|
||||
/// `ABGR10` input format + the BT.2020/PQ colour VUI. Derived per-frame from the capture format
|
||||
/// (HDR can toggle mid-session); a change re-inits the session.
|
||||
hdr: bool,
|
||||
/// The source's static HDR mastering metadata (from the capturer's `GetDesc1`), emitted as
|
||||
/// in-band SEI (`mastering_display_colour_volume` + `content_light_level_info`) on each keyframe
|
||||
/// when `hdr`. `None` = unknown → no SEI (the VUI still signals BT.2020 PQ). Set per-frame via
|
||||
/// [`Encoder::set_hdr_meta`], so a mid-session regrade is picked up on the next keyframe.
|
||||
hdr_meta: Option<punktfunk_core::quic::HdrMeta>,
|
||||
/// Registrations of the capturer's input textures, cached by texture raw pointer — NVENC encodes
|
||||
/// them in place (no per-frame copy). The cloned `ID3D11Texture2D` keeps each alive until we
|
||||
/// unregister it (the capturer may drop its copy on a device recreate before our teardown runs).
|
||||
@@ -107,6 +112,7 @@ impl NvencD3d11Encoder {
|
||||
buffer_fmt: nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB,
|
||||
bit_depth,
|
||||
hdr: false,
|
||||
hdr_meta: None,
|
||||
regs: HashMap::new(),
|
||||
next: 0,
|
||||
bitstreams: Vec::new(),
|
||||
@@ -303,16 +309,48 @@ impl NvencD3d11Encoder {
|
||||
cfg.encodeCodecConfig.hevcConfig.set_pixelBitDepthMinus8(2); // 10 - 8
|
||||
}
|
||||
|
||||
// HDR colour signaling: BT.2020 primaries + SMPTE ST 2084 (PQ) in the HEVC VUI.
|
||||
// HDR colour signaling: BT.2020 primaries + SMPTE ST.2084 (PQ) transfer + BT.2020-NCL
|
||||
// matrix, limited (studio) range — NVENC's RGB→YUV default. HEVC/H.264 carry it in the VUI;
|
||||
// AV1 has NO VUI, so the SAME CICP code points go in the sequence-header colour config
|
||||
// (`colorPrimaries`/`transferCharacteristics`/`matrixCoefficients`/`colorRange`). Without
|
||||
// this a non-HEVC decoder assumes BT.709 SDR → washed-out / colour-shifted HDR.
|
||||
//
|
||||
// This is the per-stream colour *description* only. The static mastering-display (ST.2086)
|
||||
// and content-light (MaxCLL/MaxFALL) metadata — HEVC SEI / AV1 METADATA OBUs — is a
|
||||
// separate follow-up, as is wiring AV1/H.264 to a true 10-bit (Main10) encode (only HEVC
|
||||
// sets Main10 above today).
|
||||
if self.hdr {
|
||||
let vui = &mut cfg.encodeCodecConfig.hevcConfig.hevcVUIParameters;
|
||||
vui.videoSignalTypePresentFlag = 1;
|
||||
vui.videoFullRangeFlag = 0; // limited (studio) range — NVENC RGB→YUV default
|
||||
vui.colourDescriptionPresentFlag = 1;
|
||||
vui.colourPrimaries = nv::NV_ENC_VUI_COLOR_PRIMARIES::NV_ENC_VUI_COLOR_PRIMARIES_BT2020;
|
||||
vui.transferCharacteristics =
|
||||
let prim = nv::NV_ENC_VUI_COLOR_PRIMARIES::NV_ENC_VUI_COLOR_PRIMARIES_BT2020;
|
||||
let trc =
|
||||
nv::NV_ENC_VUI_TRANSFER_CHARACTERISTIC::NV_ENC_VUI_TRANSFER_CHARACTERISTIC_SMPTE2084;
|
||||
vui.colourMatrix = nv::NV_ENC_VUI_MATRIX_COEFFS::NV_ENC_VUI_MATRIX_COEFFS_BT2020_NCL;
|
||||
let mat = nv::NV_ENC_VUI_MATRIX_COEFFS::NV_ENC_VUI_MATRIX_COEFFS_BT2020_NCL;
|
||||
match self.codec {
|
||||
Codec::H265 => {
|
||||
let vui = &mut cfg.encodeCodecConfig.hevcConfig.hevcVUIParameters;
|
||||
vui.videoSignalTypePresentFlag = 1;
|
||||
vui.videoFullRangeFlag = 0;
|
||||
vui.colourDescriptionPresentFlag = 1;
|
||||
vui.colourPrimaries = prim;
|
||||
vui.transferCharacteristics = trc;
|
||||
vui.colourMatrix = mat;
|
||||
}
|
||||
Codec::H264 => {
|
||||
let vui = &mut cfg.encodeCodecConfig.h264Config.h264VUIParameters;
|
||||
vui.videoSignalTypePresentFlag = 1;
|
||||
vui.videoFullRangeFlag = 0;
|
||||
vui.colourDescriptionPresentFlag = 1;
|
||||
vui.colourPrimaries = prim;
|
||||
vui.transferCharacteristics = trc;
|
||||
vui.colourMatrix = mat;
|
||||
}
|
||||
Codec::Av1 => {
|
||||
let av1 = &mut cfg.encodeCodecConfig.av1Config;
|
||||
av1.colorPrimaries = prim;
|
||||
av1.transferCharacteristics = trc;
|
||||
av1.matrixCoefficients = mat;
|
||||
av1.colorRange = 0; // studio/limited swing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reference-frame invalidation: keep a deeper DPB so an invalidated reference can fall back
|
||||
@@ -636,6 +674,51 @@ impl Encoder for NvencD3d11Encoder {
|
||||
encodePicFlags: flags as u32,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// In-band HDR10 SEI on every IDR (a forced keyframe, or the first frame NVENC opens with):
|
||||
// `mastering_display_colour_volume` (ST.2086) + `content_light_level_info` (CEA-861.3),
|
||||
// built from the source display's metadata. Any decoder — incl. stock Moonlight — then
|
||||
// tone-maps from the real grade. HEVC/H.264 carry SEI; AV1 uses metadata OBUs (follow-up).
|
||||
// The scratch buffers must outlive `encode_picture`, so they live in this scope.
|
||||
let is_idr = flags != 0 || pts == 0;
|
||||
let mastering_sei = self
|
||||
.hdr_meta
|
||||
.map(|m| crate::hdr::hevc_mastering_display_sei(&m));
|
||||
let cll_sei = self
|
||||
.hdr_meta
|
||||
.map(|m| crate::hdr::hevc_content_light_level_sei(&m));
|
||||
let mut sei: Vec<nv::NV_ENC_SEI_PAYLOAD> = Vec::new();
|
||||
if is_idr && self.hdr {
|
||||
if let Some(p) = mastering_sei.as_ref() {
|
||||
sei.push(nv::NV_ENC_SEI_PAYLOAD {
|
||||
payloadSize: p.len() as u32,
|
||||
payloadType: crate::hdr::SEI_TYPE_MASTERING_DISPLAY_COLOUR_VOLUME,
|
||||
payload: p.as_ptr() as *mut u8,
|
||||
});
|
||||
}
|
||||
if let Some(p) = cll_sei.as_ref() {
|
||||
sei.push(nv::NV_ENC_SEI_PAYLOAD {
|
||||
payloadSize: p.len() as u32,
|
||||
payloadType: crate::hdr::SEI_TYPE_CONTENT_LIGHT_LEVEL_INFO,
|
||||
payload: p.as_ptr() as *mut u8,
|
||||
});
|
||||
}
|
||||
}
|
||||
if !sei.is_empty() {
|
||||
// Writing a union field is safe; the pointers/len are read during encode_picture.
|
||||
match self.codec {
|
||||
Codec::H265 => {
|
||||
pic.codecPicParams.hevcPicParams.seiPayloadArray = sei.as_mut_ptr();
|
||||
pic.codecPicParams.hevcPicParams.seiPayloadArrayCnt = sei.len() as u32;
|
||||
}
|
||||
Codec::H264 => {
|
||||
pic.codecPicParams.h264PicParams.seiPayloadArray = sei.as_mut_ptr();
|
||||
pic.codecPicParams.h264PicParams.seiPayloadArrayCnt = sei.len() as u32;
|
||||
}
|
||||
// AV1 mastering/CLL ride METADATA OBUs, not SEI — separate follow-up.
|
||||
Codec::Av1 => {}
|
||||
}
|
||||
}
|
||||
(API.encode_picture)(self.encoder, &mut pic)
|
||||
.result_without_string()
|
||||
.map_err(|e| anyhow!("encode_picture: {e:?}"))?;
|
||||
@@ -649,6 +732,12 @@ impl Encoder for NvencD3d11Encoder {
|
||||
self.force_kf = true;
|
||||
}
|
||||
|
||||
fn set_hdr_meta(&mut self, meta: Option<punktfunk_core::quic::HdrMeta>) {
|
||||
// Stored and emitted as in-band SEI on the next keyframe (see `submit`). Cheap to call every
|
||||
// frame; only changes when the source is regraded or HDR toggles.
|
||||
self.hdr_meta = meta;
|
||||
}
|
||||
|
||||
fn invalidate_ref_frames(&mut self, first: i64, last: i64) -> bool {
|
||||
// No live session, the GPU can't invalidate, or a nonsense range → caller forces a full IDR.
|
||||
// (NVENC handles are single-threaded; this runs on the encode thread, like submit/poll.)
|
||||
|
||||
@@ -33,12 +33,16 @@ impl ServerIdentity {
|
||||
(Ok(c), Ok(k)) if !c.trim().is_empty() && !k.trim().is_empty() => (c, k),
|
||||
_ => {
|
||||
let (c, k) = generate()?;
|
||||
fs::create_dir_all(&dir).ok();
|
||||
fs::write(&cert_path, &c)
|
||||
.with_context(|| format!("write {}", cert_path.display()))?;
|
||||
fs::write(&key_path, &k)
|
||||
// The private key is the trust root for EVERY surface (TLS server cert, pairing
|
||||
// signing, the QUIC identity clients pin) — write it owner-only (0600 / SYSTEM-only
|
||||
// DACL) so a local user can't read it and impersonate the host. The dir is 0700.
|
||||
super::create_private_dir(&dir).ok();
|
||||
super::write_secret_file(&key_path, k.as_bytes())
|
||||
.with_context(|| format!("write {}", key_path.display()))?;
|
||||
tracing::info!(path = %cert_path.display(), "generated punktfunk host certificate (RSA-2048)");
|
||||
// The cert is public (handed to clients), but write it owner-only too for consistency.
|
||||
super::write_secret_file(&cert_path, c.as_bytes())
|
||||
.with_context(|| format!("write {}", cert_path.display()))?;
|
||||
tracing::info!(path = %cert_path.display(), "generated punktfunk host certificate (RSA-2048, key 0600)");
|
||||
(c, k)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -232,6 +232,91 @@ pub(crate) fn config_dir() -> PathBuf {
|
||||
base.join("punktfunk")
|
||||
}
|
||||
|
||||
/// Create `dir` (and parents) owner-private — **0700** on Unix (so the host's secrets aren't readable
|
||||
/// by other local users via a traversable config path). Best-effort on Windows: the dir inherits the
|
||||
/// (Users-readable) `%ProgramData%` ACL, so secret *files* are individually locked down by
|
||||
/// [`write_secret_file`]. Tightens an already-existing dir too.
|
||||
pub(crate) fn create_private_dir(dir: &std::path::Path) -> std::io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
|
||||
let r = std::fs::DirBuilder::new()
|
||||
.recursive(true)
|
||||
.mode(0o700)
|
||||
.create(dir);
|
||||
// `recursive` doesn't re-chmod an existing dir — tighten it so an old 0755 dir gets locked.
|
||||
if dir.exists() {
|
||||
let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
|
||||
}
|
||||
r
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
std::fs::create_dir_all(dir)
|
||||
}
|
||||
}
|
||||
|
||||
/// Write `contents` to `path` as an **owner-only secret**: created and re-chmod'd **0600** on Unix
|
||||
/// (never even briefly group/world-readable), and DACL-restricted to SYSTEM/Administrators/owner on
|
||||
/// Windows (the default `%ProgramData%` ACL is Users-readable). Mirrors the mgmt-token hardening; used
|
||||
/// for the host private key and the persisted trust stores so a local unprivileged user can neither
|
||||
/// read the key (impersonation) nor tamper with the paired allow-list (unauthorized pairing).
|
||||
pub(crate) fn write_secret_file(path: &std::path::Path, contents: &[u8]) -> std::io::Result<()> {
|
||||
use std::io::Write;
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.write(true).create(true).truncate(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
opts.mode(0o600);
|
||||
}
|
||||
let mut f = opts.open(path)?;
|
||||
f.write_all(contents)?;
|
||||
f.flush()?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
#[cfg(windows)]
|
||||
restrict_to_system_admins(path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Best-effort Windows DACL lockdown of a secret file: strip inherited ACEs and grant Full only to
|
||||
/// SYSTEM, Administrators, and OWNER RIGHTS (the creating account — the SYSTEM service or a manually
|
||||
/// running user keeps access). Without this the host key under the default Users-readable
|
||||
/// `%ProgramData%` ACL is readable by ANY local user. Uses `icacls` with hard-coded SIDs
|
||||
/// (locale-independent) via the absolute `%SystemRoot%` path (a privileged service must not trust
|
||||
/// `PATH`). Never fatal — on failure the file is simply left at the inherited ACL (today's behaviour).
|
||||
#[cfg(windows)]
|
||||
fn restrict_to_system_admins(path: &std::path::Path) {
|
||||
let icacls = std::env::var("SystemRoot")
|
||||
.map(|r| format!("{r}\\System32\\icacls.exe"))
|
||||
.unwrap_or_else(|_| "icacls".to_string());
|
||||
let status = std::process::Command::new(icacls)
|
||||
.arg(path.as_os_str())
|
||||
.args([
|
||||
"/inheritance:r",
|
||||
"/grant:r",
|
||||
"*S-1-5-18:(F)", // NT AUTHORITY\SYSTEM
|
||||
"/grant:r",
|
||||
"*S-1-5-32-544:(F)", // BUILTIN\Administrators
|
||||
"/grant:r",
|
||||
"*S-1-3-4:(F)", // OWNER RIGHTS
|
||||
])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status();
|
||||
match status {
|
||||
Ok(s) if s.success() => {}
|
||||
_ => tracing::warn!(
|
||||
path = %path.display(),
|
||||
"icacls hardening did not succeed — this secret may be readable by other local users"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn hostname_string() -> String {
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Some(n) = std::env::var_os("COMPUTERNAME") {
|
||||
@@ -304,7 +389,7 @@ fn load_paired() -> Vec<Vec<u8>> {
|
||||
pub(crate) fn save_paired(paired: &[Vec<u8>]) {
|
||||
let Some(path) = paired_path() else { return };
|
||||
if let Some(dir) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
let _ = create_private_dir(dir);
|
||||
}
|
||||
let bytes = match serde_json::to_vec(paired) {
|
||||
Ok(b) => b,
|
||||
@@ -313,10 +398,10 @@ pub(crate) fn save_paired(paired: &[Vec<u8>]) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
// Write to a sibling temp file, then rename over the target (atomic replace on Unix and
|
||||
// Windows). Never write `path` in place.
|
||||
// Write to a sibling temp file (owner-only, so a local user can't tamper the allow-list), then
|
||||
// rename over the target (atomic replace on Unix and Windows). Never write `path` in place.
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
if let Err(e) = std::fs::write(&tmp, &bytes) {
|
||||
if let Err(e) = write_secret_file(&tmp, &bytes) {
|
||||
tracing::warn!(error = %e, "persisting pairings failed (temp write)");
|
||||
return;
|
||||
}
|
||||
@@ -325,3 +410,29 @@ pub(crate) fn save_paired(paired: &[Vec<u8>]) {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::{create_private_dir, write_secret_file};
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
#[test]
|
||||
fn secrets_are_written_owner_only() {
|
||||
let dir = std::env::temp_dir().join(format!("pf-secret-test-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
create_private_dir(&dir).expect("create private dir");
|
||||
let dmode = std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(dmode, 0o700, "config dir must be owner-only (0700)");
|
||||
|
||||
let key = dir.join("key.pem");
|
||||
write_secret_file(&key, b"-----BEGIN PRIVATE KEY-----\n...").expect("write secret");
|
||||
let fmode = std::fs::metadata(&key).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(fmode, 0o600, "private key must be owner-only (0600)");
|
||||
|
||||
// Overwriting an existing secret keeps it 0600 (the truncate+reopen path).
|
||||
write_secret_file(&key, b"new contents").expect("rewrite secret");
|
||||
let fmode = std::fs::metadata(&key).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(fmode, 0o600);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -55,7 +55,12 @@ impl VideoPacketizer {
|
||||
pub fn new(packet_size: usize, fec_percentage: u8, min_fec: u8) -> Self {
|
||||
VideoPacketizer {
|
||||
packet_size,
|
||||
payload_per_shard: packet_size + 16 - SHARD_HEADER,
|
||||
// Defense in depth: `pps` is a divisor in `packetize` (`% pps`, `div_ceil(pps)`), so it
|
||||
// must never be 0. `blocksize = packet_size + 16`; a tiny attacker-supplied packet_size
|
||||
// (≤ SHARD_HEADER-16 = 16) would otherwise underflow (panic) or yield pps==0 (div-by-zero).
|
||||
// `stream_config` already rejects out-of-range packetSize; this saturating `.max(1)` makes
|
||||
// a degenerate value structurally unable to panic, without affecting any valid size.
|
||||
payload_per_shard: (packet_size + 16).saturating_sub(SHARD_HEADER).max(1),
|
||||
fec_percentage: fec_percentage as usize,
|
||||
min_fec: min_fec as usize,
|
||||
frame_index: 0,
|
||||
@@ -252,6 +257,18 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degenerate_packet_size_does_not_panic() {
|
||||
// A pre-auth attacker drives packetSize via the RTSP ANNOUNCE. `stream_config` rejects
|
||||
// out-of-range values, but the packetizer must ALSO never panic (div-by-zero on `% pps` /
|
||||
// `div_ceil(pps)`, or usize underflow) for ANY input — pps is clamped to >= 1.
|
||||
for ps in [0usize, 15, 16, 17, 32] {
|
||||
let mut pk = VideoPacketizer::new(ps, 20, 2);
|
||||
assert!(pk.payload_per_shard >= 1, "pps must never be 0 (ps={ps})");
|
||||
let _ = pk.packetize(&[0xCDu8; 200], FrameType::Idr, 0); // must not panic
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_block_split() {
|
||||
let mut pk = VideoPacketizer::new(1392, 0, 0); // data-only
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
//! Pure HDR static-metadata helpers shared by the capture (source mastering metadata) and encode
|
||||
//! (in-band SEI) paths — kept platform-independent and unit-tested here so the byte-level logic is
|
||||
//! verified on every target, even though the only *callers* of the SEI builders are the Windows
|
||||
//! NVENC path (`encode/nvenc.rs`) and of the display conversion the Windows DXGI/WGC capturers.
|
||||
//!
|
||||
//! Units follow the HDR10 standards so the values pass straight through:
|
||||
//! - chromaticities in 1/50000 increments (SMPTE ST.2086 / DXGI `DXGI_HDR_METADATA_HDR10`),
|
||||
//! - mastering luminance in 0.0001 cd/m²,
|
||||
//! - content light level (MaxCLL/MaxFALL) in cd/m² (nits).
|
||||
|
||||
use punktfunk_core::quic::HdrMeta;
|
||||
|
||||
/// HEVC/H.264 SEI payload type for `mastering_display_colour_volume` (SMPTE ST.2086). Same code
|
||||
/// point in AVC and HEVC.
|
||||
pub const SEI_TYPE_MASTERING_DISPLAY_COLOUR_VOLUME: u32 = 137;
|
||||
/// HEVC/H.264 SEI payload type for `content_light_level_info` (CEA-861.3 MaxCLL/MaxFALL).
|
||||
pub const SEI_TYPE_CONTENT_LIGHT_LEVEL_INFO: u32 = 144;
|
||||
|
||||
/// Quantize a CIE xy chromaticity coordinate (0.0..=1.0) to ST.2086 1/50000 units.
|
||||
fn xy_to_2086(v: f32) -> u16 {
|
||||
(v * 50000.0).round().clamp(0.0, 65535.0) as u16
|
||||
}
|
||||
|
||||
/// Build an [`HdrMeta`] from a source display's measured colour volume — the chromaticities (CIE xy)
|
||||
/// and luminances (cd/m²) reported by e.g. Windows `IDXGIOutput6::GetDesc1`. `max_cll`/`max_fall`
|
||||
/// are content light levels in nits; pass `0` when unknown (GetDesc1 doesn't expose them — Apollo
|
||||
/// zeroes them too, and a `0` lets the display fall back to the mastering luminance).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn hdr_meta_from_display(
|
||||
red: (f32, f32),
|
||||
green: (f32, f32),
|
||||
blue: (f32, f32),
|
||||
white: (f32, f32),
|
||||
max_mastering_nits: f32,
|
||||
min_mastering_nits: f32,
|
||||
max_cll: u16,
|
||||
max_fall: u16,
|
||||
) -> HdrMeta {
|
||||
HdrMeta {
|
||||
// ST.2086 stores primaries in G, B, R order.
|
||||
display_primaries: [
|
||||
[xy_to_2086(green.0), xy_to_2086(green.1)],
|
||||
[xy_to_2086(blue.0), xy_to_2086(blue.1)],
|
||||
[xy_to_2086(red.0), xy_to_2086(red.1)],
|
||||
],
|
||||
white_point: [xy_to_2086(white.0), xy_to_2086(white.1)],
|
||||
max_display_mastering_luminance: (max_mastering_nits.max(0.0) * 10_000.0).round() as u32,
|
||||
min_display_mastering_luminance: (min_mastering_nits.max(0.0) * 10_000.0).round() as u32,
|
||||
max_cll,
|
||||
max_fall,
|
||||
}
|
||||
}
|
||||
|
||||
/// A generic HDR10 default (BT.2020 primaries, D65 white, 1000-nit mastering, MaxCLL 1000 /
|
||||
/// MaxFALL 400) — the baseline a host sends until it reads the source display's real mastering
|
||||
/// metadata, and the values clients used to hardcode.
|
||||
pub fn generic_hdr10() -> HdrMeta {
|
||||
HdrMeta {
|
||||
display_primaries: [[8500, 39850], [6550, 2300], [35400, 14600]], // BT.2020 G, B, R
|
||||
white_point: [15635, 16450], // D65
|
||||
max_display_mastering_luminance: 10_000_000, // 1000 nits
|
||||
min_display_mastering_luminance: 1, // 0.0001 nits
|
||||
max_cll: 1000,
|
||||
max_fall: 400,
|
||||
}
|
||||
}
|
||||
|
||||
/// The `mastering_display_colour_volume` SEI payload (HEVC/H.264 type
|
||||
/// [`SEI_TYPE_MASTERING_DISPLAY_COLOUR_VOLUME`]) — 24 bytes, big-endian (SEI RBSP order), in G/B/R
|
||||
/// primary order per ST.2086. Pass this raw payload to NVENC's `NV_ENC_SEI_PAYLOAD` (NVENC wraps it
|
||||
/// in the SEI NAL).
|
||||
pub fn hevc_mastering_display_sei(m: &HdrMeta) -> [u8; 24] {
|
||||
let mut b = [0u8; 24];
|
||||
let mut o = 0;
|
||||
let mut put16 = |v: u16| {
|
||||
b[o..o + 2].copy_from_slice(&v.to_be_bytes());
|
||||
o += 2;
|
||||
};
|
||||
for p in m.display_primaries.iter() {
|
||||
put16(p[0]);
|
||||
put16(p[1]);
|
||||
}
|
||||
put16(m.white_point[0]);
|
||||
put16(m.white_point[1]);
|
||||
let mut put32 = |v: u32| {
|
||||
b[o..o + 4].copy_from_slice(&v.to_be_bytes());
|
||||
o += 4;
|
||||
};
|
||||
put32(m.max_display_mastering_luminance);
|
||||
put32(m.min_display_mastering_luminance);
|
||||
debug_assert_eq!(o, 24);
|
||||
b
|
||||
}
|
||||
|
||||
/// The `content_light_level_info` SEI payload (HEVC/H.264 type
|
||||
/// [`SEI_TYPE_CONTENT_LIGHT_LEVEL_INFO`]) — 4 bytes, big-endian: MaxCLL then MaxFALL.
|
||||
pub fn hevc_content_light_level_sei(m: &HdrMeta) -> [u8; 4] {
|
||||
let mut b = [0u8; 4];
|
||||
b[0..2].copy_from_slice(&m.max_cll.to_be_bytes());
|
||||
b[2..4].copy_from_slice(&m.max_fall.to_be_bytes());
|
||||
b
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn display_conversion_bt2020_1000nit() {
|
||||
// BT.2020 primaries + D65 white, a 1000-nit / 0.0001-nit mastering display.
|
||||
let m = hdr_meta_from_display(
|
||||
(0.708, 0.292), // red
|
||||
(0.170, 0.797), // green
|
||||
(0.131, 0.046), // blue
|
||||
(0.3127, 0.3290), // D65
|
||||
1000.0,
|
||||
0.0001,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
// ST.2086 G, B, R order, 1/50000 units.
|
||||
assert_eq!(
|
||||
m.display_primaries,
|
||||
[[8500, 39850], [6550, 2300], [35400, 14600]]
|
||||
);
|
||||
assert_eq!(m.white_point, [15635, 16450]);
|
||||
assert_eq!(m.max_display_mastering_luminance, 10_000_000); // 1000 * 10000
|
||||
assert_eq!(m.min_display_mastering_luminance, 1); // 0.0001 * 10000
|
||||
assert_eq!((m.max_cll, m.max_fall), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mastering_sei_is_24_bytes_big_endian_gbr() {
|
||||
let m = generic_hdr10();
|
||||
let p = hevc_mastering_display_sei(&m);
|
||||
assert_eq!(p.len(), 24);
|
||||
// First field = green.x = 8500 = 0x2134, big-endian.
|
||||
assert_eq!(&p[0..2], &8500u16.to_be_bytes());
|
||||
assert_eq!(&p[2..4], &39850u16.to_be_bytes()); // green.y
|
||||
assert_eq!(&p[4..6], &6550u16.to_be_bytes()); // blue.x
|
||||
assert_eq!(&p[12..14], &15635u16.to_be_bytes()); // white.x
|
||||
assert_eq!(&p[16..20], &10_000_000u32.to_be_bytes()); // max lum
|
||||
assert_eq!(&p[20..24], &1u32.to_be_bytes()); // min lum
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cll_sei_is_4_bytes_big_endian() {
|
||||
let m = generic_hdr10();
|
||||
let p = hevc_content_light_level_sei(&m);
|
||||
assert_eq!(p, [0x03, 0xE8, 0x01, 0x90]); // 1000, 400 big-endian
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamps_out_of_range() {
|
||||
let m = hdr_meta_from_display(
|
||||
(2.0, 2.0),
|
||||
(0.0, 0.0),
|
||||
(0.0, 0.0),
|
||||
(0.5, 0.5),
|
||||
-5.0,
|
||||
0.0,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
assert_eq!(m.display_primaries[2], [65535, 65535]); // red clamped
|
||||
assert_eq!(m.max_display_mastering_luminance, 0); // negative → 0
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ mod dmabuf_fence;
|
||||
mod drm_sync;
|
||||
mod encode;
|
||||
mod gamestream;
|
||||
mod hdr;
|
||||
mod inject;
|
||||
mod library;
|
||||
mod mgmt;
|
||||
|
||||
@@ -115,12 +115,13 @@ fn load(path: &std::path::Path) -> PairedClients {
|
||||
|
||||
fn save(state: &PairedState) -> Result<()> {
|
||||
if let Some(dir) = state.path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
crate::gamestream::create_private_dir(dir)?;
|
||||
}
|
||||
// Atomic replace: a crash/full-disk mid-write must not truncate the trust store (which would
|
||||
// silently lock out every paired client on a --require-pairing host). Temp + rename.
|
||||
// silently lock out every paired client on a --require-pairing host). Temp + rename. The temp is
|
||||
// written owner-only so a local user can't inject a fingerprint to pair themselves.
|
||||
let tmp = state.path.with_extension("json.tmp");
|
||||
std::fs::write(&tmp, serde_json::to_vec_pretty(&state.clients)?)?;
|
||||
crate::gamestream::write_secret_file(&tmp, &serde_json::to_vec_pretty(&state.clients)?)?;
|
||||
std::fs::rename(&tmp, &state.path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user