From 5c2bcbc2a28f1ae65606c926b24b697ec8f4b3c6 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 16 Jun 2026 07:08:50 +0000 Subject: [PATCH] docs(windows): secure-desktop two-process design + WGC impersonation attempt (vestigial) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validated design for adding secure-desktop (UAC/lock/login) coverage on top of the shipped WGC animation fix. Key verified constraint: WGC won't activate under SYSTEM (0x80070424) even with thread-level ImpersonateLoggedOnUser, and DDA+SendInput on Winlogon need LOCAL_SYSTEM — so one process can't do both. Architecture: SYSTEM host (QUIC + SudoVDA + DDA-secure + SendInput + AU mux) + a USER-session WGC helper (CreateProcessAsUser) that relays encoded Annex-B AUs over a named pipe; the host muxes helper-AUs (normal desktop) vs its own DDA encoder (secure desktop), switched by a desktop-name watcher. No shared GPU texture (rejected — MIC/keyed-mutex pain); just AU bytes. docs/windows-secure-desktop.md has the ordered, box-testable steps. The impersonate_active_user() in wgc.rs is kept as a harmless no-op (under a user-token process WTSQueryUserToken fails → no impersonation → WGC works natively); it does NOT make WGC work under SYSTEM (the two-process design uses a real user process for WGC instead). + Win32_System_RemoteDesktop. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/punktfunk-host/Cargo.toml | 2 + crates/punktfunk-host/src/capture/wgc.rs | 43 +++++++++++- docs/windows-secure-desktop.md | 84 ++++++++++++++++++++++++ 3 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 docs/windows-secure-desktop.md diff --git a/crates/punktfunk-host/Cargo.toml b/crates/punktfunk-host/Cargo.toml index 0e95836..0bef6f2 100644 --- a/crates/punktfunk-host/Cargo.toml +++ b/crates/punktfunk-host/Cargo.toml @@ -134,6 +134,8 @@ windows = { version = "0.62", features = [ "Graphics_DirectX_Direct3D11", "Win32_System_WinRT_Direct3D11", "Win32_System_WinRT_Graphics_Capture", + # WGC runs under SYSTEM via interactive-user impersonation (WGC won't activate as SYSTEM). + "Win32_System_RemoteDesktop", ] } # Software H.264 encoder (GPU-less path + NVENC fallback). The default `source` feature statically # compiles OpenH264 (BSD-2) — no system lib, builds on MSVC; nasm on PATH adds the SIMD fast path. diff --git a/crates/punktfunk-host/src/capture/wgc.rs b/crates/punktfunk-host/src/capture/wgc.rs index 4703a58..e84eb01 100644 --- a/crates/punktfunk-host/src/capture/wgc.rs +++ b/crates/punktfunk-host/src/capture/wgc.rs @@ -31,6 +31,7 @@ use windows::Graphics::Capture::{ }; use windows::Graphics::DirectX::DirectXPixelFormat; use windows::Graphics::SizeInt32; +use windows::Win32::Foundation::{CloseHandle, HANDLE}; use windows::Win32::Graphics::Direct3D11::{ ID3D11Device, ID3D11DeviceContext, ID3D11RenderTargetView, ID3D11ShaderResourceView, ID3D11Texture2D, D3D11_BIND_RENDER_TARGET, D3D11_BIND_SHADER_RESOURCE, D3D11_TEXTURE2D_DESC, @@ -41,12 +42,47 @@ use windows::Win32::Graphics::Dxgi::Common::{ DXGI_FORMAT_R10G10B10A2_UNORM, DXGI_FORMAT_R16G16B16A16_FLOAT, DXGI_SAMPLE_DESC, }; use windows::Win32::Graphics::Dxgi::{IDXGIDevice, IDXGIOutput6}; +use windows::Win32::Security::{ImpersonateLoggedOnUser, RevertToSelf}; +use windows::Win32::System::RemoteDesktop::{WTSGetActiveConsoleSessionId, WTSQueryUserToken}; use windows::Win32::System::WinRT::Direct3D11::{ CreateDirect3D11DeviceFromDXGIDevice, IDirect3DDxgiInterfaceAccess, }; use windows::Win32::System::WinRT::Graphics::Capture::IGraphicsCaptureItemInterop; use windows::Win32::System::WinRT::{RoInitialize, RO_INIT_MULTITHREADED}; +/// The host runs as SYSTEM (so the DDA secure-desktop path works), but WGC will NOT activate under +/// the SYSTEM account (`CreateForMonitor` → 0x80070424). Impersonate the interactive console user +/// for the WGC activation. Returns the user token (the caller reverts + closes it after activation) +/// or `None` (no active user, or the host already runs AS the user — WTSQueryUserToken then fails and +/// WGC works without impersonation). SYSTEM-only; harmless under a user-token host. +unsafe fn impersonate_active_user() -> Option { + let session = WTSGetActiveConsoleSessionId(); + if session == 0xFFFF_FFFF { + return None; + } + let mut token = HANDLE::default(); + if WTSQueryUserToken(session, &mut token).is_ok() { + if ImpersonateLoggedOnUser(token).is_ok() { + return Some(token); + } + let _ = CloseHandle(token); + } + None +} + +/// RAII: reverts the WGC-activation impersonation when it drops (covers every `?` early-return). +struct Deimpersonate(Option); +impl Drop for Deimpersonate { + fn drop(&mut self) { + if let Some(tok) = self.0.take() { + unsafe { + let _ = RevertToSelf(); + let _ = CloseHandle(tok); + } + } + } +} + /// Signal from the free-threaded FrameArrived callback to the encode thread: a monotonically /// increasing count of arrived frames + a condvar to wake `next_frame`. The encode thread tracks how /// many it has consumed; `TryGetNextFrame` is called exactly `available - consumed` times so we never @@ -101,7 +137,12 @@ impl WgcCapturer { // activation factory (RoGetActivationFactory). Initialize MTA; ignore "already initialized" // / "changed mode" (another component on this thread may have init'd a compatible apartment). let ro = RoInitialize(RO_INIT_MULTITHREADED); - tracing::info!(ro_result = ?ro, "WGC: RoInitialize(MTA)"); + // Impersonate the interactive user for the duration of WGC activation (host runs as + // SYSTEM; WGC won't activate under SYSTEM). Reverted by the guard's Drop on return. The + // WGC objects, once created, are accessed from the (SYSTEM) encode thread thereafter. + let imp = impersonate_active_user(); + let _deimp = Deimpersonate(imp); + tracing::info!(ro_result = ?ro, impersonated = imp.is_some(), "WGC: RoInitialize(MTA)"); // The SudoVDA output appears a beat after the display is created — settle-retry like DDA. let deadline = Instant::now() + Duration::from_millis(2000); let (adapter, output) = loop { diff --git a/docs/windows-secure-desktop.md b/docs/windows-secure-desktop.md new file mode 100644 index 0000000..52c77ee --- /dev/null +++ b/docs/windows-secure-desktop.md @@ -0,0 +1,84 @@ +# Windows secure-desktop capture — two-process design + +Status: **design validated, implementation in progress.** The WGC animation fix ships and works +(host in user-mode); this doc is the plan for adding **secure-desktop (UAC / lock / login) coverage** +on top of it, since WGC and the secure desktop need conflicting process tokens. + +## The constraint (verified live on the RTX 4090) + +- **WGC** (the composed-desktop capture that fixes frozen HDR animations) **will not activate under + the SYSTEM account** — `CreateForMonitor` → `0x80070424`. Thread-level `ImpersonateLoggedOnUser` is + **insufficient** (tested: `impersonated=true`, still `0x80070424`). WGC needs the *process* to run + as the interactive user. +- **DDA + SendInput on the secure desktop (Winlogon: UAC/lock/login) require LOCAL_SYSTEM** (attach to + the Winlogon desktop). This is already shipped (task #17) when the host runs as SYSTEM. +- Therefore one process can't do both. Single-process (the simpler design) is **out**. + +## Architecture: SYSTEM host + USER-session WGC helper, AU-relay (no shared GPU texture) + +- **SYSTEM host** (the existing `m3-host`, launched as SYSTEM in interactive Session 1 via the + scheduled task → PsExec `-s -i 1`): owns the punktfunk/1 QUIC session, the single SudoVDA virtual + output (+ isolate/restore RAII — the *only* topology owner), the **DDA capture + NVENC encoder for + the secure desktop**, the **single SendInput injector** (serves *both* desktops), and the **AU + source mux** that feeds the QUIC data plane. +- **USER-session WGC helper** (a new `m3-host` subcommand, spawned by the SYSTEM host via + `WTSQueryUserToken(activeConsoleSessionId)` → `DuplicateTokenEx(TokenPrimary)` → + `CreateProcessAsUserW(lpDesktop="winsta0\\default", CREATE_NO_WINDOW)`): runs the existing + **WGC → scRGB/PQ → NVENC** pipeline and ships **Annex-B AUs** (`{data, pts_ns, keyframe}`) to the + SYSTEM host over a **named pipe**. It captures the SAME SudoVDA output **by GDI name only** — it + must NOT create its own virtual output / touch display topology (a second topology owner re-triggers + the ACCESS_LOST born-lost storm). +- **Mux**: the SYSTEM host relays the helper's AUs onto QUIC while the input desktop is `Default` + (normal — WGC, HDR/animation-correct), and switches to its own DDA encoder while it's `Winlogon` + (secure — UAC/lock/login). The client sees one continuous stream; the encoder/FEC/AES-GCM/QUIC send + path is untouched (same `EncodedFrame` flow). NVENC re-inits only on a size/format change across the + swap (already handled); same-mode is a pointer re-register. +- **Input**: stays entirely in the SYSTEM host (only it can attach to Winlogon). One windowless + SendInput thread, Sunshine's **retry-on-failure-only** model (cache HDESK thread-local; SendInput + first; only on 0-injected re-`OpenInputDesktop`+`SetThreadDesktop` and retry once) — serves both + desktops with no per-event reattach. (Ctrl+Alt+Del/SAS needs `SendSAS`, out of scope; clicking UAC + Yes/No + typing the login password are plain SendInput on Winlogon.) + +Rejected: a shared NT-handle GPU texture (MIC/SDDL pain SYSTEM→user, keyed-mutex ring at 240 Hz, +nvenc pointer-cache churn — all for a static lock dialog). AU bytes over a pipe are far simpler. + +## Detection + +`DesktopWatcher`: a dedicated thread polling the input-desktop NAME at 30–60 Hz — +`OpenInputDesktop(0,FALSE,0)` + `GetUserObjectInformationW(UOI_NAME)` == `"Winlogon"` (secure) vs +`"Default"` (normal) → `Arc`. This is the authoritative signal; WTS session notifications +miss UAC entirely. (May also register `WTSRegisterSessionNotification` to short-circuit lock/unlock.) + +## Implementation steps (each independently buildable/testable on the 4090) + +1. **DesktopWatcher** (`capture/desktop_watch.rs`, ~40 lines): the poll + atomic. Test: lock / trigger + UAC over the existing stream, confirm the atomic flips `Default↔Winlogon` within a poll interval. +2. **SendInput retry-on-failure model** (`inject/sendinput.rs`): replace per-event reattach with the + cached-HDESK + retry-once model. Test: normal input unchanged; click UAC + type the lock password + land (works today via per-event reattach — this is a refactor). +3. **WGC helper subcommand** (`m3-host wgc-helper` or similar): the existing WGC pipeline → NVENC → + Annex-B AUs over a named-pipe server. Test standalone: as the user it writes a valid `.h265` to the + pipe (capturing the SudoVDA output by GDI name, no topology changes). +4. **Spawn + relay**: SYSTEM host spawns the helper (`CreateProcessAsUserW`), connects the pipe, + relays its AUs onto the live QUIC session. Test: normal-desktop stream sourced via the helper relay. +5. **Source mux**: relay helper AUs while `Default`, switch to the host's own DDA encoder while + `Winlogon` (reusing the DesktopWatcher). Test: normal (WGC, HDR) → trigger UAC → stream shows the + UAC dialog (DDA) → dismiss → back to WGC; QUIC session stays up throughout. **Full-coverage milestone.** +6. **Relaunch watchdog + soak**: `SERVICE_CONTROL_SESSIONCHANGE`-style relaunch of the helper on + console connect/disconnect; soak a few hundred lock/unlock+UAC switches (cf. task #17's 1012-switch + run) — no leak / black / disconnect. Cargo features for the fallback: `Win32_System_Threading`, + `Win32_System_Pipes`, `Win32_System_RemoteDesktop`. + +## Risks / notes + +- Validate on the real 4090 only (`ssh "Enrico Bühler"@192.168.1.174`, Session 1 via the Interactive + scheduled task) — the headless build VM can't reproduce Winlogon-on-virtual-display or WGC. +- The helper MUST capture the SudoVDA by GDI name and never create a second virtual output (avoids the + ACCESS_LOST born-lost storm — one isolate owner = the SYSTEM host). +- Confirm `reisolate` fires on a FRESH mid-session DDA open at the desktop boundary (task #17 only + validated DDA recovery within an already-DDA session). +- Brief one-frame repeat/flicker at the WGC↔DDA boundary is acceptable (the local lock/UAC transition + flickers too); never starve the encoder (repeat last frame across the swap gap). +- Pragmatic alternative if full coverage isn't worth the build: `PromptOnSecureDesktop=0` (UAC renders + on the normal desktop → WGC captures it) covers UAC (not lock/login) with one reversible registry + change.