10 Commits

Author SHA1 Message Date
enricobuehler 54d9246ca7 fix(deps): bump quinn-proto 0.11.14 -> 0.11.15 (RUSTSEC-2026-0185)
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Waiting to run
apple / swift (push) Successful in 1m7s
audit / cargo-audit (push) Successful in 1m14s
android / android (push) Successful in 4m24s
ci / web (push) Successful in 46s
ci / docs-site (push) Successful in 57s
ci / rust (push) Successful in 7m32s
windows-host / package (push) Successful in 8m47s
release / apple (push) Successful in 8m42s
apple / screenshots (push) Has started running
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m26s
ci / bench (push) Successful in 4m40s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 7s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
flatpak / build-publish (push) Has started running
docker / deploy-docs (push) Waiting to run
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m23s
deb / build-publish (push) Successful in 3m6s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has started running
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m28s
windows / build (x86_64-pc-windows-msvc) (push) Has started running
The 0.11.15 bump for S1 (pre-auth out-of-order STREAM reassembly memory
exhaustion on the default QUIC listener) was reverted before the original
fix commit, so Cargo.lock on main still pinned the vulnerable 0.11.14 and
the new cargo-audit CI gate failed. Re-apply and lock it in.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 06:05:10 +00:00
enricobuehler 91bb955d0c style(host): rustfmt the security-fix wrapping (cargo fmt --all --check)
apple / swift (push) Successful in 1m5s
ci / rust (push) Successful in 1m53s
ci / web (push) Successful in 57s
android / android (push) Successful in 3m47s
ci / docs-site (push) Successful in 1m2s
apple / screenshots (push) Successful in 5m35s
deb / build-publish (push) Successful in 2m52s
decky / build-publish (push) Successful in 22s
windows-host / package (push) Successful in 8m26s
ci / bench (push) Successful in 4m51s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 34s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 2m41s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m46s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m16s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 55s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m5s
docker / deploy-docs (push) Successful in 23s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m53s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 05:19:22 +00:00
enricobuehler 36259b264f docs(security): record remediation status for the 2026-06-28 host audit
apple / swift (push) Successful in 1m6s
ci / rust (push) Failing after 56s
ci / web (push) Successful in 52s
android / android (push) Successful in 3m24s
ci / docs-site (push) Successful in 1m4s
apple / screenshots (push) Successful in 5m23s
windows-host / package (push) Successful in 7m36s
deb / build-publish (push) Successful in 2m52s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m43s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m59s
docker / deploy-docs (push) Successful in 17s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m43s
14/18 fixed (3532e35 Linux-verified + 6f903f7 Windows DACL paths pending
CI/box); #5 deferred (needs on-box validation), #9/#13 accepted, S7
acknowledged (no upstream rsa fix).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:16:25 +00:00
enricobuehler 6f903f79bc fix(host/security): Windows DACL hardening — close audit #2, #3, #8, #11
Windows local-privilege findings from design/security-review-2026-06-28.md.
These are #[cfg(windows)] paths (verify in CI / on the box; this Linux dev
VM can't compile MSVC). They follow the existing write_secret_file/icacls
patterns; the cross-platform parts are cargo check/clippy/test green.

- #2 [HIGH]: route the mgmt bearer token write through the shared
  write_secret_file so it gets the SAME Windows DACL (SYSTEM/Administrators)
  as the host key — it was cfg(unix)-only and left Users-readable, leaking
  full mgmt admin authority to any local user.
- #3 [HIGH]: create_private_dir now applies a restrictive DACL to the
  %ProgramData%\punktfunk config directory (re-owns to Administrators to
  defeat a pre-creation, strips inheritance, SYSTEM/Admins/OWNER full +
  Users read-only) so a local user can't plant host.env/apps.json that the
  SYSTEM service trusts (env/arg-injection LPE). host.env is now written
  DACL-locked via write_secret_file; the config + logs dirs go through
  create_private_dir.
- #8 [LOW]: write the web-console password file empty, icacls-lock it, THEN
  write the secret — closes the brief write-then-icacls TOCTOU window.
- #11 [LOW]: the SYSTEM logs dir is DACL-locked (Users read-only, no
  create), so a local user can't pre-plant host.log as a reparse/hardlink to
  redirect SYSTEM's writes (subsumed by the #3 dir lockdown).

Deferred: #5 (host<->UMDF gamepad/IDD shared-section Everyone:GENERIC_ALL).
The section SDDL is intentionally permissive because the UMDF driver opens
it under a restricted token of unknown SID/integrity; scoping it blind would
likely break the live-validated gamepad/IDD pipeline, so it needs on-box
validation first. Tracked in the report.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:14:19 +00:00
enricobuehler 3532e35b75 fix(host/security): close audit findings S1,#1,#4,#10,#12,#7,#6,S2-S6 (Linux/cross-platform)
Remediations from design/security-review-2026-06-28.md verified on Linux
(cargo check/clippy/test green; Windows-gated paths verify in CI):

- S1 [HIGH]: bump quinn-proto 0.11.14 -> 0.11.15 (RUSTSEC-2026-0185,
  pre-auth out-of-order STREAM reassembly memory exhaustion on the
  always-on default QUIC listener).
- #1 [HIGH]: remove the unauthenticated nvhttp `GET /pin` endpoint; the
  GameStream PIN is delivered ONLY via the bearer-gated mgmt API, so a
  network client can no longer submit its own displayed PIN and self-pair.
- #4 [HIGH->MED]: gate the unauthenticated RTSP/UDP media plane on a paired
  `/launch` and bind it to the launching client's source IP (threaded
  through the HTTPS handler), so an unpaired peer can neither start capture
  on an idle host nor ride a paired client's active launch.
- #12: bound concurrent parked pairing waiters (MAX_PARKED_WAITERS) so a
  pre-auth peer can't pin unbounded 300s handshakes. +regression test.
- #10: throttle the per-packet ENet control GCM-decrypt-failed warn
  (exponential backoff) so a junk flood can't spam the log.
- #7 [MED->LOW]: serialize all process-global env mutation on the
  session-setup path under a new vdisplay::ENV_LOCK (apply_session_env /
  apply_input_env / the launch-cmd set_var / the gamescope env read), so
  concurrent native sessions can't race set_var/getenv (data-race UB ->
  host-wide DoS). Full per-session SessionContext threading remains a
  follow-up for cross-session value confusion.
- #6 [MED]: move the gamescope EIS socket relay from world-writable /tmp to
  $XDG_RUNTIME_DIR (per-user 0700) and reject a symlinked relay file, so a
  local user can't intercept (keylog) or deny the remote session's input.
- S2: a malformed client Opus mic frame now drops that frame instead of
  tearing down the shared host-lifetime virtual mic (cross-session DoS).
- S3: track held buttons/keys in capped HashSets (was unbounded Vec with
  O(n) scans) so a paired client can't grow per-session input state.
- S5: reject fps==0/absurd at the open_video chokepoint (covers Hello,
  ANNOUNCE, Reconfigure) so the encoder time_base/pts math can't div-by-0.
- S6: bound the shared mic mpsc (drop-newest when full).
- S4: cap Epic launcher-cache reads (catcache.bin/.item) so a planted giant
  can't OOM the host during library enumeration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:06:24 +00:00
