Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c55ec37fa | |||
| 551012bb43 |
@@ -140,11 +140,15 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
status = "Connecting to $targetHost:$targetPort…"
|
status = "Connecting to $targetHost:$targetPort…"
|
||||||
discovery.stop() // free the Wi-Fi radio before the stream session
|
discovery.stop() // free the Wi-Fi radio before the stream session
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
// Advertise HDR only when this device's display can present it (else the host sends a
|
||||||
|
// proper SDR stream rather than PQ the panel would mis-tone-map).
|
||||||
|
val hdrEnabled = displaySupportsHdr(context)
|
||||||
val handle = withContext(Dispatchers.IO) {
|
val handle = withContext(Dispatchers.IO) {
|
||||||
NativeBridge.nativeConnect(
|
NativeBridge.nativeConnect(
|
||||||
targetHost, targetPort, w, h, hz,
|
targetHost, targetPort, w, h, hz,
|
||||||
id.certPem, id.privateKeyPem, pinHex ?: "",
|
id.certPem, id.privateKeyPem, pinHex ?: "",
|
||||||
settings.bitrateKbps, settings.compositor, settings.gamepad,
|
settings.bitrateKbps, settings.compositor, settings.gamepad,
|
||||||
|
hdrEnabled,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
connecting = false
|
connecting = false
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package io.unom.punktfunk
|
package io.unom.punktfunk
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.view.Display
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User-tunable stream settings, persisted in `SharedPreferences`. A `0` resolution/refresh means
|
* User-tunable stream settings, persisted in `SharedPreferences`. A `0` resolution/refresh means
|
||||||
@@ -76,6 +77,21 @@ fun nativeDisplayMode(context: Context): Triple<Int, Int, Int> {
|
|||||||
return Triple(maxOf(w, h), minOf(w, h), hz)
|
return Triple(maxOf(w, h), minOf(w, h), hz)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when this device's display can actually present HDR10, so we should advertise HDR to the
|
||||||
|
* host. On an SDR panel we advertise `0` instead — the host then sends a proper 8-bit BT.709 stream
|
||||||
|
* rather than BT.2020 PQ the panel would mis-tone-map (the washed-out/dark failure). Mirrors the
|
||||||
|
* capability gate the Apple/Windows clients apply.
|
||||||
|
*/
|
||||||
|
fun displaySupportsHdr(context: Context): Boolean {
|
||||||
|
val display = runCatching { context.display }.getOrNull() ?: return false
|
||||||
|
@Suppress("DEPRECATION") // hdrCapabilities is the supported query on minSdk 31
|
||||||
|
val caps = display.hdrCapabilities ?: return false
|
||||||
|
return caps.supportedHdrTypes.any {
|
||||||
|
it == Display.HdrCapabilities.HDR_TYPE_HDR10 || it == Display.HdrCapabilities.HDR_TYPE_HDR10_PLUS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Resolve [Settings] (with its 0=native placeholders) to the concrete mode to request. */
|
/** Resolve [Settings] (with its 0=native placeholders) to the concrete mode to request. */
|
||||||
fun Settings.effectiveMode(context: Context): Triple<Int, Int, Int> {
|
fun Settings.effectiveMode(context: Context): Triple<Int, Int, Int> {
|
||||||
val native = nativeDisplayMode(context)
|
val native = nativeDisplayMode(context)
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ object NativeBridge {
|
|||||||
bitrateKbps: Int,
|
bitrateKbps: Int,
|
||||||
compositorPref: Int,
|
compositorPref: Int,
|
||||||
gamepadPref: Int,
|
gamepadPref: Int,
|
||||||
|
hdrEnabled: Boolean,
|
||||||
): Long
|
): Long
|
||||||
|
|
||||||
/** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */
|
/** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */
|
||||||
|
|||||||
@@ -289,10 +289,18 @@ fn android_hdr_static_info(m: &punktfunk_core::quic::HdrMeta) -> [u8; 25] {
|
|||||||
let max_nits = (m.max_display_mastering_luminance / 10_000).min(u16::MAX as u32) as u16;
|
let max_nits = (m.max_display_mastering_luminance / 10_000).min(u16::MAX as u32) as u16;
|
||||||
let min_units = m.min_display_mastering_luminance.min(u16::MAX as u32) as u16;
|
let min_units = m.min_display_mastering_luminance.min(u16::MAX as u32) as u16;
|
||||||
let fields: [u16; 12] = [
|
let fields: [u16; 12] = [
|
||||||
r[0], r[1], g[0], g[1], b_[0], b_[1], // R, G, B primaries
|
r[0],
|
||||||
m.white_point[0], m.white_point[1], // white point
|
r[1],
|
||||||
max_nits, min_units, // max (nits) / min (0.0001-nit) display luminance
|
g[0],
|
||||||
m.max_cll, m.max_fall, // MaxCLL / MaxFALL (nits)
|
g[1],
|
||||||
|
b_[0],
|
||||||
|
b_[1], // R, G, B primaries
|
||||||
|
m.white_point[0],
|
||||||
|
m.white_point[1], // white point
|
||||||
|
max_nits,
|
||||||
|
min_units, // max (nits) / min (0.0001-nit) display luminance
|
||||||
|
m.max_cll,
|
||||||
|
m.max_fall, // MaxCLL / MaxFALL (nits)
|
||||||
];
|
];
|
||||||
let mut out = [0u8; 25]; // out[0] = 0 (Type 1 descriptor id), already zero
|
let mut out = [0u8; 25]; // out[0] = 0 (Type 1 descriptor id), already zero
|
||||||
for (i, v) in fields.iter().enumerate() {
|
for (i, v) in fields.iter().enumerate() {
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
|||||||
bitrate_kbps: jint,
|
bitrate_kbps: jint,
|
||||||
compositor_pref: jint,
|
compositor_pref: jint,
|
||||||
gamepad_pref: jint,
|
gamepad_pref: jint,
|
||||||
|
hdr_enabled: jboolean,
|
||||||
) -> jlong {
|
) -> jlong {
|
||||||
let host: String = match env.get_string(&host) {
|
let host: String = match env.get_string(&host) {
|
||||||
Ok(s) => s.into(),
|
Ok(s) => s.into(),
|
||||||
@@ -184,10 +185,17 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
|||||||
CompositorPref::from_u8(compositor_pref.clamp(0, u8::MAX as jint) as u8),
|
CompositorPref::from_u8(compositor_pref.clamp(0, u8::MAX as jint) as u8),
|
||||||
GamepadPref::from_u8(gamepad_pref.clamp(0, u8::MAX as jint) as u8),
|
GamepadPref::from_u8(gamepad_pref.clamp(0, u8::MAX as jint) as u8),
|
||||||
bitrate_kbps.max(0) as u32, // 0 = host default
|
bitrate_kbps.max(0) as u32, // 0 = host default
|
||||||
// Advertise 10-bit + HDR: the host (e.g. Windows) only upgrades to a Main10 / BT.2020 PQ
|
// Advertise 10-bit + HDR ONLY when this device's display can actually present it (Kotlin
|
||||||
// encode when the client sets these. AMediaCodec decodes Main10 from the SPS and the decode
|
// checks Display.getHdrCapabilities() and passes the result): the host (e.g. Windows) then
|
||||||
// loop signals the Surface's HDR dataspace from the reported colour (see crate::decode).
|
// upgrades to a Main10 / BT.2020 PQ encode. On an SDR display we advertise 0 so the host
|
||||||
punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR,
|
// sends a proper 8-bit BT.709 stream rather than PQ the panel would mis-tone-map. AMediaCodec
|
||||||
|
// decodes Main10 from the SPS and the decode loop signals the Surface HDR dataspace + static
|
||||||
|
// metadata (see crate::decode).
|
||||||
|
if hdr_enabled != 0 {
|
||||||
|
punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
},
|
||||||
None, // launch: default app
|
None, // launch: default app
|
||||||
pin, // Some → Crypto on host-fp mismatch
|
pin, // Some → Crypto on host-fp mismatch
|
||||||
identity, // owned (cert, key) PEM, or None (anonymous)
|
identity, // owned (cert, key) PEM, or None (anonymous)
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ import Foundation
|
|||||||
import PunktfunkKit
|
import PunktfunkKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
#if canImport(AppKit)
|
||||||
|
import AppKit
|
||||||
|
#elseif canImport(UIKit)
|
||||||
|
import UIKit
|
||||||
|
#endif
|
||||||
|
|
||||||
/// Pump-thread-side frame counters; a 1 Hz main-actor timer drains them into @Published
|
/// Pump-thread-side frame counters; a 1 Hz main-actor timer drains them into @Published
|
||||||
/// values. NSLock instead of an actor — the writer is the (non-async) pump thread.
|
/// values. NSLock instead of an actor — the writer is the (non-async) pump thread.
|
||||||
final class FrameMeter: @unchecked Sendable {
|
final class FrameMeter: @unchecked Sendable {
|
||||||
@@ -93,6 +99,7 @@ final class SessionModel: ObservableObject {
|
|||||||
compositor: PunktfunkConnection.Compositor = .auto,
|
compositor: PunktfunkConnection.Compositor = .auto,
|
||||||
gamepad: PunktfunkConnection.GamepadType = .auto,
|
gamepad: PunktfunkConnection.GamepadType = .auto,
|
||||||
bitrateKbps: UInt32 = 0,
|
bitrateKbps: UInt32 = 0,
|
||||||
|
hdrEnabled: Bool = true,
|
||||||
launchID: String? = nil,
|
launchID: String? = nil,
|
||||||
allowTofu: Bool = false,
|
allowTofu: Bool = false,
|
||||||
autoTrust: Bool = false) {
|
autoTrust: Bool = false) {
|
||||||
@@ -101,17 +108,36 @@ final class SessionModel: ObservableObject {
|
|||||||
activeHost = host
|
activeHost = host
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
let pin = host.pinnedSHA256
|
let pin = host.pinnedSHA256
|
||||||
|
// Capability gate (main-actor — screen APIs): only advertise HDR when this display can
|
||||||
|
// actually present it, so the host sends a proper SDR stream to an SDR display rather than
|
||||||
|
// BT.2020 PQ the panel would mis-tone-map. The display self-tone-maps HDR from the mastering
|
||||||
|
// metadata we apply (Step 2) when it IS HDR.
|
||||||
|
let displayHDR: Bool = {
|
||||||
|
#if os(macOS)
|
||||||
|
return (NSScreen.main?.maximumExtendedDynamicRangeColorComponentValue ?? 1.0) > 1.0
|
||||||
|
#else
|
||||||
|
return UIScreen.main.potentialEDRHeadroom > 1.0
|
||||||
|
#endif
|
||||||
|
}()
|
||||||
|
let hdrCapable = hdrEnabled && displayHDR
|
||||||
Task.detached(priority: .userInitiated) {
|
Task.detached(priority: .userInitiated) {
|
||||||
// PunktfunkConnection.init blocks on the QUIC handshake — keep it off the main
|
// PunktfunkConnection.init blocks on the QUIC handshake — keep it off the main
|
||||||
// actor. The persistent identity is presented on every connect so a paired
|
// actor. The persistent identity is presented on every connect so a paired
|
||||||
// host recognizes this Mac (nil = anonymous, fine for hosts without
|
// host recognizes this Mac (nil = anonymous, fine for hosts without
|
||||||
// --require-pairing; Keychain/generation failure must not block connecting).
|
// --require-pairing; Keychain/generation failure must not block connecting).
|
||||||
let identity = (try? ClientIdentityStore.shared.load())?.identity
|
let identity = (try? ClientIdentityStore.shared.load())?.identity
|
||||||
|
// Advertise 10-bit + HDR10 when enabled: the host upgrades to a BT.2020 PQ Main10 stream
|
||||||
|
// only for actual HDR content (its own gate); the VideoToolbox/Metal present path is
|
||||||
|
// HDR-capable (P010 + itur_2100_PQ + EDR). 0 keeps the 8-bit BT.709 SDR stream.
|
||||||
|
let videoCaps: UInt8 = hdrCapable
|
||||||
|
? (PunktfunkConnection.videoCap10Bit | PunktfunkConnection.videoCapHDR)
|
||||||
|
: 0
|
||||||
let result = Result { try PunktfunkConnection(
|
let result = Result { try PunktfunkConnection(
|
||||||
host: host.address, port: host.port,
|
host: host.address, port: host.port,
|
||||||
width: width, height: height, refreshHz: hz,
|
width: width, height: height, refreshHz: hz,
|
||||||
pinSHA256: pin, identity: identity, compositor: compositor,
|
pinSHA256: pin, identity: identity, compositor: compositor,
|
||||||
gamepad: gamepad, bitrateKbps: bitrateKbps, launchID: launchID) }
|
gamepad: gamepad, bitrateKbps: bitrateKbps, videoCaps: videoCaps,
|
||||||
|
launchID: launchID) }
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
// The user may have abandoned this attempt (window closed, another host
|
// The user may have abandoned this attempt (window closed, another host
|
||||||
|
|||||||
@@ -532,6 +532,11 @@ public final class PunktfunkConnection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Video-capability bit: the client can decode a 10-bit (Main10) HEVC stream.
|
||||||
|
public static let videoCap10Bit: UInt8 = UInt8(PUNKTFUNK_VIDEO_CAP_10BIT)
|
||||||
|
/// Video-capability bit: the client can present BT.2020 PQ HDR10 (implies 10-bit).
|
||||||
|
public static let videoCapHDR: UInt8 = UInt8(PUNKTFUNK_VIDEO_CAP_HDR)
|
||||||
|
|
||||||
/// Static HDR mastering metadata (SMPTE ST.2086 + content light level) the host sent for an HDR
|
/// Static HDR mastering metadata (SMPTE ST.2086 + content light level) the host sent for an HDR
|
||||||
/// session. Mirrors the wire/ABI `PunktfunkHdrMeta`; primaries are in ST.2086 **G, B, R** order,
|
/// session. Mirrors the wire/ABI `PunktfunkHdrMeta`; primaries are in ST.2086 **G, B, R** order,
|
||||||
/// 1/50000 units; mastering luminance in 0.0001 cd/m²; MaxCLL/MaxFALL in nits.
|
/// 1/50000 units; mastering luminance in 0.0001 cd/m²; MaxCLL/MaxFALL in nits.
|
||||||
|
|||||||
@@ -128,6 +128,11 @@ public final class Stage2Pipeline {
|
|||||||
lastFramesDropped = dropped
|
lastFramesDropped = dropped
|
||||||
recovery.request()
|
recovery.request()
|
||||||
}
|
}
|
||||||
|
// Drain any HDR mastering-metadata update (0xCE) and hand it to the decoder, which
|
||||||
|
// attaches it to subsequent HDR frames. Non-blocking; only HDR sessions emit these.
|
||||||
|
if connection.isHDR, let meta = try? connection.nextHdrMeta(timeoutMs: 0) {
|
||||||
|
decoder.setHdrMeta(meta)
|
||||||
|
}
|
||||||
guard let au = try connection.nextAU(timeoutMs: 100) else { continue }
|
guard let au = try connection.nextAU(timeoutMs: 100) else { continue }
|
||||||
onFrame?(au)
|
onFrame?(au)
|
||||||
if let f = AnnexB.formatDescription(fromIDR: au.data) {
|
if let f = AnnexB.formatDescription(fromIDR: au.data) {
|
||||||
|
|||||||
@@ -49,6 +49,12 @@ public final class VideoDecoder: @unchecked Sendable {
|
|||||||
/// pump can re-gate on the next IDR.
|
/// pump can re-gate on the next IDR.
|
||||||
private let onDecodeError: @Sendable (OSStatus) -> Void
|
private let onDecodeError: @Sendable (OSStatus) -> Void
|
||||||
|
|
||||||
|
/// Latest source HDR mastering metadata (from `PunktfunkConnection.nextHdrMeta`), attached to
|
||||||
|
/// each decoded HDR pixel buffer so the compositor tone-maps from the real grade. Guarded by its
|
||||||
|
/// own lock — written by the pump thread, read on the VT decode callback.
|
||||||
|
private let metaLock = NSLock()
|
||||||
|
private var hdrMeta: PunktfunkConnection.HdrMeta?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
onDecoded: @escaping @Sendable (ReadyFrame) -> Void,
|
onDecoded: @escaping @Sendable (ReadyFrame) -> Void,
|
||||||
onDecodeError: @escaping @Sendable (OSStatus) -> Void = { _ in }
|
onDecodeError: @escaping @Sendable (OSStatus) -> Void = { _ in }
|
||||||
@@ -59,6 +65,14 @@ public final class VideoDecoder: @unchecked Sendable {
|
|||||||
|
|
||||||
deinit { teardown() }
|
deinit { teardown() }
|
||||||
|
|
||||||
|
/// Set the source HDR mastering metadata (drained from `PunktfunkConnection.nextHdrMeta`). It's
|
||||||
|
/// attached to subsequent decoded HDR pixel buffers. Thread-safe; cheap to call on each update.
|
||||||
|
public func setHdrMeta(_ meta: PunktfunkConnection.HdrMeta) {
|
||||||
|
metaLock.lock()
|
||||||
|
hdrMeta = meta
|
||||||
|
metaLock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
/// Submit one AU for asynchronous decode, (re)creating the session if `format` changed. The
|
/// Submit one AU for asynchronous decode, (re)creating the session if `format` changed. The
|
||||||
/// caller resolves `format` from the IDR exactly as stage-1 does (`AnnexB.formatDescription`).
|
/// caller resolves `format` from the IDR exactly as stage-1 does (`AnnexB.formatDescription`).
|
||||||
/// Returns false if the session couldn't be created or the frame couldn't be submitted.
|
/// Returns false if the session couldn't be created or the frame couldn't be submitted.
|
||||||
@@ -185,6 +199,22 @@ public final class VideoDecoder: @unchecked Sendable {
|
|||||||
let isHDR =
|
let isHDR =
|
||||||
CVPixelBufferGetPixelFormatType(imageBuffer)
|
CVPixelBufferGetPixelFormatType(imageBuffer)
|
||||||
== kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange
|
== kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange
|
||||||
|
// Attach the source's mastering display + content light level (ST.2086 / CEA-861.3) so the
|
||||||
|
// compositor tone-maps from the real grade rather than inferring from the PQ colourspace
|
||||||
|
// alone. The SEI byte payloads map 1:1 to these CVImageBuffer attachment keys.
|
||||||
|
if isHDR {
|
||||||
|
metaLock.lock()
|
||||||
|
let meta = hdrMeta
|
||||||
|
metaLock.unlock()
|
||||||
|
if let meta {
|
||||||
|
CVBufferSetAttachment(
|
||||||
|
imageBuffer, kCVImageBufferMasteringDisplayColorVolumeKey,
|
||||||
|
meta.masteringDisplayColorVolume() as CFData, .shouldPropagate)
|
||||||
|
CVBufferSetAttachment(
|
||||||
|
imageBuffer, kCVImageBufferContentLightLevelInfoKey,
|
||||||
|
meta.contentLightLevelInfo() as CFData, .shouldPropagate)
|
||||||
|
}
|
||||||
|
}
|
||||||
onDecoded(
|
onDecoded(
|
||||||
ReadyFrame(ptsNs: ptsNs, decodedNs: decodedNs, pixelBuffer: imageBuffer, isHDR: isHDR))
|
ReadyFrame(ptsNs: ptsNs, decodedNs: decodedNs, pixelBuffer: imageBuffer, isHDR: isHDR))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -619,6 +619,36 @@ fn blob_bytes(blob: &ID3DBlob) -> &[u8] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True if any attached display is currently in HDR (BT.2020 PQ) mode. The client advertises HDR
|
||||||
|
/// caps only when this holds, so an SDR display gets a proper 8-bit BT.709 stream instead of PQ it
|
||||||
|
/// would mis-tone-map (the washed-out/dark failure); an HDR display self-tone-maps from the
|
||||||
|
/// mastering metadata. Coarse — checks ANY output, not the app's specific monitor; a mid-session
|
||||||
|
/// monitor move to/from HDR is a follow-up (the `Reconfigure` downgrade).
|
||||||
|
pub fn display_supports_hdr() -> bool {
|
||||||
|
unsafe {
|
||||||
|
let factory: IDXGIFactory1 = match CreateDXGIFactory1() {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
let mut ai = 0u32;
|
||||||
|
while let Ok(adapter) = factory.EnumAdapters1(ai) {
|
||||||
|
ai += 1;
|
||||||
|
let mut oi = 0u32;
|
||||||
|
while let Ok(output) = adapter.EnumOutputs(oi) {
|
||||||
|
oi += 1;
|
||||||
|
if let Ok(o6) = output.cast::<IDXGIOutput6>() {
|
||||||
|
if let Ok(desc) = o6.GetDesc1() {
|
||||||
|
if desc.ColorSpace == DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
/// Generic HDR10 mastering metadata: BT.2020 primaries + D65 white, a 1000-nit mastering display,
|
/// Generic HDR10 mastering metadata: BT.2020 primaries + D65 white, a 1000-nit mastering display,
|
||||||
/// MaxCLL 1000 / MaxFALL 400. The fallback used only until the host's real `0xCE` metadata arrives.
|
/// MaxCLL 1000 / MaxFALL 400. The fallback used only until the host's real `0xCE` metadata arrives.
|
||||||
fn generic_hdr10_metadata() -> DXGI_HDR_METADATA_HDR10 {
|
fn generic_hdr10_metadata() -> DXGI_HDR_METADATA_HDR10 {
|
||||||
|
|||||||
@@ -107,13 +107,19 @@ fn pump(
|
|||||||
params.compositor,
|
params.compositor,
|
||||||
params.gamepad,
|
params.gamepad,
|
||||||
params.bitrate_kbps,
|
params.bitrate_kbps,
|
||||||
// Advertise 10-bit + HDR10 (when enabled): the presenter handles BT.2020 PQ frames (P010 on
|
// Advertise 10-bit + HDR10 only when the user enabled HDR AND a display is actually in HDR
|
||||||
// the GPU path, X2BGR10 on software), so the host may upgrade HDR content to a Main10/PQ
|
// mode: the host then upgrades HDR content to a Main10/PQ stream (its own 10-bit gate still
|
||||||
// stream — it still only does so for actual HDR content with its own 10-bit gate. 8-bit SDR
|
// applies). On an SDR display we advertise `0` so the host sends a proper 8-bit BT.709 stream
|
||||||
// is unaffected. A client that turns HDR off advertises `0` and always gets the 8-bit stream.
|
// rather than PQ the panel would mis-tone-map (washed-out/dark). An HDR display self-tone-maps
|
||||||
if params.hdr_enabled {
|
// from the mastering metadata we apply. The presenter handles BT.2020 PQ frames (P010 / X2BGR10).
|
||||||
|
if params.hdr_enabled && crate::present::display_supports_hdr() {
|
||||||
punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR
|
punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR
|
||||||
} else {
|
} else {
|
||||||
|
if params.hdr_enabled {
|
||||||
|
tracing::info!(
|
||||||
|
"HDR enabled in settings but no HDR display detected — requesting SDR"
|
||||||
|
);
|
||||||
|
}
|
||||||
0
|
0
|
||||||
},
|
},
|
||||||
None, // launch: the Windows client has no library picker yet
|
None, // launch: the Windows client has no library picker yet
|
||||||
|
|||||||
@@ -296,8 +296,9 @@ impl Reassembler {
|
|||||||
stats: &StatsCounters,
|
stats: &StatsCounters,
|
||||||
) -> Result<Option<Frame>> {
|
) -> Result<Option<Frame>> {
|
||||||
// On a lossy datagram link a malformed or non-video packet is dropped, never
|
// 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
|
// fatal: it must not abort `poll_frame`. A FEC reconstruction failure (corrupt or
|
||||||
// failure propagates as an error.
|
// 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 {
|
if pkt.len() < HEADER_LEN {
|
||||||
StatsCounters::add(&stats.packets_dropped, 1);
|
StatsCounters::add(&stats.packets_dropped, 1);
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -407,8 +408,22 @@ impl Reassembler {
|
|||||||
.iter()
|
.iter()
|
||||||
.filter(|s| s.is_some())
|
.filter(|s| s.is_some())
|
||||||
.count();
|
.count();
|
||||||
let recovered =
|
let recovered = match coder.reconstruct(
|
||||||
coder.reconstruct(block.data_shards, block.recovery_shards, &mut block.shards)?;
|
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;
|
block.done = true;
|
||||||
StatsCounters::add(
|
StatsCounters::add(
|
||||||
&stats.fec_recovered_shards,
|
&stats.fec_recovered_shards,
|
||||||
|
|||||||
@@ -1490,6 +1490,12 @@ pub mod endpoint {
|
|||||||
server_from_der(cert_der, key_der, addr)
|
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(
|
fn server_from_der(
|
||||||
cert_der: rustls::pki_types::CertificateDer<'static>,
|
cert_der: rustls::pki_types::CertificateDer<'static>,
|
||||||
key_der: rustls::pki_types::PrivateKeyDer<'static>,
|
key_der: rustls::pki_types::PrivateKeyDer<'static>,
|
||||||
@@ -1500,10 +1506,11 @@ pub mod endpoint {
|
|||||||
// identity is fingerprinted post-handshake (pairing / --require-pairing checks);
|
// identity is fingerprinted post-handshake (pairing / --require-pairing checks);
|
||||||
// one that presents none still connects (and is rejected at the app layer when
|
// one that presents none still connects (and is rejected at the app layer when
|
||||||
// pairing is required).
|
// pairing is required).
|
||||||
let rustls_cfg = rustls::ServerConfig::builder()
|
let mut rustls_cfg = rustls::ServerConfig::builder()
|
||||||
.with_client_cert_verifier(Arc::new(AcceptAnyClientCert))
|
.with_client_cert_verifier(Arc::new(AcceptAnyClientCert))
|
||||||
.with_single_cert(vec![cert_der], key_der)
|
.with_single_cert(vec![cert_der], key_der)
|
||||||
.map_err(|e| anyhow_result::Error::msg(format!("server config: {e}")))?;
|
.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)
|
let quic_cfg = quinn::crypto::rustls::QuicServerConfig::try_from(rustls_cfg)
|
||||||
.map_err(|e| anyhow_result::Error::msg(format!("quic server config: {e}")))?;
|
.map_err(|e| anyhow_result::Error::msg(format!("quic server config: {e}")))?;
|
||||||
let mut server_config = quinn::ServerConfig::with_crypto(Arc::new(quic_cfg));
|
let mut server_config = quinn::ServerConfig::with_crypto(Arc::new(quic_cfg));
|
||||||
@@ -1580,7 +1587,7 @@ pub mod endpoint {
|
|||||||
pin,
|
pin,
|
||||||
observed: observed.clone(),
|
observed: observed.clone(),
|
||||||
}));
|
}));
|
||||||
let rustls_cfg = match identity {
|
let mut rustls_cfg = match identity {
|
||||||
None => builder.with_no_client_auth(),
|
None => builder.with_no_client_auth(),
|
||||||
Some((cert_pem, key_pem)) => {
|
Some((cert_pem, key_pem)) => {
|
||||||
use rustls::pki_types::pem::PemObject;
|
use rustls::pki_types::pem::PemObject;
|
||||||
@@ -1596,6 +1603,8 @@ pub mod endpoint {
|
|||||||
.map_err(|e| anyhow_result::Error::msg(format!("client auth: {e}")))?
|
.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)
|
let quic_cfg = quinn::crypto::rustls::QuicClientConfig::try_from(rustls_cfg)
|
||||||
.map_err(|e| anyhow_result::Error::msg(format!("quic client config: {e}")))?;
|
.map_err(|e| anyhow_result::Error::msg(format!("quic client config: {e}")))?;
|
||||||
let mut client_cfg = quinn::ClientConfig::new(Arc::new(quic_cfg));
|
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
|
// Service the pads' force-feedback protocol every tick (games block inside
|
||||||
// EVIOCSFF until answered) and relay mixed rumble levels to the client.
|
// 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) {
|
if let (Some(pid), Some(scheme)) = (peer, detected) {
|
||||||
let key = state.launch.lock().unwrap().map(|s| s.gcm_key);
|
let key = state.launch.lock().unwrap().map(|s| s.gcm_key);
|
||||||
if let Some(key) = key {
|
if let Some(key) = key {
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ pub fn sha256(parts: &[&[u8]]) -> [u8; 32] {
|
|||||||
h.finalize().into()
|
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).
|
/// 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] {
|
pub fn pin_key(salt: &[u8; 16], pin: &str) -> [u8; 16] {
|
||||||
let d = sha256(&[salt, pin.as_bytes()]);
|
let d = sha256(&[salt, pin.as_bytes()]);
|
||||||
|
|||||||
@@ -224,7 +224,8 @@ impl Pairing {
|
|||||||
let client_secret = &data[..16];
|
let client_secret = &data[..16];
|
||||||
let client_sig = &data[16..];
|
let client_sig = &data[16..];
|
||||||
let expected = crypto::sha256(&[&s.server_challenge, &s.client_cert_sig, client_secret]);
|
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();
|
let sig_ok = verify256(&s.client_pubkey, client_secret, client_sig).is_ok();
|
||||||
if hash_ok && sig_ok {
|
if hash_ok && sig_ok {
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -15,12 +15,34 @@ use anyhow::{Context, Result};
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::net::{TcpListener, TcpStream};
|
use std::net::{TcpListener, TcpStream};
|
||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
/// Opaque per-session payload the client echoes as its first UDP datagram (port-learning).
|
/// Opaque per-session payload the client echoes as its first UDP datagram (port-learning).
|
||||||
const PING_PAYLOAD: &str = "0011223344556677";
|
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.
|
/// Bind 48010 and accept RTSP connections on a dedicated thread.
|
||||||
pub fn spawn(state: Arc<AppState>) -> Result<()> {
|
pub fn spawn(state: Arc<AppState>) -> Result<()> {
|
||||||
let listener = TcpListener::bind(("0.0.0.0", RTSP_PORT))
|
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() {
|
for conn in listener.incoming() {
|
||||||
match conn {
|
match conn {
|
||||||
Ok(stream) => {
|
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();
|
let st = state.clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
|
let _guard = guard; // releases the slot on exit/panic
|
||||||
if let Err(e) = handle_conn(stream, st) {
|
if let Err(e) = handle_conn(stream, st) {
|
||||||
tracing::warn!(error = %format!("{e:#}"), "RTSP connection ended");
|
tracing::warn!(error = %format!("{e:#}"), "RTSP connection ended");
|
||||||
}
|
}
|
||||||
@@ -57,6 +90,8 @@ struct Request {
|
|||||||
|
|
||||||
fn handle_conn(mut stream: TcpStream, state: Arc<AppState>) -> Result<()> {
|
fn handle_conn(mut stream: TcpStream, state: Arc<AppState>) -> Result<()> {
|
||||||
let peer = stream.peer_addr().ok();
|
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();
|
let mut buf: Vec<u8> = Vec::new();
|
||||||
// GameStream RTSP is one request per TCP connection: moonlight-common-c reads the
|
// 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
|
// 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>> {
|
fn read_message(stream: &mut TcpStream, buf: &mut Vec<u8>) -> Result<Option<Request>> {
|
||||||
loop {
|
loop {
|
||||||
if let Some(end) = find_subslice(buf, b"\r\n\r\n") {
|
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 head = std::str::from_utf8(&buf[..end]).context("RTSP header utf8")?;
|
||||||
let content_len = header_value(head, "content-length")
|
let content_len = header_value(head, "content-length")
|
||||||
.and_then(|v| v.trim().parse::<usize>().ok())
|
.and_then(|v| v.trim().parse::<usize>().ok())
|
||||||
.unwrap_or(0);
|
.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;
|
let total = end + 4 + content_len;
|
||||||
if buf.len() < total {
|
if buf.len() < total {
|
||||||
// headers complete but body still arriving — read more
|
// 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);
|
buf.drain(..total);
|
||||||
return Ok(Some(parse_request(&head, body)));
|
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 mut tmp = [0u8; 8192];
|
||||||
let n = stream.read(&mut tmp).context("RTSP read")?;
|
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
|
return Ok(None); // peer closed
|
||||||
}
|
}
|
||||||
buf.extend_from_slice(&tmp[..n]);
|
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).
|
// 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") {
|
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
|
// 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
|
let compositor = app
|
||||||
.and_then(|a| a.compositor)
|
.and_then(|a| a.compositor)
|
||||||
.map(Ok)
|
.map(Ok)
|
||||||
.unwrap_or_else(|| crate::vdisplay::detect().context("detect compositor"))?;
|
.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!(
|
tracing::info!(
|
||||||
?compositor,
|
?compositor,
|
||||||
app = ?app.map(|a| &a.title),
|
app = ?app.map(|a| &a.title),
|
||||||
@@ -122,6 +117,9 @@ fn run(
|
|||||||
"video source: virtual display (native client resolution)"
|
"video source: virtual display (native client resolution)"
|
||||||
);
|
);
|
||||||
let mut vd = crate::vdisplay::open(compositor).context("open virtual display")?;
|
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
|
let vout = vd
|
||||||
.create(punktfunk_core::Mode {
|
.create(punktfunk_core::Mode {
|
||||||
width: cfg.width,
|
width: cfg.width,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ use crate::gamestream::{
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, Request, State},
|
extract::{Path, Request, State},
|
||||||
http::{header, StatusCode},
|
http::{header, Method, StatusCode},
|
||||||
middleware::{self, Next},
|
middleware::{self, Next},
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
routing::get,
|
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
|
return next.run(req).await; // liveness probe is always open
|
||||||
}
|
}
|
||||||
// A paired native client authenticates by its mTLS certificate — the same identity + trust the
|
// 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
|
// QUIC data plane uses. But "paired to STREAM" is not "paired to ADMINISTER": a streaming cert
|
||||||
// `serve_https` from the verified peer cert; we authorize it iff it's in the paired store.
|
// 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 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;
|
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
|
/// Compare SHA-256 digests instead of the strings — constant-time with respect to the
|
||||||
/// secret without pulling in a ct-eq dependency.
|
/// secret without pulling in a ct-eq dependency.
|
||||||
fn token_eq(presented: &str, expected: &str) -> bool {
|
fn token_eq(presented: &str, expected: &str) -> bool {
|
||||||
@@ -1274,6 +1300,86 @@ mod tests {
|
|||||||
axum::http::Request::get(path).body(Body::empty()).unwrap()
|
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]
|
#[tokio::test]
|
||||||
async fn health_is_open_and_versioned() {
|
async fn health_is_open_and_versioned() {
|
||||||
let app = test_app(test_state(), None);
|
let app = test_app(test_state(), None);
|
||||||
|
|||||||
@@ -564,10 +564,12 @@ async fn serve_session(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Resolve a requested library launch (the client sends only the store-qualified id;
|
// 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
|
// we look it up in OUR library so a client can't inject a command). The bare-spawn gamescope
|
||||||
// backend's app env var, exactly as the GameStream /launch path does — safe per-session
|
// backend picks this up via the `PUNKTFUNK_GAMESCOPE_APP` env fallback in `spawn` (on a shared
|
||||||
// (one session at a time). Only the bare-spawn gamescope path reads it; on a shared
|
// desktop / attach-to-existing session it's a harmless no-op). This is the process-global env
|
||||||
// desktop (kwin/mutter/wlroots) or an attach-to-existing session it's a harmless no-op.
|
// 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() {
|
if let Some(id) = hello.launch.as_deref() {
|
||||||
match crate::library::launch_command(id) {
|
match crate::library::launch_command(id) {
|
||||||
Some(cmd) => {
|
Some(cmd) => {
|
||||||
|
|||||||
@@ -50,6 +50,11 @@ pub trait VirtualDisplay: Send {
|
|||||||
/// Create a virtual output of the given mode. Teardown is RAII: drop the returned
|
/// Create a virtual output of the given mode. Teardown is RAII: drop the returned
|
||||||
/// [`VirtualOutput`]'s `keepalive`.
|
/// [`VirtualOutput`]'s `keepalive`.
|
||||||
fn create(&mut self, mode: Mode) -> Result<VirtualOutput>;
|
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).
|
/// 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 +
|
/// * `PUNKTFUNK_GAMESCOPE_NODE=<id|auto>` — ATTACH to an already-running gamescope (capture +
|
||||||
/// inject, no lifecycle ownership).
|
/// inject, no lifecycle ownership).
|
||||||
/// * else — SPAWN a bare headless gamescope sized to the mode, running `PUNKTFUNK_GAMESCOPE_APP`.
|
/// * 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.
|
/// A running host-managed session (its transient systemd --user unit) + the mode it was launched at.
|
||||||
struct SessionState {
|
struct SessionState {
|
||||||
@@ -79,7 +84,7 @@ static STEAMOS_TOOK_OVER: std::sync::Mutex<bool> = std::sync::Mutex::new(false);
|
|||||||
|
|
||||||
impl GamescopeDisplay {
|
impl GamescopeDisplay {
|
||||||
pub fn new() -> Result<Self> {
|
pub fn new() -> Result<Self> {
|
||||||
Ok(GamescopeDisplay)
|
Ok(GamescopeDisplay::default())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +93,10 @@ impl VirtualDisplay for GamescopeDisplay {
|
|||||||
"gamescope"
|
"gamescope"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn set_launch_command(&mut self, cmd: Option<String>) {
|
||||||
|
self.cmd = cmd;
|
||||||
|
}
|
||||||
|
|
||||||
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
|
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
|
||||||
// Host-managed gamescope-session-plus at the CLIENT's mode (the Bazzite path): launch the
|
// 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
|
// 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
|
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
|
// gamescope creates its PipeWire node a moment after start; poll for it (the proc is held
|
||||||
// alive meanwhile, and killed if we give up).
|
// alive meanwhile, and killed if we give up).
|
||||||
let node_id = wait_for_node(Duration::from_secs(15)).ok_or_else(|| {
|
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
|
/// 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`]
|
/// 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.
|
/// so the input injector can connect to gamescope's EIS server from outside.
|
||||||
fn spawn(w: u32, h: u32, hz: u32) -> Result<Child> {
|
fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>) -> Result<Child> {
|
||||||
let app =
|
// A non-empty per-session command (set via `set_launch_command`) wins; else the
|
||||||
std::env::var("PUNKTFUNK_GAMESCOPE_APP").unwrap_or_else(|_| "sleep infinity".to_string());
|
// `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 _ = std::fs::remove_file(EI_SOCKET_FILE); // stale socket path from a previous session
|
||||||
let mut cmd = Command::new("gamescope");
|
let mut cmd = Command::new("gamescope");
|
||||||
cmd.args(["--backend", "headless"])
|
cmd.args(["--backend", "headless"])
|
||||||
|
|||||||
+40
-13
@@ -186,20 +186,47 @@ the protocol, and gives an Apollo/Moonlight on-glass parity gate.
|
|||||||
display's real luminance and VUI 9/16/9; stock Moonlight shows correct (not washed-out) HDR.
|
display's real luminance and VUI 9/16/9; stock Moonlight shows correct (not washed-out) HDR.
|
||||||
Add **encoder-CSC-range == signaled-range** check.
|
Add **encoder-CSC-range == signaled-range** check.
|
||||||
|
|
||||||
### Step 2 — Clients apply the metadata (Windows + Apple + Android, parallelizable)
|
### Step 2 — Clients apply the metadata *(landed; CI/on-glass validation pending)*
|
||||||
- **Windows:** feed `hdr10_metadata()` from the received `HdrMeta` (drop the hardcode); **log**
|
All three clients now drain the protocol's `HdrMeta` (`next_hdr_meta` / `nextHdrMeta`) and apply it,
|
||||||
`SetColorSpace1`/`SetHDRMetaData` failures.
|
each remapping from the wire form (ST.2086 G,B,R order, mastering luminance in 0.0001 cd/m²) to the
|
||||||
- **Apple:** attach `kCVImageBufferMasteringDisplayColorVolumeKey` + `ContentLightLevelInfoKey`
|
platform's expected layout:
|
||||||
/ `CAEDRMetadata` from `HdrMeta`; CV color attachments from Welcome.
|
- **Windows (Rust, CI-compiled):** session pump drains `next_hdr_meta` into a `LATEST_HDR_META`
|
||||||
- **Android:** set `MediaFormat KEY_HDR_STATIC_INFO` from `HdrMeta`.
|
slot; `present_newest` applies it via `Presenter::set_hdr_metadata` → real `SetHDRMetaData`
|
||||||
|
(`hdr_meta_to_dxgi`: G,B,R→R,G,B reorder, 0.0001-nit→nit for `MaxMasteringLuminance`), dropping
|
||||||
|
the 1000/1000/400 hardcode. `SetColorSpace1`/`SetHDRMetaData` failures + an SDR-display
|
||||||
|
colour-space rejection are now **logged**, not swallowed.
|
||||||
|
- **Apple (Swift, mac-runner CI):** connect now advertises caps via `punktfunk_connect_ex5`
|
||||||
|
(`SessionModel` computes `videoCap10Bit|videoCapHDR` from `hdrEnabled`) — *this is the fix that
|
||||||
|
resurrects Apple's previously-dead HDR pipeline*. `nextHdrMeta`/`colorInfo` wrappers added; the
|
||||||
|
pump drains `nextHdrMeta` → `VideoDecoder.setHdrMeta` → `CVBufferSetAttachment` of
|
||||||
|
`kCVImageBufferMasteringDisplayColorVolumeKey` (24-byte BE SEI) +
|
||||||
|
`kCVImageBufferContentLightLevelInfoKey` (4-byte BE) on each HDR pixel buffer (the correct path
|
||||||
|
for the itur_2100_PQ layer; `CAEDRMetadata` on a PQ layer is ambiguous and was avoided).
|
||||||
|
- **Android (Rust `decode.rs`, cargo-ndk verified):** when `client.color.is_hdr()`, drain the first
|
||||||
|
`next_hdr_meta` and set `MediaFormat` `hdr-static-info` (`KEY_HDR_STATIC_INFO`) before
|
||||||
|
`configure()` — `android_hdr_static_info` builds the 25-byte CTA-861.3 Type-1 blob (LE, **R,G,B**
|
||||||
|
order, max-lum in **nits-u16**). `Display.getHdrCapabilities` gate deferred (the Surface DataSpace
|
||||||
|
already drives SurfaceFlinger tone-mapping on non-HDR displays).
|
||||||
|
|
||||||
### Step 3 — Display-capability query + client tone-mapping + robust fallback
|
### Step 3 — Display-capability gate *(landed; CI/on-glass validation pending)*
|
||||||
The common-case correctness step — most displays are SDR.
|
The common-case correctness step — most client displays are SDR. **Chosen approach: capability-gate**
|
||||||
|
(not an in-shader BT.2390 tone-map). Rationale: with Steps 1–2 the host sends *correct* mastering
|
||||||
- **HDR→SDR on every client** (defined BT.2390 EETF / Hable), not silent OS fallback.
|
metadata, so an HDR display self-tone-maps from it; the real remaining gap is SDR displays, best
|
||||||
- Content-peak > display-peak roll-off (`GetDesc1` / `NSScreen.maximumEDR…` /
|
fixed by **not advertising HDR you can't present** — the host then sends a proper BT.709 SDR stream
|
||||||
`Display.getHdrCapabilities`); explicit SDR fallback when HDR present fails.
|
instead of PQ the panel would mis-tone-map (washed-out/dark). No guessed tone-map curve, deterministic.
|
||||||
- Optional client→host "send me SDR" downgrade as a trailing field on `Reconfigure`.
|
- **Windows** (`present::display_supports_hdr` via DXGI: any `IDXGIOutput6` colour space ==
|
||||||
|
`G2084`): `session.rs` ANDs it with the user's HDR setting before advertising caps; logs when it
|
||||||
|
drops to SDR.
|
||||||
|
- **Apple** (`SessionModel`, main-actor): `NSScreen.maximumExtendedDynamicRangeColorComponentValue
|
||||||
|
> 1` (macOS) / `UIScreen.main.potentialEDRHeadroom > 1` (iOS) ANDed with `hdrEnabled`.
|
||||||
|
- **Android** (`Settings.displaySupportsHdr` via `Display.getHdrCapabilities` HDR10/HDR10+): Kotlin
|
||||||
|
passes it to `nativeConnect`; `session.rs` gates the caps on the new `hdr_enabled` jboolean
|
||||||
|
(cargo-ndk-verified).
|
||||||
|
- **Deferred** (need on-glass / the RTX box): the **mid-session `Reconfigure` "downgrade to SDR"**
|
||||||
|
for a monitor move HDR↔SDR; and confirming the **host produces SDR for an SDR client even off an
|
||||||
|
HDR desktop** — on the native path the per-session SudoVDA follows the negotiated depth (SDR
|
||||||
|
client → SDR virtual display → SDR stream), so it should hold end-to-end; verify the
|
||||||
|
stale-HDR-SudoVDA edge case on the RTX box.
|
||||||
|
|
||||||
### Step 4 — Linux (last; capture blocked upstream)
|
### Step 4 — Linux (last; capture blocked upstream)
|
||||||
- **8-bit→Main10 NVENC upconvert shim** (`encode/linux.rs`) — Main10 transport with correct
|
- **8-bit→Main10 NVENC upconvert shim** (`encode/linux.rs`) — Main10 transport with correct
|
||||||
|
|||||||
@@ -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).
|
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
|
## 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.
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user