enricobuehler 6b846913f5 docs(security): 2026-06-28 host security audit (follow-up) report
Multi-agent follow-up audit of the privileged streaming host: 18 attack
surfaces, every finding adversarially double-verified, plus a coverage
critic. Records 15 confirmed + 9 partial findings and a prior-fix
re-verification of the 2026-06-21 review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:05:58 +00:00
enricobuehler 26c6c939a2 fix(ci/apple): set CMAKE_POLICY_VERSION_MINIMUM=3.5 for the vendored libopus
apple / swift (push) Successful in 1m6s
release / apple (push) Successful in 8m50s
ci / rust (push) Successful in 1m17s
ci / web (push) Successful in 52s
apple / screenshots (push) Successful in 5m40s
ci / docs-site (push) Successful in 1m27s
android / android (push) Successful in 3m46s
deb / build-publish (push) Successful in 2m53s
decky / build-publish (push) Successful in 10s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m43s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m0s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m45s
With cmake now found, Homebrew's CMake 4 refuses the vendored libopus's
`cmake_minimum_required(VERSION <3.5)` ("Compatibility with CMake < 3.5 has been
removed"). Export CMAKE_POLICY_VERSION_MINIMUM=3.5 (the same knob the Windows
build uses) so the cmake crate's child cmake configures the audiopus_sys libopus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 21:49:27 +00:00
enricobuehler b6e6f2bff5 fix(ci/apple): locate Homebrew explicitly for the cmake install
apple / swift (push) Failing after 31s
release / apple (push) Failing after 8s
apple / screenshots (push) Has been skipped
android / android (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / web (push) Has been cancelled
ci / rust (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m25s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m30s
The self-hosted macOS runner runs steps with `bash --noprofile --norc`, so
Homebrew's bin dir is not on PATH — the previous `brew install cmake` died with
`brew: command not found` (exit 127). Find brew at its known prefix, install cmake
only if missing, and export the brew bin dir to $GITHUB_PATH so the subsequent
xcframework build (audiopus_sys → vendored libopus) actually finds `cmake`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 21:47:05 +00:00
enricobuehler e3034958ee fix(ci): unbreak the Apple + Windows-client builds after the surround-audio merge
apple / swift (push) Failing after 2s
release / apple (push) Failing after 3s
apple / screenshots (push) Has been skipped
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m17s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m15s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m2s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m6s
android / android (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / web (push) Has been cancelled
ci / rust (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
decky / build-publish (push) Has been cancelled
deb / build-publish (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
The 5.1/7.1 surround commit (75627c8) added in-core Opus, which broke two CI jobs
that the merge didn't touch:

  * Windows MSIX client: clients/windows/src/main.rs's headless `SessionParams`
    initializer was missing the new `audio_channels` field (the GUI path sets it
    from settings). Default the CLI/test path to stereo (2), matching trust.rs.
  * Apple xcframework (apple.yml + release.yml): in-core Opus decode pulls
    `audiopus_sys`, which builds a vendored *static* libopus via CMake when
    pkg-config finds no system Opus — keeping the xcframework self-contained (no
    runtime libopus.dylib on end-user Macs/devices). The self-hosted macOS runner
    lacked `cmake`; install it self-healing before every xcframework build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 21:44:44 +00:00
enricobuehler 8672026e97 fix(host): clear clippy doc_lazy_continuation in the 4:4:4 docs
apple / swift (push) Failing after 7s
apple / screenshots (push) Has been skipped
android / android (push) Successful in 3m17s
ci / rust (push) Successful in 1m17s
ci / web (push) Successful in 50s
ci / docs-site (push) Successful in 58s
windows-host / package (push) Successful in 7m27s
deb / build-publish (push) Successful in 2m54s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m39s
docker / deploy-docs (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
A line-wrap put `+`/`*`-style markers at the start of two doc lines, which
clippy (Windows host job, rust 1.96) reads as markdown list items whose
unindented follow-on lines trip `doc_lazy_continuation` under `-D warnings`:

  - encode/windows/nvenc.rs `chroma_444` field doc (the failing Windows-host
    clippy job): "+ chromaFormatIDC = 3" → "and chromaFormatIDC = 3".
  - encode/linux/vaapi.rs `probe_can_encode_444` doc: "+ validate" → "and
    validate" (last line, didn't fire yet, but fragile — fixed pre-emptively).

Pure doc rewording, no behaviour change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 21:38:07 +00:00
24 changed files with 927 additions and 143 deletions
+35
View File
@@ -32,6 +32,25 @@ jobs:
dirname "$RUSTUP" >> "$GITHUB_PATH"
"$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin
# `punktfunk-core` now decodes Opus in-core for the Apple client (surround), pulling
# `audiopus_sys`, which builds a vendored static libopus via CMake when pkg-config can't find a
# system Opus — so the xcframework is self-contained (no runtime libopus.dylib on end-user Macs).
# CMake must be on PATH; install it self-healing on a fresh runner.
- name: CMake (for the vendored libopus audiopus_sys builds)
run: |
# Runner steps run with `bash --noprofile --norc`, so Homebrew's bin dir isn't on PATH —
# locate brew explicitly, install cmake if missing, and export its bin dir to GITHUB_PATH so
# the xcframework build step (audiopus_sys → vendored libopus) finds `cmake`.
for B in /opt/homebrew/bin/brew /usr/local/bin/brew; do [ -x "$B" ] && BREW="$B" && break; done
if [ -z "$BREW" ]; then echo "::error::Homebrew not found on the runner"; exit 1; fi
BREW_BIN="$(dirname "$BREW")"; export PATH="$BREW_BIN:$PATH"
command -v cmake >/dev/null || "$BREW" install cmake
echo "$BREW_BIN" >> "$GITHUB_PATH"
# Homebrew's CMake 4 dropped compatibility with the vendored libopus's pre-3.5
# `cmake_minimum_required`; treat 3.5 as the policy minimum (the cmake crate's child cmake
# inherits this from the env during the xcframework build).
echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV"
- name: Build PunktfunkCore.xcframework
run: bash scripts/build-xcframework.sh
@@ -71,6 +90,22 @@ jobs:
"$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin \
aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
# See the swift job: audiopus_sys (via the in-core Opus decode) builds vendored libopus with CMake.
- name: CMake (for the vendored libopus audiopus_sys builds)
run: |
# Runner steps run with `bash --noprofile --norc`, so Homebrew's bin dir isn't on PATH —
# locate brew explicitly, install cmake if missing, and export its bin dir to GITHUB_PATH so
# the xcframework build step (audiopus_sys → vendored libopus) finds `cmake`.
for B in /opt/homebrew/bin/brew /usr/local/bin/brew; do [ -x "$B" ] && BREW="$B" && break; done
if [ -z "$BREW" ]; then echo "::error::Homebrew not found on the runner"; exit 1; fi
BREW_BIN="$(dirname "$BREW")"; export PATH="$BREW_BIN:$PATH"
command -v cmake >/dev/null || "$BREW" install cmake
echo "$BREW_BIN" >> "$GITHUB_PATH"
# Homebrew's CMake 4 dropped compatibility with the vendored libopus's pre-3.5
# `cmake_minimum_required`; treat 3.5 as the policy minimum (the cmake crate's child cmake
# inherits this from the env during the xcframework build).
echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV"
- name: Build PunktfunkCore.xcframework (mac + iOS slices)
run: BUILD_IOS=1 bash scripts/build-xcframework.sh
+17
View File
@@ -118,6 +118,23 @@ jobs:
"$RUSTUP" toolchain install nightly --profile minimal
"$RUSTUP" component add rust-src --toolchain nightly
# The in-core Opus decode (surround) pulls audiopus_sys, which builds a vendored static libopus
# via CMake — keep the xcframework self-contained (no runtime libopus.dylib on end-user devices).
- name: CMake (for the vendored libopus audiopus_sys builds)
run: |
# Runner steps run with `bash --noprofile --norc`, so Homebrew's bin dir isn't on PATH —
# locate brew explicitly, install cmake if missing, and export its bin dir to GITHUB_PATH so
# the xcframework build step (audiopus_sys → vendored libopus) finds `cmake`.
for B in /opt/homebrew/bin/brew /usr/local/bin/brew; do [ -x "$B" ] && BREW="$B" && break; done
if [ -z "$BREW" ]; then echo "::error::Homebrew not found on the runner"; exit 1; fi
BREW_BIN="$(dirname "$BREW")"; export PATH="$BREW_BIN:$PATH"
command -v cmake >/dev/null || "$BREW" install cmake
echo "$BREW_BIN" >> "$GITHUB_PATH"
# Homebrew's CMake 4 dropped compatibility with the vendored libopus's pre-3.5
# `cmake_minimum_required`; treat 3.5 as the policy minimum (the cmake crate's child cmake
# inherits this from the env during the xcframework build).
echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV"
- name: Build PunktfunkCore.xcframework (mac + iOS + tvOS)
# tvOS is a tier-3 target (nightly -Zbuild-std): slow on the first build, then cached on
# the self-hosted runner. Built on canary too so the tvOS archive/upload below runs on the
Generated
+15 -15
View File
@@ -121,7 +121,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -132,7 +132,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -1001,7 +1001,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -1424,7 +1424,7 @@ dependencies = [
"gobject-sys",
"libc",
"system-deps",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -1925,7 +1925,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -2342,7 +2342,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -2963,9 +2963,9 @@ dependencies = [
[[package]]
name = "quinn-proto"
version = "0.11.14"
version = "0.11.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e"
dependencies = [
"bytes",
"fastbloom",
@@ -3304,7 +3304,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -3372,7 +3372,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -3762,7 +3762,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -3883,7 +3883,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -4232,7 +4232,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -4682,7 +4682,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -5192,7 +5192,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
dependencies = [
"cfg-if",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
+2
View File
@@ -177,6 +177,8 @@ fn run_headless_cli(args: &[String], identity: (String, String)) {
compositor: CompositorPref::Auto,
gamepad: GamepadPref::Auto,
bitrate_kbps,
// Headless CLI path (test/scripting) — stereo baseline; the GUI sources this from settings.
audio_channels: 2,
mic_enabled: flag("--mic"),
hdr_enabled: !flag("--no-hdr"),
decoder,
+8
View File
@@ -230,6 +230,14 @@ pub fn open_video(
chroma: ChromaFormat,
) -> Result<Box<dyn Encoder>> {
validate_dimensions(codec, width, height)?;
// Refresh/fps must be positive and sane: fps feeds the encoder time_base (`Rational(1, fps)`)
// and the pts→ns conversion (`pts * 1e9 / fps`), so 0 builds a 1/0 rational / divides by zero.
// The mid-stream Reconfigure path already guards `refresh_hz > 0`; enforcing it at this single
// open chokepoint makes EVERY path (initial Hello, GameStream ANNOUNCE, Reconfigure) safe
// regardless of which backend opens (security-review 2026-06-28 S5).
if fps == 0 || fps > 1000 {
anyhow::bail!("invalid refresh/fps {fps}: must be 1..=1000 Hz");
}
// 4:4:4 is HEVC-only. The negotiator should never pass `Yuv444` for another codec (it gates on
// `codec == H265`), but defend the contract here so a future caller can't silently emit a stream
// no decoder expects: a non-HEVC 4:4:4 request degrades to 4:2:0 with a warning.
@@ -166,7 +166,7 @@ pub fn probe_can_encode(codec: Codec) -> bool {
/// validated hardware to build + verify the 4:4:4 surface/profile path against. Returning `false`
/// keeps the negotiation honest: a VAAPI host resolves every session to 4:2:0 before the Welcome, so
/// the client never builds a 4:4:4 decoder it would only get 4:2:0 frames for. (Follow-up: implement
/// + validate on an Intel Arc / RDNA4-class box that advertises a HEVC 4:4:4 encode entrypoint.)
/// and validate on an Intel Arc / RDNA4-class box that advertises a HEVC 4:4:4 encode entrypoint.)
pub fn probe_can_encode_444(_codec: Codec) -> bool {
tracing::info!("VAAPI HEVC 4:4:4 encode is not implemented yet — declining (encoding 4:2:0)");
false
@@ -58,8 +58,8 @@ pub struct NvencD3d11Encoder {
/// Encoded bit depth (8 or 10). 10 → HEVC Main10 (NVENC upconverts the 8-bit ARGB input).
bit_depth: u8,
/// Full-chroma 4:4:4 (HEVC Range Extensions, `chroma_format_idc = 3`) requested for this session.
/// NVENC ingests the RGB (ARGB/ABGR10) input and CSCs it to YUV444 internally the `FREXT` profile
/// + `chromaFormatIDC = 3` in the encode config carry the chroma. Gated on the GPU's
/// NVENC ingests the RGB (ARGB/ABGR10) input and CSCs it to YUV444 internally; the `FREXT` profile
/// and `chromaFormatIDC = 3` in the encode config carry the chroma. Gated on the GPU's
/// `NV_ENC_CAPS_SUPPORT_YUV444_ENCODE` (cleared in `query_caps` on a card that lacks it) and on an
/// RGB input format (NV12/P010 capture can't reconstruct 4:4:4). HEVC-only.
chroma_444: bool,
@@ -56,6 +56,9 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
.spawn(move || {
// GCM scheme detected from the first authenticating packet; reused thereafter.
let mut detected: Option<Scheme> = None;
// Consecutive control-decrypt failures for this peer — throttles the warn log so a
// junk-packet flood can't spam unbounded lines (security-review 2026-06-28 #10).
let mut decrypt_fails: u64 = 0;
// Decoded keyboard/mouse is forwarded to a dedicated host-lifetime injector thread —
// NEVER injected inline, so a slow Wayland/libei/SendInput call can't head-block ENet
// keepalive/retransmit servicing on this thread. The injector owns non-Send compositor
@@ -77,6 +80,7 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
Event::Disconnect { .. } => {
tracing::info!("control: client disconnected");
detected = None;
decrypt_fails = 0;
peer = None;
// Unplug the session's virtual pads.
pads = GamepadManager::new();
@@ -89,6 +93,7 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
channel_id,
packet.data(),
&mut detected,
&mut decrypt_fails,
&inj_tx,
&mut pads,
);
@@ -163,6 +168,7 @@ fn on_receive(
_channel_id: u8,
d: &[u8],
detected: &mut Option<Scheme>,
decrypt_fails: &mut u64,
inj_tx: &Sender<InputEvent>,
pads: &mut GamepadManager,
) {
@@ -180,10 +186,20 @@ fn on_receive(
tracing::info!(?scheme, "control: GCM scheme locked in");
}
*detected = Some(scheme);
*decrypt_fails = 0;
pt
}
None => {
tracing::warn!(len = d.len(), "control: GCM decrypt failed");
// Throttle: a junk-packet flood must not spam one warn line per packet. Log the first
// failure, then only at exponentially-spaced counts (1, 2, 4, 8, …).
*decrypt_fails += 1;
if decrypt_fails.is_power_of_two() {
tracing::warn!(
len = d.len(),
fails = *decrypt_fails,
"control: GCM decrypt failed"
);
}
return;
}
};
+63 -4
View File
@@ -90,6 +90,11 @@ pub struct LaunchSession {
pub fps: u32,
/// `/launch?appid=N` — selects the app-catalog entry (session recipe).
pub appid: u32,
/// Source IP of the paired HTTPS client that issued `/launch`. The unauthenticated RTSP/UDP
/// media plane binds to this so only the launching peer can start/own the stream — an
/// unpaired RTSP peer cannot ride a paired client's launch (security-review 2026-06-28 #4).
/// `None` if the address could not be captured (then RTSP falls back to launch-present only).
pub peer_ip: Option<std::net::IpAddr>,
}
/// Shared control-plane state used as the axum app state.
@@ -262,9 +267,10 @@ pub(crate) fn config_dir() -> PathBuf {
}
/// 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.
/// by other local users via a traversable config path). On Windows, applies a restrictive DACL
/// ([`restrict_dir_to_system_admins`]) so a local unprivileged user can't pre-create / plant files in
/// the config tree (the default `%ProgramData%` ACL grants Users *create*; security-review
/// 2026-06-28 #3/#11). Tightens (and re-owns) an already-existing dir too.
pub(crate) fn create_private_dir(dir: &std::path::Path) -> std::io::Result<()> {
#[cfg(unix)]
{
@@ -281,7 +287,60 @@ pub(crate) fn create_private_dir(dir: &std::path::Path) -> std::io::Result<()> {
}
#[cfg(not(unix))]
{
std::fs::create_dir_all(dir)
let r = std::fs::create_dir_all(dir);
#[cfg(windows)]
restrict_dir_to_system_admins(dir);
r
}
}
/// Best-effort Windows DACL lockdown of the config *directory* (the companion to
/// [`restrict_to_system_admins`] for files). The default `%ProgramData%` ACL lets `BUILTIN\Users`
/// create subfolders/files (and become `CREATOR OWNER`), so a non-admin could pre-create the
/// `punktfunk` dir or plant a `host.env`/`apps.json` that the privileged SYSTEM service then trusts
/// (LPE; security-review 2026-06-28 #3). This re-owns the dir to Administrators (defeating a
/// pre-creation), strips inheritance, and sets an explicit DACL: SYSTEM/Administrators/OWNER full
/// (object+container inherit so child files/dirs inherit it), and Users **read-only** (so existing
/// reads of non-secret config keep working but a local user can no longer write/plant). Secret files
/// are additionally locked to SYSTEM/Admins by [`write_secret_file`]. Hard-coded SIDs
/// (locale-independent) via the absolute `%SystemRoot%` path; never fatal.
#[cfg(windows)]
fn restrict_dir_to_system_admins(dir: &std::path::Path) {
let icacls = std::env::var("SystemRoot")
.map(|r| format!("{r}\\System32\\icacls.exe"))
.unwrap_or_else(|_| "icacls".to_string());
// Reset ownership of the directory object to Administrators first, so a dir a non-admin may have
// pre-created can't keep OWNER control (an owner can always rewrite the DACL). No `/T` — re-owning
// the dir itself is what defeats the pre-creation; recursing a large captures tree each call is
// needless churn (secret files are individually owner-locked by `write_secret_file`).
let _ = std::process::Command::new(&icacls)
.arg(dir.as_os_str())
.args(["/setowner", "*S-1-5-32-544"]) // BUILTIN\Administrators
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
let status = std::process::Command::new(&icacls)
.arg(dir.as_os_str())
.args([
"/inheritance:r",
"/grant:r",
"*S-1-5-18:(OI)(CI)(F)", // NT AUTHORITY\SYSTEM
"/grant:r",
"*S-1-5-32-544:(OI)(CI)(F)", // BUILTIN\Administrators
"/grant:r",
"*S-1-3-4:(OI)(CI)(F)", // OWNER RIGHTS
"/grant:r",
"*S-1-5-32-545:(OI)(CI)(RX)", // BUILTIN\Users — read-only (no create/write → no plant)
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
match status {
Ok(s) if s.success() => {}
_ => tracing::warn!(
dir = %dir.display(),
"config-dir DACL hardening did not fully succeed — a local user may be able to plant config files"
),
}
}
+13 -18
View File
@@ -1,9 +1,14 @@
//! The nvhttp servers: plain HTTP on 47989 and mutual-TLS on 47984. Serves `/serverinfo`,
//! the `/pair` flow, `/applist`, and `/launch`/`/resume`/`/cancel`, plus a punktfunk-only
//! `/pin` endpoint to deliver the Moonlight-displayed PIN. Over HTTPS the client is
//! the `/pair` flow, `/applist`, and `/launch`/`/resume`/`/cancel`. Over HTTPS the client is
//! mutual-TLS-authenticated, so `/serverinfo` reports `PairStatus=1` there.
//!
//! The pairing PIN is delivered out-of-band ONLY through the bearer-authenticated management
//! API (`POST /api/v1/pair/pin`): the operator reads the PIN off the Moonlight client and
//! types it into the host console. There is deliberately NO unauthenticated nvhttp PIN
//! endpoint — one would let a network client submit its own displayed PIN and drive the whole
//! ceremony to a pinned cert with no operator consent (security-review 2026-06-28 #1).
use super::tls::PeerCertFingerprint;
use super::tls::{PeerAddr, PeerCertFingerprint};
use super::{serverinfo, AppState, LaunchSession, HTTPS_PORT, HTTP_PORT, RTSP_PORT};
use anyhow::{anyhow, Context, Result};
use axum::{
@@ -58,7 +63,6 @@ fn router(state: Arc<AppState>, https: bool) -> Router {
Router::new()
.route("/serverinfo", get(h_serverinfo))
.route("/pair", get(h_pair))
.route("/pin", get(h_pin))
.route("/applist", get(h_applist))
.route("/launch", get(h_launch))
.route("/resume", get(h_resume))
@@ -82,19 +86,6 @@ async fn h_serverinfo(
xml(serverinfo::serverinfo_xml(&st.host, https, paired))
}
async fn h_pin(
State(st): State<Arc<AppState>>,
Query(q): Query<HashMap<String, String>>,
) -> impl IntoResponse {
match q.get("pin").filter(|p| !p.is_empty()) {
Some(pin) => {
st.pairing.pin.submit(pin.clone());
"PIN accepted\n".to_string()
}
None => "usage: GET /pin?pin=NNNN\n".to_string(),
}
}
async fn h_applist(
State(st): State<Arc<AppState>>,
peer: Option<Extension<PeerCertFingerprint>>,
@@ -110,6 +101,7 @@ async fn h_applist(
async fn h_launch(
State(st): State<Arc<AppState>>,
peer: Option<Extension<PeerCertFingerprint>>,
addr: Option<Extension<PeerAddr>>,
Query(q): Query<HashMap<String, String>>,
) -> impl IntoResponse {
if !peer_is_paired(&peer, &st) {
@@ -117,7 +109,9 @@ async fn h_launch(
return xml(error_xml());
}
match launch(&st, &q) {
Ok(session) => {
Ok(mut session) => {
// Bind the (unauthenticated) RTSP/UDP media plane to this paired client's source IP.
session.peer_ip = addr.map(|Extension(PeerAddr(a))| a.ip());
*st.launch.lock().unwrap() = Some(session);
tracing::info!(
w = session.width,
@@ -193,6 +187,7 @@ fn launch(_st: &AppState, q: &HashMap<String, String>) -> Result<LaunchSession>
height,
fps,
appid,
peer_ip: None, // set by `h_launch` from the verified HTTPS peer address
})
}
@@ -17,9 +17,14 @@ use std::sync::Mutex;
use std::time::Duration;
use tokio::sync::Notify;
/// Out-of-band PIN delivery. Moonlight generates + displays a PIN; the user submits it
/// (via the management API's `POST /api/v1/pair/pin` or nvhttp's `GET /pin?pin=NNNN`).
/// `getservercert` parks until a PIN arrives.
/// Out-of-band PIN delivery. Moonlight generates + displays a PIN; the operator submits it
/// via the bearer-authenticated management API (`POST /api/v1/pair/pin`) only — there is no
/// unauthenticated nvhttp delivery path (a network client must never be able to submit its
/// own PIN; security-review 2026-06-28 #1). `getservercert` parks until a PIN arrives.
/// Max pairing handshakes parked in [`PinGate::take`] at once (each holds a slot for up to
/// 300s), bounding a pre-auth waiter flood. Real pairing is one operator-driven client at a time.
const MAX_PARKED_WAITERS: usize = 4;
pub struct PinGate {
pin: Mutex<Option<String>>,
notify: Notify,
@@ -48,7 +53,20 @@ impl PinGate {
}
async fn take(&self, timeout: Duration) -> Option<String> {
self.waiters.fetch_add(1, Ordering::SeqCst);
// Bound the number of pairing handshakes parked at once: each `getservercert` is
// pre-auth and parks for up to 300s, so without a cap an unpaired LAN peer could pin
// unbounded tasks + keep `awaiting_pin` asserted (security-review 2026-06-28 #12).
// Reserve a slot atomically; refuse (treated as "no PIN") once the cap is reached.
if self
.waiters
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |n| {
(n < MAX_PARKED_WAITERS).then_some(n + 1)
})
.is_err()
{
tracing::warn!("pairing: too many handshakes awaiting a PIN — refusing");
return None;
}
// Decrement on every exit path (PIN delivered, timeout, or future cancellation).
struct WaiterGuard<'a>(&'a AtomicUsize);
impl Drop for WaiterGuard<'_> {
@@ -117,7 +135,8 @@ impl Pairing {
tracing::info!(
uniqueid,
"pairing phase 1 (getservercert) — awaiting PIN: submit `GET /pin?pin=NNNN`"
"pairing phase 1 (getservercert) — awaiting PIN: deliver it via the management \
API `POST /api/v1/pair/pin` (operator reads the PIN off the Moonlight client)"
);
let pin = self
.pin
@@ -304,4 +323,28 @@ mod tests {
assert_eq!(pairing.pin.take(Duration::from_millis(10)).await, None);
assert!(!pairing.pin.awaiting_pin());
}
/// A pre-auth peer flood can park at most `MAX_PARKED_WAITERS` pairing handshakes; the next
/// `take` is refused immediately (returns `None` without parking), bounding the 300s-waiter DoS
/// (security-review 2026-06-28 #12).
#[tokio::test]
async fn pin_gate_caps_parked_waiters() {
let pairing = Arc::new(Pairing::new());
let mut handles = Vec::new();
for _ in 0..MAX_PARKED_WAITERS {
let p = pairing.clone();
handles.push(tokio::spawn(async move {
p.pin.take(Duration::from_secs(5)).await
}));
}
// Wait until all the slots are taken.
while pairing.pin.waiters.load(Ordering::SeqCst) < MAX_PARKED_WAITERS {
tokio::time::sleep(Duration::from_millis(2)).await;
}
// One more is refused right away (no parking), even with a long timeout.
assert_eq!(pairing.pin.take(Duration::from_secs(5)).await, None);
for h in handles {
h.abort();
}
}
}
+32 -22
View File
@@ -14,7 +14,7 @@ use crate::encode::Codec;
use anyhow::{Context, Result};
use std::collections::HashMap;
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
@@ -102,13 +102,12 @@ fn handle_conn(mut stream: TcpStream, state: Arc<AppState>) -> Result<()> {
"RTSP {} | {}", req.head.replace("\r\n", " | "),
if req.body.is_empty() { String::new() } else { format!("body: {}", req.body.replace("\r\n", " | ")) }
);
let resp = handle_request(&req, &state);
let resp = handle_request(&req, &state, peer);
stream.write_all(resp.as_bytes()).context("RTSP write")?;
stream.flush().ok();
// Close (FIN after the flushed response) so the client detects end-of-response.
let _ = stream.shutdown(std::net::Shutdown::Both);
}
let _ = peer;
Ok(())
}
@@ -171,7 +170,7 @@ fn parse_request(head: &str, body: String) -> Request {
}
}
fn handle_request(req: &Request, state: &AppState) -> String {
fn handle_request(req: &Request, state: &AppState, peer: Option<SocketAddr>) -> String {
match req.method.as_str() {
"OPTIONS" => response(
&req.cseq,
@@ -216,16 +215,30 @@ fn handle_request(req: &Request, state: &AppState) -> String {
response(&req.cseq, &[], None)
}
"PLAY" => {
// The RTSP/UDP media plane is UNAUTHENTICATED. A stream may start only for the paired
// client that completed the pairing-gated `/launch` (which set `state.launch`), and —
// when the launching IP is known — only from that same source IP. So an unpaired RTSP
// peer can neither start a stream on an idle host nor ride a paired client's active
// launch (security-review 2026-06-28 #4). `nvhttp` gates `/launch` on a pinned cert.
let launch = *state.launch.lock().unwrap();
let Some(ls) = launch else {
tracing::warn!(?peer, "RTSP PLAY — refused: no paired `/launch` session");
return response_status("401 Unauthorized", &req.cseq, &[], None);
};
if let (Some(want), Some(got)) = (ls.peer_ip, peer.map(|p| p.ip())) {
if want != got {
tracing::warn!(
%want, %got,
"RTSP PLAY — refused: peer IP does not match the launching client"
);
return response_status("401 Unauthorized", &req.cseq, &[], None);
}
}
let cfg = *state.stream.lock().unwrap();
match cfg {
Some(cfg) if !state.streaming.swap(true, Ordering::SeqCst) => {
// Resolve the launched catalog entry (session recipe) for the stream.
let app = state
.launch
.lock()
.unwrap()
.map(|l| l.appid)
.and_then(super::apps::by_id);
let app = super::apps::by_id(ls.appid);
tracing::info!(app = ?app.as_ref().map(|a| &a.title), "RTSP PLAY — starting video stream");
stream::start(
cfg,
@@ -243,18 +256,15 @@ fn handle_request(req: &Request, state: &AppState) -> String {
// Audio runs independently (Opus on UDP 48000, stereo or 5.1/7.1 multistream per
// the ANNOUNCE); it needs the launch key for the AES-CBC payload encryption the
// client expects.
let launch = *state.launch.lock().unwrap();
if let Some(ls) = launch {
if !state.audio_streaming.swap(true, Ordering::SeqCst) {
tracing::info!("RTSP PLAY — starting audio stream");
audio::start(
state.audio_streaming.clone(),
ls.gcm_key,
ls.rikeyid,
*state.audio_params.lock().unwrap(),
state.audio_cap.clone(),
);
}
if !state.audio_streaming.swap(true, Ordering::SeqCst) {
tracing::info!("RTSP PLAY — starting audio stream");
audio::start(
state.audio_streaming.clone(),
ls.gcm_key,
ls.rikeyid,
*state.audio_params.lock().unwrap(),
state.audio_cap.clone(),
);
}
response(&req.cseq, &[("Session", "DEADBEEFCAFE;timeout = 90")], None)
}
+12 -4
View File
@@ -24,6 +24,12 @@ use std::sync::Arc;
#[derive(Clone)]
pub(crate) struct PeerCertFingerprint(pub Option<String>);
/// The TCP source address of an HTTPS request, injected per-connection by [`serve_https`]. Used by
/// `/launch` to record which paired client owns the session so the unauthenticated RTSP/UDP media
/// plane can bind to that peer's IP (security-review 2026-06-28 #4).
#[derive(Clone, Copy)]
pub(crate) struct PeerAddr(pub SocketAddr);
/// HTTPS server that surfaces the verified client cert to handlers. `axum_server` can't expose the
/// peer cert, so this runs the rustls handshake itself (tokio-rustls), reads the peer certificate,
/// and serves the axum `Router` over hyper with the peer's fingerprint attached to every request as
@@ -39,7 +45,7 @@ pub(crate) async fn serve_https(
.await
.with_context(|| format!("bind HTTPS {bind}"))?;
loop {
let (tcp, _peer) = match listener.accept().await {
let (tcp, peer) = match listener.accept().await {
Ok(v) => v,
Err(e) => {
tracing::warn!(error = %e, "HTTPS accept failed");
@@ -63,14 +69,16 @@ pub(crate) async fn serve_https(
.peer_certificates()
.and_then(|c| c.first())
.map(|c| hex::encode(punktfunk_core::quic::endpoint::cert_fingerprint(c.as_ref())));
let peer = PeerCertFingerprint(fp);
let fp = PeerCertFingerprint(fp);
let addr = PeerAddr(peer);
let svc =
hyper::service::service_fn(move |req: hyper::Request<hyper::body::Incoming>| {
let app = app.clone();
let peer = peer.clone();
let fp = fp.clone();
async move {
let mut req = req.map(axum::body::Body::new);
req.extensions_mut().insert(peer);
req.extensions_mut().insert(fp);
req.extensions_mut().insert(addr);
app.oneshot(req).await // Router error is Infallible
}
});
+1 -3
View File
@@ -76,9 +76,7 @@ pub fn open(backend: Backend) -> Result<Box<dyn InputInjector>> {
#[cfg(target_os = "linux")]
{
Ok(Box::new(libei::LibeiInjector::open_with(
libei::EiSource::SocketPathFile(
crate::vdisplay::gamescope_ei_socket_file().into(),
),
libei::EiSource::SocketPathFile(crate::vdisplay::gamescope_ei_socket_file()),
)?))
}
#[cfg(not(target_os = "linux"))]
@@ -305,6 +305,19 @@ async fn connect_socket_file(file: &std::path::Path) -> Result<UnixStream> {
let deadline = std::time::Instant::now() + Duration::from_secs(15);
let mut logged = String::new();
loop {
// Defense-in-depth: never follow a symlinked relay file. It lives under `$XDG_RUNTIME_DIR`
// (per-user 0700) so a cross-user plant is already blocked, but refuse a symlink outright
// rather than read through one to an attacker-chosen target (a rogue EIS server would
// keylog/deny the session's input; security-review 2026-06-28 #6).
if std::fs::symlink_metadata(file)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
{
return Err(anyhow!(
"EIS relay file {} is a symlink — refusing to follow it",
file.display()
));
}
if let Ok(s) = std::fs::read_to_string(file) {
let name = s.trim();
if !name.is_empty() {
+22 -3
View File
@@ -577,10 +577,11 @@ impl LibraryProvider for EpicProvider {
if p.extension().and_then(|e| e.to_str()) != Some("item") {
continue;
}
let Ok(text) = std::fs::read_to_string(&p) else {
// `.item` manifests are small JSON; cap the read so a planted giant can't OOM the host.
let Some(bytes) = read_capped(&p, 1024 * 1024) else {
continue;
};
let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) else {
let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
continue;
};
if let Some(g) = epic_entry(&v, &art) {
@@ -650,6 +651,23 @@ fn epic_entry(
})
}
/// Read a launcher cache/manifest with a hard size cap, so a local unprivileged user can't plant a
/// multi-GB file under the launcher's (Users-writable) data dir that OOMs the privileged host when
/// it's loaded — then base64/JSON-decoded into further copies — during library enumeration
/// (security-review 2026-06-28 S4). Returns `None` if missing, empty, or over `max`. Mirrors the
/// Linux lutris-art reader's 1 MiB cap.
#[cfg(windows)]
fn read_capped(path: &Path, max: u64) -> Option<Vec<u8>> {
let meta = std::fs::metadata(path).ok()?;
if meta.len() == 0 || meta.len() > max {
if meta.len() > max {
tracing::warn!(path = %path.display(), len = meta.len(), max, "launcher cache exceeds size cap — skipping");
}
return None;
}
std::fs::read(path).ok()
}
/// Best-effort parse of `catcache.bin` (base64-encoded JSON array of catalog items) into
/// catalogItemId → [`Artwork`] from each item's `keyImages`. Empty map on any read/decode failure
/// (the format is community-reverse-engineered + can lag a fresh install → titles just show no art).
@@ -657,7 +675,8 @@ fn epic_entry(
fn epic_art_index(catcache: &Path) -> std::collections::HashMap<String, Artwork> {
use base64::Engine as _;
let mut map = std::collections::HashMap::new();
let Ok(raw) = std::fs::read(catcache) else {
// 32 MiB cap: comfortably fits a real catalog cache, blocks a planted giant (S4).
let Some(raw) = read_capped(catcache, 32 * 1024 * 1024) else {
return map;
};
let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(raw) else {
+2
View File
@@ -1680,6 +1680,7 @@ mod tests {
height: 1440,
fps: 120,
appid: 1,
peer_ip: None,
});
state.streaming.store(true, Ordering::SeqCst);
@@ -1805,6 +1806,7 @@ mod tests {
height: 1080,
fps: 60,
appid: 1,
peer_ip: None,
});
let del = axum::http::Request::delete("/api/v1/session")
+13 -17
View File
@@ -11,9 +11,6 @@
use anyhow::{Context, Result};
use rand::RngCore;
use std::fs;
use std::io::Write;
#[cfg(unix)]
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use std::path::Path;
const ENV_VAR: &str = "PUNKTFUNK_MGMT_TOKEN";
@@ -38,9 +35,11 @@ pub fn load_or_generate() -> Result<String> {
rand::thread_rng().fill_bytes(&mut buf);
let token = hex::encode(buf);
let dir = crate::gamestream::config_dir();
fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
// Owner-private dir (0700 Unix / DACL-locked Windows) so the token can't leak via the config path.
crate::gamestream::create_private_dir(&dir)
.with_context(|| format!("create {}", dir.display()))?;
write_token(&path, &token)?;
tracing::info!(path = %path.display(), "generated and persisted management API token (0600)");
tracing::info!(path = %path.display(), "generated and persisted management API token (owner-only)");
Ok(token)
}
@@ -55,19 +54,15 @@ fn parse_token(contents: &str) -> Option<String> {
(!tok.is_empty()).then(|| tok.to_string())
}
/// Write `PUNKTFUNK_MGMT_TOKEN=<token>` to `path`, mode 0600 (never briefly world-readable).
/// Write `PUNKTFUNK_MGMT_TOKEN=<token>` to `path` as an owner-only secret — 0600 on Unix AND
/// DACL-locked to SYSTEM/Administrators on Windows. Routes through the shared `write_secret_file` so
/// the mgmt bearer token (full admin authority) gets the SAME Windows lockdown as the host key; the
/// bespoke `cfg(unix)`-only writer used to leave it readable by any local user (security-review
/// 2026-06-28 #2).
fn write_token(path: &Path, token: &str) -> Result<()> {
let mut opts = fs::OpenOptions::new();
opts.write(true).create(true).truncate(true);
#[cfg(unix)]
opts.mode(0o600);
let mut f = opts
.open(path)
.with_context(|| format!("write {}", path.display()))?;
writeln!(f, "PUNKTFUNK_MGMT_TOKEN={token}")?;
#[cfg(unix)]
let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
Ok(())
let line = format!("PUNKTFUNK_MGMT_TOKEN={token}\n");
crate::gamestream::write_secret_file(path, line.as_bytes())
.with_context(|| format!("write {}", path.display()))
}
#[cfg(test)]
@@ -95,6 +90,7 @@ mod tests {
assert_eq!(parse_token(&read).as_deref(), Some("cafef00d"));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
}
+53 -25
View File
@@ -497,7 +497,7 @@ async fn serve_session(
opts: &Punktfunk1Options,
audio_cap: &AudioCapSlot,
inj_tx: std::sync::mpsc::Sender<InputEvent>,
mic_tx: std::sync::mpsc::Sender<Vec<u8>>,
mic_tx: std::sync::mpsc::SyncSender<Vec<u8>>,
host_fp: &[u8; 32],
np: &NativePairing,
last_pairing: &std::sync::Mutex<Option<std::time::Instant>>,
@@ -597,9 +597,11 @@ async fn serve_session(
// we look it up in OUR library so a client can't inject a command). The bare-spawn gamescope
// backend picks this up via the `PUNKTFUNK_GAMESCOPE_APP` env fallback in `spawn` (on a shared
// desktop / attach-to-existing session it's a harmless no-op). This is the process-global env
// path — safe under today's ONE-session-at-a-time model; when concurrent native sessions land
// (`what's left` §3), resolve the command into the per-session VirtualDisplay via
// `set_launch_command` (as the GameStream path now does) so sessions can't stomp each other.
// path; the write is serialized via `vdisplay::with_env_lock` so concurrent native-session
// handshakes can't race the `set_var` (security-review 2026-06-28 #7). The remaining
// cross-session *value* confusion (B's launch id stomping A's pending gamescope spawn) wants
// the command resolved into the per-session VirtualDisplay via `set_launch_command` (as the
// GameStream path does) — a follow-up; the data-race UB is closed here.
if let Some(id) = hello.launch.as_deref() {
// Linux: resolve the id to a gamescope-nested command and stash it in the env the
// gamescope backend reads. Windows has no gamescope to nest into — the data plane launches
@@ -609,7 +611,9 @@ async fn serve_session(
match crate::library::launch_command(id) {
Some(cmd) => {
tracing::info!(launch_id = id, command = %cmd, "launching library title");
std::env::set_var("PUNKTFUNK_GAMESCOPE_APP", &cmd);
crate::vdisplay::with_env_lock(|| {
std::env::set_var("PUNKTFUNK_GAMESCOPE_APP", &cmd)
});
}
None => tracing::warn!(
launch_id = id,
@@ -907,8 +911,9 @@ async fn serve_session(
while let Ok(d) = input_conn.read_datagram().await {
if let Some((_seq, _pts, opus)) = punktfunk_core::quic::decode_mic_datagram(&d) {
mic_count += 1;
// Host-lifetime mic service; a send error just means the host is shutting down.
let _ = mic_tx.send(opus.to_vec());
// Host-lifetime mic service (bounded queue): `try_send` drops the frame when the
// service is full or gone, never blocking this datagram loop (security-review S6).
let _ = mic_tx.try_send(opus.to_vec());
} else if let Some(rich) = punktfunk_core::quic::RichInput::decode(&d) {
rich_count += 1;
if rich_tx.send(rich).is_err() {
@@ -1185,6 +1190,8 @@ const INJECTOR_REOPEN_BACKOFF: std::time::Duration = std::time::Duration::from_s
/// Mic is 48 kHz stereo — matches the Opus stereo decoder and the host→client audio layout.
const MIC_CHANNELS: u32 = 2;
/// Bound for the shared mic frame queue (drop-newest when full). See [`MicService::start`].
const MIC_QUEUE_CAP: usize = 64;
/// Host-lifetime virtual microphone, shared across punktfunk/1 sessions (mirror of
/// [`InjectorService`]). One thread owns the PipeWire `Audio/Source` + an Opus decoder; sessions
@@ -1192,12 +1199,16 @@ const MIC_CHANNELS: u32 = 2;
/// feeds the source. Opened lazily on the first frame, the source node persists across sessions
/// (no per-session registration churn), and reopens after a backoff if the source/decoder fails.
struct MicService {
tx: std::sync::mpsc::Sender<Vec<u8>>,
tx: std::sync::mpsc::SyncSender<Vec<u8>>,
}
impl MicService {
fn start() -> MicService {
let (tx, rx) = std::sync::mpsc::channel::<Vec<u8>>();
// Bounded so the host-lifetime mic queue (shared across all concurrent sessions) can't grow
// without limit under a near-line-rate flood; the producer drops the newest frame when full
// (audio is lossy by design) rather than buffering unboundedly (security-review 2026-06-28
// S6). 64 × 510 ms frames ≈ 0.30.6 s of slack, far more than the decode loop ever lags.
let (tx, rx) = std::sync::mpsc::sync_channel::<Vec<u8>>(MIC_QUEUE_CAP);
if let Err(e) = std::thread::Builder::new()
.name("punktfunk1-mic".into())
.spawn(move || mic_service_thread(rx))
@@ -1209,7 +1220,7 @@ impl MicService {
/// A sender a session forwards the client's Opus mic frames to. Cloned per session; dropping a
/// clone does NOT stop the service (it holds the original sender for the host life).
fn sender(&self) -> std::sync::mpsc::Sender<Vec<u8>> {
fn sender(&self) -> std::sync::mpsc::SyncSender<Vec<u8>> {
self.tx.clone()
}
}
@@ -1224,14 +1235,17 @@ fn mic_service_thread(rx: std::sync::mpsc::Receiver<Vec<u8>>) {
/// The host-lifetime mic worker: lazily open the virtual mic + decoder, then Opus-decode each
/// forwarded frame and push the PCM into the source. Reopen (after [`INJECTOR_REOPEN_BACKOFF`])
/// on open failure or a decode error. Exits when every session sender and the service's own
/// sender drop (host shutdown), tearing the virtual mic down. Linux = PipeWire `Audio/Source`;
/// Windows = a virtual audio device's render endpoint (see `audio::wasapi_mic`).
/// only on a backend OPEN failure; a per-frame Opus DECODE error is just a dropped frame (it must
/// not tear down this mic, which is shared across every concurrent session — otherwise one paired
/// client's junk frames would deny everyone's mic; security-review 2026-06-28 S2). Exits when every
/// session sender and the service's own sender drop (host shutdown), tearing the virtual mic down.
/// Linux = PipeWire `Audio/Source`; Windows = a virtual audio device's render endpoint.
#[cfg(any(target_os = "linux", target_os = "windows"))]
fn mic_service_thread(rx: std::sync::mpsc::Receiver<Vec<u8>>) {
let mut mic: Option<Box<dyn crate::audio::VirtualMic>> = None;
let mut decoder: Option<opus::Decoder> = None;
let mut last_failed: Option<std::time::Instant> = None;
let mut decode_fails: u64 = 0;
let mut pcm = vec![0f32; 5760 * MIC_CHANNELS as usize]; // up to 120 ms scratch
for opus_frame in rx {
if opus_frame.is_empty() {
@@ -1267,12 +1281,16 @@ fn mic_service_thread(rx: std::sync::mpsc::Receiver<Vec<u8>>) {
Ok(samples_per_ch) => {
let total = (samples_per_ch * MIC_CHANNELS as usize).min(pcm.len());
m.push(&pcm[..total]);
decode_fails = 0;
}
Err(e) => {
tracing::warn!(error = %e, "mic opus decode failed — reopening");
mic = None;
decoder = None;
last_failed = Some(std::time::Instant::now());
// Malformed/garbage frame: drop it and keep the (shared) mic + decoder open. The
// next valid frame decodes normally; only a backend OPEN failure reopens. Throttle
// the log (1, 2, 4, … fails) so a junk flood can't spam.
decode_fails += 1;
if decode_fails.is_power_of_two() {
tracing::warn!(error = %e, fails = decode_fails, "mic opus decode failed — dropping frame");
}
}
}
}
@@ -1454,8 +1472,14 @@ fn input_thread(
// left-button-down then turns every later click into a drag: windows move, but clicking buttons
// and text inputs does nothing). We synthesize the matching up-events when this session ends —
// see the release loop after the `break`.
let mut held_buttons: Vec<u32> = Vec::new();
let mut held_keys: Vec<u32> = Vec::new();
// Sets (not Vecs) so the presence test is O(1), not O(n) per event, and bounded by `MAX_HELD`
// so a client flooding distinct never-released codes can't grow the tracking state or spike the
// input thread (security-review 2026-06-28 S3). A real keyboard+mouse holds far fewer at once;
// codes past the cap simply aren't tracked for end-of-session release (worst case: one unreleased
// key on a pathological disconnect, which the injector's own state still bounds).
const MAX_HELD: usize = 256;
let mut held_buttons: std::collections::HashSet<u32> = std::collections::HashSet::new();
let mut held_keys: std::collections::HashSet<u32> = std::collections::HashSet::new();
loop {
match rx.recv_timeout(std::time::Duration::from_millis(4)) {
Ok(ev) => match ev.kind {
@@ -1473,14 +1497,18 @@ fn input_thread(
_ => {
// Track press/release so a mid-press disconnect can be undone below.
match ev.kind {
InputKind::MouseButtonDown if !held_buttons.contains(&ev.code) => {
held_buttons.push(ev.code)
InputKind::MouseButtonDown if held_buttons.len() < MAX_HELD => {
held_buttons.insert(ev.code);
}
InputKind::MouseButtonUp => held_buttons.retain(|&c| c != ev.code),
InputKind::KeyDown if !held_keys.contains(&ev.code) => {
held_keys.push(ev.code)
InputKind::MouseButtonUp => {
held_buttons.remove(&ev.code);
}
InputKind::KeyDown if held_keys.len() < MAX_HELD => {
held_keys.insert(ev.code);
}
InputKind::KeyUp => {
held_keys.remove(&ev.code);
}
InputKind::KeyUp => held_keys.retain(|&c| c != ev.code),
_ => {}
}
// Pointer/keyboard → the host-lifetime injector service (one persistent
+24 -6
View File
@@ -358,13 +358,30 @@ fn find_wayland_socket(runtime: &str, uid: u32) -> Option<String> {
cands.into_iter().next().map(|(_, n)| n)
}
/// Serializes ALL process-global env mutation on the per-session setup path. `std::env::set_var`
/// concurrent with another thread's `set_var` (glibc `environ` realloc) is a data race = UB. With
/// the default concurrent native sessions each running `resolve_compositor` in its own
/// `spawn_blocking`, the per-session env retargeting would otherwise race and could crash the host
/// (security-review 2026-06-28 #7). Every env write on the setup path takes this lock; steady-state
/// streaming reads cached config, not env. This removes the memory-unsafety; it is NOT a full fix
/// for cross-session env *value* confusion (that needs per-session `SessionContext` threading, as the
/// GameStream/Windows path already does via `set_launch_command`).
pub static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Run `f` with [`ENV_LOCK`] held. Use around any `set_var`/`remove_var` on the session-setup path.
pub fn with_env_lock<R>(f: impl FnOnce() -> R) -> R {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
f()
}
/// Write a detected session's [`SessionEnv`] into the process env so every backend (video capture
/// and input alike) that reads `WAYLAND_DISPLAY` / `XDG_RUNTIME_DIR` / `DBUS_SESSION_BUS_ADDRESS` /
/// `XDG_CURRENT_DESKTOP` at open time targets the live session. The host serves one session at a
/// time, so a process-global write is sound; the next connect re-detects and re-applies. Same
/// `set_var` discipline already used for `PUNKTFUNK_GAMESCOPE_APP` on the launch path.
/// `XDG_CURRENT_DESKTOP` at open time targets the live session. Serialized via [`ENV_LOCK`] so
/// concurrent session handshakes can't race the `set_var`s; the next connect re-detects and
/// re-applies. Same `set_var` discipline used for `PUNKTFUNK_GAMESCOPE_APP` on the launch path.
#[cfg(target_os = "linux")]
pub fn apply_session_env(active: &ActiveSession) {
let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let e = &active.env;
std::env::set_var("XDG_RUNTIME_DIR", &e.xdg_runtime_dir);
std::env::set_var("DBUS_SESSION_BUS_ADDRESS", &e.dbus_session_bus_address);
@@ -455,6 +472,7 @@ pub fn settle_desktop_portal(_chosen: Compositor) {}
/// `PUNKTFUNK_GAMESCOPE_MANAGED` forces managed over either.
#[cfg(target_os = "linux")]
pub fn apply_input_env(chosen: Compositor) {
let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let backend = match chosen {
Compositor::Gamescope => "gamescope",
// KWin: org_kde_kwin_fake_input — direct injection, no RemoteDesktop portal / approval
@@ -587,10 +605,10 @@ pub fn probe(compositor: Compositor) -> Result<()> {
}
/// Path of the file where the gamescope backend relays the nested session's `LIBEI_SOCKET`
/// (gamescope's EIS server) for the input injector.
/// (gamescope's EIS server) for the input injector. Under `$XDG_RUNTIME_DIR` (per-user 0700).
#[cfg(target_os = "linux")]
pub fn gamescope_ei_socket_file() -> &'static str {
gamescope::EI_SOCKET_FILE
pub fn gamescope_ei_socket_file() -> std::path::PathBuf {
gamescope::ei_socket_file()
}
/// Call when a client session ends: if the host-managed gamescope path took over a box's autologin
@@ -670,11 +670,11 @@ pub fn start_restore_worker() -> std::sync::Arc<()> {
}
/// Point the libei injector at the running gamescope's EIS socket (it reads the relay file
/// [`EI_SOCKET_FILE`]). Best-effort — video still works without it (input just won't reach the
/// [`ei_socket_file`]). Best-effort — video still works without it (input just won't reach the
/// session). Shared by the attach and host-managed-session paths.
fn point_injector_at_eis() {
match find_gamescope_eis_socket() {
Some(sock) => match std::fs::write(EI_SOCKET_FILE, &sock) {
Some(sock) => match std::fs::write(ei_socket_file(), &sock) {
Ok(()) => {
tracing::info!(socket = %sock, "gamescope: pointed injector at the session's EIS socket")
}
@@ -770,18 +770,31 @@ fn stop_session(unit_name: &str) {
let _ = Command::new("systemctl")
.args(["--user", "stop", unit_name])
.status();
let _ = std::fs::remove_file(EI_SOCKET_FILE);
let _ = std::fs::remove_file(ei_socket_file());
}
/// File where the wrapper below writes gamescope's `LIBEI_SOCKET` (its EIS server socket),
/// read by the libei injector to drive input into the nested app. See [`crate::inject`].
pub const EI_SOCKET_FILE: &str = "/tmp/punktfunk-gamescope-ei";
/// File where the wrapper below writes gamescope's `LIBEI_SOCKET` (its EIS server socket), read by
/// the libei injector to drive input into the nested app. See [`crate::inject`].
///
/// Placed under `$XDG_RUNTIME_DIR` (a per-user, 0700 directory) — NOT a world-writable `/tmp` —
/// so a second unprivileged local user can neither read the relayed socket path nor pre-plant the
/// file to redirect the host's injector to a rogue EIS server (which would let them keylog or deny
/// the remote session's keyboard/mouse input; security-review 2026-06-28 #6). Falls back to `/tmp`
/// only if `XDG_RUNTIME_DIR` is unset (gamescope itself requires it, so this is rare); the reader
/// ([`crate::inject`]) additionally rejects a symlinked relay file as defense-in-depth.
pub fn ei_socket_file() -> std::path::PathBuf {
let runtime = crate::vdisplay::with_env_lock(|| std::env::var_os("XDG_RUNTIME_DIR"));
match runtime {
Some(rt) if !rt.is_empty() => std::path::PathBuf::from(rt).join("punktfunk-gamescope-ei"),
_ => std::path::PathBuf::from("/tmp/punktfunk-gamescope-ei"),
}
}
/// Spawn `gamescope --backend headless -W w -H h -r hz -- <app>`. The app comes from
/// `PUNKTFUNK_GAMESCOPE_APP` (default a no-op that just keeps gamescope alive — set it to a real
/// game/GL app for actual content, e.g. `steam -gamepadui` for the SteamOS-like session).
/// 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.
fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>) -> Result<Child> {
// A non-empty per-session command (set via `set_launch_command`) wins; else the
@@ -791,10 +804,15 @@ fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>) -> Result<Child> {
let app = cmd
.map(str::to_string)
.filter(|s| !s.trim().is_empty())
.or_else(|| std::env::var("PUNKTFUNK_GAMESCOPE_APP").ok())
// Read the env fallback under the shared env lock so it can't race a concurrent session's
// `set_var` of the same key (security-review 2026-06-28 #7).
.or_else(|| {
crate::vdisplay::with_env_lock(|| 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 relay = ei_socket_file();
let _ = std::fs::remove_file(&relay); // stale socket path from a previous session
let mut cmd = Command::new("gamescope");
cmd.args(["--backend", "headless"])
.args(["-W", &w.to_string()])
@@ -804,7 +822,10 @@ fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>) -> Result<Child> {
.args([
"sh",
"-c",
&format!("printf %s \"$LIBEI_SOCKET\" > {EI_SOCKET_FILE}; exec \"$@\""),
&format!(
"printf %s \"$LIBEI_SOCKET\" > '{}'; exec \"$@\"",
relay.display()
),
"sh",
])
.args(app.split_whitespace())
@@ -997,7 +1018,7 @@ impl Drop for GamescopeProc {
let _ = self.0.wait();
// Clear the relayed EIS socket name so the host-lifetime injector can't reconnect to this
// now-dead session's socket between sessions (the stale path is the "Connection refused").
let _ = std::fs::remove_file(EI_SOCKET_FILE);
let _ = std::fs::remove_file(ei_socket_file());
}
}
+9 -2
View File
@@ -271,8 +271,11 @@ fn set_web_password(pw_path: &Path, pw_file: Option<&str>) {
}
});
if let Some(pw) = password {
if std::fs::write(pw_path, format!("PUNKTFUNK_UI_PASSWORD={pw}\n")).is_err() {
eprintln!("warning: could not write {}", pw_path.display());
// Create the file EMPTY first, lock its DACL, THEN write the secret — so the cleartext
// password is never present at the inherited (Users-readable) %ProgramData% ACL, even for
// the brief window before icacls runs (security-review 2026-06-28 #8).
if std::fs::write(pw_path, b"").is_err() {
eprintln!("warning: could not create {}", pw_path.display());
return;
}
// Lock down: drop inheritance, grant only Administrators (S-1-5-32-544) + SYSTEM (S-1-5-18).
@@ -287,6 +290,10 @@ fn set_web_password(pw_path: &Path, pw_file: Option<&str>) {
"*S-1-5-18:F",
],
);
// Now write the secret into the already-locked file (truncate keeps the explicit DACL).
if std::fs::write(pw_path, format!("PUNKTFUNK_UI_PASSWORD={pw}\n")).is_err() {
eprintln!("warning: could not write {}", pw_path.display());
}
}
}
+12 -4
View File
@@ -114,13 +114,15 @@ pub fn main(args: &[String]) -> Result<()> {
/// stdout/stderr are redirected to `host.log` in the same dir.
pub fn service_log_path() -> PathBuf {
let dir = crate::gamestream::config_dir().join("logs");
let _ = std::fs::create_dir_all(&dir);
// DACL-locked (Users read-only, no create) so a local user can't pre-plant SYSTEM log files as
// reparse points / hardlinks to redirect the SYSTEM service's writes (security-review #11).
let _ = crate::gamestream::create_private_dir(&dir);
dir.join("service.log")
}
fn host_log_path() -> PathBuf {
let dir = crate::gamestream::config_dir().join("logs");
let _ = std::fs::create_dir_all(&dir);
let _ = crate::gamestream::create_private_dir(&dir);
dir.join("host.log")
}
@@ -684,7 +686,9 @@ fn ensure_default_host_env() -> Result<()> {
return Ok(());
}
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).ok();
// DACL-lock the config dir on creation so a local user can't pre-create it and plant a
// host.env (which feeds the SYSTEM service's env + command line) — security-review #3.
crate::gamestream::create_private_dir(dir).ok();
}
let default = "# punktfunk host configuration (read by the Windows service).\n\
# KEY=VALUE per line; '#' comments. Restart the service after editing:\n\
@@ -707,7 +711,11 @@ fn ensure_default_host_env() -> Result<()> {
\n\
# Force a specific render GPU by name substring (multi-GPU boxes only):\n\
# PUNKTFUNK_RENDER_ADAPTER=4090\n";
std::fs::write(&path, default).with_context(|| format!("write {}", path.display()))?;
// Write host.env DACL-locked to SYSTEM/Administrators: it controls the SYSTEM service's
// environment + launched command line, so a local user must not be able to read or tamper with
// it (security-review 2026-06-28 #3).
crate::gamestream::write_secret_file(&path, default.as_bytes())
.with_context(|| format!("write {}", path.display()))?;
println!("Wrote default config: {}", path.display());
Ok(())
}
+481
View File
@@ -0,0 +1,481 @@
# punktfunk host — security audit (2026-06-28, follow-up)
> **Status:** AUDIT COMPLETE (2026-06-28). Follow-up to the 2026-06-21 whole-project review
> ([`security-review.md`](security-review.md)), scoped to the privileged streaming **host**
> (`crates/punktfunk-host`) — re-verifying the prior 12 findings and hunting the code added since
> (`library.rs` + store providers, `stats_recorder.rs`, `kwin_fake_input.rs`, session-watch /
> Desktop↔Game follow, "launch apps on Windows/Linux non-gamescope hosts", "driver/web install into
> the host exe"). Method: a multi-agent fan-out over **18 attack surfaces** (13 in pass 1 + 5
> gap-driven in pass 2), every candidate finding **adversarially double-verified** from two
> independent lenses (reachability/attacker-control + existing-mitigation/correctness), plus a
> coverage-gap critic. **15 confirmed + 9 partial** issues carried; **8 refuted** recorded for
> completeness. No memory-unsafety or RCE on attacker wire bytes was found; the residual risk is in
> dependency hygiene, the opt-in GameStream surface, and Windows local-privilege ACLs.
## Remediation status (2026-06-28)
Fixes landed on `main` in `3532e35` (Linux/cross-platform, cargo check/clippy/test green here) and
`6f903f7` (Windows `#[cfg(windows)]` DACL paths — verify in CI / on the RTX box; this Linux dev VM
can't compile MSVC). Items whose fix would risk a validated pipeline, or that have no upstream
remedy, are deferred/accepted with a reason.
| # | Sev | Status |
|---|-----|--------|
| S1 | High | **FIXED** (`3532e35`) — `quinn-proto` → 0.11.15 (RUSTSEC-2026-0185) |
| #1 | High | **FIXED** (`3532e35`) — unauthenticated nvhttp `GET /pin` removed; PIN only via bearer mgmt API |
| #2 | High | **FIXED** (`6f903f7`, *Win CI/box pending*) — mgmt token written via `write_secret_file` (SYSTEM/Admins DACL) |
| #3 | High | **FIXED** (`6f903f7`, *Win CI/box pending*) — config dir DACL-locked + re-owned; `host.env` locked. Residual: a host.env planted before the very first DACL apply is still loaded (an owner-check on load is a noted follow-up) |
| #4 | High→Med | **FIXED** (`3532e35`) — RTSP/PLAY gated on a paired `/launch` + bound to the launching peer's IP |
| #5 | Med | **DEFERRED** — the shared-section SDDL is permissive for a restricted-token UMDF driver; scoping it needs on-box validation to avoid breaking the live-validated gamepad/IDD pipeline |
| #6 | Med | **FIXED** (`3532e35`) — EIS relay moved to `$XDG_RUNTIME_DIR` (0700) + symlink reject |
| #7 | Med→Low | **FIXED** (`3532e35`) — `vdisplay::ENV_LOCK` serializes setup-path env mutation (data-race UB closed); full per-session `SessionContext` threading for value-confusion is a follow-up |
| #8 | Low | **FIXED** (`6f903f7`, *Win CI/box pending*) — web-password file created empty → locked → written |
| #9 | Low | **ACCEPTED** — disarm-on-any-attempt IS the documented single-online-guess (prior-fix #2); the delegated-approval flow is structurally immune. Steer hostile LANs to it |
| #10 | Low | **FIXED** (`3532e35`) — ENet decrypt-failed warn throttled (exponential) |
| #11 | Low | **FIXED** (`6f903f7`, *Win CI/box pending*) — logs dir DACL-locked (subsumed by #3) |
| #12 | Low/Info | **FIXED** (`3532e35`) — parked pairing-waiter cap (+regression test) |
| #13 | Info | **ACCEPTED**`PENDING_CAP` + LRU + `requested_at` refresh make an actively-retrying device non-evictable |
| S2 | LowMed | **FIXED** (`3532e35`) — a malformed Opus frame drops the frame, keeps the shared mic open |
| S3 | Low | **FIXED** (`3532e35`) — held buttons/keys are capped `HashSet`s |
| S4 | Low | **FIXED** (`3532e35`) — Epic launcher-cache reads size-capped |
| S5 | Low→Info | **FIXED** (`3532e35`) — `fps==0`/absurd rejected at the `open_video` chokepoint |
| S6 | Low→Info | **FIXED** (`3532e35`) — shared mic mpsc bounded (drop-newest) |
| S7 | Low→Info | **ACKNOWLEDGED**`rsa 0.9` Marvin has no fixed upstream release; GameStream is off by default and this is a signing (not decryption-oracle) path. Migrate the GameStream identity to Ed25519/ECDSA when feasible |
**Net:** 14 of 18 fixed (5 Linux-verified clusters + 4 Windows DACL paths awaiting CI/box); #5
deferred pending on-box validation; #9/#13 accepted-with-rationale; S7 acknowledged (no upstream fix).
## Consolidated overview & top priorities
The host's **core trust architecture remains sound**: native SPAKE2 pairing (single-use
disarm-before-verify, CSPRNG PIN, sanitized device names, atomic+rollback persist), post-pair
cert-pinning that verifies the real `CertificateVerify` signature, the management API authn/authz
split (read-only-cert allowlist vs. bearer-gated mutations), uniformly bounds-checked client→host
wire decoders (no reachable parse panic/OOB), memory-safe client-geometry→encoder/FFI paths, a clean
driver-IPC ABI, and a fail-closed app-layer pairing gate. The new library/launch surface is notably
well-defended against the network adversary (client ids resolve against the host's own catalog,
argv-only, no shell, **no SSRF**). Most prior fixes are present and not regressed.
The real risk clusters in **three** places: (1) a **vulnerable QUIC dependency on the always-on
default listener**, (2) the **opt-in GameStream/Moonlight compatibility surface** (two pre-auth
boundary bypasses), and (3) **Windows `%ProgramData%` ACLs** (the prior secret-file fix did not cover
the directory or two newer writers).
**Fix promptly (priority order):**
| P | Finding | Sev | Auth | Surface |
|---|---------|-----|------|---------|
| 1 | **S1** `quinn-proto 0.11.14` (RUSTSEC-2026-0185) → pre-auth remote memory-exhaustion DoS on the **default** `serve` QUIC listener | High | pre-auth | dep / native QUIC |
| 2 | **#1** Unauthenticated GameStream `GET /pin` → full pre-auth self-pairing (consent bypass) → capture + input injection | High | pre-auth | GameStream (opt-in) |
| 3 | **#2** Windows mgmt bearer token written without DACL — any local user reads the admin credential | High | local | secrets |
| 4 | **#3** `%ProgramData%\punktfunk` dir + `host.env` not DACL-locked → local user → SYSTEM env/arg injection (LPE) | High | local | Windows service |
| 5 | **#4** Pre-auth RTSP/UDP media plane has no pairing gate → desktop disclosure (portal) + stream-slot DoS | High→Med | pre-auth | GameStream (opt-in) |
**Medium:** **#5** Windows gamepad/IDD shared sections `Everyone:GENERIC_ALL` (local input-inject /
screen read) · **#6** gamescope EIS socket via predictable `/tmp` relay (local keylog / input DoS) ·
**#7** process-global env retargeting unsound under default concurrent sessions (`set_var`/`getenv`
data-race UB → host-wide DoS; the live form of deferred prior-fix #7) · **S2** malformed client Opus
frame tears down the shared host-lifetime virtual mic (cross-session DoS).
**Low / info:** **#8** `web-password` write-then-`icacls` TOCTOU · **#9** pairing-window-burn DoS ·
**#10** ENet control-flood warn-log spam · **#11** SYSTEM `host.log` link-redirection (sub-case of
#3) · **#12** legacy pairing no rate-limit · **#13** pending-approval queue flood · **S3** unbounded
held-button/key `Vec` growth · **S4** unbounded read of Epic launcher caches · **S5** refresh/fps
lower-bound unvalidated on the Hello path (self-inflicted single-session panic) · **S6** unbounded
mpsc into the shared mic service · **S7** `rsa 0.9` Marvin advisory on the opt-in GameStream signing
path (not practically reachable).
**Highest-leverage remediations** (each closes a cluster): (a) `cargo update -p quinn-proto
--precise 0.11.15` + wire `cargo audit` into CI as a failing gate; (b) delete the unauthenticated
nvhttp `/pin` and bind RTSP/PLAY to a paired `/launch` session; (c) DACL-lock the Windows config
directory and route **all** config/secret writes through `write_secret_file`; (d) thread per-session
launch/compositor/input env through `SessionContext` instead of process-global `std::env`.
---
The two passes' full verified detail follows verbatim (pass 1 = the 13-surface report; pass 2 = the
supplement completing the native-protocol/unsafe-FFI surfaces + coverage-critic gaps), then the
coverage-gap appendix.
---
# Pass 1 — 13-surface report
# punktfunk host — security audit (2026-06-28, follow-up)
**Status:** Follow-up audit of the privileged streaming host (`crates/punktfunk-host`), focused on code added since the 2026-06-21 review (`library.rs` + store providers, `stats_recorder.rs`, `kwin_fake_input.rs`, session-watch/Desktop-Game follow, the "launch apps on Windows/Linux non-gamescope hosts" path, and the "move driver/web install into the host exe" path), plus a regression re-verification of the prior twelve findings. Thirteen surface areas reviewed; every candidate finding was adversarially double-verified. **9 confirmed + 4 partial** issues are carried; **6 refuted** items are recorded for completeness.
## Executive summary
The host's core trust architecture remains sound: the native SPAKE2 pairing ceremony, the post-pair mTLS cert-pinning model, the management API authn/authz split (read-only cert allowlist vs. bearer-gated mutations), and the RTSP/input/gamepad wire parsers are all carefully hardened and, where re-verified, the prior fixes are present and not regressed. The new game-library/launch surface is notably well-defended against the network adversary — client-supplied launch ids are resolved against the host's own scanned catalog, numeric/charset-validated, and spawned argv-based (no shell) on every non-operator path.
The real risks cluster in two places. **First, the opt-in GameStream/Moonlight compatibility surface (`serve --gamestream`) deviates from its own trust boundary in two pre-auth ways:** the legacy nvhttp `GET /pin` endpoint is completely unauthenticated, letting an unpaired LAN peer drive the *entire* pairing ceremony with no operator consent and obtain a persistent paired identity with full capture + input injection (Finding 1, the single highest-leverage issue); and the RTSP/UDP media plane performs no pairing/launch check at all, so an unpaired peer can start capture/encode and receive the desktop stream (Finding 4). Both are gated only by the opt-in `--gamestream` flag and the documented "trusted-LAN-only" posture — but within that supported mode they are genuine pre-auth bypasses of the pairing boundary that `/launch` otherwise enforces.
**Second, the Windows LocalSystem service has three local-privilege gaps rooted in one cause — the prior fix #1 hardened secret *files* but not the `%ProgramData%\punktfunk` *directory* or two newer files written into it.** The management bearer token is written with no Windows DACL (Finding 2), and `host.env` — which feeds the SYSTEM service's environment and command-line arguments — is neither DACL-locked nor is its directory (Finding 3). These give a local unprivileged user a path to the admin management plane and, via directory pre-creation / env injection, toward SYSTEM. On Linux/gamescope, a world-readable `/tmp` EIS-socket relay lets a second local user keylog or deny the remote session's input (Finding 6). The remaining items are lower-severity local IPC ACL over-breadth (gamepad shared memory), a concurrency-introduced `std::env::set_var` data race that is now reachable because concurrent native sessions became the default (Finding 7, the live form of deferred prior-fix #7), and pre-auth DoS edges.
Overall posture is good and improving; the GameStream pairing/media pre-auth bypasses and the Windows config-directory ACL gap are the items that warrant prompt remediation.
## Findings
| # | Severity | Surface | Title | Status |
|---|----------|---------|-------|--------|
| 1 | High | GameStream pairing | Unauthenticated nvhttp `GET /pin` → full pre-auth GameStream self-pairing (consent bypass) | Confirmed |
| 2 | High | Secrets / mgmt | Windows mgmt bearer token written without DACL — local-user disclosure of host admin credential | Confirmed |
| 3 | High | Windows service / config | `%ProgramData%\punktfunk` directory + `host.env` not DACL-locked → local user → SYSTEM env/arg injection | Confirmed (apps.json sub-vector: Partial) |
| 4 | High→Med | GameStream RTSP/media | Pre-auth RTSP ANNOUNCE+PLAY starts capture/encode with no pairing gate (desktop disclosure + stream-slot DoS) | Partial |
| 5 | Medium | Input injection | Windows host↔UMDF gamepad shared sections are `Everyone:GENERIC_ALL` — local cross-session input injection/tamper | Confirmed |
| 6 | Medium | Session lifecycle (gamescope) | EIS socket path relayed via predictable world-accessible `/tmp` file — local keylog / input DoS | Confirmed |
| 7 | Medium→Low | Session lifecycle | Process-global env retargeting unsound under now-default concurrent native sessions (data race + cross-session confusion) | Confirmed |
| 8 | Low | Secrets | `web-password` written world-readable then `icacls`'d — brief TOCTOU disclosure | Confirmed |
| 9 | Low | Native pairing | Unpaired LAN peer can burn the operator's single-use pairing window (pairing-ceremony DoS) | Confirmed |
| 10 | Low | GameStream control | ENet control flood → unbounded per-packet warn-log spam (+ transient CPU) | Confirmed |
| 11 | Low→Info | Windows service | SYSTEM `host.log` predictable name in Users-writable dir (link-redirection of SYSTEM appends) | Partial |
| 12 | Low→Info | GameStream pairing | Legacy pairing has no rate-limit; parks unbounded 300 s waiters | Partial |
| 13 | Info | Native pairing | Pending-approval queue floodable by LAN cert flood (eviction of a genuine knock) | Confirmed |
---
## Finding details (confirmed & partial)
### 1. [High] Unauthenticated nvhttp `GET /pin` enables full pre-auth GameStream self-pairing — *Confirmed*
- **Surface:** GameStream pairing ceremony / nvhttp.
- **Refs:** `gamestream/nvhttp.rs:61`, `nvhttp.rs:85-96` (`h_pin`, plain-HTTP router), `gamestream/pairing.rs:40-43` (`PinGate::submit`), `pairing.rs:102-150` (`getservercert`), `pairing.rs:226-234` (phase 4 / `save_paired`), `crypto.rs:35-40` (`pin_key`).
- **Threat actor:** Malicious network client, **pre-auth** (#1). Requires `serve --gamestream`.
- **Mechanism:** The GameStream PIN is the sole proof of operator consent (`aes_key = SHA-256(salt ‖ pin)`), and the host has no independent knowledge of the correct PIN — it derives the key from whatever is delivered to `PinGate::submit`. The operator-channel (`mgmt` `POST /api/v1/pair/pin`) is bearer-gated for exactly this reason, **but the host also exposes `GET /pin?pin=NNNN` on the unauthenticated nvhttp router with no auth and no `awaiting_pin` guard**, on `0.0.0.0:47989` (plain HTTP) and `:47984`. Because the attacker controls both the `getservercert` request (its own salt + cert) *and* can submit the PIN itself, it supplies both sides of the ceremony. There is no operator "arm pairing" gate for the legacy GameStream path (unlike native SPAKE2).
- **Attack scenario:** (1) Attacker sends `GET /pair?phrase=getservercert&uniqueid=X&salt=<32hex>&clientcert=<own-cert-hex>` → parks on `pin.take(300s)`. (2) Attacker sends unauthenticated `GET /pin?pin=4242` → the parked `take()` returns it; host computes `aes_key = SHA-256(attacker_salt ‖ "4242")`, which the attacker also knows. (3) Attacker completes `clientchallenge`/`serverchallengeresp`/`clientpairingsecret` (all derivable — it knows the key and owns its cert); phase 4 pins the attacker cert via `save_paired`. (4) Attacker reconnects over HTTPS:47984 with its now-pinned cert; `peer_is_paired()` is true → `/launch` + `/applist` succeed → desktop capture and keyboard/mouse/gamepad injection on the privileged host. **No operator action at any step.**
- **Existing mitigations:** GameStream is opt-in and documented "trusted-LAN only"; default `serve` does not start nvhttp. The post-pair launch surface is correctly gated by `peer_is_paired` — it just gets satisfied because the attacker self-pairs. None of these is a control on `/pin`.
- **Verifier adjudication:** Both verifiers **confirmed reachable + attacker-controlled**, downgrading the original *critical* to **high** only because the surface is the opt-in, documented-weaker `--gamestream` mode (smaller affected population than the always-on native listener). This is **not** subsumed by accepted-risk #9 (which covers `/pair` being plain HTTP / a MITM brute-force, not unauthenticated PIN self-delivery).
- **Recommendation:** Remove the unauthenticated nvhttp `GET /pin` endpoint entirely; PIN delivery must come only from the bearer-gated mgmt API. If a nvhttp delivery path must remain, require an explicit operator "arm GameStream pairing" step (mirror native `native_pairing` arm-on-demand) and bind the submitted PIN to that armed window. Ideally have GameStream pairing display a *host-generated* PIN the operator confirms, rather than accepting an arbitrary client-side PIN.
---
### 2. [High] Windows mgmt bearer token written without DACL lockdown — *Confirmed*
- **Surface:** Secret-file permissions / management authz. (Reported independently by two surface auditors; same defect.)
- **Refs:** `mgmt_token.rs:59-71` (`write_token`), `mgmt_token.rs:40-44` (dir via `fs::create_dir_all`), `gamestream/mod.rs:251-261` (`config_dir` = `%ProgramData%\punktfunk`), `gamestream/mod.rs:282-285` (`create_private_dir` is a no-op for ACLs on Windows), `gamestream/mod.rs:293-347` (`write_secret_file`/`restrict_to_system_admins`).
- **Threat actor:** Local unprivileged user (#4). Windows host only (Unix is correctly `O_CREAT 0600`).
- **Mechanism:** The mgmt bearer token grants full admin authority over the management API. It is persisted by `write_token`, the **only** host secret writer that does not route through `write_secret_file → restrict_to_system_admins`; it applies a Unix `0600` mode but has **no `#[cfg(windows)]` arm**. On the LocalSystem service, `config_dir()` is `%ProgramData%\punktfunk`, whose inherited default DACL grants `BUILTIN\Users` read; `create_private_dir` applies no DACL on Windows and explicitly relies on each secret file being individually locked by `write_secret_file`. The token file is therefore left Users-readable. (The host key, cert, and both trust stores *are* locked — the token is the regressed outlier; the `write_secret_file` doc comment ironically claims it "Mirrors the mgmt-token hardening.")
- **Attack scenario:** A local unprivileged user reads `C:\ProgramData\punktfunk\mgmt-token`, then presents `Authorization: Bearer <token>` to the loopback mgmt HTTPS API (default `127.0.0.1:47990`; self-signed cert trivially ignored). They now hold full admin authority: arm native pairing and read the PIN, approve their own device into the paired trust store, unpair/add clients, control sessions, and `POST /library/custom` with a `command` LaunchSpec that the host subsequently executes — a plausible path to code execution beyond the user's own privileges.
- **Existing mitigations:** Default bind is loopback; API still requires HTTPS+bearer — but that bearer is exactly what leaks. The sibling `web-password` *is* `icacls`-hardened (`install.rs:280-289`), confirming this is a missed file, not a design choice.
- **Verifier adjudication:** Both verifiers (across two surfaces) **confirmed at high**; this is the same class/severity as prior HIGH #1 (host key readable by any local user) and a genuine regression of that principle. `attacker_controlled=false` correctly reflects that this is a credential disclosure, not value injection.
- **Recommendation:** Route the mgmt-token write through `gamestream::write_secret_file` (or call `restrict_to_system_admins` on the path after writing) and create the dir with `create_private_dir`'s Windows DACL. Re-tighten any pre-existing token file on startup.
---
### 3. [High] Windows config directory and `host.env` are not DACL-locked → local user → SYSTEM env/arg injection — *Confirmed* (apps.json sub-vector *Partial*)
- **Surface:** Windows LocalSystem service / config & discovery. (Merges the `host.env` finding and the config-directory finding — same root cause.)
- **Refs:** `windows/service.rs:681-713` (`ensure_default_host_env` plain `std::fs::write`, skips if file `exists()`), `service.rs:159-180` (`load_host_env` `set_var`s *every* KEY=VALUE, not just `PUNKTFUNK_*`), `service.rs:301-302` (`format!("\"{}\" {host_cmd}", exe)` from `PUNKTFUNK_HOST_CMD`), `gamestream/mod.rs:264-286` (config dir never DACL-locked; `create_private_dir` no-op on Windows), `gamestream/apps.rs:40-95` + `stream.rs:140-145` (apps.json `cmd`).
- **Threat actor:** Local unprivileged user (#4). Windows host only.
- **Mechanism:** Secret *files* are individually `icacls`-locked, but the `%ProgramData%\punktfunk` *directory* is never DACL-restricted and `host.env` is written with a bare `std::fs::write`. Under the default `%ProgramData%` ACL, `BUILTIN\Users` inherit a container "create folders" right (and become `CREATOR OWNER` of subfolders they create). A non-admin who pre-creates the `punktfunk` subfolder before the elevated installer/service populates it owns it with full control and can plant `host.env`/`apps.json`; `ensure_default_host_env` then skips writing because the file already `exists()`. On service start, `load_host_env` injects every line of `host.env` into the SYSTEM process environment, and `supervise()` builds the SYSTEM child command line verbatim from `PUNKTFUNK_HOST_CMD`.
- **Attack scenario / impact:** The surviving primitives (after verifier scrutiny) are: (a) **arbitrary SYSTEM-process environment injection** — e.g. set `PATH`/DLL-search vars in `host.env` to an attacker-writable directory and plant a hijackable DLL the SYSTEM host loads by name; (b) **attacker-controlled SYSTEM argv** to the fixed signed `punktfunk-host.exe`; (c) config-dir/trust-store tampering. Each independently sustains a **local privilege escalation toward NT AUTHORITY\SYSTEM**. The planted-`apps.json` `cmd` vector is weaker than originally stated: `launch_gamestream_command``interactive::spawn_in_active_session` runs the cmd under the **interactive console user** token (`WTSQueryUserToken`+`CreateProcessAsUserW`), not SYSTEM — so apps.json planting yields code execution *as the interactive user*, not SYSTEM.
- **Verifier corrections:** The literal `PUNKTFUNK_HOST_CMD=... & malware.exe` shell-injection payload does **not** work — `spawn_host` uses `CreateProcessAsUserW` with no shell, so `&` is an inert argv token. Exploitation is gated on **directory pre-creation** (the punktfunk subfolder must be absent at attack time — fresh install before first launch, or a removed dir); on a normally installed box the elevated installer/SYSTEM service owns the dir and the default ACL grants Users *create-subdirectory* but not *create-file*, blocking overwrite of an existing admin-owned `host.env`. One verifier adjusted to **medium** on these grounds; the other held **high**. Carried at **high** because the env/arg-injection LPE primitives are real and the directory is genuinely never re-secured.
- **Existing mitigations:** Secret files are DACL-locked individually; the elevated installer creates the dir in normal flows. GameStream/apps.json launch is opt-in and additionally needs a launch to occur.
- **Recommendation:** Apply a restrictive DACL to the config directory at creation on Windows (`SYSTEM`/`Administrators` full + `CREATOR OWNER`, strip inheritance) inside `create_private_dir`; write `host.env` through `write_secret_file`; and refuse to load `host.env`/honor `PUNKTFUNK_HOST_CMD` (and trust `apps.json` `cmd`) unless the file/dir is owned by SYSTEM/Administrators.
---
### 4. [High→Medium] Pre-auth RTSP/UDP media plane has no pairing gate — *Partial*
- **Surface:** GameStream RTSP / video stream.
- **Refs:** `gamestream/rtsp.rs:91` (`handle_conn`, no auth), `rtsp.rs:204-216` (ANNOUNCE writes `state.stream` unauthenticated), `rtsp.rs:218-239` (PLAY starts video on `Some(cfg) && !streaming.swap(true)`, never checks `state.paired`/`state.launch`), `gamestream/stream.rs:90-108` (UDP 47998 binds and `connect()`s the first pinger), `gamestream/mod.rs:214` (`rtsp::spawn` only under `--gamestream`).
- **Threat actor:** Malicious network client, **pre-auth** (#1). Requires `serve --gamestream`.
- **Mechanism:** nvhttp gates `/launch`/`/applist`/`/resume`/`/cancel` on `peer_is_paired()`, but the RTSP listener (TCP 48010) and the UDP media planes are unauthenticated. `ANNOUNCE` stores a client-chosen `StreamConfig` (width/height/fps/codec/packetSize) with no auth; `PLAY` starts the video stream consulting neither `state.paired` nor `state.launch` (only the optional audio sub-stream requires the launch `gcm_key`). Video is sent in plaintext, so no key is needed. There is no per-launch session token and no binding between the paired nvhttp client and the RTSP/UDP peer (unlike Sunshine, which validates the launch session).
- **Attack scenario:** Unpaired attacker sends a minimal ANNOUNCE SDP → host stores a config; sends PLAY → host spawns the video pipeline, detects the compositor, creates a virtual output / opens the encoder; sends any UDP datagram to 47998 → host `connect()`s there and streams. Net effects: (a) **pre-auth desktop disclosure** — full real-monitor leak on the `PUNKTFUNK_VIDEO_SOURCE=portal` path; on the recommended `virtual` source the attacker captures a *fresh blank* virtual output (no app, since `/launch` is pairing-gated), and the default source is a synthetic test pattern; (b) **unconditional pre-auth resource consumption** (forces virtual-output creation + GPU encode); (c) **stream-slot DoS**`streaming.swap` allows only one stream, so an attacker can grab and hold the slot against legitimate clients (an in-progress legit session cannot be concurrently hijacked).
- **Existing mitigations:** Opt-in `--gamestream`; documented trusted-LAN-only; `streaming.swap` single-stream lock; packetSize bounded `[64,2048]`; `encode::validate_dimensions` bounds ANNOUNCE width/height. **None is an authentication check on the media plane.**
- **Verifier adjudication:** Both verifiers confirmed the bypass is real and unconditional; severity split **high vs. medium** turning on the capture source (portal = real-desktop leak → high; virtual/default = blank/test-pattern, leaving DoS + boundary bypass → medium). Carried at **high→medium**: the pairing authz boundary is unconditionally bypassed and the portal path leaks the real desktop, but the most-common `virtual` configuration limits disclosure.
- **Recommendation:** Require a valid recent `/launch` session (set by a paired HTTPS client) before ANNOUNCE/PLAY will start a stream, and bind the RTSP/UDP peer to the launching client's address / a per-launch session secret (as Sunshine does). At minimum, refuse PLAY when `state.launch` is `None` and no paired client has an active session.
---
### 5. [Medium] Windows host↔UMDF gamepad shared sections are world-writable (`Everyone:GENERIC_ALL`) — *Confirmed*
- **Surface:** Input injection (Windows virtual-pad IPC).
- **Refs:** `inject/windows/gamepad_raii.rs:43` (SDDL literal `D:(A;;GA;;;WD)`), `gamepad_raii.rs:37-81` (`Shm::create`), `inject/windows/dualsense_windows.rs:239`, `dualshock4_windows.rs:40`, `gamepad_windows.rs:158`; same SDDL at `capture/windows/idd_push.rs:245` (`Global\pfvd-*` frame textures).
- **Threat actor:** Local unprivileged user (#4). Windows host only.
- **Mechanism:** Every virtual-pad backend creates its host↔driver section in the kernel `Global\` namespace with a SECURITY_ATTRIBUTES built from `D:(A;;GA;;;WD)``WD` = Everyone (S-1-1-0), `GA` = GENERIC_ALL — and **no mandatory integrity label** (so the SYSTEM-created object defaults to medium IL / `NO_WRITE_UP` only). The host writes the live HID input report into `OFF_INPUT`; the privileged UMDF driver streams those exact bytes to games as virtual-controller input. The DACL grants full access to Everyone, so any interactive medium-IL local user can `OpenFileMapping("Global\pfds-shm-0", FILE_MAP_WRITE)` while a session has a pad active.
- **Attack scenario:** A separate unprivileged local account (different session / fast-user-switch / RDP) opens the named section and overwrites `OFF_INPUT` with attacker-chosen button/stick/trigger values → the driver injects them into the streaming user's game. It can also corrupt the magic/`device_type` (DoS / device confusion) and observe the streaming user's input. The identical SDDL on `idd_push.rs` additionally lets any local user **read captured screen frames**.
- **Existing mitigations:** `Global\` creation needs `SeCreateGlobalPrivilege`, preventing pre-creation/squatting — but **opening** an existing object only needs DACL access. The section exists only while a pad is active; keyboard/mouse use `SendInput` (not this channel), so injection is gamepad-only.
- **Verifier adjudication:** Both verifiers **confirmed at medium** — genuine cross-session/cross-privilege input injection + IPC tamper + (via the shared SDDL) screen-content disclosure; bounded below high by being local-only, needing a concurrent local account and a live pad.
- **Recommendation:** Scope the section DACL to exactly the principal the WUDFHost runs as (grant SYSTEM and the specific WUDF/driver service SID) instead of `Everyone`, and add a mandatory label / deny lower-IL writers (e.g. replace `WD` with the WUDFHost service account SID + `S:(ML;;NW;;;ME)`). Apply the identical fix to the `Global\pfvd-*` frame-texture sections in `capture/windows/idd_push.rs`.
---
### 6. [Medium] Gamescope EIS socket path relayed through a predictable, world-accessible `/tmp` file — *Confirmed*
- **Surface:** Session lifecycle / libei input injection (gamescope backend).
- **Refs:** `vdisplay/linux/gamescope.rs:778` (`EI_SOCKET_FILE = /tmp/punktfunk-gamescope-ei`), `gamescope.rs:797` (`remove_file`, error ignored), `gamescope.rs:807` (`printf %s "$LIBEI_SOCKET" > /tmp/...`), `gamescope.rs:677` (`fs::write`), `inject/linux/libei.rs:298-345` (`connect_socket_file`: `read_to_string` + `UnixStream::connect`, no ownership/symlink/stat check), `libei.rs:193` (wiring).
- **Threat actor:** Local unprivileged user (#4). Gamescope hosts only (Steam Deck / Bazzite gaming mode, or `PUNKTFUNK_COMPOSITOR=gamescope`). KWin/Mutter/Sway use D-Bus `ConnectToEIS` and are unaffected.
- **Mechanism:** The nested session writes gamescope's `LIBEI_SOCKET` path to the fixed world-readable `/tmp/punktfunk-gamescope-ei`. The libei injector reads that file and `UnixStream::connect`s to whatever absolute path it contains — **with no verification that the file or target socket is owned by the host uid** — then streams the remote client's keyboard/mouse events to it as a libei client. EIS has no peer authentication, so a fake server captures the input stream. On sticky `/tmp` (1777), if a different uid pre-creates the relay file (owner=attacker, mode 0644), the host's `remove_file` and `> file`/`fs::write` truncate both fail (EPERM/EACCES, errors ignored), so the attacker's content survives and the host connects to the attacker's socket. `stop_session` removes the host-owned file on each teardown, giving a recurring re-plant window.
- **Attack scenario:** Local attacker runs `echo /home/attacker/evil.sock > /tmp/punktfunk-gamescope-ei` (0644) and listens on `evil.sock` as an EIS server. When a remote client streams, the injector connects there instead of gamescope's real EIS; every keystroke/pointer event the remote user sends (game/Steam input, typed credentials) is delivered to the attacker, and gamescope receives no input (input DoS).
- **Existing mitigations:** The real EIS socket lives under `XDG_RUNTIME_DIR` (0700) — but only its *path* is leaked/overridable via the `/tmp` relay. `protected_symlinks` does not help (regular file, not symlink). The injector retries only `ConnectionRefused`/`NotFound`; a live attacker socket returns `Ok` and is trusted.
- **Verifier adjudication:** Both verifiers **confirmed at medium**. Impact is high (full remote keystroke capture incl. credentials, plus input DoS) but local-only, gamescope-backend-only, and most gamescope deployments are single-user, capping practical likelihood.
- **Recommendation:** Relay the EIS path through a host-private location (a file in `XDG_RUNTIME_DIR`, 0700, created `O_EXCL`) instead of `/tmp`, and/or `stat` the relay file and reject it unless owned by the host uid, mode ≤0644, not a symlink, before reading. Apply the same hardening to the predictable world-readable `/tmp/punktfunk-gamescope.log`.
---
### 7. [Medium→Low] Process-global env retargeting is unsound under now-default concurrent native sessions — *Confirmed*
- **Surface:** Session lifecycle / library-launch. (Merges the "native concurrent launch-env race" and the "apply_session_env/apply_input_env" findings — one root cause; the live, generalized form of deferred prior-fix #7.)
- **Refs:** `punktfunk1.rs:150` (`DEFAULT_MAX_CONCURRENT=4`), `punktfunk1.rs:254` (Semaphore), `punktfunk1.rs:612` (`std::env::set_var("PUNKTFUNK_GAMESCOPE_APP", &cmd)`), `punktfunk1.rs:1871`/`1885` (`apply_session_env`/`apply_input_env` calls), `vdisplay.rs:367-397`/`457-485` (env setters), `vdisplay/linux/gamescope.rs:791-794` (reads the global env), `punktfunk1.rs:600`/`vdisplay.rs:363-365` (stale "ONE-session-at-a-time" comments).
- **Threat actor:** Malicious network client, **post-auth** (#2, paired/trusted-tier).
- **Mechanism:** The native host now serves up to 4 concurrent sessions by default, yet the per-session handshake mutates *process-global* environment via `std::env::set_var` (resolved launch id into `PUNKTFUNK_GAMESCOPE_APP`; plus `WAYLAND_DISPLAY`/`XDG_RUNTIME_DIR`/`DBUS_SESSION_BUS_ADDRESS`/`PUNKTFUNK_INPUT_BACKEND`/etc. via `apply_session_env`/`apply_input_env`). These run inside `spawn_blocking` for each concurrent handshake and are then read by backends/injectors at open time. The in-code invariant ("the host serves one session at a time, so a process-global write is sound") is now false. Two effects: (1) **concurrent `set_var` while another thread `getenv`s is documented UB in Rust** (glibc `environ` realloc) → potential host-wide crash taking down all live sessions; (2) session B's handshake overwrites the env session A's gamescope-spawn/injector is about to read → A launches B's (operator-approved) title or routes input to B's backend.
- **Attack scenario:** Two paired clients connect concurrently (or one reconnects in a tight loop while another session is active). The racing `set_var`/`getenv` can abort the host (DoS affecting all sessions); concurrently A's session can be mispointed.
- **Verifier adjudication:** Both **confirmed** the technical defect; severity split **medium vs. low**. The cross-session *launch/input misrouting* grants no new authority (both peers are already authorized to view/inject on the shared desktop; the `uid` filter prevents cross-user selection), so under "only NEW authority counts" it is a correctness bug. The surviving security impact is the **`set_var`/`getenv` data-race UB → non-deterministic host-wide DoS**, triggerable by an already-paired device. Carried at **medium→low** accordingly.
- **Existing mitigations:** Pairing gate runs before `resolve_compositor` (post-auth). `detect_active_session` filters `/proc` by the host's own uid (no cross-user selection). Most env writes are gated to auto-detect mode (skipped when `PUNKTFUNK_COMPOSITOR` is set). No lock serializes the env writes, and there is no per-session config object for these knobs (unlike the Windows/GameStream `SessionContext.launch`).
- **Recommendation:** Stop using process-global env on the per-session path. Thread launch command, compositor, input-backend, and session env into the per-session `VirtualDisplay`/`SessionContext` (as GameStream already does via `set_launch_command`) and pass them as explicit args to backend/injector open calls. At minimum serialize all `set_var` writes + dependent backend-open under one mutex, or force `max_concurrent=1` while the auto env-retargeting state machine is active.
---
### 8. [Low] `web-password` written world-readable then `icacls`'d — brief TOCTOU disclosure — *Confirmed*
- **Surface:** Secret-file permissions (Windows install).
- **Refs:** `windows/install.rs:273-290`.
- **Threat actor:** Local unprivileged user (#4). Windows, install/upgrade time only.
- **Mechanism:** `set_web_password` writes the cleartext `PUNKTFUNK_UI_PASSWORD=<pw>` via `std::fs::write` (creating the file at the inherited Users-readable `%ProgramData%` ACL) and only *afterward* strips inheritance with `icacls`. Between the write and the `icacls` child-process completion (a full process spawn = a race-winnable window) the web-console login password is readable by any local user.
- **Attack scenario:** A local user polling `%ProgramData%\punktfunk` during a fresh install reads `web-password` before `icacls` applies, obtaining the web-console login credential.
- **Existing mitigations:** Window is fresh-install-only (on upgrade the existing file's locked DACL is preserved across a truncating write, so no window reopens — the "upgrade rewrites the password" sub-claim does not hold); install is operator-initiated and one-time; `icacls` locks immediately after; impact limited to web-console access.
- **Verifier adjudication:** One verifier **confirmed low**; the other adjusted to **info**, noting the write-then-`icacls` pattern is the established Windows secret pattern (used by `write_secret_file` for far higher-value secrets), so the "anomalously non-atomic" framing is overstated and this is the lowest-value secret affected. Carried at **low**.
- **Recommendation:** Create the file with a restrictive DACL atomically (`CreateFile` with a SECURITY_DESCRIPTOR, or write to a per-process temp under an already-locked dir then rename), or write empty + `icacls` before writing the secret bytes.
---
### 9. [Low] Unpaired LAN peer can burn the operator's single-use pairing window — *Confirmed*
- **Surface:** Native SPAKE2 pairing.
- **Refs:** `punktfunk1.rs:459` (`np.disarm()` before proof verification), `punktfunk1.rs:438` (`pake.finish` accepts a wrong-PIN message), `punktfunk1.rs:517-531` (cooldown / `current_pin()`), `native_pairing.rs:216-218` (`disarm`), `quic.rs:1581` (`AcceptAnyClientCert`).
- **Threat actor:** Malicious network client, **pre-auth** (#1). Native path, while pairing is armed.
- **Mechanism:** The single-use design disarms the PIN on *any* well-formed pairing attempt, **before** verifying the guess (the disarm-before-verify behavior is exactly prior-fix #2, which gives the single-online-guess guarantee). `pake.finish()` does not reject a wrong-PIN `spake_a` (only malformed messages), so an unpaired peer with a self-signed cert and a SPAKE2 message built from any random PIN guess reaches `disarm` and consumes the window without knowing the PIN.
- **Attack scenario:** Operator arms pairing; an attacker polling the QUIC port every ~2 s (the `PAIRING_COOLDOWN`) lands an attempt inside the ~120 s armed window; the host disarms. The legitimate device then submits the real PIN and is told "pairing not armed." Repeat indefinitely.
- **Existing mitigations:** Availability-only (1/10000 chance a blind guess actually pairs — the documented single online guess). The attack only works *while a window is armed* (outside it, `current_pin()` is `None` and the handshake bails before touching disarm), so it cannot permanently disable pairing — it races an open window. **The delegated-approval flow (knock → console approve) is structurally immune** and remains usable on hostile LANs.
- **Verifier adjudication:** One verifier **confirmed low**; the other adjusted to **info** as a self-acknowledged, in-code-documented design tradeoff with an immune fallback. Carried at **low**.
- **Recommendation:** Prefer the delegated-approval flow on hostile LANs (already immune). Document that PIN arming should be brief. If retaining PIN arming, consider only consuming the window on a key-confirmation match when the failure is observable (trading some brute-force resistance for availability).
---
### 10. [Low] ENet control flood → unbounded per-packet warn-log spam — *Confirmed*
- **Surface:** GameStream ENet control plane.
- **Refs:** `gamestream/control.rs:84`/`161` (`on_receive`), `control.rs:186` (per-packet `tracing::warn!`, no throttle), `control.rs:316`/`347-378` (decrypt + scheme sweep), `control.rs:79` (`detected` reset on Disconnect).
- **Threat actor:** Malicious network client, **pre-auth** (#1). Requires `--gamestream` and an active paired session.
- **Mechanism:** The ENet control host (UDP 47999, `peer_limit=4`) accepts unauthenticated connections. Once a paired client has launched (global `gcm_key` set), any `0x0001`-prefixed packet with a ≥16-byte payload that fails to authenticate emits one `tracing::warn` per packet with **no rate limit or sampling**. The full ~72-candidate GCM scheme-sweep runs only while `detected` is `None` (a transient window; the attacker can reset it via its own Disconnect but steady state is one GCM op + one warn per packet).
- **Attack scenario:** With a paired session active, an attacker ENet-connects and floods junk `0x0001` packets → unbounded warn-log lines (disk/observability pressure) + intermittent CPU.
- **Existing mitigations:** `peer_limit=4`; the expensive sweep is `detected`-gated; AES-GCM open on tiny buffers is microseconds; input injection itself stays cryptographically gated on the HTTPS-delivered `gcm_key` (no forgery). Opt-in, trusted-LAN.
- **Verifier adjudication:** **Confirmed**; the "per-packet GCM brute-force" framing is largely neutralized by the `detected` fast-path, but the **unthrottled per-packet warn log** is genuinely unmitigated. Low severity (DoS/observability only, no injection or memory unsafety).
- **Recommendation:** Throttle/aggregate the "GCM decrypt failed" warning (sampled, not per-packet) and drop a peer after N consecutive auth failures; optionally skip the scheme-sweep for a peer that has produced no authenticating packet.
---
### 11. [Low→Info] SYSTEM `host.log` opened with predictable name in a Users-writable directory — *Partial*
- **Surface:** Windows service / logging.
- **Refs:** `windows/service.rs:121-125` (logs dir via plain `create_dir_all`), `service.rs:574-602` (`open_log_handle`, `OPEN_ALWAYS`, append-only, inheritable, `FILE_SHARE_READ|WRITE`).
- **Threat actor:** Local unprivileged user (#4). Windows.
- **Mechanism:** The SYSTEM service opens `%ProgramData%\punktfunk\logs\host.log` and redirects the host child's stdout/stderr to it. The logs dir lives under the non-DACL-locked config tree (Finding 3). A local user able to create files there could pre-create `host.log` as an NTFS hardlink to an attacker-chosen target, causing SYSTEM's appends to land on that target.
- **Impact:** Limited integrity: SYSTEM appends *attacker-uncontrolled* log text (append-only handle — no truncation, no chosen-offset writes) to an attacker-chosen file. No content control → no realistic code-exec path; a log-tamper/nuisance/DoS primitive at most.
- **Verifier adjudication:** Both verifiers found the redirect-*target* control hinges on a non-admin holding `FILE_ADD_FILE` on a SYSTEM-created subdir, which the default `%ProgramData%` ACL does **not** grant (Users get create-subfolder, not create-file). The only residual is the same **pre-install directory-squatting** edge as Finding 3, and even then the writes are append-only uncontrolled text. One verifier **partial/low**, one **partial/info**. Effectively a sub-case of Finding 3.
- **Recommendation:** Fixing Finding 3 (DACL-lock the config/logs dir to SYSTEM+Administrators) fixes this. Optionally open the log rejecting reparse points / create the dir with a restrictive DACL before first write.
---
### 12. [Low→Info] Legacy GameStream pairing has no rate-limit and parks unbounded 300 s waiters — *Partial*
- **Surface:** GameStream pairing.
- **Refs:** `gamestream/pairing.rs:102-127` (`getservercert` parks `pin.take(300s)`), `pairing.rs:50-60` (`WaiterGuard`), `nvhttp.rs:215-244` (unauthenticated `/pair` route, no rate limit / connection cap).
- **Threat actor:** Malicious network client, **pre-auth** (#1). Requires `--gamestream`.
- **Mechanism:** `/pair?phrase=getservercert` is reachable pre-auth with an attacker-chosen `uniqueid` and no per-IP/global rate limit; each parks a tokio task for up to 300 s and keeps `awaiting_pin` asserted. The HTTP server has no connection cap (bare `axum_server::bind`).
- **Verifier adjudication:** Both verifiers **confirmed the no-rate-limit/parked-waiter core but refuted the alarming "unbounded never-evicted HashMap"** sub-claim — the `sessions` insert is downstream of a successful `pin.take()`, which requires an operator-delivered PIN, so the map grows at most one entry per PIN submission (not attacker-driven). The residual is a bounded (300 s self-heal, cheap tasks), opt-in slow-loris + `awaiting_pin` nuisance on a surface already covered by accepted-risk #5/#9, plus a minor enlargement of the Finding-9-class PIN race. Both adjusted to **info**.
- **Recommendation:** Add a per-source-IP / concurrent-handshake cap on pairing attempts and evict the per-`uniqueid` session on success/timeout (not only on failure).
---
### 13. [Info] Pending-approval queue floodable by a LAN cert flood — *Confirmed*
- **Surface:** Native pairing / delegated-approval queue.
- **Refs:** `native_pairing.rs:336-357` (`note_pending`, `PENDING_CAP=32` eviction of least-recently-active), `native_pairing.rs:81-83` (cap + 10-min TTL), `punktfunk1.rs:566` (called per unpaired knock, no per-source rate limit).
- **Threat actor:** Malicious network client, **pre-auth** (#1).
- **Mechanism:** `note_pending` is called for every unpaired-but-identified knock with no per-source rate limit; past 32 entries the least-recently-active is evicted. An attacker minting >32 distinct self-signed certs can churn the queue, potentially evicting a quiet legitimate knock before the operator approves it.
- **Verifier adjudication:** One **confirmed info**, one **refuted** — the in-place refresh resets `requested_at` on every same-fingerprint re-knock, so an actively-retrying legitimate device is structurally non-evictable; only a one-shot knock-and-wait device is at risk and it recovers instantly by re-knocking. Each junk slot costs a full QUIC handshake; no trust-store/PIN/key impact. Carried at **info** (transient self-healing availability nuisance on the convenience queue only).
- **Recommendation:** Optionally cap pending entries per source IP/subnet, or surface a "pending overflow" indicator. Low priority.
---
## Prior-fix verification (#1#12)
- **#1 — HIGH (secret-file perms 0600/0700 Unix; SYSTEM+Admins DACL Windows): PRESENT but INCOMPLETE — regressed for two newer files.** The core helpers `create_private_dir` (0700 Unix) and `write_secret_file`/`restrict_to_system_admins` (Unix 0600 + Windows `icacls` SYSTEM/Admins/OWNER) are correct and used for `key.pem`, `cert.pem`, GameStream `paired.json`, native `punktfunk1-paired.json`, and `web-password` (all atomic temp+rename, no world-readable window, never logged). **Gaps:** the mgmt-token writer (`mgmt_token.rs:write_token`) hardens only `cfg(unix)` and never applies the Windows DACL (**Finding 2**); `host.env` is written with a bare `std::fs::write` and the Windows config *directory* is never DACL-locked (**Finding 3**); `web-password` has a brief write-then-`icacls` TOCTOU window (**Finding 8**). Non-secret files (`uniqueid`, `library.json`, art cache, stats captures) carry no key material — acceptable.
- **#2 — HIGH (native SPAKE2 PIN single-use): VERIFIED INTACT.** `np.disarm()` runs unconditionally before reading the client proof (`punktfunk1.rs:459`); a malformed `spake_a` errors earlier but makes no guess. The global `PAIRING_COOLDOWN` (2 s) + per-attempt `current_pin()` close the concurrency TOCTOU; CSPRNG PIN; CLI arm-at-start is also consumed. No path leaves a static reusable PIN. (The single-use design's only side effect is the availability edge of **Finding 9**.) *Caveat:* the **legacy GameStream** `PinGate` is a separate mechanism — `PinGate::take()` consumes the PIN and the mgmt path guards `awaiting_pin()`, but the nvhttp `/pin` path does **not** guard and is unauthenticated (**Finding 1**).
- **#3 — MED (RTSP packetSize clamp + saturating packetizer): VERIFIED PRESENT.** `rtsp.rs:330-339` rejects packetSize outside `64..=2048`; `video.rs:63` clamps `payload_per_shard` so all divisors are ≥1 (regression test `degenerate_packet_size_does_not_panic`).
- **#4 — LOW (mgmt mTLS cert restricted to read-only allowlist): VERIFIED COMPLETE.** `cert_may_access` (`mgmt.rs:514-528`) is GET-only over an exact-path set excluding every state-changing/pairing/stats route; all `/api/v1` routes share `route_layer(require_auth)`; cert branch additionally requires `native.is_paired(fp)`. No streaming cert can read the PIN, self-approve, mutate the library, or reach `/stats/*`. Not regressed by any newly-added route.
- **#5 — LOW ACCEPTED (legacy control-stream GCM nonce reuse): UNCHANGED.** Still legacy/Moonlight-compat (`control.rs:108-117`); not reachable on the default `serve` path. Not re-flagged.
- **#6 — LOW (RTSP header/Content-Length caps + read timeout + connection cap): VERIFIED PRESENT.** `MAX_RTSP_CONNS=8`, `RTSP_READ_TIMEOUT=15s`, 16K header / 64K body / 128K message caps enforced; `ConnGuard` releases the slot on panic.
- **#7 — LOW PARTIAL (per-session launch command; native path used a process-global env): STILL UNRESOLVED and now REGRESSED IN IMPACT.** The native path still does `std::env::set_var("PUNKTFUNK_GAMESCOPE_APP", cmd)` and the gamescope backend reads that global; the in-code "ONE-session-at-a-time" justification is invalidated by `DEFAULT_MAX_CONCURRENT=4`. The GameStream/Windows path correctly threads launch into a per-session `SessionContext`. This is now **Finding 7** (generalized to the whole env-retargeting state machine + a `set_var`/`getenv` data race).
- **#8 — INFO (GameStream phase-4 hash compare constant-time): VERIFIED PRESENT.** `pairing.rs:228` uses `crypto::ct_eq`, a proper no-early-exit fold; `hash_ok` and `sig_ok` are both computed before branching. Mgmt `token_eq` similarly SHA-256-hashes both sides.
- **#9 — INFO ACCEPTED (/pair over plain HTTP): UNCHANGED** as a transport matter. **Note:** the *unauthenticated `/pin` self-delivery* (Finding 1) is a distinct, newly-surfaced defect, **not** subsumed by #9.
- **#10 — INFO (fixed ALPN `pkf1` on QUIC): VERIFIED PRESENT.**
- **#11 — INFO (FEC reconstruct failure = drop not fatal): VERIFIED PRESENT.** Host encode uses `encode(...).unwrap_or_default()`; audio returns `None` to skip a block; no fatal path.
- **#12 — LOW DEFERRED (web `NODE_TLS_REJECT_UNAUTHORIZED`): out of host scope, not examined.**
## Refuted / investigated — not vulnerabilities
- **PinGate PIN not bound to uniqueid/cert (confused-deputy PIN theft) — *refuted.*** The global PIN-slot race is real (enables a pairing DoS, folded into Finding 9's class), but the escalation is cryptographically impossible: in GameStream the PIN is *generated and displayed by the Moonlight client* and the host never echoes it, so a racing attacker consumes the PIN-submission *event* but never learns the PIN *value*; without it the phase-2/4 hash + RSA checks fail closed. No paired identity gained.
- **Attacker-chosen device name in the approval queue (trusted-device impersonation) — *refuted.*** The unpaired knock is hard-rejected; the fingerprint (the value actually pinned) is displayed alongside the sanitized name, and bidi/control/homoglyph chars are stripped. Approval requires a bearer-authenticated human; "approving on the label without reading the fingerprint" is social engineering inherent to any human-in-the-loop pairing, with the standard mitigation already present.
- **Lutris cover-art slug path traversal — *refuted.*** The `..`-joined read is real, but `slug` originates from the host user's own `~/.local/share/lutris/pga.db` (a same-user local file), not controllable by any in-scope network/MITM/local-unpriv adversary; the disclosure recipient is an already-paired client with strictly greater authority, and the read is `.jpg`-only, ≤1 MiB. Charset-validating the slug is worthwhile defense-in-depth.
- **Privileged install invokes system tools by bare name (PATH/CWD hijack) — *refuted.*** Premise is wrong for the Rust toolchain: `std::process::Command` resolves the executable itself, searches `System32` *before* the CWD, and never searches the spawning process's directory. All cited tools are System32 binaries, so a planted CWD copy loses. Using absolute `%SystemRoot%\System32\…` paths is reasonable consistency hardening but addresses no reachable threat.
- **`uniqueid`/mgmt-token create the config dir with `create_dir_all` (brief 0755) — *refuted.*** Every secret file is written 0600/DACL-locked regardless of directory mode; the only non-secret file (`uniqueid`) is a public serverinfo identifier; on Linux the dir is under the owning user's per-user home; `create_private_dir` later tightens it to 0700. Code-consistency cleanup, no disclosure.
- **Unbounded on-disk stats capture files — *refuted.*** Every `/stats/*` route is bearer-token-gated (excluded from the cert allowlist); the captures dir is 0700; the file id is host-generated. No pre-auth, post-auth, MITM, or local-unpriv path can create captures — only the trusted operator over their own disk. Pruning/streamed `list()` parsing is a reasonable operational improvement, not a security fix.
## Cross-cutting themes
1. **GameStream/Moonlight compatibility is the soft underbelly.** Both pre-auth bypasses (Findings 1, 4) and the control-plane DoS (Finding 10) live exclusively on the opt-in `--gamestream` surface, whose authz model is weaker by design (accepted-risk #5/#9). The native punktfunk/1 plane is markedly stronger. The two genuinely new pre-auth issues — unauthenticated `/pin` self-pairing and the ungated RTSP media plane — are *bypasses of GameStream's own `peer_is_paired` boundary*, not inherent-protocol weaknesses, and are fixable without breaking stock-Moonlight compatibility.
2. **Prior-fix #1 hardened secret *files* but not the Windows config *directory* or two files added since.** Findings 2, 3, 8, 11 all trace to the `%ProgramData%\punktfunk` ACL gap plus the bespoke `write_token`/`std::fs::write` paths that bypass `write_secret_file`. A single remediation — DACL-lock the config directory and route *all* config writes through `write_secret_file` — closes most of the Windows local-privilege surface.
3. **Concurrency outgrew single-session assumptions.** Finding 7 (and the regressed prior-fix #7) is the codebase shipping default `max_concurrent=4` while per-session state still uses process-global `std::env` mutation written under a one-session invariant. The `SessionContext`/`set_launch_command` pattern already used on the Windows/GameStream path is the correct fix to generalize.
4. **Local IPC and temp-file trust.** The Windows gamepad/IDD shared sections (`Everyone:GENERIC_ALL`, Finding 5) and the Linux gamescope EIS `/tmp` relay (Finding 6) both trust a local channel that a second unprivileged account can read/write. Scope DACLs to the consuming principal and move relays into owner-private runtime dirs.
## Security controls done right (positives)
- **Native SPAKE2 pairing is well-hardened:** single-use disarm-before-verify, global cooldown, atomic+rollback persist, fail-closed load, CSPRNG PIN, device-name sanitization (C0/C1 + bidi/format stripped, 64-char cap) at every sink, with regression tests. No path lets an unpaired peer self-approve, read the PIN, or poison the trust store.
- **Post-pair cert-pinning is sound:** the TLS layer verifies the `CertificateVerify` signature (key ownership) even though it "accepts any" cert at handshake, and `peer_is_paired` pins SHA-256(DER) against the saved cert — a stolen public cert cannot impersonate a paired client.
- **Management authz is solid:** every `/api/v1` route gated (even on loopback), `run` refuses to start without a token, loopback-default bind, constant-time (SHA-256-hashed) token compare, 256-bit token entropy, no cookie/CSRF surface, and a correct read-only-cert vs. bearer-mutation split.
- **The new library/launch surface is strong against the network adversary:** client ids resolve against the host's own scanned catalog (never client-supplied launch strings), Steam appids are digit-validated, Heroic/Epic/AUMID values charset-validated, all non-operator spawns are argv-based with no shell, and the only `cmd.exe /c`/`sh -c` sinks consume operator-typed input only. No SSRF in the cover-art warmer (fixed trusted hosts, ids in the path component only). XML/JSON/VDF parsers are entity-expansion-safe.
- **Wire parsers are memory-safe:** RTSP has connection caps, read timeouts, header/body/message caps, and clamps every attacker-controlled numeric; the video packetizer is structurally panic-proof; input/gamepad decoders are fully `.get()`-bounded with `idx < MAX_PADS` checks; DualSense/DS4 output-report parsers bounds-check before indexed reads.
- **The stats-capture surface is clean:** bearer-only routes, host-generated path-safe ids with traversal rejection (tested), 0700 captures dir, bounded samples, lock-serialized hot-path feed, and host-derived (non-free-form) metadata fields.
- **Session/cross-user isolation holds:** the Desktop↔Game follow watcher and `detect_active_session` filter `/proc` strictly by the host's own uid, so a session can never follow or expose a different user's compositor; per-session virtual-output/encoder teardown is sound RAII (no monitor/FD/zombie leaks); `--max-concurrent` genuinely caps concurrency.
- **Windows service launch hygiene:** fully-quoted `current_exe` binPath with fixed args (no unquoted-service-path), correct token scoping (drops to the user token for store launchers/WGC, retains SYSTEM only for our own streamer), anonymous inherited pipes for the host↔helper channel, and no command line built from network input.
---
## Supplement (2026-06-28, follow-up pass 2 — completed surfaces + coverage-critic gaps)
### (a) Summary
This pass closes the two finders that failed in the main audit (native protocol; unsafe FFI — here split into control-plane, data-plane, encode/capture, and driver-IPC) and the three coverage-critic gaps (mic/Opus → virtual mic + cross-session isolation; `main.rs` default-security posture + dependency RUSTSEC; cover-art outbound egress/SSRF). The headline answers: **the native control plane is fail-closed for unpaired peers at the application layer**`serve_session` rejects anonymous/unpaired clients before any session machinery (`punktfunk1.rs:544-573`) — **but the QUIC *transport* underneath is not**, and it is the only genuinely pre-auth crown-jewel-adjacent exposure found here: `quinn-proto 0.11.14` (RUSTSEC-2026-0185, CVSS 7.5 unbounded out-of-order reassembly) is reachable by any unpaired peer who completes the 1-RTT handshake with a throwaway cert *before* the pairing gate runs → remote memory-exhaustion DoS of the always-on default listener. **Client geometry is well bounds-checked** (W/H caps applied on Hello, Reconfigure, and ANNOUNCE; Opus mic buffer math is exact; gamepad/touchpad indices clamped) with one consistent gap: the **refresh/fps lower bound is unvalidated on the initial Hello path** (the Reconfigure path guards it), yielding at worst a self-inflicted single-session divide-by-zero panic. **Cover-art egress is SSRF-safe against every in-scope adversary** (hardcoded hosts, id only in the path segment, TLS verification on); the only residual is an out-of-scope supply-chain redirect-follow. **The rsa 0.9 Marvin oracle is not practically reachable** — it is a signing path (not the classic PKCS#1v1.5 decryption oracle), on the opt-in trusted-LAN-only GameStream plane. The mic/Opus surface adds one real cross-session defect: a malformed Opus frame tears down the single host-lifetime virtual mic shared by all concurrent sessions. The driver-IPC surface is **memory-safe and clean** (the only weakness is the already-reported world-writable section ACL).
### (b) Confirmed and partial findings
#### S1 — Pre-auth remote memory exhaustion via vulnerable `quinn-proto 0.11.14` on the always-on native QUIC control plane (RUSTSEC-2026-0185) — **CONFIRMED, severity HIGH**
- **Surface:** cli-posture-deps / native QUIC transport. **Files:** `Cargo.lock` (quinn-proto 0.11.14, line ~2966), `crates/punktfunk-core/src/quic.rs:1540-1589,1580-1581,1723-1740`, `crates/punktfunk-host/src/punktfunk1.rs:176-181,503`.
- **Threat actor / auth:** malicious network client, **pre-auth** (unpaired, unauthenticated).
- **Mechanism:** `serve` (the secure default) always builds the native QUIC listener bound to `0.0.0.0:9777`. The rustls `ServerConfig` uses `AcceptAnyClientCert` and defers *all* identity/pairing verification to a post-handshake app-layer fingerprint check. An unpaired peer therefore presents any self-signed cert, completes the QUIC 1-RTT handshake, and reaches `quinn-proto`'s stream-reassembly path **before** the `--require-pairing` gate. RUSTSEC-2026-0185: unbounded out-of-order STREAM-frame buffering → remote memory exhaustion.
- **Scenario:** attacker on the LAN sends a ClientHello, finishes the handshake with a throwaway cert, opens a stream, floods out-of-order STREAM frames with large gaps; the privileged host buffers unboundedly → OOM, killing streaming for all paired clients and possibly the box.
- **Existing mitigations:** `--max-concurrent` bounds session *count* but not per-connection reassembly memory; the pairing gate runs after the vulnerable transport layer; `stream_transport()` sets only idle-timeout/keep-alive, not receive-window limits. None neutralize this.
- **Recommendation:** `cargo update -p quinn-proto --precise 0.11.15` (or bump `quinn`), and wire `cargo audit` into CI as a failing gate on the QUIC path.
- **Verifiers:** both confirmed, **adjusted_severity HIGH** (availability-only — no key/trust-store impact — so high, not critical). Exploit path corroborated end-to-end: `main.rs:503` always-on default → `server_with_identity``AcceptAnyClientCert` accepts any cert → handshake reaches quinn-proto reassembly pre-pairing.
#### S2 — Malformed client Opus mic frame tears down the shared host-lifetime virtual mic (cross-session DoS) — **CONFIRMED, severity LOWMEDIUM**
- **Surface:** audio-mic-decode. **Files:** `crates/punktfunk-host/src/punktfunk1.rs:1231-1280` (esp. 1266-1277), `:221,:292/:300`; `crates/punktfunk-core/src/quic.rs:1210`.
- **Threat actor / auth:** malicious paired client, **post-auth**.
- **Mechanism:** `mic_service_thread` treats *any* `opus::Decoder::decode_float` error as a backend failure: it sets `mic=None; decoder=None; last_failed=now`, tearing down the PipeWire/WASAPI virtual mic and forcing a 2s `INJECTOR_REOPEN_BACKOFF`. The Opus payload is raw attacker bytes (`decode_mic_datagram` checks only `len>=13` and forwards `b[13..]` verbatim), and libopus returns `OPUS_INVALID_PACKET` on a malformed TOC, so a single crafted ≥14-byte datagram triggers it. Critically, the `MicService` is **one host-lifetime resource shared by every concurrent session** (created once in `serve()`, sender cloned per session).
- **Scenario:** paired client #2 (a second concurrent session) sends one garbage Opus frame every ~2s; the shared mic thread repeatedly drops the virtual mic and re-enters backoff, keeping the microphone unavailable for session #1's recording/voice-chat app — a **cross-session** denial of an optional feature beyond the offender's own tier.
- **Existing mitigations:** pairing-gated; 2s backoff bounds reopen churn; DTX/empty frames skipped; no memory blow-up. None prevent the cross-session denial because there is no per-session decoder/mic isolation.
- **Recommendation:** treat a codec decode error as a per-frame drop (rate-limited log), keeping decoder+mic open; only tear down on an actual backend `push` error; reset (not destroy) decoder state; ideally use a per-session decoder.
- **Verifiers:** both confirmed; **adjusted_severity split MEDIUM / LOW** — medium because a low-effort paired client denies an honest concurrent session's mic (genuine new authority via the shared resource); low because the impact is confined to one optional feature, churn-bounded, no crash/disclosure/exec, and all paired clients already share one desktop at a high mutual-trust tier. Net: treat as **LOWMEDIUM**, fix is cheap and warranted.
#### S3 — Unbounded held-button/held-key tracking `Vec` grows on attacker-chosen input codes (per-session DoS) — **CONFIRMED, severity LOW**
- **Surface:** native-data-plane. **Files:** `crates/punktfunk-host/src/punktfunk1.rs:1457-1483` (esp. 1476-1483); `crates/punktfunk-core/src/input.rs:136-149`.
- **Threat actor / auth:** malicious paired client, **post-auth**.
- **Mechanism:** every `MouseButtonDown`/`KeyDown` whose 32-bit `ev.code` (read straight off the wire at `input.rs:144`, no range/validity check) is not already present is pushed into the per-session `held_buttons`/`held_keys` `Vec`, with no cap and a linear `Vec::contains` presence test (O(n) per event, O(n²) over a run). Entries are removed only by a matching Up. The upstream mpsc is also unbounded with no per-packet throttle.
- **Scenario:** paired client floods `MouseButtonDown`/`KeyDown` with monotonically increasing `code`s and never sends Up → the `Vec` grows unbounded and the quadratic scan spikes the session's input-thread CPU for the session lifetime.
- **Existing mitigations:** per-session `Vec`s dropped on disconnect; input injection is in-scope-by-design (the *only* new harm is the unbounded *tracking* state); QUIC intake is receive-buffer bounded.
- **Recommendation:** bound the held-state sets with a `HashSet` keyed by `code` (removes the O(n²) scan) and/or reject codes outside valid button/key ranges before tracking; cap the number of distinct held codes.
- **Verifiers:** both confirmed, **adjusted_severity LOW** — self-confined to one session thread, no host crash, inverted amplification (wire bytes > memory), but a real unnecessary unbounded-growth defect.
#### S4 — Unbounded read of local launcher caches (Epic `catcache.bin` / `.item` manifests) — memory-exhaustion DoS — **CONFIRMED, severity LOW**
- **Surface:** cover-art-egress / library enumeration. **Files:** `crates/punktfunk-host/src/library.rs:657-665` (esp. `std::fs::read` at ~660 + base64 decode ~663), `:580` (`read_to_string`).
- **Threat actor / auth:** local unprivileged user (Windows host), **post-auth N/A** (local).
- **Mechanism:** `epic_art_index` reads the entire `%ProgramData%\Epic\EpicGamesLauncher\Data\Catalog\catcache.bin` with **no size cap**, then base64-decodes it (a second ~0.75× allocation), then `serde_json` parses — stacked unbounded allocations in the LocalSystem host. Each `.item` manifest is likewise read whole. Default ProgramData ACLs commonly let a standard user create/replace files in app subfolders (Epic itself grants Users modify so its user-mode launcher can rewrite the cache).
- **Scenario:** local user plants a multi-GB `catcache.bin`; the next library enumeration (mgmt list / GameStream serverinfo-applist / art warmer `all_games()`) loads it plus its decoded copy into the privileged host → OOM.
- **Existing mitigations:** best-effort (failures return empty map, no crash); triggered per-enumeration, not continuously; Windows-only. Notably the Linux `lutris_image` reader (`library.rs:372-377`) **already caps at 1 MiB** — the pattern is known and simply not applied here.
- **Recommendation:** `fs::metadata` size check or a `take()`-limited reader (a few MB for `catcache.bin`, tens of KB per `.item`) before read/decode; skip oversize files.
- **Verifiers:** both confirmed, **adjusted_severity LOW** — DoS only, ACL-precondition reduces exploitability but not the verdict; the author's own Linux cap proves the omission.
#### S5 — Client refresh/fps lower bound not validated before encoder open (Hello path; folded across two finders) — **PARTIAL, severity LOW→INFO**
- **Surface:** native-control-plane + unsafe-encode-capture (these two finders are the **same defect** at different depths; reported once here). **Files:** `crates/punktfunk-host/src/encode.rs:195-211` (`validate_dimensions`), `crates/punktfunk-host/src/punktfunk1.rs:574-579,804,3659-3663`, `crates/punktfunk-host/src/encode/linux/mod.rs:247-248,474`, `crates/punktfunk-host/src/encode/linux/vaapi.rs:98,184`.
- **Threat actor / auth:** malicious paired client, **post-auth** (pre-auth only on opt-in `--open`/`--allow-tofu`).
- **Mechanism:** `validate_dimensions` caps W/H but ignores refresh. The mid-stream **Reconfigure** path explicitly checks `req.mode.refresh_hz > 0` (`punktfunk1.rs:804`) — proving the invariant is known — but the **initial Hello** path does not. On the common Linux backends (gamescope/wlroots/mutter) `preferred_mode` echoes the requested refresh, so `effective_hz`'s `.filter(|hz| hz>0).unwrap_or(mode.refresh_hz)` collapses a requested `refresh_hz=0` back to 0, reaching `open_video(fps=0)``time_base = Rational(1,0)` and the unchecked `pts * 1e9 / self.fps` divide at `encode/linux/mod.rs:474` (and `vaapi.rs:184`).
- **Scenario:** a paired client sends `Hello{mode: WxHx0}`; on a Mutter/wlroots/gamescope host either `avcodec_open2` rejects the `1/0` time_base (clean Err) or the first packet triggers a divide-by-zero panic on the encode thread.
- **Impact / mitigations:** at worst a **single-session-thread panic** isolated by `spawn_blocking`/`panic=unwind` (surfaces as a JoinError at `punktfunk1.rs:1092-1094`; the persistent listener and sibling sessions survive). KWin reports a real achieved Hz and dodges it. The **GameStream half is refuted**: `rtsp.rs:340-342` floors `maxFPS` with `.filter(|&f| f>0).unwrap_or(60)`, so `cfg.fps` is never 0.
- **Recommendation:** fold a refresh lower-bound (`>0`, ideally clamp `1..=480`) into `validate_dimensions` so Hello and Reconfigure enforce the same invariant; defensively use `self.fps.max(1)` at the two division sites.
- **Verifiers:** all four lenses PARTIAL; **adjusted_severity INFOLOW** — a real validation asymmetry and reachable divide-by-zero, but the outcome is a self-inflicted teardown of the attacker's *own* isolated session granting no new authority (post-auth) or reducing to the already-accepted stream-slot DoS (on opt-in `--open` hosts). Worth the trivial fix; not a boundary crossing.
#### S6 — Unbounded mpsc into the host-lifetime shared `MicService` (0xCB) — **PARTIAL (leaning info), severity LOW→INFO**
- **Surface:** native-data-plane / audio. **Files:** `crates/punktfunk-host/src/punktfunk1.rs:905-911,1200,1231-1280`; sinks `audio/linux/mod.rs:151-153`, `audio/windows/wasapi_mic.rs:107-120`.
- **Threat actor / auth:** malicious paired client, **post-auth**.
- **Mechanism (as filed):** each session forwards every 0xCB frame into an unbounded host-lifetime `std::sync::mpsc` shared across all sessions, with no backpressure/cap; the single consumer does an Opus decode + virtual-mic push per iteration.
- **Verifier correction:** the filed DoS mechanism — "the push blocks on the audio backend, so the queue grows without bound" — is **factually wrong**. Both `VirtualMic::push` impls are non-blocking and self-bounded: Linux uses `try_send` (drops when behind); Windows takes a quick mutex with a drop-oldest `MAX_QUEUE_BYTES` cap. The consumer is therefore CPU-throughput-limited (decode-only), runs on its own thread, and never stalls; the producer is QUIC-receive-rate bounded doing comparable per-item work. Items are only the ~sub-1KB Opus payload.
- **Residual:** a genuine but minor robustness gap — an unbounded shared channel with no explicit cap/rate-limit; under a sustained near-line-rate flood exceeding decode throughput, a small producer>consumer gap could accumulate.
- **Recommendation:** use a bounded (drop-oldest) channel for the mic forward, or rate-limit/coalesce per-session before the shared service.
- **Verifiers:** both PARTIAL, **adjusted_severity INFOLOW** — structural claim holds, stated stall mechanism refuted by the non-blocking sinks.
#### S7 — GameStream RSA pairing uses `rsa 0.9` (RUSTSEC-2023-0071 Marvin timing side-channel) — **PARTIAL (leaning info), severity LOW→INFO**
- **Surface:** cli-posture-deps. **Files:** `Cargo.toml` (rsa 0.9.10), `crates/punktfunk-host/src/gamestream/cert.rs:54-55`, `crates/punktfunk-host/src/gamestream/pairing.rs:200`.
- **Threat actor / auth:** network adversary on the GameStream pairing flow, **pre-auth** (the pairing flow itself; the consent bypass is already tracked in the main audit).
- **Mechanism:** the host's persistent RSA-2048 identity (the trust root: pinned TLS cert + pairing signer) is loaded into a PKCS1v15 `SigningKey` and used to `sign(&serversecret)` during the unauthenticated nvhttp pairing ceremony. `rsa 0.9.10` carries RUSTSEC-2023-0071 (variable-time private-key op, no fixed upstream release), so signing-response timing is data-dependent on the secret key. Recovery would defeat client cert-pinning (host impersonation).
- **Existing mitigations:** GameStream is **off by default and documented trusted-LAN-only** (#5/#9 inherent caveat); the native plane uses Ed25519/SPAKE2 and is unaffected. Crucially this is the **signing** path, not the PKCS#1v1.5 **decryption** oracle Marvin classically targets, and `serversecret` is host-generated random (not attacker-chosen) — so a remote network-timed RSA-2048 key recovery over a jittery LAN is theoretical, requiring enormous high-precision sampling.
- **Recommendation:** track the advisory; when a blinded/constant-time `rsa` release lands, upgrade; consider migrating the GameStream identity to ECDSA/Ed25519; keep GameStream gated off by default.
- **Verifiers:** both PARTIAL, **adjusted_severity INFOLOW** — claim technically accurate and no code-level fix exists upstream, but the off-by-default posture, signing-not-decryption distinction, and lack of any demonstrated practical remote key recovery reduce this to a transitive-advisory exposure.
### (c) Refuted / not vulnerabilities
- **Single shared virtual mic + stateful Opus decoder across concurrent sessions (no isolation)** — *refuted (downgraded to info).* Concurrent sessions are co-tenancy of ONE desktop by design (`punktfunk1.rs:244-246`); a paired client already injects keystrokes/captures that desktop via the identically-shared input service, so sharing the mic grants no new authority. Decoder "corruption" is self-healing (reopen) audio-quality, not security. Document the limitation alongside the known gamescope multi-user gap.
- **Cover-art warmer follows HTTP redirects → blind SSRF** — *refuted under the in-scope threat model.* URLs are hardcoded `https://api.gog.com` / `https://displaycatalog.mp.microsoft.com` constants reached over verified TLS (ureq 2.x → rustls + webpki-roots); the id is only a path segment. No in-scope adversary (network client, on-path MITM with no host key, local user) can emit the 30x `Location` — that requires a genuine compromise/cert-hijack of those domains (supply-chain, out of scope). A local user can only poison the path segment of a request still sent to the real upstream over TLS. Defense-in-depth: still set `.redirects(0)`.
- **GameStream RSA signing direct attacker-control** — partial-leaning: the adversary observes a timing side-channel, not a value flowing to a sink; see S7.
### (d) Positives confirmed on these surfaces
- **Native control plane is fail-closed at the app layer:** `serve_session` (`punktfunk1.rs:544-573`) rejects unpaired/anonymous clients before `validate_dimensions`, compositor resolution, the `can_encode_444` GPU probe, encoder open, and vdisplay create.
- **Client→host wire decoders are uniformly bounds-checked, no reachable parse panic/OOB:** `Hello.decode` uses checked `.get()` for every trailing field (the one `u32at` is gated by `len>=20`); `RichInput` (`quic.rs:1271`), `InputEvent` (`input.rs:136`), and `decode_mic_datagram` all length-check before indexed reads; unknown datagram tags are a non-fatal drop.
- **No client field reaches HDR SEI:** the 0xCE/HDR, 0xCA rumble, 0xCD HidOut datagrams are host→client only; the SEI builders are fed only host-derived values.
- **Geometry → unsafe FFI is memory-safe:** W/H caps applied on Hello, Reconfigure, and ANNOUNCE; CPU upload paths re-derive `src_row` from the encoder's own width and bound-check `bytes.len() >= src_row*h` before `sws_scale`/copy; encoder fully rebuilds on size change (no stale-size OOB); CUDA pitch math driver-bounded; Drop SAFETY contracts hold (no UAF/double-free); pf_vdisplay/SudoVDA ioctls use `size_of`-sized buffers with no attacker-controlled length.
- **Driver-IPC ABI is clean:** `pf-driver-proto` pins all offsets/sizes via compile-time `offset_of!`/`size_of` asserts; gamepad output reports, XUSB rumble, IDD-push publish token, and the WGC AU pipe all bounds-check before indexed reads and never use an attacker byte as offset/length/index; the only residual is the already-reported world-writable section ACL.
- **Opus mic buffer math exact:** 5760×2 f32 = the 120 ms max stereo frame; the safe `opus` crate returns `BufferTooSmall` rather than overflowing; `(samples×2).min(pcm.len())`.
- **Gamepad accumulation clamped at every layer:** `idx < MAX_WIRE_PADS(16)`, `idx >= MAX_PADS(4)` rejects, finger/touchpad/stick/trigger clamps.
- **No production GameStream client→host Opus decode path:** the only `MSDecoder::new(..., client_mapping)` call sites are inside `#[cfg(test)]` (the prompt's G1 premise corrected) — that attack surface does not exist in shipped code.
- **CLI default-security posture sound:** `require_pairing` / `open` use exact-string scans (malformed/quoted args can't flip them); the mgmt token is mandatory on every bind including loopback (`mgmt.rs:86-92,471-507`); empty `--mgmt-token` rejected; dev subcommands expose no weaker-trust default listener.
- **Cover-art direct SSRF safe:** hardcoded hosts, id only in path, TLS verification on, body capped at ureq's 10 MB; catalog art URLs flow only to clients, never re-fetched by the host.
- **Concurrency/probe bounds:** `max_concurrent` via owned semaphore permit before `accept()`; probe duration/rate clamped (`MAX_PROBE_MS=5s`, `MAX_PROBE_KBPS=10Gbps`); `ClockProbe` answered 1:1 (no amplification).
---
## Appendix — coverage-gap critic (pass 1) and how pass 2 addressed it
# Coverage gaps & follow-up
I enumerated all 82 host source files and mapped them to the 13 audit surfaces. Below are files / data-paths / cross-cutting concerns that **no surface clearly owns**, ranked for a follow-up pass.
## Gaps in per-file coverage
### G1 — Client mic-uplink Opus decode → privileged virtual mic (MED)
Files: `src/audio.rs`, `src/audio/linux/mod.rs`, `src/audio/windows/wasapi_cap.rs`+`wasapi_mic.rs`, decode sinks at `punktfunk1.rs:1233-1266` and `gamestream/audio.rs:610-732`.
The `native-protocol` surface covers the *demux* (0xCB → `mic_tx`) and `gamestream-wire` covers RTP framing, but the **Opus decode itself and the PCM injection into a host-wide virtual microphone** is owned by no surface. This is an attacker-controlled byte stream (`opus::Decoder::decode_float` on raw network bytes, `punktfunk1.rs:1266`) decoded into a system-visible recording device. Worse on the GameStream path: `gamestream/audio.rs:637/724` builds an `opus::MSDecoder` from a **client-derived channel mapping/layout** (`layout.streams`, `layout.coupled`, `client_mapping`) — verify those are bounds-checked before reaching libopus, and that decode errors can't panic/DoS the host-lifetime mic thread. Native path is post-auth; the GameStream mic path rides weaker GameStream trust. No audio-decode surface existed.
### G2 — Shared host-lifetime mic/input services across concurrent sessions (MED)
`punktfunk1.rs:219-300` (`mic_service` / `mic_tx` shared, host-lifetime). With `--max-concurrent` sessions sharing **one** virtual mic and input service, a paired client's mic stream / input can bleed into a *different* concurrent session's desktop. This spans `audio` + `session-lifecycle` + `input-injection` and no single surface would catch the cross-session isolation question. Adversary: post-auth client #2 against session #1 (multi-user isolation, explicitly listed as "remaining piece" in CLAUDE.md for gamescope).
### G3 — `main.rs` CLI parsing & default-security posture (MED)
`src/main.rs` (734 LOC) is owned by no surface. It decides the crown-jewel default: `require_pairing: !args.iter().any(|a| a == "--allow-tofu")` (`main.rs:388`) — a substring/exact-match flag scan that gates whether unpaired clients are accepted. Also hosts the `spike` and `punktfunk1-host` dev subcommands shipped in the production binary, and the `--mgmt-bind` parse (`main.rs:516`, non-loopback requires a token — good, but verify the loopback check can't be bypassed by `0.0.0.0`/IPv6-mapped forms). A default-posture/flag-parsing regression here silently disables pairing. Cross-cutting; no surface re-derives it.
### G4 — Cover-art warmer outbound egress + parse (LOW-MED)
`library.rs:1004-1090` (`fetch_gog_art`, `fetch_xbox_art`, host-lifetime warmer over `ureq`) and Epic `catcache.bin` base64 decode. `library-launch` likely covered launch-command construction, but the **outbound HTTP egress** (host as SSRF client fetching URLs influenced by on-disk store files / operator custom entries, `library.rs:481-697`) and the base64/JSON parse of attacker-influenceable launcher caches are a distinct trust boundary. Confirm `library-launch` actually traced the fetch side, not just launch exec.
### G5 — `hdr.rs` metadata path (LOW)
`src/hdr.rs` (168 LOC) — HDR/color-info construction. If any field derives from client `ColorInfo` (0xCE / connect_ex5 caps), it's attacker-influenced metadata fed to the encoder SEI. No surface names it.
### G6 — Glue/init files unmapped (LOW)
`pipeline.rs`, `pwinit.rs`, `session_tuning.rs`, `linux/dmabuf_fence.rs`, `linux/drm_sync.rs` — mostly internal glue, but the dmabuf/drm-sync FFI files border `unsafe-ffi`; confirm that surface's scope included them (they were not in its cited list of zerocopy/encode/capture).
## Cross-cutting concerns no per-surface review can catch
### X1 — Dependency / RUSTSEC posture (MED)
`Cargo.toml` is owned by no surface. Notable: **`rsa = "0.9"`** is subject to RUSTSEC-2023-0071 (Marvin timing side-channel) and is used directly by the **GameStream RSA pairing** ceremony — a network-adjacent oracle concern for `gamestream/crypto.rs`+`pairing.rs`. `ureq = "2"` backs the cover-art egress (G4). Run `cargo audit` against the workspace lock as a follow-up; per-surface reviewers won't.
### X2 — Secret-file create→chmod TOCTOU across modules (LOW)
`secrets-perms` verifies final perms, but the create-then-restrict ordering window is implemented independently in `gamestream/cert.rs`, `mgmt_token.rs`, `native_pairing.rs`, and the captures/art writers (`stats_recorder.rs`, `library.rs`). A single helper vs N call-sites is a cross-module check: confirm every secret is created with restrictive perms atomically (O_CREAT mode), not world-readable-then-chmod, on **every** path including ones added since the prior audit.
### X3 — On-disk capture / cache write paths (LOW)
`stats_recorder.rs` captures (`~/.config/punktfunk/captures/*.json`) and `library.rs` art cache are operator-readable artifacts; `stats-capture` covered the endpoints but confirm the **filename derivation** for saved captures can't be influenced by a network field (path traversal into the captures dir).
### X4 — `windows/install.rs` driver/web install moved into host exe (MED — verify owned)
`windows/install.rs` + `windows/interactive.rs` should be under `windows-service-priv`, but given commit 125a51d is new, explicitly confirm that surface traced: the source of bundled driver paths (pnputil install), any download/verify of the web bundle, and that `CreateProcessAsUserW`/scheduled-task launch can't be redirected by an unprivileged local user (adversary #4) writing into a host-readable staging dir.
Net: G1 (mic decode → virtual mic) and G3 (main.rs default posture) are the most likely real-blind-spots; X1 (rsa 0.9 in GameStream pairing) is the cleanest cross-cutting follow-up.