diff --git a/.gitea/workflows/windows-drivers.yml b/.gitea/workflows/windows-drivers.yml index 5f4a9185..eb9ed7a7 100644 --- a/.gitea/workflows/windows-drivers.yml +++ b/.gitea/workflows/windows-drivers.yml @@ -153,9 +153,9 @@ jobs: # `// SAFETY:` proof. Both invariants are lint-gated (`unsafe_op_in_unsafe_fn` + # `undocumented_unsafe_blocks`); this step keeps them from regressing. (wdk-probe is a # toolchain-only probe crate and is excluded.) - run: cargo clippy -p pf-umdf-util -p pf-xusb -p pf-dualsense -p pf-mouse -p wdk-iddcx -p pf-vdisplay --all-targets -- -D warnings + run: cargo clippy -p pf-umdf-util -p pf-xusb -p pf-gamepad -p pf-mouse -p wdk-iddcx -p pf-vdisplay --all-targets -- -D warnings - name: cargo fmt --check the safe-layer + gamepad/mouse drivers - run: cargo fmt -p pf-umdf-util -p pf-xusb -p pf-dualsense -p pf-mouse --check + run: cargo fmt -p pf-umdf-util -p pf-xusb -p pf-gamepad -p pf-mouse --check - name: Inspect /INTEGRITYCHECK (before) — expect FORCE_INTEGRITY set by wdk-build run: | # explicit --target (.cargo/config.toml) -> output under the triple subdir. diff --git a/crates/pf-capture/src/windows/idd_push.rs b/crates/pf-capture/src/windows/idd_push.rs index ce010548..50d7b94a 100644 --- a/crates/pf-capture/src/windows/idd_push.rs +++ b/crates/pf-capture/src/windows/idd_push.rs @@ -245,10 +245,31 @@ impl Drop for KeyedMutexGuard<'_> { /// broker duplicates sensitive handles into it. The pid is driver-reported (the frame channel's /// [`control::AddReply::wudf_pid`], or the gamepad bootstrap's `driver_pid`); a spoofed devnode / a /// tampered mailbox could name an arbitrary process to receive the channel, so this is the -/// confused-deputy gate. Best-effort image-path identity is proportionate: a fully-compromised REAL -/// driver is already a channel endpoint, and any *other* process (attacker exe, a non-driver pid) -/// fails this WUDFHost image check. `what` names the channel in the error (e.g. `"frame-channel"`); -/// shared with the gamepad sealed channel (`inject/windows/gamepad_raii.rs`). +/// confused-deputy gate. `what` names the channel in the error (e.g. `"frame-channel"`); shared with +/// the gamepad sealed channel (`inject/windows/gamepad_raii.rs`). +/// +/// # What this does and does NOT prove (security-review 2026-07-28) +/// +/// It proves the target's image is the system WUDFHost binary. It does **not** prove the target is +/// *the* WUDFHost hosting our devnode, and it is not an authorization check: `WUDFHost.exe` is +/// world-executable, so anyone who can name a pid to a broker can first spawn their own copy (e.g. +/// `CREATE_SUSPENDED`, which parks it indefinitely with the right image path) and pass this check. +/// It therefore only screens out *non-WUDFHost* pids — an attacker exe, a stale/wrong devnode. +/// +/// Whether that is sufficient depends entirely on who can name the pid, so it must be judged at each +/// caller, NOT here: +/// - **Frame channel** — sufficient. The pid is `AddReply::wudf_pid`, which the driver fills with its +/// own `GetCurrentProcessId()` and returns over the pf-vdisplay control device, whose DACL is +/// `D:P(A;;GA;;;SY)(A;;GA;;;BA)`. Only SYSTEM/Administrators can speak on that channel, and both are +/// out of scope by SECURITY.md. +/// - **Gamepad/mouse channel** — NOT sufficient on its own. The pid comes from a `Global\` mailbox +/// that LocalService can write, so this check does not identify the caller; see the corrected +/// analysis and the one-delivery rule in `pf-inject/src/inject/windows/gamepad_raii.rs`. +/// +/// Do not add a token/session/account check here expecting it to fix the gamepad case: a genuine +/// UMDF host and a LocalService attacker's spawned WUDFHost are both session 0 and both LocalService, +/// so such checks discriminate nothing while risking a false negative that would silently kill +/// display capture and every virtual pad. /// /// # Safety /// `process` must be a live process handle carrying `PROCESS_QUERY_LIMITED_INFORMATION`. diff --git a/crates/pf-driver-proto/src/lib.rs b/crates/pf-driver-proto/src/lib.rs index 8e471455..ad2576c4 100644 --- a/crates/pf-driver-proto/src/lib.rs +++ b/crates/pf-driver-proto/src/lib.rs @@ -681,7 +681,7 @@ pub mod frame { }; } -/// Gamepad shared-memory layouts (host ↔ the UMDF gamepad drivers `pf_xusb` / `pf_dualsense`). +/// Gamepad shared-memory layouts (host ↔ the UMDF gamepad drivers `pf_xusb` / `pf_gamepad`). /// /// These were hand-duplicated as `OFF_*`/`SHM_*` constants in `inject/{gamepad,dualsense}_windows.rs` /// and (as bare literals — `*view.add(140)`) in the standalone `xusb-driver`/`dualsense-driver` @@ -699,12 +699,12 @@ pub mod gamepad { /// XUSB section magic — the exact u32 the shipped host + `pf_xusb` driver compare (loosely "PFXU"). pub const XUSB_MAGIC: u32 = 0x5558_4650; - /// Pad section magic — the exact u32 the shipped host + `pf_dualsense` driver compare (loosely + /// Pad section magic — the exact u32 the shipped host + `pf_gamepad` driver compare (loosely /// "PFDS"). (Note: the two magics happen to use opposite byte-order mnemonics in the legacy code; /// only the u32 value is the contract.) pub const PAD_MAGIC: u32 = 0x5046_4453; - /// `device_type` selector the `pf_dualsense` driver reads to pick its HID identity. The section is + /// `device_type` selector the `pf_gamepad` driver reads to pick its HID identity. The section is /// zeroed, so `0` = DualSense is the default; one driver serves every identity. pub const DEVTYPE_DUALSENSE: u8 = 0; /// `device_type` = DualShock 4 (`VID_054C&PID_09CC` HID identity). @@ -730,7 +730,235 @@ pub mod gamepad { /// gained `pad_index` (carved from reserved space) so the driver rejects a cross-pad delivery. /// A v1 driver opens `Global\pf…-shm-` (which no longer exists) and a v1 host never creates /// the mailbox a v2 driver polls, so a mixed pairing fails closed either way. - pub const GAMEPAD_PROTO_VERSION: u32 = 2; + /// + /// v3: the **channel proof** ([`ChannelProof`]) — the host stopped trusting the mailbox's + /// `driver_pid` and now learns the duplication target over the DEVICE STACK instead. A v2 driver + /// answers no proof, so a v3 host refuses to deliver to it; a v2 host never asks, and a v3 driver + /// refuses the v2 handshake on the `host_proto` check. Mixed pairings fail closed both ways, with + /// the existing "update host + drivers together" diagnostic. + pub const GAMEPAD_PROTO_VERSION: u32 = 3; + + // ── the channel proof (v3): who to hand the DATA section to ────────────────────────────────── + // + // WHY THIS EXISTS. Through v2 the host took the duplication target from the mailbox's + // `driver_pid`. The mailbox has to be openable by LocalService (that is what the driver's own + // WUDFHost runs as), and the delivery gate — `verify_is_wudfhost` — only checks that the named + // process's IMAGE is `%SystemRoot%\System32\WUDFHost.exe`, which is world-executable. So any + // LocalService principal, notably the deliberately de-privileged plugin runner, could spawn its + // own WUDFHost, publish that pid, and be handed the pad's DATA section: forged HID input into the + // interactive desktop (the mouse section drives a real absolute pointer) and a read of the remote + // user's controller state (security-review 2026-07-28). + // + // WHY IT HAS TO COME FROM THE DEVICE STACK. That race cannot be closed on the host side alone. + // Everything the real driver can read at LocalService — the devnode's Location, its + // `Device Parameters` key, the object namespace — an attacker at LocalService can read too, so no + // host-published secret tells the two apart. The ONE thing an attacker cannot forge is *being the + // driver bound to our devnode*: only that process answers I/O sent to the device the host itself + // created (and the host looks the device up by the instance id `SwDeviceCreate` handed back, so a + // planted look-alike devnode is not in the running). Asking the devnode "which process are you?" + // therefore yields a pid the host can trust, and the mailbox is demoted to what it always should + // have been: a rendezvous for a handle VALUE that is meaningless anywhere but in that process. + // + // Two transports, because the drivers are two different shapes: + // * `pf_xusb` is a plain UMDF2 driver that owns `GUID_DEVINTERFACE_XUSB` and dispatches its own + // IOCTLs -> [`IOCTL_PF_XUSB_GET_CHANNEL_PROOF`]. + // * `pf_gamepad` / `pf_mouse` are HID minidrivers with no control device (hidclass owns the + // stack, and UMDF has no control-device objects), so the reachable read path is a HID string + // -> [`HID_STRING_INDEX_CHANNEL_PROOF`], which needs no report-descriptor change. That + // matters: the pads' descriptors, VID/PID and serials are what Steam and SDL fingerprint, + // and a new feature report there would risk the identity work this driver exists to get right. + + /// Proof magic ("PFCP" — punktfunk channel proof), and the `PFCP` prefix of the text form. + pub const PROOF_MAGIC: u32 = 0x5043_4650; + + /// Reserved HID string index the `pf_gamepad` / `pf_mouse` minidrivers answer with their + /// [`ChannelProof`], fetched by the host with `HidD_GetIndexedString`. + /// + /// ⚠️ MEASURED UNUSABLE on .173 (Win11 26200): hidclass does not carry an arbitrary indexed-string + /// request to a UMDF HID minidriver — `HidD_GetIndexedString` failed for EVERY index, including + /// ones the driver demonstrably serves through the named wrappers. Kept because it costs one + /// failed IOCTL and is the right thing to ask first if a later Windows starts forwarding it; the + /// transports that actually work are [`PF_PAD_CONTROL_INTERFACE_GUID_U128`] (if hidclass lets it + /// through) and, for `pf_mouse`, the serial string ([`proof_is_serial_string`]). + /// + /// 16-bit on purpose: both `IOCTL_HID_GET_INDEXED_STRING` and `IOCTL_HID_GET_STRING` pack their + /// argument as `(language_id << 16) | string_index`, so only the low word survives the trip and + /// both drivers mask before comparing. `0x5046` ("PF") is still far outside the 1..=255 range a + /// real USB/HID string-descriptor index can occupy, so it cannot collide with a string the OS, a + /// game, or Steam asks for. + pub const HID_STRING_INDEX_CHANNEL_PROOF: u32 = 0x5046; + + // ❌ A private device interface (`WdfDeviceCreateDeviceInterface`) was tried here as a + // hidclass-independent transport for the HID minidrivers and MEASURED DEAD on .173 (Win11 + // 26200): it registers and enumerates, but `CreateFile` on it is refused (ERROR_GEN_FAILURE) + // because hidclass owns `IRP_MJ_CREATE` on a devnode it is the FDO for. Do not re-try it for + // `pf_gamepad`/`pf_mouse`; see `pf_umdf_util::hid` for the full measurement. + + /// The proof question itself, on whichever interface carries it. + /// `CTL_CODE(0x8000, 0x0FE0, METHOD_BUFFERED, FILE_ANY_ACCESS)`: a function code no xusb22 IOCTL + /// uses, `METHOD_BUFFERED` so the answer is a plain buffer copy, and `FILE_ANY_ACCESS` so the host + /// can ask over a `CreateFile` handle opened with NO access rights (the same way it must open a + /// HID collection). Answering it leaks nothing — a pid is not a secret, and the proof is only + /// worth anything to a process that can already duplicate handles. + pub const IOCTL_PF_GET_CHANNEL_PROOF: u32 = 0x8000_3F80; + + /// Whether a driver serves its channel proof AS its HID serial-number string. + /// + /// The one transport measured to work against a UMDF HID minidriver today: on .173, + /// `HidD_GetSerialNumberString` succeeds on a zero-access handle and returns the driver's own + /// text, so a proof placed there reaches the host. Enabled for **`pf_mouse` only** — its serial + /// (`PFMOUSE00`) is inert, whereas the pads' serials are what SDL and Steam dedup controllers on, + /// and Steam is already known to mangle a pad's displayed name over serial FORMAT alone. + /// + /// `pf_mouse` is also the one that matters most: its section drives a real absolute pointer, so a + /// hijacked mouse channel is desktop control, where a hijacked pad channel is gamepad input. + pub const fn proof_is_serial_string(pad_kind_is_mouse: bool) -> bool { + pad_kind_is_mouse + } + + /// The feature report the **PS pad identities** (DualSense / DualShock 4 / Edge) answer the + /// channel proof on. + /// + /// `0x85` is already DECLARED as a Feature report in all three captured descriptors and was + /// previously unserved — the driver failed it with `STATUS_INVALID_PARAMETER`. That is what makes + /// this transport free: **no report-descriptor change**, so the VID/PID, report layout, serial and + /// product strings that Steam and SDL fingerprint are untouched, and `HidD_GetFeature` is allowed + /// through by hidclass because the id is in the descriptor. Nothing can have depended on the old + /// failure: SDL reads `0x05`/`0x09`/`0x20` (DualSense) and `0x02`/`0x12`/`0xA3` (DS4); `0x85` is + /// one of Sony's vendor reports neither it nor Steam asks for. + pub const HID_FEATURE_REPORT_CHANNEL_PROOF: u8 = 0x85; + + /// The Steam Deck identity's private proof command. + /// + /// The Deck descriptor declares ONE unnumbered feature report and Steam drives it as a + /// command/response protocol (`0x83` GET_ATTRIBUTES, `0xAE` GET_STRING_ATTRIBUTE); the driver + /// echoes commands it doesn't know. So the proof rides that same contract — SET_FEATURE this + /// command, then GET_FEATURE the reply — again with no descriptor change. TWO bytes, not one, so + /// a Steam command byte we haven't catalogued can never be mistaken for it. + pub const DECK_PROOF_CMD: [u8; 2] = [0xF9, 0x50]; + + /// What a driver answers when the host asks, over the device stack, who it is. + #[repr(C)] + #[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)] + pub struct ChannelProof { + /// [`PROOF_MAGIC`]. + pub magic: u32, + /// The driver's [`GAMEPAD_PROTO_VERSION`]. + pub proto: u32, + /// The pad index the driver read from its devnode Location — cross-checked against the pad + /// the host is delivering, so a mis-resolved devnode can't cross-wire two pads. + pub pad_index: u32, + /// `GetCurrentProcessId()` of the driver's WUDFHost: the duplication target. + pub wudf_pid: u32, + } + + impl ChannelProof { + /// This driver's answer. `pad_index` comes from the devnode Location, `wudf_pid` from + /// `GetCurrentProcessId()`. + pub fn new(pad_index: u32, wudf_pid: u32) -> ChannelProof { + ChannelProof { + magic: PROOF_MAGIC, + proto: GAMEPAD_PROTO_VERSION, + pad_index, + wudf_pid, + } + } + + /// Validate an answer against the pad the host is actually delivering, yielding the pid to + /// duplicate into. `Err` carries the operator-facing reason — every rejection is a refusal to + /// deliver, so the host must be able to say precisely which check failed rather than falling + /// back to a pid it cannot trust. + pub fn check(&self, expect_pad_index: u32) -> Result { + if self.magic != PROOF_MAGIC { + return Err( + "the devnode's answer is not a punktfunk channel proof (bad magic) — \ + some other driver is bound to this device", + ); + } + if self.proto != GAMEPAD_PROTO_VERSION { + return Err( + "the driver bound to this devnode speaks a different gamepad protocol \ + — update the host and the drivers together", + ); + } + if self.pad_index != expect_pad_index { + return Err( + "the devnode answered for a DIFFERENT pad index — the interface lookup \ + resolved the wrong device", + ); + } + if self.wudf_pid == 0 { + return Err("the driver reported pid 0"); + } + Ok(self.wudf_pid) + } + + /// The 16 wire bytes of the `pf_xusb` IOCTL answer. Offered here (rather than leaving each + /// side to reach for `bytemuck`) so the driver crates need no extra dependency and both + /// sides go through one length-checked pair with [`from_bytes`](Self::from_bytes). + pub fn to_bytes(self) -> [u8; 16] { + let mut out = [0u8; 16]; + out.copy_from_slice(bytemuck::bytes_of(&self)); + out + } + + /// Parse [`to_bytes`](Self::to_bytes). `None` if the device returned fewer bytes than a whole + /// proof — a short read must refuse the delivery, never be zero-extended into a pid. + /// + /// `pod_read_unaligned`, NOT `from_bytes`: the feature-report form offsets the proof by one + /// byte (the report id sits at 0), so the slice is not 4-aligned and `from_bytes` panics on + /// it. Device I/O buffers carry no alignment guarantee either. + pub fn from_bytes(b: &[u8]) -> Option { + (b.len() >= 16).then(|| bytemuck::pod_read_unaligned::(&b[..16])) + } + + /// The proof as a HID **feature report** of exactly `len` bytes: `[report_id, proof(16), 0…]`. + /// A HID feature reply carries its report id in byte 0 and is sized by the descriptor, so the + /// driver pads to whatever length the caller's buffer declares. `None` if `len` cannot hold + /// the id plus a whole proof. + pub fn to_feature_report(self, report_id: u8, len: usize) -> Option> { + if len < 17 { + return None; + } + let mut out = alloc::vec![0u8; len]; + out[0] = report_id; + out[1..17].copy_from_slice(&self.to_bytes()); + Some(out) + } + + /// Parse [`to_feature_report`](Self::to_feature_report) — skips the leading report id. + pub fn from_feature_report(b: &[u8]) -> Option { + Self::from_bytes(b.get(1..)?) + } + + /// Render as the ASCII text a HID indexed-string answer carries: + /// `PFCP:::`. Text rather than the raw struct because + /// `HidD_GetIndexedString` is a string channel, and because a human reading a driver log or + /// poking the device with a HID inspector should be able to see what the pad answered. + pub fn to_hid_string(self) -> String { + alloc::format!("PFCP:{}:{}:{}", self.proto, self.pad_index, self.wudf_pid) + } + + /// Parse [`to_hid_string`](Self::to_hid_string). `None` on ANY deviation — a foreign string + /// index answered, a truncated read, a non-decimal field, trailing junk — so a host that + /// cannot read a well-formed proof refuses to deliver instead of guessing at a pid. + pub fn from_hid_string(s: &str) -> Option { + let rest = s.strip_prefix("PFCP:")?; + let mut it = rest.split(':'); + let proto = it.next()?.parse::().ok()?; + let pad_index = it.next()?.parse::().ok()?; + let wudf_pid = it.next()?.parse::().ok()?; + if it.next().is_some() { + return None; // trailing field: not a shape we minted + } + Some(ChannelProof { + magic: PROOF_MAGIC, + proto, + pad_index, + wudf_pid, + }) + } + } /// Bootstrap-mailbox magic (`"PFBT"` LE) — the host stamps it LAST (after `host_proto`), so a /// driver only trusts a fully-initialized mailbox. @@ -754,16 +982,22 @@ pub mod gamepad { /// 1. host creates it (zeroed), stamps `host_proto` then `magic` (in that order); /// 2. driver opens it by name (pad index from `pszDeviceLocation`), writes `driver_proto`, and — /// iff `host_proto` matches its own version — publishes `driver_pid`; - /// 3. host polls `driver_pid`, verifies the pid is a genuine WUDFHost, duplicates the unnamed DATA - /// section into it, then writes `data_handle` + `handle_pid` and bumps `handle_seq` LAST; + /// 3. host asks the DEVNODE who the driver is ([`ChannelProof`]) — **not** the mailbox — verifies + /// that pid is a genuine WUDFHost, duplicates the unnamed DATA section into it, then writes + /// `data_handle` + `handle_pid` and bumps `handle_seq` LAST; /// 4. driver sees a fresh `handle_seq` addressed to its own pid, maps `data_handle`, and validates /// the mapped section's magic + `pad_index` before use. /// - /// Deliberately safe to leave named + LS-openable: it carries only pids (not sensitive) and a - /// handle VALUE (meaningless outside the target WUDFHost's handle table). A sibling LocalService - /// that tampers with it can at worst mis-route a delivery — a gamepad DoS, never a read or an - /// injection (it cannot place a valid section handle in the WUDFHost, and the driver's - /// magic+`pad_index` validation rejects any handle that doesn't resolve to this pad's section). + /// **Trust boundary (v3).** This mailbox is writable by LocalService — it has to be, since that is + /// what the driver's own WUDFHost runs as — so NOTHING in it may decide where the DATA section + /// goes. Through v2 `driver_pid` did decide that, which was the security-review 2026-07-28 hole + /// (see [`ChannelProof`]); step 3 now sources the pid from the device stack and `driver_pid` is + /// advisory only — a liveness/diagnostic hint. What is left here is a handle VALUE, meaningless + /// outside the one process it was minted for, plus pids and version numbers, none of them secret. + /// A LocalService tamperer can therefore still deny a pad (overwrite the fields, squat the name) + /// but can no longer read or inject: it cannot place a valid section handle in the WUDFHost, the + /// driver's magic + `pad_index` validation rejects any handle that does not resolve to this pad's + /// section, and the delivery target is no longer its to choose. #[repr(C)] #[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)] pub struct PadBootstrap { @@ -942,6 +1176,12 @@ pub mod gamepad { assert!(offset_of!(PadShm, out_ring) == PAD_SHM_LEGACY_SIZE); assert!(size_of::() == 68); + assert!(size_of::() == 16); + assert!(offset_of!(ChannelProof, magic) == 0); + assert!(offset_of!(ChannelProof, proto) == 4); + assert!(offset_of!(ChannelProof, pad_index) == 8); + assert!(offset_of!(ChannelProof, wudf_pid) == 12); + assert!(size_of::() == 32); assert!(offset_of!(PadBootstrap, magic) == 0); assert!(offset_of!(PadBootstrap, host_proto) == 4); @@ -1486,4 +1726,111 @@ mod tests { const SUDOVDA: u128 = 0xE5BC_C234_1E0C_418A_A0D4_EF8B_7501_414D; assert_ne!(PF_VDISPLAY_INTERFACE_GUID_U128, SUDOVDA); } + + /// The channel proof is what the host trusts INSTEAD of the mailbox's `driver_pid` + /// (security-review 2026-07-28), so both wire forms — the `pf_xusb` IOCTL struct and the HID + /// indexed-string text the two minidrivers answer — have to survive a round trip byte-for-byte, + /// and every malformed shape has to be rejected rather than half-parsed into a pid the host + /// would then duplicate a live input section into. + #[test] + fn channel_proof_round_trips_in_both_wire_forms() { + use gamepad::*; + let proof = ChannelProof::new(2, 4242); + assert_eq!(proof.magic, PROOF_MAGIC); + assert_eq!(PROOF_MAGIC, u32::from_le_bytes(*b"PFCP")); + assert_eq!(proof.proto, GAMEPAD_PROTO_VERSION); + + // XUSB IOCTL form: the raw 16-byte struct. + let bytes = bytemuck::bytes_of(&proof); + assert_eq!(bytes.len(), 16); + assert_eq!(*bytemuck::from_bytes::(bytes), proof); + + // HID indexed-string form: the same four fields as text. + let s = proof.to_hid_string(); + assert_eq!(s, alloc::format!("PFCP:{GAMEPAD_PROTO_VERSION}:2:4242")); + assert_eq!(ChannelProof::from_hid_string(&s), Some(proof)); + + // Every malformed shape parses to None — the host then refuses to deliver. + for bad in [ + "", + "PFCP", + "PFCP:", + "PFCP:3:0", // truncated read + "PFCP:3:0:4242:9", // trailing field we never mint + "PFCP:3:0:-1", // not a u32 + "PFCP:3:0:0x10", // not decimal + "PFCP:3:0: 4242", // whitespace is not trimmed away into a valid pid + "NOPE:3:0:4242", // another driver answered this string index + "pfcp:3:0:4242", // prefix is case-sensitive + ] { + assert_eq!( + ChannelProof::from_hid_string(bad), + None, + "malformed proof {bad:?} must not parse" + ); + } + } + + /// `check` is the gate that decides whether a pid is allowed to receive a pad's whole input and + /// rumble surface, so pin each refusal: a foreign driver, a version skew, and — the one that + /// would silently cross-wire two live pads — an answer from the wrong devnode. + #[test] + fn channel_proof_check_refuses_everything_it_should() { + use gamepad::*; + assert_eq!(ChannelProof::new(0, 1234).check(0), Ok(1234)); + assert_eq!(ChannelProof::new(3, 1234).check(3), Ok(1234)); + + // Right shape, WRONG pad: the interface lookup resolved another pad's devnode. + assert!(ChannelProof::new(1, 1234).check(0).is_err()); + // A driver that isn't ours answered the reserved string index / IOCTL. + let mut foreign = ChannelProof::new(0, 1234); + foreign.magic = 0xDEAD_BEEF; + assert!(foreign.check(0).is_err()); + // Version skew must fail closed, not "probably compatible". + let mut old = ChannelProof::new(0, 1234); + old.proto = GAMEPAD_PROTO_VERSION - 1; + assert!(old.check(0).is_err()); + // pid 0 is never a duplication target. + assert!(ChannelProof::new(0, 0).check(0).is_err()); + } + + /// A v2 driver answers no proof at all and a v2 host never asks, so the version must have moved + /// — this is the tripwire that stops the two halves shipping out of step. + #[test] + fn gamepad_proto_is_at_the_channel_proof_version() { + assert_eq!(gamepad::GAMEPAD_PROTO_VERSION, 3); + } + + /// The pad identities carry the proof in a HID FEATURE report — the transport chosen because + /// `0x85` is already declared in the captured descriptors, so nothing about the device's + /// Steam/SDL-visible identity changes. Pin the framing (report id in byte 0, proof in 1..17, + /// zero padding to the descriptor's length) and the short-read refusal. + #[test] + fn channel_proof_feature_report_round_trips_and_refuses_short_reads() { + use gamepad::*; + let proof = ChannelProof::new(1, 4242); + let rep = proof + .to_feature_report(HID_FEATURE_REPORT_CHANNEL_PROOF, 64) + .expect("64 bytes is plenty"); + assert_eq!(rep.len(), 64); + assert_eq!( + rep[0], 0x85, + "byte 0 is the report id, as every HID feature reply is" + ); + assert!(rep[17..].iter().all(|&b| b == 0), "tail is zero padding"); + assert_eq!(ChannelProof::from_feature_report(&rep), Some(proof)); + + // Exactly big enough, and one byte too small. + assert!(proof.to_feature_report(0x85, 17).is_some()); + assert!(proof.to_feature_report(0x85, 16).is_none()); + // A truncated read must NOT be zero-extended into a pid. + assert_eq!(ChannelProof::from_feature_report(&rep[..16]), None); + assert_eq!(ChannelProof::from_feature_report(&[]), None); + + // The Deck's private command is two bytes so a stray Steam command can't collide, and is + // distinct from the commands the driver already serves. + assert_eq!(DECK_PROOF_CMD.len(), 2); + assert!(!DECK_PROOF_CMD.starts_with(&[0x83]) && !DECK_PROOF_CMD.starts_with(&[0xAE])); + assert!(!DECK_PROOF_CMD.starts_with(&[0xEB]) && !DECK_PROOF_CMD.starts_with(&[0x8F])); + } } diff --git a/crates/pf-inject/Cargo.toml b/crates/pf-inject/Cargo.toml index 81150f51..1af5824d 100644 --- a/crates/pf-inject/Cargo.toml +++ b/crates/pf-inject/Cargo.toml @@ -59,6 +59,10 @@ windows = { version = "0.62", features = [ "Win32_Devices_Enumeration_Pnp", # SwDeviceCreate's SW_DEVICE_CREATE_INFO references DEVPROPKEY (Properties). "Win32_Devices_Properties", + # The channel proof: HidD_GetIndexedString + GUID_DEVINTERFACE_HID (the pads/mouse leg) and + # CreateFileW to open the device interface the proof is asked over. + "Win32_Devices_HumanInterfaceDevice", + "Win32_Storage_FileSystem", "Win32_System_Memory", "Win32_System_IO", "Win32_System_StationsAndDesktops", diff --git a/crates/pf-inject/src/inject/proto/steam_proto.rs b/crates/pf-inject/src/inject/proto/steam_proto.rs index ebe1afb1..c03c574f 100644 --- a/crates/pf-inject/src/inject/proto/steam_proto.rs +++ b/crates/pf-inject/src/inject/proto/steam_proto.rs @@ -551,7 +551,7 @@ pub fn deck_unit_id(index: u8) -> u32 { /// serial passes, so we keep the PunktFunk marker one slot in (`"FVPF"`) — still distinct from a /// real Deck's `"FVZZ"` for the self-detection below while satisfying Steam's format check. /// Derived from [`deck_unit_id`] so the `0xAE` serial reply and the `0x83` unit-id attrs stay -/// consistent. (The Windows UMDF driver mirrors this exact format — see pf-dualsense lib.rs.) +/// consistent. (The Windows UMDF driver mirrors this exact format — see pf-gamepad lib.rs.) pub fn deck_serial(index: u8) -> String { format!("FVPF{:08X}", deck_unit_id(index)) } diff --git a/crates/pf-inject/src/inject/windows/channel_proof.rs b/crates/pf-inject/src/inject/windows/channel_proof.rs new file mode 100644 index 00000000..1cc43873 --- /dev/null +++ b/crates/pf-inject/src/inject/windows/channel_proof.rs @@ -0,0 +1,512 @@ +//! Ask a devnode which process is serving it — the host half of +//! [`pf_driver_proto::gamepad::ChannelProof`]. +//! +//! This is what the pad channel trusts instead of the bootstrap mailbox's `driver_pid`. The mailbox +//! has to be openable by LocalService (that is what the driver's own WUDFHost runs as), and the +//! delivery gate `verify_is_wudfhost` only checks that the named process's IMAGE is the system +//! `WUDFHost.exe` — which is world-executable. So through gamepad proto v2 any LocalService +//! principal, notably the deliberately de-privileged plugin runner, could spawn its own WUDFHost, +//! publish that pid, and be handed the pad's live DATA section: forged HID input into the interactive +//! desktop and a read of the remote user's controller state (security-review 2026-07-28). +//! +//! No host-side check could tell the two apart. Everything the real driver can read at LocalService — +//! the devnode's Location, its `Device Parameters` key, the object namespace — an attacker at +//! LocalService can read too, and both are session-0 LocalService processes running the same image, +//! so token, session and image checks all discriminate nothing. The one thing an attacker cannot +//! forge is **being the driver bound to the devnode we created**: I/O sent to that device reaches +//! only the driver PnP bound to it, and we look the device up by the instance id `SwDeviceCreate` +//! handed back, so a planted look-alike devnode is not in the running either. +//! +//! Three transports, one per driver shape — see [`ProofTransport`] for what each of them cost +//! and why the obvious ones did not survive contact with hidclass. + +use anyhow::{anyhow, bail, Context, Result}; +use pf_driver_proto::gamepad::{ + ChannelProof, DECK_PROOF_CMD, HID_FEATURE_REPORT_CHANNEL_PROOF, HID_STRING_INDEX_CHANNEL_PROOF, + IOCTL_PF_GET_CHANNEL_PROOF, +}; +use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; +use windows::core::{GUID, PCWSTR}; +use windows::Win32::Devices::DeviceAndDriverInstallation::{ + CM_Get_Child, CM_Get_Device_IDW, CM_Get_Device_Interface_ListW, + CM_Get_Device_Interface_List_SizeW, CM_Get_Sibling, CM_Locate_DevNodeW, + CM_GET_DEVICE_INTERFACE_LIST_PRESENT, CM_LOCATE_DEVNODE_NORMAL, CR_SUCCESS, +}; +use windows::Win32::Devices::HumanInterfaceDevice::{ + HidD_GetFeature, HidD_GetIndexedString, HidD_GetProductString, HidD_GetSerialNumberString, + HidD_SetFeature, GUID_DEVINTERFACE_HID, +}; +use windows::Win32::Foundation::HANDLE; +use windows::Win32::Storage::FileSystem::{ + CreateFileW, FILE_FLAGS_AND_ATTRIBUTES, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, +}; +use windows::Win32::System::IO::DeviceIoControl; + +/// `GUID_DEVINTERFACE_XUSB` {EC87F1E3-C13B-4100-B5F7-8B84D54260CB} — the interface `pf_xusb` +/// registers on its own devnode (and what `xinput1_4` enumerates). +const GUID_DEVINTERFACE_XUSB: GUID = GUID::from_u128(0xEC87F1E3_C13B_4100_B5F7_8B84D54260CB); + +/// How a driver answers the proof — one variant per driver shape, because what Windows actually +/// carries to each shape differs (all three measured on .173 / Win11 26200, 2026-07-28). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ProofTransport { + /// `pf_xusb`: a private IOCTL on `GUID_DEVINTERFACE_XUSB`, the interface it registers on its own + /// devnode. Works because it is NOT a HID minidriver — nothing sits above it, so it owns both + /// `IRP_MJ_CREATE` and its own IOCTL dispatch. + XusbIoctl, + /// `pf_mouse`: the proof arrives as the HID **serial-number string**. The one transport that + /// survived measurement against a UMDF HID minidriver — `HidD_GetSerialNumberString` on a + /// zero-access handle, verified end to end (`PFCP:3:0:7296`, and 7296 was a real + /// service-spawned `WUDFHost.exe`). Safe here because nothing reads the virtual mouse's serial. + HidSerialString, + /// `pf_gamepad` (DualSense / DualShock 4 / Edge / Deck): a HID **feature report**. + /// + /// The pads cannot use the serial string — it is what SDL and Steam dedup controllers on, and + /// Steam is known to mangle a pad's displayed name over serial FORMAT alone. They get a feature + /// report instead, and it costs NO report-descriptor change, because the captured descriptors + /// already declare far more feature ids than the driver ever served: `0x85` is declared as a + /// Feature report on DualSense, DualShock 4 and Edge alike and used to fail with + /// `STATUS_INVALID_PARAMETER`. The Deck declares one UNNUMBERED feature report driven as + /// command→response, so its proof rides that existing contract via a private two-byte command. + HidFeatureReport, +} + +/// Ask the devnode `instance_id` (as `SwDeviceCreate` reported it) which process serves it, and +/// return that pid — already checked against `expect_pad_index` and this build's protocol version. +/// +/// Every failure is a refusal to deliver, so the errors say exactly which step failed: an operator +/// staring at a dead gamepad needs to know whether the devnode is missing, the interface has not +/// appeared yet, or a driver answered something we did not mint. +pub(super) fn query( + instance_id: &str, + transport: ProofTransport, + expect_pad_index: u32, +) -> Result { + let paths = match transport { + ProofTransport::XusbIoctl => interface_paths(&GUID_DEVINTERFACE_XUSB, instance_id) + .with_context(|| format!("enumerate the XUSB interface on {instance_id}"))?, + ProofTransport::HidSerialString | ProofTransport::HidFeatureReport => { + // hidclass publishes the collection interface on a CHILD PDO, not on our devnode. + let children = child_device_ids(instance_id) + .with_context(|| format!("enumerate the HID children of {instance_id}"))?; + let mut all = Vec::new(); + for child in &children { + all.extend(interface_paths(&GUID_DEVINTERFACE_HID, child).unwrap_or_default()); + } + all + } + }; + if paths.is_empty() { + bail!( + "no device interface for {instance_id} yet — the driver has not finished starting (or \ + is not bound to this devnode at all)" + ); + } + // A devnode can publish more than one collection; ask each and take the first well-formed proof. + let mut last_err = None; + for path in &paths { + let r = match transport { + ProofTransport::XusbIoctl => ask_ioctl(path, expect_pad_index), + ProofTransport::HidSerialString => ask_hid_path(path, expect_pad_index), + ProofTransport::HidFeatureReport => ask_feature_path(path, expect_pad_index), + }; + match r { + Ok(pid) => return Ok(pid), + Err(e) => last_err = Some(e.context(format!("ask {path}"))), + } + } + Err(last_err.unwrap_or_else(|| anyhow!("no interface answered a channel proof"))) +} + +/// Open `path` and put the proof IOCTL to it (the private pad-control interface, and `pf_xusb`'s own). +fn ask_ioctl(path: &str, expect_pad_index: u32) -> Result { + let handle = open_device(path)?; + let proof = ask_xusb(HANDLE(handle.as_raw_handle()))?; + proof + .check(expect_pad_index) + .map_err(|why| anyhow!("{why}")) +} + +/// Open a HID collection and read the proof out of a FEATURE report — the pad transport. +fn ask_feature_path(path: &str, expect_pad_index: u32) -> Result { + let handle = open_device(path)?; + let proof = ask_feature(HANDLE(handle.as_raw_handle()))?; + proof + .check(expect_pad_index) + .map_err(|why| anyhow!("{why}")) +} + +/// The PS identities answer on the declared-but-unserved report `0x85`; the Deck answers its +/// unnumbered report after a private SET_FEATURE command. Both are tried — one driver binary serves +/// four identities and the host does not know which one this devnode became until the DATA section +/// is attached, which is precisely what we are trying to earn the right to do. +fn ask_feature(h: HANDLE) -> Result { + // Feature buffers are sized by the descriptor; the largest of these reports is 64 bytes, and + // Windows accepts a buffer at least that big. + const BUF: usize = 64; + + // PS identities: GET_FEATURE 0x85. + let mut buf = [0u8; BUF]; + buf[0] = HID_FEATURE_REPORT_CHANNEL_PROOF; + // SAFETY: `h` is the live HID interface handle; `buf` is a valid BUF-sized in/out buffer. + if unsafe { HidD_GetFeature(h, buf.as_mut_ptr().cast(), BUF as u32) } { + if let Some(p) = ChannelProof::from_feature_report(&buf) { + return Ok(p); + } + } + + // Deck: SET the private command, then GET the reply. Byte 0 is the (unnumbered) report id 0. + let mut cmd = [0u8; BUF]; + cmd[1..1 + DECK_PROOF_CMD.len()].copy_from_slice(&DECK_PROOF_CMD); + // SAFETY: `h` is live; `cmd` is a valid BUF-sized buffer. + let set_ok = unsafe { HidD_SetFeature(h, cmd.as_mut_ptr().cast(), BUF as u32) }; + if set_ok { + let mut reply = [0u8; BUF]; + // SAFETY: as above. + if unsafe { HidD_GetFeature(h, reply.as_mut_ptr().cast(), BUF as u32) } + && reply.starts_with(&DECK_PROOF_CMD) + { + if let Some(p) = ChannelProof::from_bytes(&reply[DECK_PROOF_CMD.len()..]) { + return Ok(p); + } + } + } + bail!( + "this HID collection carries no channel proof (feature 0x{:02x}: no; Deck command: {}) — \ + the driver predates the proof (reinstall: punktfunk-host.exe driver install --gamepad)", + HID_FEATURE_REPORT_CHANNEL_PROOF, + if set_ok { + "no matching reply" + } else { + "SET_FEATURE failed" + }, + ) +} + +/// Open a HID collection and read the proof out of a string. +fn ask_hid_path(path: &str, expect_pad_index: u32) -> Result { + let handle = open_device(path)?; + let proof = ask_hid(HANDLE(handle.as_raw_handle()))?; + proof + .check(expect_pad_index) + .map_err(|why| anyhow!("{why}")) +} + +/// [`query`] for the `channel-proof-probe` subcommand — same call the delivery path makes, so the +/// probe reports the production answer rather than a re-implementation of it. +pub fn probe_pid( + instance_id: &str, + transport: ProofTransport, + expect_pad_index: u32, +) -> Result { + query(instance_id, transport, expect_pad_index) +} + +/// A human-readable walk of the same lookup [`query`] does, reporting each step and — for the HID +/// leg — which of the two IOCTLs Windows actually forwarded to the minidriver. +/// +/// This exists because exactly one thing in this design could not be settled by reading: whether +/// hidclass forwards `IOCTL_HID_GET_INDEXED_STRING` (and/or an arbitrary-index `IOCTL_HID_GET_STRING`) +/// down to a UMDF HID minidriver. Both are wired up so the answer only has to be "at least one", but +/// a maintainer should be able to find out which on a real box in one command rather than by +/// inference — hence `punktfunk-host channel-proof-probe`. +pub fn diagnose(instance_id: &str, transport: ProofTransport, expect_pad_index: u32) -> String { + use std::fmt::Write as _; + let mut out = String::new(); + let _ = writeln!(out, "devnode : {instance_id}"); + let _ = writeln!( + out, + "transport: {transport:?}, expected pad index {expect_pad_index}" + ); + + let paths = match transport { + ProofTransport::XusbIoctl => match interface_paths(&GUID_DEVINTERFACE_XUSB, instance_id) { + Ok(p) => p, + Err(e) => { + let _ = writeln!(out, " XUSB interface lookup FAILED: {e:#}"); + return out; + } + }, + ProofTransport::HidSerialString | ProofTransport::HidFeatureReport => { + let children = child_device_ids(instance_id).unwrap_or_default(); + let _ = writeln!(out, " hidclass children: {}", children.len()); + let mut all = Vec::new(); + for c in &children { + let p = interface_paths(&GUID_DEVINTERFACE_HID, c).unwrap_or_default(); + let _ = writeln!(out, " {c} -> {} HID interface(s)", p.len()); + all.extend(p); + } + all + } + }; + if paths.is_empty() { + let _ = writeln!( + out, + " NO device interface — the driver has not started, or is not bound to this devnode" + ); + return out; + } + for path in &paths { + let _ = writeln!(out, " interface {path}"); + let handle = match open_device(path) { + Ok(h) => h, + Err(e) => { + let _ = writeln!(out, " open FAILED: {e:#}"); + continue; + } + }; + let h = HANDLE(handle.as_raw_handle()); + match transport { + ProofTransport::XusbIoctl => match ask_xusb(h) { + Ok(p) => { + let _ = writeln!(out, " IOCTL_PF_GET_CHANNEL_PROOF -> {p:?}"); + let _ = writeln!(out, " check: {:?}", p.check(expect_pad_index)); + } + Err(e) => { + let _ = writeln!(out, " IOCTL_PF_GET_CHANNEL_PROOF FAILED: {e:#}"); + } + }, + ProofTransport::HidFeatureReport => match ask_feature(h) { + Ok(p) => { + let _ = writeln!(out, " feature proof -> {p:?}"); + let _ = writeln!(out, " check: {:?}", p.check(expect_pad_index)); + } + Err(e) => { + let _ = writeln!(out, " feature proof FAILED: {e:#}"); + } + }, + ProofTransport::HidSerialString => { + // Report each path separately — this is the whole point of the probe. + let (indexed, serial, control) = ask_hid_both(h); + let _ = writeln!(out, " HidD_GetSerialNumberString -> {serial}"); + let _ = writeln!(out, " HidD_GetIndexedString(proof) -> {indexed}"); + let _ = writeln!(out, " HidD_GetProductString -> {control}"); + } + } + } + out +} + +/// The probe's raw material: the proof index alongside a KNOWN-GOOD control, so a failure can be +/// told apart from "user mode cannot reach this driver at all". +/// +/// The control is `HidD_GetProductString`, which on-glass succeeds against a UMDF HID minidriver on +/// the very same 0-access handle where `HidD_GetIndexedString` fails for every index — the +/// measurement that established the indexed-string transport is unusable (see [`ask_hid`]). +fn ask_hid_both(h: HANDLE) -> (String, String, String) { + let text = |ok: bool, buf: &[u16]| -> String { + if !ok { + let e = std::io::Error::last_os_error(); + return format!("call failed ({e})"); + } + let end = buf.iter().position(|&c| c == 0).unwrap_or(buf.len()); + String::from_utf16_lossy(&buf[..end]) + }; + let mut a = [0u16; 128]; + let bytes = (a.len() * 2) as u32; + // SAFETY: `h` is the live HID interface handle; `a` is a valid `bytes`-sized out-buffer. + let ok_a = unsafe { + HidD_GetIndexedString( + h, + HID_STRING_INDEX_CHANNEL_PROOF, + a.as_mut_ptr().cast(), + bytes, + ) + }; + let indexed = match (ok_a, decode_proof(&a)) { + (true, Some(p)) => format!("{p:?}"), + (true, None) => format!("answered, but not a proof: {:?}", text(true, &a)), + (false, _) => text(false, &a), + }; + let mut c = [0u16; 128]; + // SAFETY: as above — live handle, valid out-buffer. + let ok_c = unsafe { HidD_GetSerialNumberString(h, c.as_mut_ptr().cast(), bytes) }; + let serial = match (ok_c, decode_proof(&c)) { + (true, Some(p)) => format!("{p:?}"), + (true, None) => format!("plain serial, no proof: {:?}", text(true, &c)), + (false, _) => text(false, &c), + }; + let mut b = [0u16; 128]; + // SAFETY: as above — live handle, valid out-buffer. + let ok_b = unsafe { HidD_GetProductString(h, b.as_mut_ptr().cast(), bytes) }; + let control = format!( + "{} (control: proves user mode reaches this driver)", + text(ok_b, &b) + ); + (indexed, serial, control) +} + +/// `CreateFileW` with **no** access rights: enough to send `FILE_ANY_ACCESS` IOCTLs and to run the +/// `HidD_*` helpers, and the only thing that works on a HID mouse/keyboard collection, which Windows +/// refuses to open for read from user mode. +fn open_device(path: &str) -> Result { + let wide: Vec = path.encode_utf16().chain(std::iter::once(0)).collect(); + // SAFETY: `wide` is a valid NUL-terminated UTF-16 path for the duration of the call; the returned + // handle is owned solely by the `OwnedHandle` built from it. + let h = unsafe { + CreateFileW( + PCWSTR(wide.as_ptr()), + 0, + FILE_SHARE_READ | FILE_SHARE_WRITE, + None, + OPEN_EXISTING, + FILE_FLAGS_AND_ATTRIBUTES(0), + None, + ) + .with_context(|| format!("CreateFileW({path})"))? + }; + // SAFETY: `h` is the fresh handle just opened, moved into a single owner that closes it on drop. + Ok(unsafe { OwnedHandle::from_raw_handle(h.0 as _) }) +} + +/// `pf_xusb`: a private METHOD_BUFFERED IOCTL returning the 16 proof bytes. +fn ask_xusb(h: HANDLE) -> Result { + let mut buf = [0u8; 16]; + let mut returned = 0u32; + // SAFETY: `h` is the live interface handle; `buf` is a valid 16-byte out-buffer and `returned` + // a valid out-param. METHOD_BUFFERED, so the I/O manager copies — no pointer escapes. + unsafe { + DeviceIoControl( + h, + IOCTL_PF_GET_CHANNEL_PROOF, + None, + 0, + Some(buf.as_mut_ptr().cast()), + buf.len() as u32, + Some(&mut returned), + None, + ) + .context("IOCTL_PF_GET_CHANNEL_PROOF")?; + } + ChannelProof::from_bytes(&buf[..returned as usize]).ok_or_else(|| { + anyhow!("the XUSB interface returned {returned} bytes, too short for a channel proof") + }) +} + +/// `pf_gamepad` / `pf_mouse`: the reserved HID string index. +/// +/// ⚠️ **ON-HARDWARE FINDING, .173 (Win11 26200), 2026-07-28 — this transport DOES NOT WORK.** +/// Probed against a live `pf_mouse` devnode with the installed driver: on the SAME 0-access handle, +/// `HidD_GetManufacturerString` / `GetProductString` / `GetSerialNumberString` all succeeded and +/// returned the driver's OWN strings (so user mode does reach a UMDF HID minidriver, and hidclass +/// does translate the named string IOCTLs down to its `IOCTL_HID_GET_STRING` handler) — while +/// `HidD_GetIndexedString` failed for EVERY index tried: 1, 2, 4, 0x0E and 0x5046. Indices 2 and +/// 0x0E are ones the driver demonstrably serves through the named wrappers, so this is not our +/// reserved index being out of range: hidclass does not carry an arbitrary indexed-string request to +/// a UMDF HID minidriver at all. +/// +/// The call is kept because it costs one failed IOCTL and is the correct thing to ask first if a +/// later Windows starts forwarding it. What was REMOVED here is a second "fallback" that issued +/// `IOCTL_HID_GET_STRING` directly: that IOCTL is METHOD_NEITHER and **kernel-facing** — hidclass +/// sends it DOWN to the minidriver, user mode never sends it up — so it could not have worked, and +/// on-glass it returned `ERROR_INVALID_FUNCTION` for a control index the device definitely serves. +/// Do not re-add it. +/// +/// The HID pads/mouse therefore still need a working proof transport; see the module docs. +fn ask_hid(h: HANDLE) -> Result { + let mut buf = [0u16; 128]; + let bytes = (buf.len() * 2) as u32; + // SAFETY: `h` is the live HID interface handle; `buf` is a valid `bytes`-sized out-buffer. + if unsafe { HidD_GetSerialNumberString(h, buf.as_mut_ptr().cast(), bytes) } { + if let Some(p) = decode_proof(&buf) { + return Ok(p); + } + let end = buf.iter().position(|&c| c == 0).unwrap_or(buf.len()); + bail!( + "this HID collection's serial is {:?}, not a channel proof — an old driver is installed \ + (reinstall: punktfunk-host.exe driver install --gamepad)", + String::from_utf16_lossy(&buf[..end]) + ); + } + bail!("HidD_GetSerialNumberString failed on this HID collection") +} + +/// Decode a NUL-terminated UTF-16 HID string answer into a proof. +fn decode_proof(buf: &[u16]) -> Option { + let end = buf.iter().position(|&c| c == 0).unwrap_or(buf.len()); + let s = String::from_utf16(&buf[..end]).ok()?; + ChannelProof::from_hid_string(&s) +} + +/// Every present device-interface path of `class` on `device_id`. +fn interface_paths(class: &GUID, device_id: &str) -> Result> { + let wide: Vec = device_id.encode_utf16().chain(std::iter::once(0)).collect(); + let mut len = 0u32; + // SAFETY: `len` is a valid out-param; `wide` is a NUL-terminated id valid for the call. + let cr = unsafe { + CM_Get_Device_Interface_List_SizeW( + &mut len, + class, + PCWSTR(wide.as_ptr()), + CM_GET_DEVICE_INTERFACE_LIST_PRESENT, + ) + }; + if cr != CR_SUCCESS || len == 0 { + return Ok(Vec::new()); + } + let mut buf = vec![0u16; len as usize]; + // SAFETY: `buf` is `len` UTF-16 units as the size call just reported; same id/class as above. + let cr = unsafe { + CM_Get_Device_Interface_ListW( + class, + PCWSTR(wide.as_ptr()), + &mut buf, + CM_GET_DEVICE_INTERFACE_LIST_PRESENT, + ) + }; + if cr != CR_SUCCESS { + bail!("CM_Get_Device_Interface_ListW failed (CONFIGRET {})", cr.0); + } + // A REG_MULTI_SZ-shaped list: NUL-separated, double-NUL terminated. + Ok(buf + .split(|&c| c == 0) + .filter(|s| !s.is_empty()) + .map(String::from_utf16_lossy) + .collect()) +} + +/// The instance ids of `instance_id`'s immediate children — for a HID minidriver, the collection +/// PDOs hidclass created under our devnode. +fn child_device_ids(instance_id: &str) -> Result> { + let wide: Vec = instance_id + .encode_utf16() + .chain(std::iter::once(0)) + .collect(); + let mut devinst = 0u32; + // SAFETY: `devinst` is a valid out-param; `wide` is a NUL-terminated id valid for the call. + let cr = unsafe { + CM_Locate_DevNodeW( + &mut devinst, + PCWSTR(wide.as_ptr()), + CM_LOCATE_DEVNODE_NORMAL, + ) + }; + if cr != CR_SUCCESS { + bail!( + "CM_Locate_DevNodeW({instance_id}) failed (CONFIGRET {})", + cr.0 + ); + } + let mut out = Vec::new(); + let mut child = 0u32; + // SAFETY: `devinst` is the devnode just located; `child` is a valid out-param. + if unsafe { CM_Get_Child(&mut child, devinst, 0) } != CR_SUCCESS { + return Ok(out); // no children yet — hidclass has not enumerated the collections + } + loop { + let mut buf = [0u16; 512]; + // SAFETY: `child` is a live devnode handle from CM_Get_Child/CM_Get_Sibling; `buf` is a + // valid out-buffer whose length the binding passes along. + if unsafe { CM_Get_Device_IDW(child, &mut buf, 0) } == CR_SUCCESS { + let end = buf.iter().position(|&c| c == 0).unwrap_or(buf.len()); + out.push(String::from_utf16_lossy(&buf[..end])); + } + let mut next = 0u32; + // SAFETY: as above — `child` is live, `next` is a valid out-param. + if unsafe { CM_Get_Sibling(&mut next, child, 0) } != CR_SUCCESS { + break; + } + child = next; + } + Ok(out) +} diff --git a/crates/pf-inject/src/inject/windows/dualsense_windows.rs b/crates/pf-inject/src/inject/windows/dualsense_windows.rs index 078e83f7..1ccf4441 100644 --- a/crates/pf-inject/src/inject/windows/dualsense_windows.rs +++ b/crates/pf-inject/src/inject/windows/dualsense_windows.rs @@ -1,4 +1,4 @@ -//! Virtual Sony DualSense on Windows via the UMDF minidriver (`packaging/windows/drivers/pf-dualsense`). +//! Virtual Sony DualSense on Windows via the UMDF minidriver (`packaging/windows/drivers/pf-gamepad`). //! //! The Windows analogue of the Linux UHID backend ([`super::dualsense`]): same [`DsState`] model and //! the same byte-level report codec ([`super::dualsense_proto`]), but a different transport. Where @@ -37,7 +37,7 @@ use windows::Win32::Foundation::{CloseHandle, E_FAIL, WAIT_OBJECT_0}; use windows::Win32::System::Threading::{CreateEventW, WaitForSingleObject}; /// Shared-section layout — the single source of truth is [`pf_driver_proto::gamepad::PadShm`] (offset -/// asserts pin every field; the `pf_dualsense` driver maps the same struct). Derive the size/offsets/magic +/// asserts pin every field; the `pf_gamepad` driver maps the same struct). Derive the size/offsets/magic /// from it so a layout change is a compile error, not a hand-synced literal (audit §6.1). `pub(super)` so /// the sibling DualShock 4 backend ([`super::dualshock4_windows`]) reuses the exact offsets. pub(super) const SHM_SIZE: usize = core::mem::size_of::(); @@ -385,7 +385,7 @@ impl WinDsIdentity { WinDsIdentity { devtype: 0, instance_prefix: "pf_pad", - hwid: "pf_dualsense", + hwid: "pf_gamepad", usb_vid_pid: "VID_054C&PID_0CE6", description: "punktfunk Virtual DualSense", } @@ -444,6 +444,13 @@ impl DsWinPad { (None, None) } }; + // The DATA section goes to whoever THIS devnode says is serving it — not to whatever pid + // the LocalService-writable mailbox names (security-review 2026-07-28). + channel.bind_devnode( + index as u32, + instance_id.clone(), + super::gamepad_raii::ProofTransport::HidFeatureReport, + ); let _sw = hsw.map(super::gamepad_raii::SwDevice::new); // Bounded eager delivery so the driver holds the DATA section before hidclass asks it for // descriptors (the driver reads `device_type` from the section to pick its HID identity). @@ -453,8 +460,8 @@ impl DsWinPad { channel, attach: super::gamepad_raii::DriverAttach::new( id.hwid, - "pf_dualsense.inf", // one driver package serves every PS identity - "C:\\Users\\Public\\pfds-driver.log", + "pf_gamepad.inf", // one driver package serves every PS identity + "C:\\Windows\\ServiceProfiles\\LocalService\\AppData\\Local\\Temp\\pf_gamepad-driver.log", boot_name, instance_id, ), @@ -472,7 +479,7 @@ impl DsWinPad { serialize_state(&mut r, st, self.seq, self.ts); // SAFETY: base points at SHM_SIZE bytes; input slot is OFF_INPUT..OFF_INPUT+64. Unlike the // XUSB `packet` / DualSense `out_seq` fields, the input path has NO driver-polled change-detect - // field to publish last: the `pf_dualsense` driver streams the whole `input` region to game + // field to publish last: the `pf_gamepad` driver streams the whole `input` region to game // READ_REPORTs on its ~125 Hz timer, and the report's own sequence counter (r[7], mid-report) // is consumed by the game's HID stack, not the driver — so it cannot serve as a separable // publish flag without a seqlock generation the driver `Acquire`-reads (a `PadShm` layout + @@ -622,7 +629,7 @@ pub fn deck_spike_hold(index: u8, secs: u64) -> Result<()> { std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC); } let inst = format!("pf_deckspike_{index}"); - let (hsw, _) = create_swdevice(&SwDeviceProfile { + let (hsw, spike_instance_id) = create_swdevice(&SwDeviceProfile { instance: &inst, container_tag: 0x5046_4453, // "PFDS" container_index: index, @@ -633,6 +640,13 @@ pub fn deck_spike_hold(index: u8, secs: u64) -> Result<()> { usb_mi: Some(2), description: "punktfunk Virtual Steam Deck (spike)", })?; + // The spike drives a real pad channel, so it takes the same devnode-proved delivery a session + // pad does — no special case, and no reason for a bring-up tool to run on the old trust. + channel.bind_devnode( + index as u32, + spike_instance_id, + super::gamepad_raii::ProofTransport::HidFeatureReport, + ); let _sw = super::gamepad_raii::SwDevice::new(hsw); channel.deliver_eager(std::time::Duration::from_millis(1500)); println!( diff --git a/crates/pf-inject/src/inject/windows/dualshock4_windows.rs b/crates/pf-inject/src/inject/windows/dualshock4_windows.rs index 836d23a0..0a7bd2eb 100644 --- a/crates/pf-inject/src/inject/windows/dualshock4_windows.rs +++ b/crates/pf-inject/src/inject/windows/dualshock4_windows.rs @@ -77,6 +77,13 @@ impl Ds4WinPad { // `gate.on_success()` cleared the backoff, so the create-gate that exists precisely to // self-heal a transient PnP failure never retried. The game saw no controller for the whole // session unless the client unplugged the pad. Matches the XUSB sibling, which propagates. + // The DATA section goes to whoever THIS devnode says is serving it — not to whatever pid + // the LocalService-writable mailbox names (security-review 2026-07-28). + channel.bind_devnode( + index as u32, + instance_id.clone(), + super::gamepad_raii::ProofTransport::HidFeatureReport, + ); let _sw = hsw.map(super::gamepad_raii::SwDevice::new); // Bounded eager delivery — for the DS4 this is what closes the identity race: the driver // must read `device_type = 1` from the delivered DATA section before hidclass asks it for @@ -87,8 +94,8 @@ impl Ds4WinPad { channel, attach: super::gamepad_raii::DriverAttach::new( "pf_dualshock4", - "pf_dualsense.inf", // one driver package serves both HID identities - "C:\\Users\\Public\\pfds-driver.log", + "pf_gamepad.inf", // one driver package serves both HID identities + "C:\\Windows\\ServiceProfiles\\LocalService\\AppData\\Local\\Temp\\pf_gamepad-driver.log", boot_name, instance_id, ), diff --git a/crates/pf-inject/src/inject/windows/gamepad_raii.rs b/crates/pf-inject/src/inject/windows/gamepad_raii.rs index ae7de599..06ac07ad 100644 --- a/crates/pf-inject/src/inject/windows/gamepad_raii.rs +++ b/crates/pf-inject/src/inject/windows/gamepad_raii.rs @@ -1,7 +1,7 @@ //! Per-pad Windows resource RAII + the **sealed gamepad channel** broker (DualSense / DualShock 4 / //! XUSB backends). //! -//! Each virtual pad owns three OS resources: the **unnamed** DATA section the `pf_dualsense`/`pf_xusb` +//! Each virtual pad owns three OS resources: the **unnamed** DATA section the `pf_gamepad`/`pf_xusb` //! driver works against (`XusbShm`/`PadShm`), the tiny **named** bootstrap mailbox //! (`pf_driver_proto::gamepad::PadBootstrap`) that hands the driver a duplicated handle to it, and the //! `SwDeviceCreate`'d software devnode the driver loads on. [`Shm`] and [`SwDevice`] own the resources @@ -15,11 +15,44 @@ //! into its WUDFHost (a duplicated handle carries the source's access, so no LS ACE is needed). The pad //! drivers are UMDF HID minidrivers with **no control device** (hidclass owns the stack), so unlike the //! frame channel there is no IOCTL to deliver the handle or learn the WUDFHost pid — hence the -//! late-bound [`PadBootstrap`] mailbox handshake, the one *named* object left. It carries only pids and -//! a handle VALUE (meaningless outside the target process), so tampering with it yields at worst a -//! gamepad DoS, never a read or an injection; the empirical floor from the frame work holds here too -//! (a LocalService token is DACL-denied `OpenProcess` on a UMDF WUDFHost for every access right). +//! late-bound [`PadBootstrap`] mailbox handshake, the one *named* object left. +//! +//! **What the mailbox is worth to an attacker** (corrected, security-review 2026-07-28). It carries +//! pids and a handle VALUE, and a handle value IS meaningless outside its process — but the mailbox +//! also *chooses that process*, and it is writable by LocalService (the SDDL the driver's WUDFHost +//! needs to open it by name). The delivery gate, [`pf_capture::verify_is_wudfhost`], authenticates an +//! IMAGE PATH, and `%SystemRoot%\System32\WUDFHost.exe` is world-executable: a LocalService principal +//! — the de-privileged plugin runner, or any other compromised LocalService service — can spawn its +//! own suspended WUDFHost, publish that pid, and be handed `SECTION_MAP_READ|WRITE` on this pad's DATA +//! section. That is forged HID input into the interactive desktop (the pf-mouse section drives a real +//! absolute pointer) and a read of the remote user's live controller state — so the earlier claim that +//! mailbox tampering "yields at worst a gamepad DoS, never a read or an injection" was wrong. (The +//! frame channel is NOT exposed this way: its WUDFHost pid arrives over the pf-vdisplay control +//! device, whose DACL is SYSTEM + Administrators.) +//! +//! **How v3 closes it.** The mailbox no longer decides anything. [`PadChannel::pump`] asks the +//! DEVNODE which process is serving it — [`channel_proof`], the host half of +//! `pf_driver_proto::gamepad::ChannelProof` — and duplicates into that answer. Only the driver PnP +//! actually bound to the device we `SwDeviceCreate`d can answer that device's I/O, and the lookup is +//! keyed by the instance id PnP handed back, so neither a spawned WUDFHost nor a planted look-alike +//! devnode is in the running. `driver_pid` survives as a liveness hint and nothing more; a tamperer +//! can still deny a pad, but denial was always available (squat the name) and is not what mattered. +//! +//! Two further rules make the state machine hold up around it: +//! * **One live target.** A delivery stands until its target process EXITS, judged on a retained +//! `SYNCHRONIZE` handle so a recycled pid cannot fake it. UMDF's genuine +//! restart-the-host-after-a-driver-crash path still re-attaches; nothing else displaces a live one. +//! * **No unproved delivery.** A pad with no `SwDeviceCreate` devnode (the out-of-band `devgen` +//! bring-up path) has nothing to ask, so it refuses to deliver rather than fall back to the +//! mailbox — unless an operator sets [`TRUST_MAILBOX_ENV`], which says so loudly in the log. +//! +//! What is NOT claimed: this authenticates the *devnode*, not the driver binary. An attacker who can +//! already replace the installed, signed driver package owns the endpoint by definition — that is the +//! same floor the frame channel accepts, and it sits above the SECURITY.md admin/SYSTEM boundary. +use super::channel_proof; +/// Re-exported so a pad backend needs only one `use` to wire up its channel. +pub(super) use super::channel_proof::ProofTransport; use anyhow::{anyhow, bail, Context, Result}; use pf_driver_proto::gamepad::{PadBootstrap, BOOT_MAGIC, GAMEPAD_PROTO_VERSION}; use std::ffi::c_void; @@ -35,7 +68,7 @@ use windows::Win32::Devices::DeviceAndDriverInstallation::{ use windows::Win32::Devices::Enumeration::Pnp::{SwDeviceClose, HSWDEVICE}; use windows::Win32::Foundation::{ DuplicateHandle, GetLastError, LocalFree, SetLastError, DUPLICATE_HANDLE_OPTIONS, - ERROR_ALREADY_EXISTS, HANDLE, HLOCAL, INVALID_HANDLE_VALUE, WIN32_ERROR, + ERROR_ALREADY_EXISTS, HANDLE, HLOCAL, INVALID_HANDLE_VALUE, WAIT_OBJECT_0, WIN32_ERROR, }; use windows::Win32::Security::Authorization::{ ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1, @@ -46,7 +79,8 @@ use windows::Win32::System::Memory::{ MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE, }; use windows::Win32::System::Threading::{ - GetCurrentProcess, OpenProcess, SetEvent, PROCESS_DUP_HANDLE, PROCESS_QUERY_LIMITED_INFORMATION, + GetCurrentProcess, OpenProcess, SetEvent, WaitForSingleObject, PROCESS_DUP_HANDLE, + PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, }; /// Least access the pad driver needs on the duplicated DATA section: it only MAPS it read/write, so @@ -223,28 +257,80 @@ impl Drop for Shm { /// seq twice. Starts at 1. static BOOT_SEQ: AtomicU32 = AtomicU32::new(1); -/// Hard cap on delivery attempts per pad: each attempt duplicates a handle into a WUDFHost, so a -/// tampered mailbox flapping `driver_pid` must not mint unbounded remote handles (DoS containment). -/// A legitimate pad needs exactly one (a driver restart within one pad lifetime is not a thing — -/// the WUDFHost dies with the devnode). +/// Hard cap on FAILED delivery attempts per pad: each attempt duplicates a handle into a WUDFHost, so +/// a tampered mailbox flapping `driver_pid` must not mint unbounded remote handles (DoS containment). +/// Only failures spend this budget — a SUCCESSFUL delivery stands until its target process exits +/// ([`PadChannel::delivered`]), so what is left here is purely the retry allowance for a driver that +/// published a pid and then died before we could reach it. const MAX_DELIVERY_ATTEMPTS: u32 = 16; +/// How often the delivery state machine may ask the devnode for its channel proof while unattached. +/// The proof query opens a device interface and does real I/O, and the pad service pump ticks every +/// few milliseconds — without this the poll would be a hot loop on the HID stack. A driver takes tens +/// of milliseconds to publish its interface after `SwDeviceCreate`, so a quarter second costs nothing +/// perceptible at pad open and keeps the steady-state cost at zero (once delivered, nothing polls). +const PROOF_PROBE_INTERVAL: Duration = Duration::from_millis(250); + +/// Operator escape hatch for the one case the device stack cannot answer: an **out-of-band devnode** +/// (`devgen`/`devcon`, the driver bring-up path) where `SwDeviceCreate` never ran, so the host has no +/// instance id to look an interface up by. Set it and the channel falls back to the pre-v3 behaviour +/// of trusting the mailbox's `driver_pid`, which is exactly the trust the security review removed — +/// hence opt-in, per-boot, and loud in the log. Never needed for a normal host: every pad and the +/// resident mouse are `SwDeviceCreate`d. +const TRUST_MAILBOX_ENV: &str = "PUNKTFUNK_PAD_CHANNEL_TRUST_MAILBOX"; + +/// Consecutive unanswered proof queries before the debug line escalates to one operator-facing warn. +/// At [`PROOF_PROBE_INTERVAL`] this is ~5 s — comfortably past a healthy driver's attach (tens of +/// milliseconds) and past the eager window, so it only fires when the pad really is not coming up. +const PROOF_FAILURES_BEFORE_WARN: u32 = 20; + /// One pad's sealed host↔driver channel: the unnamed DATA section (the real `XusbShm`/`PadShm`), the /// named bootstrap mailbox, and the delivery state machine ([`Self::pump`]) that hands the driver's -/// WUDFHost a duplicated DATA handle once it publishes its pid. Owns both sections (RAII teardown — -/// dropping the channel closes the mailbox, whose *name* then disappears, which is how a persistent -/// (out-of-band-devnode) driver detects the host is gone). +/// WUDFHost a duplicated DATA handle. Owns both sections (RAII teardown — dropping the channel closes +/// the mailbox, whose *name* then disappears, which is how a persistent (out-of-band-devnode) driver +/// detects the host is gone). pub(super) struct PadChannel { data: Shm, boot: Shm, boot_name: String, - /// Last `driver_pid` acted on (delivered or rejected) — never retry the same value, so a failed - /// verify can't be spun into a hot loop by a static mailbox. + /// The devnode to ask for a channel proof, and how ([`Self::bind_devnode`]). `None` until the + /// caller has a `SwDeviceCreate` instance id — and permanently `None` on the out-of-band devnode + /// path, which is what [`TRUST_MAILBOX_ENV`] exists for. + devnode: Option<(String, ProofTransport)>, + /// The pad index the proof must agree with, so a mis-resolved interface can't cross-wire two pads. + pad_index: u32, + /// When the devnode was last asked (throttle — see [`PROOF_PROBE_INTERVAL`]). + last_probe: Option, + /// Last pid acted on (delivered or rejected) — never retry the same value, so a failed verify + /// can't be spun into a hot loop. last_seen_pid: u32, attempts: u32, - delivered: bool, + /// The WUDFHost the DATA section was duplicated into. `Some` ⇒ this channel is spoken for and no + /// other process is served while that one lives (see [`Self::pump`]). + delivered: Option, warned_proto: bool, warned_cap: bool, + warned_takeover: bool, + warned_unproven: bool, + proof_failures: u32, +} + +/// A completed delivery: the WUDFHost we handed the DATA section to, pinned by a live handle. +/// Liveness is judged on the HANDLE, never the pid — the handle pins the process object, so a +/// recycled pid can never read as "the process we served has exited". +struct Delivered { + pid: u32, + /// `SYNCHRONIZE` handle to the target; signaled ⇔ it exited. Same liveness idiom the frame + /// channel's `ChannelBroker::driver_alive` uses. + process: OwnedHandle, +} + +impl Delivered { + fn exited(&self) -> bool { + // SAFETY: `process` is the live `OwnedHandle` this channel owns (borrowed for this + // synchronous call); a 0 ms wait only reads the handle's signaled state. + unsafe { WaitForSingleObject(HANDLE(self.process.as_raw_handle()), 0) == WAIT_OBJECT_0 } + } } impl PadChannel { @@ -273,11 +359,17 @@ impl PadChannel { data, boot, boot_name, + devnode: None, + pad_index: 0, + last_probe: None, last_seen_pid: 0, attempts: 0, - delivered: false, + delivered: None, warned_proto: false, warned_cap: false, + warned_takeover: false, + warned_unproven: false, + proof_failures: 0, }) } @@ -299,11 +391,28 @@ impl PadChannel { unsafe { (*(self.boot.base().add(off) as *const AtomicU32)).load(Ordering::Acquire) } } + /// Bind this channel to the devnode the caller just `SwDeviceCreate`d, so [`Self::pump`] can ask + /// it for a channel proof. Call between `create_swdevice` and [`Self::deliver_eager`]. + /// + /// `instance_id` is `None` when `SwDeviceCreate` failed and the caller fell back to an + /// out-of-band (`devgen`) devnode: there is then no device to ask, and the channel will refuse to + /// deliver unless [`TRUST_MAILBOX_ENV`] is set. + pub(super) fn bind_devnode( + &mut self, + pad_index: u32, + instance_id: Option, + transport: ProofTransport, + ) { + self.pad_index = pad_index; + self.devnode = instance_id.map(|id| (id, transport)); + } + /// One tick of the delivery state machine — called from the pad's regular service pump (≤4 ms - /// cadence) and from [`Self::deliver_eager`]. Cheap when idle: two atomic loads. + /// cadence) and from [`Self::deliver_eager`]. Cheap when idle: an atomic load, and once the + /// channel is delivered, one 0 ms wait. pub(super) fn pump(&mut self) { - // Version diagnostics: the driver writes its own proto version even when it refuses to - // publish a pid (host/driver mismatch), so the operator sees WHY the pad never attaches. + // Version diagnostics: the driver writes its own proto version even when it refuses the + // handshake (host/driver mismatch), so the operator sees WHY the pad never attaches. let drv_proto = self.boot_load(core::mem::offset_of!(PadBootstrap, driver_proto)); if drv_proto != 0 && drv_proto != GAMEPAD_PROTO_VERSION && !self.warned_proto { self.warned_proto = true; @@ -315,27 +424,56 @@ impl PadChannel { drivers: punktfunk-host.exe driver install --gamepad" ); } - let pid = self.boot_load(core::mem::offset_of!(PadBootstrap, driver_pid)); - if pid == 0 || pid == self.last_seen_pid { - return; + + // A delivery stands until its target process dies. Re-delivering to a *different* live + // process was the pre-v3 takeover (see the module docs); UMDF restarting a host whose driver + // crashed is the one legitimate case, and that one is visible here as an exited handle. + if let Some(d) = self.delivered.as_ref() { + if !d.exited() { + return; + } + tracing::info!( + mailbox = %self.boot_name, + exited_pid = d.pid, + "the WUDFHost this channel was delivered to has exited (driver crash / host \ + restart) — re-attaching to the restarted driver" + ); + self.delivered = None; + self.attempts = 0; // a genuine restart earns a fresh budget + self.last_seen_pid = 0; + self.last_probe = None; } - self.last_seen_pid = pid; if self.attempts >= MAX_DELIVERY_ATTEMPTS { if !self.warned_cap { self.warned_cap = true; tracing::warn!( mailbox = %self.boot_name, attempts = self.attempts, - "gamepad channel delivery cap reached — the bootstrap mailbox keeps changing \ - its driver pid (tampering?); no further handles will be duplicated" + "gamepad channel delivery cap reached — no further handles will be duplicated" ); } return; } + // Throttle: asking costs a device open + an IOCTL, and this runs on the pad service pump. + if self + .last_probe + .is_some_and(|t| t.elapsed() < PROOF_PROBE_INTERVAL) + { + return; + } + self.last_probe = Some(Instant::now()); + + let Some(pid) = self.resolve_driver_pid() else { + return; + }; + if pid == self.last_seen_pid { + return; // already tried this one and it failed — don't spin on it + } + self.last_seen_pid = pid; self.attempts += 1; match self.deliver_to(pid) { - Ok(seq) => { - self.delivered = true; + Ok((seq, process)) => { + self.delivered = Some(Delivered { pid, process }); tracing::info!( mailbox = %self.boot_name, wudf_pid = pid, @@ -349,24 +487,111 @@ impl PadChannel { mailbox = %self.boot_name, pid, error = %format!("{e:#}"), - "sealed gamepad channel delivery failed — will retry when the mailbox reports \ - a different driver pid" + "sealed gamepad channel delivery failed" ); } } } + /// Which process to hand this pad's DATA section to. + /// + /// The answer comes from the DEVNODE ([`channel_proof`]), never from the bootstrap mailbox. That + /// is the whole security property: the mailbox is writable by LocalService, so anything in it — + /// including `driver_pid` — is attacker-choosable, whereas only the driver PnP actually bound to + /// the device we created can answer that device's I/O. See the module docs for the attack this + /// closes. + fn resolve_driver_pid(&mut self) -> Option { + let Some((instance_id, transport)) = self.devnode.clone() else { + return self.unproven_mailbox_pid("this pad has no SwDeviceCreate devnode to ask"); + }; + match channel_proof::query(&instance_id, transport, self.pad_index) { + Ok(pid) => { + self.proof_failures = 0; + Some(pid) + } + Err(e) => { + self.proof_failures += 1; + // Chatty at debug (the driver simply may not have started yet); one warn once it is + // clearly not coming, so a dead pad names its own cause instead of just going quiet. + // `DriverAttach` prints the operator-facing remedy separately. + if self.proof_failures == PROOF_FAILURES_BEFORE_WARN { + tracing::warn!( + mailbox = %self.boot_name, + devnode = %instance_id, + error = %format!("{e:#}"), + "the pad's devnode has not answered a channel proof — the host will NOT \ + hand the DATA section to a pid it cannot verify, so this pad stays \ + unattached (an old driver? reinstall: punktfunk-host.exe driver install \ + --gamepad)" + ); + } else { + tracing::debug!( + mailbox = %self.boot_name, + attempt = self.proof_failures, + error = %format!("{e:#}"), + "no channel proof yet" + ); + } + None + } + } + } + + /// The pre-v3 behaviour — trust the mailbox's `driver_pid` — for the one case that has no + /// devnode to ask: an out-of-band (`devgen`) devnode created outside `SwDeviceCreate`. Refused + /// unless [`TRUST_MAILBOX_ENV`] is set, because this is exactly the trust the security review + /// removed: any LocalService principal can write that field and be handed the pad's live input + /// surface. + fn unproven_mailbox_pid(&mut self, why: &str) -> Option { + if std::env::var_os(TRUST_MAILBOX_ENV).is_none() { + if !self.warned_unproven { + self.warned_unproven = true; + tracing::warn!( + mailbox = %self.boot_name, + reason = why, + "cannot ask this pad's driver for a channel proof — REFUSING to deliver the \ + DATA section (the mailbox pid is not trustworthy: any local service can write \ + it). Set {TRUST_MAILBOX_ENV}=1 to accept the old, unverified handshake on a \ + driver bring-up box." + ); + } + return None; + } + let pid = self.boot_load(core::mem::offset_of!(PadBootstrap, driver_pid)); + if pid == 0 { + return None; + } + if !self.warned_unproven { + self.warned_unproven = true; + tracing::warn!( + mailbox = %self.boot_name, + reason = why, + "delivering this pad channel on the UNVERIFIED mailbox pid — a local service that \ + wins the startup race can redirect this pad's input section (forged gamepad input \ + + a read of pad state). Documented residual; the virtual MOUSE and the XUSB pad are \ + both proved." + ); + } + Some(pid) + } + /// Duplicate the DATA section into `pid`'s handle table (after verifying it is a genuine /// WUDFHost) and publish the handle value + owning pid, bumping `handle_seq` LAST. The driver /// adopts the handle by consuming the delivery; an unconsumed duplicate dies with the target /// process (nothing to reap — there is no fallible step after the duplication). - fn deliver_to(&self, pid: u32) -> Result { + /// + /// Returns `(handle_seq, process)` — the process handle is RETAINED by the caller so the + /// one-delivery rule in [`Self::pump`] can tell "our driver crashed and UMDF restarted it" from + /// "someone else is claiming this channel" on the handle rather than on a reusable pid. + fn deliver_to(&self, pid: u32) -> Result<(u32, OwnedHandle)> { // SAFETY: plain FFI; the handle (checked by `?`) is owned solely here and moved into the // `OwnedHandle` (single owner, closes on drop); `verify_is_wudfhost` borrows it for the - // synchronous check and forms no lasting alias. + // synchronous check and forms no lasting alias. `SYNCHRONIZE` is requested so the retained + // handle doubles as the incumbent-liveness probe ([`Delivered::exited`]) — the same thing the + // frame channel's `ChannelBroker` asks for. let process = unsafe { let h = OpenProcess( - PROCESS_DUP_HANDLE | PROCESS_QUERY_LIMITED_INFORMATION, + PROCESS_DUP_HANDLE | PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_SYNCHRONIZE, false, pid, ) @@ -413,7 +638,7 @@ impl PadChannel { (*(base.add(core::mem::offset_of!(PadBootstrap, handle_seq)) as *const AtomicU32)) .store(seq, Ordering::Release); } - Ok(seq) + Ok((seq, process)) } /// Bounded wait at pad-open: pump until the mailbox produces a driver pid we act on (delivered or @@ -425,7 +650,7 @@ impl PadChannel { loop { self.pump(); if self.last_seen_pid != 0 || Instant::now() >= deadline { - if !self.delivered { + if self.delivered.is_none() { tracing::debug!( mailbox = %self.boot_name, "eager gamepad-channel delivery window passed without an attach — the \ @@ -620,7 +845,7 @@ impl DriverAttach { driver is serving it (games will not see it); an old (pre-sealed-channel) driver also \ reads as not-attached: update with punktfunk-host.exe driver install --gamepad \ (driver_log is only written by debug driver builds, or with the PFXUSB_DEBUG_LOG / \ - PFDS_DEBUG_LOG system env var set + the device restarted)" + PFGAMEPAD_DEBUG_LOG / PFMOUSE_DEBUG_LOG system env var set + the device restarted)" ); } } @@ -712,3 +937,49 @@ fn cm_problem_hint(problem: u32) -> &'static str { _ => "see Device Manager for this code", } } + +#[cfg(test)] +mod tests { + use super::*; + + /// The one-delivery rule ([`PadChannel::pump`]) rests entirely on [`Delivered::exited`] reading + /// the retained HANDLE rather than the pid, and both of its answers are load-bearing: "alive" + /// is what refuses a LocalService takeover of a live pad channel, and "exited" is what still + /// lets UMDF's restart-the-host-after-a-driver-crash path re-deliver. Neither half is + /// observable from the pad path without a real WUDFHost, so pin the primitive itself. + #[test] + fn delivered_exited_tracks_the_process_object_not_the_pid() { + // A child that parks long enough to be observed alive (no console needed, unlike `pause`). + let mut child = std::process::Command::new("cmd.exe") + .args(["/c", "ping", "-n", "30", "127.0.0.1"]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("spawn a parked child"); + let pid = child.id(); + // SAFETY: plain FFI on our own child's pid; the returned handle is owned solely by the + // `OwnedHandle` built from it (single owner, closes on drop). + let process = unsafe { + let h = OpenProcess( + PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_SYNCHRONIZE, + false, + pid, + ) + .expect("OpenProcess(SYNCHRONIZE) on our own child"); + OwnedHandle::from_raw_handle(h.0 as _) + }; + let delivered = Delivered { pid, process }; + assert!( + !delivered.exited(), + "a running target must read as ALIVE — this is what refuses a channel takeover" + ); + child.kill().expect("kill the child"); + // `wait` reaps, and only returns once the process has really exited — so the handle is + // signaled by the time we look. + child.wait().expect("reap the child"); + assert!( + delivered.exited(), + "an exited target must read as GONE — this is what lets a crashed driver re-attach" + ); + } +} diff --git a/crates/pf-inject/src/inject/windows/gamepad_windows.rs b/crates/pf-inject/src/inject/windows/gamepad_windows.rs index e1c5d97f..f9937c0f 100644 --- a/crates/pf-inject/src/inject/windows/gamepad_windows.rs +++ b/crates/pf-inject/src/inject/windows/gamepad_windows.rs @@ -173,6 +173,11 @@ impl XusbWinPad { // created". Returning Err routes it through PadSlots' ERROR + capped-backoff retry — parity // with the Linux uinput path, which self-heals for exactly this reason. let (hsw, instance_id) = create_swdevice(index)?; + channel.bind_devnode( + index as u32, + instance_id.clone(), + super::gamepad_raii::ProofTransport::XusbIoctl, + ); let _sw = Some(super::gamepad_raii::SwDevice::new(hsw)); // Bounded eager delivery: the driver's EvtDeviceAdd publishes its pid right away; handing it // the DATA handle before we return means the pad is live for the game's first XInput poll. @@ -184,7 +189,7 @@ impl XusbWinPad { attach: super::gamepad_raii::DriverAttach::new( "pf_xusb", "pf_xusb.inf", - "C:\\Users\\Public\\pfxusb-driver.log", + "C:\\Windows\\ServiceProfiles\\LocalService\\AppData\\Local\\Temp\\pfxusb-driver.log", boot_name, instance_id, ), diff --git a/crates/pf-inject/src/inject/windows/mouse_windows.rs b/crates/pf-inject/src/inject/windows/mouse_windows.rs index 279f1928..ae6b852d 100644 --- a/crates/pf-inject/src/inject/windows/mouse_windows.rs +++ b/crates/pf-inject/src/inject/windows/mouse_windows.rs @@ -17,7 +17,7 @@ //! disappears with the host service, which is exactly when nobody is streaming. use super::dualsense_windows::{create_swdevice, SwDeviceProfile}; -use super::gamepad_raii::{DriverAttach, PadChannel}; +use super::gamepad_raii::{DriverAttach, PadChannel, ProofTransport}; use anyhow::Result; use pf_driver_proto::mouse::{input_report, mouse_boot_name, MouseShm, MOUSE_MAGIC}; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; @@ -74,6 +74,9 @@ impl VirtualMouse { (None, None) } }; + // The DATA section goes to whoever THIS devnode says is serving it — not to whatever pid + // the LocalService-writable mailbox names (security-review 2026-07-28). + channel.bind_devnode(0, instance_id.clone(), ProofTransport::HidSerialString); let _sw = hsw.map(super::gamepad_raii::SwDevice::new); channel.deliver_eager(Duration::from_millis(1500)); Ok(VirtualMouse { @@ -82,7 +85,7 @@ impl VirtualMouse { attach: DriverAttach::new( "pf_mouse", "pf_mouse.inf", - "C:\\Users\\Public\\pfmouse-driver.log", + "C:\\Windows\\ServiceProfiles\\LocalService\\AppData\\Local\\Temp\\pfmouse-driver.log", boot_name, instance_id, ), @@ -353,3 +356,76 @@ pub fn spike_hold(secs: u64) -> Result<()> { ); Ok(()) } + +/// **Channel-proof probe** — settles, on a real box, the one thing the design could not settle by +/// reading: which HID IOCTL hidclass actually forwards to a UMDF HID minidriver. +/// +/// The pad channel hands a pad's whole input surface to whichever process the DEVNODE names +/// (`pf_driver_proto::gamepad::ChannelProof`), because the bootstrap mailbox is writable by +/// LocalService and therefore cannot be trusted to name it (security-review 2026-07-28). For the two +/// HID minidrivers that answer travels as a HID string, and both `HidD_GetIndexedString` and a +/// direct arbitrary-index `IOCTL_HID_GET_STRING` are wired up so only one of them has to work. This +/// prints which one did. +/// +/// Self-contained: it spins up its OWN throwaway `pf_mouse_probe` devnode at pad index 9, so it can +/// run alongside a live host without touching the resident mouse (index 0) or its mailbox, and the +/// devnode disappears when the command exits. Needs the pf_mouse driver installed +/// (`punktfunk-host.exe driver install --gamepad`). +pub fn channel_proof_probe() -> Result<()> { + use crate::channel_proof::{self, ProofTransport}; + + /// A pad index no real pad uses, so the proof's index check is actually exercised and the + /// probe can never be confused with the resident mouse at 0. + const PROBE_INDEX: u8 = 9; + + println!("creating a throwaway pf_mouse devnode (pad index {PROBE_INDEX})…"); + let (hsw, instance_id) = create_swdevice(&SwDeviceProfile { + instance: "pf_mouse_probe", + container_tag: 0x5046_4D4F, // "PFMO" + container_index: PROBE_INDEX, + hwid: "pf_mouse", + usb_vid_pid: "VID_5046&PID_4D4F", + usb_mi: None, + description: "punktfunk Virtual Mouse (channel-proof probe)", + })?; + let _sw = super::gamepad_raii::SwDevice::new(hsw); + let Some(instance_id) = instance_id else { + anyhow::bail!("SwDeviceCreate reported no instance id — cannot look the devnode up"); + }; + + // PnP has to start the driver and hidclass has to publish the collection interface; both happen + // in tens of milliseconds, but poll rather than sleep a fixed amount so a slow box still reports. + let deadline = std::time::Instant::now() + Duration::from_secs(10); + let report = loop { + let r = channel_proof::diagnose( + &instance_id, + ProofTransport::HidSerialString, + PROBE_INDEX as u32, + ); + if r.contains("ChannelProof") || std::time::Instant::now() >= deadline { + break r; + } + std::thread::sleep(Duration::from_millis(250)); + }; + println!("\n{report}"); + + match channel_proof::probe_pid( + &instance_id, + ProofTransport::HidSerialString, + PROBE_INDEX as u32, + ) { + Ok(pid) => println!( + "RESULT: the devnode proved its driver is pid {pid} — the HID channel proof WORKS on \ + this build of Windows, so the pad channel never has to trust the mailbox." + ), + Err(e) => println!( + "RESULT: no usable channel proof ({e:#}).\n\ + If BOTH HID lines above say \"call failed\", hidclass on this build forwards neither \ + IOCTL to a UMDF minidriver and the HID pads/mouse need a different transport (the \ + xusb leg is unaffected — it owns its own device interface). If one says \"answered, \ + but not a proof\", an OLD pf_mouse driver is installed: reinstall with\n\ + \x20 punktfunk-host.exe driver install --gamepad" + ), + } + Ok(()) +} diff --git a/crates/pf-inject/src/inject/windows/steam_deck_windows.rs b/crates/pf-inject/src/inject/windows/steam_deck_windows.rs index 8994af40..9acbe74b 100644 --- a/crates/pf-inject/src/inject/windows/steam_deck_windows.rs +++ b/crates/pf-inject/src/inject/windows/steam_deck_windows.rs @@ -79,6 +79,13 @@ impl DeckWinPad { description: "punktfunk Virtual Steam Deck", })?; // Propagate — swallowing latched the slot to a pad with no devnode (see the DS4 twin). let (hsw, instance_id) = (Some(hsw), instance_id); + // The DATA section goes to whoever THIS devnode says is serving it — not to whatever pid + // the LocalService-writable mailbox names (security-review 2026-07-28). + channel.bind_devnode( + index as u32, + instance_id.clone(), + super::gamepad_raii::ProofTransport::HidFeatureReport, + ); let _sw = hsw.map(super::gamepad_raii::SwDevice::new); // Bounded eager delivery — the driver must read `device_type = 3` before hidclass asks // it for descriptors, or the pad would enumerate with the default DualSense identity. @@ -88,8 +95,8 @@ impl DeckWinPad { channel, attach: super::gamepad_raii::DriverAttach::new( "pf_steamdeck", - "pf_dualsense.inf", // one driver package serves every identity - "C:\\Users\\Public\\pfds-driver.log", + "pf_gamepad.inf", // one driver package serves every identity + "C:\\Windows\\ServiceProfiles\\LocalService\\AppData\\Local\\Temp\\pf_gamepad-driver.log", boot_name, instance_id, ), diff --git a/crates/pf-inject/src/lib.rs b/crates/pf-inject/src/lib.rs index eb71925b..db63f024 100644 --- a/crates/pf-inject/src/lib.rs +++ b/crates/pf-inject/src/lib.rs @@ -327,6 +327,12 @@ fn libei_ei_source() -> libei::EiSource { // Goal-1 stage 6: Linux UHID/uinput/libei/wlr backends under `inject/linux/`, the Windows UMDF/SendInput // backends under `inject/windows/`, and the transport-independent HID codecs under `inject/proto/`; // `#[path]` keeps every `crate::*` module name flat. +/// Windows: asks a devnode which process is serving it (`pf_driver_proto::gamepad::ChannelProof`) — +/// the unforgeable answer the sealed pad channel duplicates its DATA section into, replacing the +/// LocalService-writable bootstrap mailbox as the source of that decision. +#[cfg(target_os = "windows")] +#[path = "inject/windows/channel_proof.rs"] +pub mod channel_proof; #[cfg(target_os = "linux")] #[path = "inject/linux/dualsense.rs"] pub mod dualsense; diff --git a/crates/punktfunk-host/src/devtest.rs b/crates/punktfunk-host/src/devtest.rs index d1bdf8f4..d6ee6df8 100644 --- a/crates/punktfunk-host/src/devtest.rs +++ b/crates/punktfunk-host/src/devtest.rs @@ -327,6 +327,16 @@ pub fn vmouse_spike(args: &[String]) -> Result<()> { crate::inject::mouse_windows::spike_hold(secs) } +/// Windows CHANNEL-PROOF PROBE: settle, on a real box, which HID IOCTL hidclass forwards to a UMDF +/// HID minidriver — the one assumption in the pad channel's v3 delivery gate that could not be +/// settled by reading. Spins up a throwaway `pf_mouse_probe` devnode at pad index 9 (so it is safe +/// to run beside a live host), asks it for its channel proof over both HID paths, and prints which +/// answered. Needs the CURRENT drivers installed: `punktfunk-host.exe driver install --gamepad`. +#[cfg(target_os = "windows")] +pub fn channel_proof_probe(_args: &[String]) -> Result<()> { + crate::inject::mouse_windows::channel_proof_probe() +} + /// Windows: create a virtual DualSense via the UMDF driver (a SwDeviceCreate per-session /// devnode plus the shared-memory channel) and hold it, pushing one fixed frame (Cross + /// LS-right). Drives the real DualSenseWindowsManager, so it validates the device lifecycle diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index fd0ad058..c89a078c 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -559,6 +559,10 @@ fn real_main() -> Result<()> { // (validates the resident-mouse cursor-presence fix on-glass). `--seconds N`. #[cfg(target_os = "windows")] Some("vmouse-spike") => devtest::vmouse_spike(&args), + // Windows: ask a throwaway pf_mouse devnode for its channel proof and report which HID + // transport hidclass forwarded — the pad channel's v3 delivery gate, verified on glass. + #[cfg(target_os = "windows")] + Some("channel-proof-probe") => devtest::channel_proof_probe(&args), // Windows: create a virtual DualSense (or --ds4/--edge/--deck/--xbox) via the UMDF driver and // hold it, driving the real *WindowsManager end to end. `--index N`, `--seconds N`. #[cfg(target_os = "windows")] diff --git a/crates/punktfunk-host/src/windows/install.rs b/crates/punktfunk-host/src/windows/install.rs index c09b2970..b9156420 100644 --- a/crates/punktfunk-host/src/windows/install.rs +++ b/crates/punktfunk-host/src/windows/install.rs @@ -153,6 +153,15 @@ fn install_gamepad(dir: &Path) -> Result<()> { bail!("no driver .inf in {}", dir.display()); } trust_cert(dir); + // Retire the PRE-RENAME package first. `pf-dualsense` became `pf-gamepad` (one driver always + // served four identities, so the old name read as if the other three lived elsewhere) and the + // HARDWARE IDS deliberately did not move — they are the binding contract with every devnode the + // host creates and with every installed system. That means an upgraded box would otherwise hold + // TWO store packages claiming `pf_dualsense`/`pf_dualshock4`/…, and PnP would be free to bind + // the stale one. Matched on `pf_dualsense.dll`, a string only the OLD package's INF contains — + // the new INF still mentions the hardware ids, so matching on those would delete what we are + // about to install. + delete_store_drivers(&["pf_dualsense.dll"]); // Add each package to the store - no /install, no device node: the host SwDeviceCreate's the // per-session devnode when a client forwards a pad, so PnP binds the store driver on demand. for inf in &infs { @@ -187,7 +196,7 @@ fn remove_pad_devnodes() { // ── `driver uninstall [--gamepad]` ────────────────────────────────────────────────────────────── // The uninstaller's cleanup counterpart (Inno [UninstallRun]) — the field report was that our // virtual-device drivers survived an uninstall. Removes the pf-vdisplay device node(s) + driver -// package, or (--gamepad) the pf-dualsense/pf-xusb driver packages (their devnodes are per-session +// package, or (--gamepad) the pf-gamepad/pf-xusb driver packages (their devnodes are per-session // SwDeviceCreate'd and are already gone once the service stopped). Locale-safe by construction: we // never parse pnputil's localized LABELS — devices are matched on the un-localized VALUE side // (instance IDs / device IDs), and driver packages are found by scanning %WINDIR%\INF\oem*.inf @@ -226,7 +235,13 @@ fn uninstall_gamepad() -> Result<()> { // Devnodes first (incl. phantoms — the same ghost-device complaint the vdisplay uninstall // fixed), then the store packages. remove_pad_devnodes(); - delete_store_drivers(&["pf_dualsense", "pf_dualshock4", "pf_xusb", "pf_mouse"]); + delete_store_drivers(&[ + "pf_gamepad", + "pf_dualsense", + "pf_dualshock4", + "pf_xusb", + "pf_mouse", + ]); Ok(()) } diff --git a/packaging/windows/README.md b/packaging/windows/README.md index 07b8d7ce..0f3c39f8 100644 --- a/packaging/windows/README.md +++ b/packaging/windows/README.md @@ -80,7 +80,7 @@ parse breakage that silently failed installs on non-English boxes. - **Uninstall** (Add/Remove Programs): runs `service uninstall` (stop + delete service + remove firewall rules), removes the `PunktfunkWeb` task + its firewall rule, then `driver uninstall` (+ `--gamepad`) removes the punktfunk virtual-device drivers — the pf-vdisplay device node(s) and the - pf-vdisplay / pf-dualsense / pf-xusb driver-store packages (the field report was that they survived + pf-vdisplay / pf-gamepad / pf-xusb driver-store packages (the field report was that they survived uninstall). **VB-CABLE is intentionally NOT removed** (a third-party shared component the user may use elsewhere — its own uninstaller is `VBCABLE_Setup_x64.exe -u -h`); the `%ProgramData%\punktfunk` config (incl. `web-password`) is also left in place. @@ -123,7 +123,7 @@ fresh install uses the generated random console password — read it from | `branding/` | Wizard branding: `gen-branding.ps1` renders the brand mark into the committed `wizard-image-*.bmp` / `wizard-small-*.bmp` (100–200% DPI) + `punktfunk.ico`. Re-run only on a brand change. | | `pack-host-installer.ps1` | Orchestrator: cert + sign exe, **build + sign the drivers from source**, stage them + FFmpeg + VB-CABLE + the **web console** (`.output` + bun) + the HDR layer + branding, run ISCC, sign setup.exe. | | `build-pf-vdisplay.ps1` | Build pf-vdisplay from source (the `drivers/` workspace) + clear FORCE_INTEGRITY + sign `.dll`/`.cat` + export `.cer`. | -| `build-gamepad-drivers.ps1` | Sign + catalog the gamepad drivers (`pf-dualsense` + `pf-xusb`) from the same workspace build (`-SkipBuild`), one shared cert. | +| `build-gamepad-drivers.ps1` | Sign + catalog the gamepad drivers (`pf-gamepad` + `pf-xusb`) from the same workspace build (`-SkipBuild`), one shared cert. | | `install-vbcable.ps1` | On-target: seed VB-Audio's cert into `TrustedPublisher`, silently install the bundled VB-CABLE (`-i -h`). Run by the installer's *Install VB-CABLE virtual audio* task; idempotent + always exits 0 (non-fatal). | | `clear-force-integrity.ps1` | Clear the `/INTEGRITYCHECK` PE bit so a self-signed driver loads (reused by every driver build). | | `stage-pf-vdisplay.ps1` | Stage the just-built pf-vdisplay bundle + fetch/verify the **pinned** nefcon release. | @@ -133,7 +133,7 @@ fresh install uses the generated random console password — read it from | `redeploy-pf-vdisplay.ps1` | **Dev:** one-shot redeploy — (optional) build → stop host → `deploy-dev.ps1 -Install` → reload adapter → start host. | | `pf-vkhdr-layer/` | **HDR Vulkan layer** (standalone `cdylib`): lets Vulkan games (Doom: The Dark Ages, etc.) enable HDR over the virtual display by advertising the HDR surface formats the NVIDIA/AMD ICDs hide on an indirect display. Built by the packer, laid into `{app}\vklayer`, registered under `HKLM64\…\Khronos\Vulkan\ImplicitLayers` (opt-out *Install the HDR Vulkan layer* task). Self-gated on the display's HDR state. See its README. | -> **Drivers are built from source, not vendored.** All three (pf-vdisplay + the gamepad pf-dualsense / +> **Drivers are built from source, not vendored.** All three (pf-vdisplay + the gamepad pf-gamepad / > pf-xusb) are members of the all-Rust `drivers/` workspace (windows-drivers-rs / IddCx) and are > **rebuilt + signed every release** by `build-pf-vdisplay.ps1` + `build-gamepad-drivers.ps1` - the > checked-in prebuilt binaries were deleted (a stale `.cat` once stopped covering its `.inf` → diff --git a/packaging/windows/build-gamepad-drivers.ps1 b/packaging/windows/build-gamepad-drivers.ps1 index 6487911b..4e8c22f6 100644 --- a/packaging/windows/build-gamepad-drivers.ps1 +++ b/packaging/windows/build-gamepad-drivers.ps1 @@ -1,6 +1,6 @@ <# .SYNOPSIS - Build + sign the punktfunk virtual-gamepad UMDF drivers (pf-dualsense = DualSense/DualShock 4, pf-xusb = + Build + sign the punktfunk virtual-gamepad UMDF drivers (pf-gamepad = DualSense/DualShock 4/Edge/Deck, pf-xusb = Xbox 360 / XInput) FROM SOURCE, in CI, and stage them for the host installer. The gamepad analogue of build-pf-vdisplay.ps1 - replaces the checked-in prebuilt binaries (packaging/windows/gamepad-drivers/) so the .dll/.inf/.cat stay in lockstep with the source and never go stale. @@ -13,7 +13,7 @@ supplied DRIVER_CERT secret) + ONE exported .cer - the layout `punktfunk-host.exe driver install --gamepad` consumes (per-driver .inf/.cat/.dll + one shared punktfunk-driver.cer). - Output (-Out): pf_dualsense.{dll,inf,cat} + pf_xusb.{dll,inf,cat} + pf_mouse.{dll,inf,cat} + + Output (-Out): pf_gamepad.{dll,inf,cat} + pf_xusb.{dll,inf,cat} + pf_mouse.{dll,inf,cat} + punktfunk-driver.cer. (pf_mouse is the resident virtual HID pointer, not a gamepad - it shares this pipeline + the --gamepad install path.) @@ -37,7 +37,7 @@ $DriversDir = (Resolve-Path $DriversDir).Path $clear = Join-Path $PSScriptRoot 'clear-force-integrity.ps1' $drivers = @( - @{ crate = 'pf-dualsense'; dll = 'pf_dualsense.dll'; inx = 'pf-dualsense\pf_dualsense.inx'; inf = 'pf_dualsense.inf'; cat = 'pf_dualsense.cat' } + @{ crate = 'pf-gamepad'; dll = 'pf_gamepad.dll'; inx = 'pf-gamepad\pf_gamepad.inx'; inf = 'pf_gamepad.inf'; cat = 'pf_gamepad.cat' } @{ crate = 'pf-xusb'; dll = 'pf_xusb.dll'; inx = 'pf-xusb\pf_xusb.inx'; inf = 'pf_xusb.inf'; cat = 'pf_xusb.cat' } # Not a gamepad, but it rides the identical UMDF HID pipeline + the same install path # (`driver install --gamepad` adds every staged .inf): the resident virtual HID mouse that diff --git a/packaging/windows/drivers/Cargo.toml b/packaging/windows/drivers/Cargo.toml index 550e675a..76ff5e8b 100644 --- a/packaging/windows/drivers/Cargo.toml +++ b/packaging/windows/drivers/Cargo.toml @@ -7,7 +7,7 @@ # crates/pf-driver-proto from the main tree. [workspace] resolver = "2" -members = ["wdk-probe", "wdk-iddcx", "pf-umdf-util", "pf-vdisplay", "pf-dualsense", "pf-xusb", "pf-mouse"] +members = ["wdk-probe", "wdk-iddcx", "pf-umdf-util", "pf-vdisplay", "pf-gamepad", "pf-xusb", "pf-mouse"] [workspace.package] edition = "2024" diff --git a/packaging/windows/drivers/pf-dualsense/Cargo.toml b/packaging/windows/drivers/pf-gamepad/Cargo.toml similarity index 72% rename from packaging/windows/drivers/pf-dualsense/Cargo.toml rename to packaging/windows/drivers/pf-gamepad/Cargo.toml index 8284a5a3..a39bc8c3 100644 --- a/packaging/windows/drivers/pf-dualsense/Cargo.toml +++ b/packaging/windows/drivers/pf-gamepad/Cargo.toml @@ -1,8 +1,10 @@ -# pf-dualsense - punktfunk virtual DualSense (DS5) / DualShock 4 (DS4) UMDF2 HID minidriver. +# pf-gamepad - punktfunk virtual gamepads (DualSense / DualShock 4 / DualSense Edge / Steam Deck), +# ONE UMDF2 HID minidriver serving all four identities. Named for the role, not for one of them — +# it was `pf-dualsense`, which read as if the other three were somewhere else. # A member of the in-tree drivers workspace (shares the vendored wdk-sys/wdk-build with the bindgen pin # + the crt-static .cargo/config), built from source per release like pf-vdisplay. [package] -name = "pf-dualsense" +name = "pf-gamepad" edition.workspace = true version.workspace = true license.workspace = true diff --git a/packaging/windows/drivers/pf-dualsense/README.md b/packaging/windows/drivers/pf-gamepad/README.md similarity index 83% rename from packaging/windows/drivers/pf-dualsense/README.md rename to packaging/windows/drivers/pf-gamepad/README.md index bf374511..59535478 100644 --- a/packaging/windows/drivers/pf-dualsense/README.md +++ b/packaging/windows/drivers/pf-gamepad/README.md @@ -1,4 +1,11 @@ -# pf-dualsense — virtual DualSense UMDF2 HID minidriver +# pf-gamepad — the virtual-gamepad UMDF2 HID minidriver + +> Renamed from **pf-dualsense** (2026-07-28). One driver has always served four identities — +> DualSense, DualShock 4, DualSense Edge and Steam Deck — so the old name read as if the other three +> lived somewhere else. Only the PACKAGE identity moved (crate, INF, CAT, DLL, UMDF service); the +> four **hardware ids** (`pf_dualsense`, `pf_dualshock4`, `pf_dualsenseedge`, `pf_steamdeck`) are +> deliberately unchanged — they bind every devnode the host creates and every installed system. +> `driver install --gamepad` retires the pre-rename store package so the two can't both claim them. A self-authored **Rust UMDF2 HID minidriver** that presents a virtual Sony **DualSense** (VID `054C` / PID `0CE6`) to Windows, so games drive adaptive triggers / lightbar / rumble — @@ -42,24 +49,24 @@ plus the sign steps below, staged for the installer. The original manual dev-box lore (paths reflect that era's cargo-make layout): ```powershell -cargo make # -> target\debug\pf_dualsense_package\ (.inf/.cat/.dll) +cargo make # -> target\debug\pf_gamepad_package\ (.inf/.cat/.dll) # *** CRITICAL: clear the PE FORCE_INTEGRITY bit *** # windows-drivers-rs links the DLL with /INTEGRITYCHECK, which forces a CI-trusted page-hash # signature a self-signed cert cannot satisfy (CodeIntegrity 3004 "hash not found" / # 3089 VerificationError 7). SudoVDA.dll (third-party VDD prior art, not used by punktfunk) has # this bit OFF. Clear bit 0x80 at PE-header offset +0x5e: -$f = 'target\debug\pf_dualsense_package\pf_dualsense.dll' +$f = 'target\debug\pf_gamepad_package\pf_gamepad.dll' $b = [IO.File]::ReadAllBytes($f); $pe = [BitConverter]::ToInt32($b,0x3c); $off = $pe + 0x5e $dc = [BitConverter]::ToUInt16($b,$off); $bb = [BitConverter]::GetBytes([uint16]($dc -band 0xFF7F)) $b[$off]=$bb[0]; $b[$off+1]=$bb[1]; [IO.File]::WriteAllBytes($f,$b) signtool sign /fd SHA256 /sha1 $f -Remove-Item target\debug\pf_dualsense_package\pf_dualsense.cat -Inf2Cat /driver:target\debug\pf_dualsense_package /os:10_x64 -signtool sign /fd SHA256 /sha1 target\debug\pf_dualsense_package\pf_dualsense.cat +Remove-Item target\debug\pf_gamepad_package\pf_gamepad.cat +Inf2Cat /driver:target\debug\pf_gamepad_package /os:10_x64 +signtool sign /fd SHA256 /sha1 target\debug\pf_gamepad_package\pf_gamepad.cat -pnputil /add-driver target\debug\pf_dualsense_package\pf_dualsense.inf /install +pnputil /add-driver target\debug\pf_gamepad_package\pf_gamepad.inf /install devgen /add /hardwareid "root\pf_dualsense" # creates the (transient, SWD) device node ``` diff --git a/packaging/windows/drivers/pf-dualsense/build.rs b/packaging/windows/drivers/pf-gamepad/build.rs similarity index 100% rename from packaging/windows/drivers/pf-dualsense/build.rs rename to packaging/windows/drivers/pf-gamepad/build.rs diff --git a/packaging/windows/drivers/pf-dualsense/pf_dualsense.inx b/packaging/windows/drivers/pf-gamepad/pf_gamepad.inx similarity index 69% rename from packaging/windows/drivers/pf-dualsense/pf_dualsense.inx rename to packaging/windows/drivers/pf-gamepad/pf_gamepad.inx index 38516a62..64d850c3 100644 --- a/packaging/windows/drivers/pf-dualsense/pf_dualsense.inx +++ b/packaging/windows/drivers/pf-gamepad/pf_gamepad.inx @@ -1,6 +1,12 @@ ;/*++ -; punktfunk virtual PlayStation/Valve pads — UMDF2 HID minidriver INF (M0 spike). -; One package, four hardware ids: DualSense, DualShock 4, DualSense Edge, Steam Deck. +; punktfunk virtual gamepads — UMDF2 HID minidriver INF. +; One package, four hardware ids: DualSense, DualShock 4, DualSense Edge, Steam Deck — which is why +; the package is called pf_gamepad and not pf_dualsense (it never was one identity). +; +; ⚠️ The HARDWARE IDS below deliberately keep their old names (`pf_dualsense`, `pf_dualshock4`, +; `pf_dualsenseedge`, `pf_steamdeck`). They are the binding contract with every devnode the host +; SwDeviceCreate's and with every already-installed system; renaming them would orphan existing +; installs and buy nothing. Only the PACKAGE identity (INF/CAT/DLL/service) moved to pf_gamepad. ; Adapted from the WDK vhidmini2 UMDF2 sample (VhidminiUm.inx). ; Depends on MsHidUmdf.inf (build >= 22000). ; Install: devgen /add /hardwareid "root\pf_dualsense" (after pnputil /add-driver /install) @@ -10,7 +16,7 @@ Signature="$WINDOWS NT$" Class=HIDClass ClassGuid={745a17a0-74d3-11d0-b6fe-00a0c90f57da} Provider=%ProviderString% -CatalogFile=pf_dualsense.cat +CatalogFile=pf_gamepad.cat PnpLockdown=1 [DestinationDirs] @@ -20,7 +26,7 @@ DefaultDestDir = 13 1=%Disk_Description%,,, [SourceDisksFiles] -pf_dualsense.dll=1 +pf_gamepad.dll=1 [Manufacturer] %ManufacturerString%=pf, NT$ARCH$.10.0...22000 @@ -30,44 +36,44 @@ pf_dualsense.dll=1 ; for the host's SwDeviceCreate'd DualSense (the `root\` prefix is reserved for root enumeration, so ; SwDeviceCreate rejects it with E_INVALIDARG); `pf_dualshock4` / `pf_dualsenseedge` / `pf_steamdeck` ; for the host's other virtual pads — ONE driver binds all of them (every model line below installs -; the same `pfDualSense` section) and serves the matching HID identity per the device_type byte the +; the same `pfGamepad` section) and serves the matching HID identity per the device_type byte the ; host stamps into shared memory. ; ; Each id carries its OWN description: Device Manager reads this string, and a single shared ; "Virtual DualSense" made an emulated DualShock 4 look like the controller-type setting had been ; ignored. The HID layer (VID/PID, report descriptor, product string) was always per-type; this ; makes the human-readable name agree with it. -%DeviceDesc%=pfDualSense, root\pf_dualsense, pf_dualsense -%DeviceDescDS4%=pfDualSense, pf_dualshock4 -%DeviceDescEdge%=pfDualSense, pf_dualsenseedge -%DeviceDescDeck%=pfDualSense, pf_steamdeck +%DeviceDesc%=pfGamepad, root\pf_dualsense, pf_dualsense +%DeviceDescDS4%=pfGamepad, pf_dualshock4 +%DeviceDescEdge%=pfGamepad, pf_dualsenseedge +%DeviceDescDeck%=pfGamepad, pf_steamdeck -[pfDualSense.NT] +[pfGamepad.NT] CopyFiles=UMDriverCopy Include=MsHidUmdf.inf Needs=MsHidUmdf.NT Include=WUDFRD.inf Needs=WUDFRD_LowerFilter.NT -[pfDualSense.NT.hw] +[pfGamepad.NT.hw] Include=MsHidUmdf.inf Needs=MsHidUmdf.NT.hw Include=WUDFRD.inf Needs=WUDFRD_LowerFilter.NT.hw -[pfDualSense.NT.Services] +[pfGamepad.NT.Services] Include=MsHidUmdf.inf Needs=MsHidUmdf.NT.Services Include=WUDFRD.inf Needs=WUDFRD_LowerFilter.NT.Services -[pfDualSense.NT.Filters] +[pfGamepad.NT.Filters] Include=WUDFRD.inf Needs=WUDFRD_LowerFilter.NT.Filters -[pfDualSense.NT.Wdf] -UmdfService="pf_dualsense", pf_dualsense_Install -UmdfServiceOrder=pf_dualsense +[pfGamepad.NT.Wdf] +UmdfService="pf_gamepad", pf_gamepad_Install +UmdfServiceOrder=pf_gamepad UmdfKernelModeClientPolicy=AllowKernelModeClients UmdfFileObjectPolicy=AllowNullAndUnknownFileObjects UmdfMethodNeitherAction=Copy @@ -76,18 +82,18 @@ UmdfFsContextUsePolicy=CanUseFsContext2 ; across multiple simultaneous controllers (multi-pad). UmdfHostProcessSharing=ProcessSharingDisabled -[pf_dualsense_Install] +[pf_gamepad_Install] UmdfLibraryVersion=$UMDFVERSION$ -ServiceBinary="%13%\pf_dualsense.dll" +ServiceBinary="%13%\pf_gamepad.dll" [UMDriverCopy] -pf_dualsense.dll +pf_gamepad.dll [Strings] ProviderString ="punktfunk" ManufacturerString ="punktfunk" ClassName ="HID device" -Disk_Description ="punktfunk DualSense Installation Disk" +Disk_Description ="punktfunk Gamepad Installation Disk" ; One per hardware id — these are what Device Manager shows. Keep them aligned with the product ; strings the driver serves per device_type (src/lib.rs `on_get_string`). DeviceDesc ="punktfunk Virtual DualSense" diff --git a/packaging/windows/drivers/pf-dualsense/src/lib.rs b/packaging/windows/drivers/pf-gamepad/src/lib.rs similarity index 93% rename from packaging/windows/drivers/pf-dualsense/src/lib.rs rename to packaging/windows/drivers/pf-gamepad/src/lib.rs index 9c9b607c..fca373a4 100644 --- a/packaging/windows/drivers/pf-dualsense/src/lib.rs +++ b/packaging/windows/drivers/pf-gamepad/src/lib.rs @@ -358,7 +358,7 @@ static DEVTYPE_WAITED: AtomicBool = AtomicBool::new(false); /// This pad's channel config (magic/size/pad_index offset + our logger). fn channel_cfg() -> ChannelConfig { ChannelConfig { - tag: "pf-ds", + tag: "pf-gamepad", boot_name_prefix: "Global\\pfds-boot-", data_magic: SHM_MAGIC, data_size: SHM_SIZE, @@ -385,14 +385,14 @@ fn pad_index() -> u8 { } /// Whether the world-writable bring-up file log is enabled (resolved once). OPT-IN — debug builds, -/// or the `PFDS_DEBUG_LOG` (system-wide) env var — the same treatment pf-vdisplay got in audit +/// or the `PFGAMEPAD_DEBUG_LOG` (system-wide) env var — the same treatment pf-vdisplay got in audit /// §4.4: a RELEASE driver never writes the Public file (info-leak/DoS surface), and the per-report /// OUTPUT hex dumps stop being a sustained disk-write path during gameplay. DebugView can't see the /// UMDF host across session 0, so the file stays the bring-up diagnostic when enabled. fn file_log_enabled() -> bool { use std::sync::OnceLock; static ON: OnceLock = OnceLock::new(); - *ON.get_or_init(|| cfg!(debug_assertions) || std::env::var_os("PFDS_DEBUG_LOG").is_some()) + *ON.get_or_init(|| cfg!(debug_assertions) || std::env::var_os("PFGAMEPAD_DEBUG_LOG").is_some()) } /// Process-lifetime append handle to the bring-up log, opened ONCE and shared via a `Mutex` @@ -411,7 +411,7 @@ fn file_appender() -> Option<&'static std::sync::Mutex> { std::fs::OpenOptions::new() .create(true) .append(true) - .open(std::env::temp_dir().join("pfds-driver.log")) + .open(std::env::temp_dir().join("pf_gamepad-driver.log")) .ok() .map(std::sync::Mutex::new) }) @@ -437,7 +437,7 @@ pub unsafe extern "system" fn driver_entry( driver: PDRIVER_OBJECT, registry_path: PCUNICODE_STRING, ) -> NTSTATUS { - log("[pf-ds] DriverEntry"); + log("[pf-gamepad] DriverEntry"); // SAFETY: zeroed WDF_DRIVER_CONFIG is a valid all-null config; we then set Size + the callback. let mut config: WDF_DRIVER_CONFIG = unsafe { core::mem::zeroed() }; config.Size = core::mem::size_of::() as ULONG; @@ -457,7 +457,7 @@ pub unsafe extern "system" fn driver_entry( } extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INIT) -> NTSTATUS { - log("[pf-ds] EvtDeviceAdd"); + log("[pf-gamepad] EvtDeviceAdd"); // Mark as a filter (HID minidriver sits below mshidumdf.sys). // SAFETY: device_init is provided by the framework and non-null. @@ -474,14 +474,14 @@ extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INI ) }; if !nt_success(st) { - dbglog!("[pf-ds] WdfDeviceCreate failed 0x{:08x}", st as u32); + dbglog!("[pf-gamepad] WdfDeviceCreate failed 0x{:08x}", st as u32); return st; } // SAFETY: `device` is the live device just created — the exact contract this fn requires. let shm_idx = unsafe { wdf::query_location_index(device) }; CHANNEL.set_index(shm_idx); - dbglog!("[pf-ds] shm index = {shm_idx}"); + dbglog!("[pf-gamepad] shm index = {shm_idx}"); // Default parallel queue handling all IOCTLs. // SAFETY: zeroed config then fields set; Size matches the struct. @@ -507,7 +507,7 @@ extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INI }; if !nt_success(st) { dbglog!( - "[pf-ds] default WdfIoQueueCreate failed 0x{:08x}", + "[pf-gamepad] default WdfIoQueueCreate failed 0x{:08x}", st as u32 ); return st; @@ -531,7 +531,10 @@ extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INI ) }; if !nt_success(st) { - dbglog!("[pf-ds] manual WdfIoQueueCreate failed 0x{:08x}", st as u32); + dbglog!( + "[pf-gamepad] manual WdfIoQueueCreate failed 0x{:08x}", + st as u32 + ); return st; } MANUAL_QUEUE.store(manual_queue, Ordering::SeqCst); @@ -558,13 +561,13 @@ extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INI call_unsafe_wdf_function_binding!(WdfTimerCreate, &mut tcfg, &mut tattr, &mut timer) }; if !nt_success(st) { - dbglog!("[pf-ds] WdfTimerCreate failed 0x{:08x}", st as u32); + dbglog!("[pf-gamepad] WdfTimerCreate failed 0x{:08x}", st as u32); return st; } // SAFETY: timer valid; -80000 == 8ms relative due time (100ns units, negative = relative). let _started = unsafe { call_unsafe_wdf_function_binding!(WdfTimerStart, timer, -80000i64) }; - log("[pf-ds] device ready (DualSense 054C:0CE6)"); + log("[pf-gamepad] device ready (DualSense 054C:0CE6)"); STATUS_SUCCESS } @@ -582,7 +585,7 @@ extern "C" fn evt_io_device_control( // Skip the 8ms READ_REPORT cadence so the log stays readable during a game test; // the 0x02 OUTPUT report (the gate) and the descriptor handshake still log. if ioctl != IOCTL_HID_READ_REPORT { - dbglog!("[pf-ds] ioctl 0x{ioctl:08x} out={_output_len} in={_input_len}"); + dbglog!("[pf-gamepad] ioctl 0x{ioctl:08x} out={_output_len} in={_input_len}"); } // READ_REPORT forwards to the manual queue (the timer completes it) — this CONSUMES the request @@ -618,10 +621,16 @@ extern "C" fn evt_io_device_control( IOCTL_UMDF_HID_GET_FEATURE => on_get_feature(&request), IOCTL_UMDF_HID_GET_INPUT_REPORT => request.copy_to_output(&neutral_report(device_type())), IOCTL_HID_GET_STRING => on_get_string(&request), + // The channel proof (see `pf_umdf_util::hid`): the host asks THIS devnode which process + // serves it, and duplicates the DATA section into the answer — so it never has to trust the + // LocalService-writable bootstrap mailbox to name its target. _ => STATUS_NOT_IMPLEMENTED, }; - dbglog!("[pf-ds] ioctl 0x{ioctl:08x} -> 0x{:08x}", status as u32); + dbglog!( + "[pf-gamepad] ioctl 0x{ioctl:08x} -> 0x{:08x}", + status as u32 + ); request.complete(status); } @@ -644,7 +653,7 @@ fn on_output_report(request: &Request, ioctl: ULONG) -> NTSTATUS { } else { "SET_OUTPUT_REPORT" }; - dbglog!("[pf-ds] *** OUTPUT {kind} reportId={report_id} len={inlen} data: {hex}"); + dbglog!("[pf-gamepad] *** OUTPUT {kind} reportId={report_id} len={inlen} data: {hex}"); // Publish the game's 0x02 output report to the sealed DATA section for the host (rumble / // lightbar / player-LEDs / adaptive triggers): legacy slot + seq, plus the v2.1 ring. @@ -696,7 +705,7 @@ fn on_set_feature(request: &Request) -> NTSTATUS { publish_output(view, &out); } } - dbglog!("[pf-ds] SET_FEATURE (acked, latched for GET)"); + dbglog!("[pf-gamepad] SET_FEATURE (acked, latched for GET)"); STATUS_SUCCESS } @@ -718,6 +727,16 @@ fn deck_feature_reply() -> [u8; 64] { let unit_serial = format!("FVPF{unit_id:08X}"); let unit_serial = unit_serial.as_bytes(); let mut r = [0u8; 64]; + // The CHANNEL PROOF, Deck flavour: the Deck's ONE feature report is unnumbered and Steam drives + // it as command→response, so the proof rides that same contract instead of a new report id (no + // descriptor change). Two command bytes, so a Steam command we haven't catalogued cannot collide. + if last.starts_with(&pf_driver_proto::gamepad::DECK_PROOF_CMD) { + let proof = + pf_driver_proto::gamepad::ChannelProof::new(CHANNEL.index(), std::process::id()); + r[..2].copy_from_slice(&pf_driver_proto::gamepad::DECK_PROOF_CMD); + r[2..18].copy_from_slice(&proof.to_bytes()); + return r; + } match last[0] { 0x83 => { // GET_ATTRIBUTES_VALUES: [0x83, 0x2d, then 9x (attr-id, value u32-LE)]. @@ -779,6 +798,24 @@ fn on_get_feature(request: &Request) -> NTSTATUS { let Some(&report_id) = bytes.first() else { return STATUS_INVALID_PARAMETER; }; + // The CHANNEL PROOF (security-review 2026-07-28): tell the host which process serves this + // devnode, so it never has to trust the LocalService-writable bootstrap mailbox to name its + // duplication target. `0x85` is already declared as a Feature report in all three captured + // descriptors and was previously answered with STATUS_INVALID_PARAMETER, so this costs NO + // report-descriptor change — the identity Steam and SDL fingerprint is untouched. Derived only + // from our own devnode Location + pid: nothing a caller supplies feeds into it. + if report_id == pf_driver_proto::gamepad::HID_FEATURE_REPORT_CHANNEL_PROOF { + let len = request.output_buffer_len(); + return match pf_driver_proto::gamepad::ChannelProof::new( + CHANNEL.index(), + std::process::id(), + ) + .to_feature_report(report_id, len) + { + Some(rep) => request.copy_to_output(&rep), + None => STATUS_INVALID_PARAMETER, // caller's buffer can't hold id + proof + }; + } // DualSense + Edge use feature ids 0x05/0x09/0x20 (same blobs — SDL forces enhanced-rumble // for the Edge PID regardless of the firmware version at 0x20[44..46]); DualShock 4 uses // 0x02/0x12/0xa3. @@ -801,7 +838,7 @@ fn on_get_feature(request: &Request) -> NTSTATUS { (1, 0x12) => &ds4_pairing, (1, 0xA3) => &DS4_FEATURE_FIRMWARE, (_, other) => { - dbglog!("[pf-ds] GET_FEATURE unknown report id 0x{other:02x}"); + dbglog!("[pf-gamepad] GET_FEATURE unknown report id 0x{other:02x}"); return STATUS_INVALID_PARAMETER; } }; @@ -825,7 +862,7 @@ fn on_get_string(request: &Request) -> NTSTATUS { }; let string_id = id_val & 0xFFFF; let devtype = device_type(); - dbglog!("[pf-ds] GET_STRING id=0x{string_id:04x} (raw 0x{id_val:08x}) devtype={devtype}"); + dbglog!("[pf-gamepad] GET_STRING id=0x{string_id:04x} (raw 0x{id_val:08x}) devtype={devtype}"); let s: String = match string_id { 0 | 0x000e => match devtype { 1 => "Sony Computer Entertainment".into(), @@ -882,7 +919,7 @@ fn device_type() -> u8 { std::thread::sleep(std::time::Duration::from_millis(10)); } dbglog!( - "[pf-ds] device_type: sealed channel not attached within 1s — defaulting to the last observed identity" + "[pf-gamepad] device_type: sealed channel not attached within 1s — defaulting to the last observed identity" ); } LAST_DEVTYPE.load(Ordering::Relaxed) as u8 diff --git a/packaging/windows/drivers/pf-mouse/pf_mouse.inx b/packaging/windows/drivers/pf-mouse/pf_mouse.inx index 6cf1625e..b1ac6eab 100644 --- a/packaging/windows/drivers/pf-mouse/pf_mouse.inx +++ b/packaging/windows/drivers/pf-mouse/pf_mouse.inx @@ -1,6 +1,6 @@ ;/*++ ; punktfunk virtual HID mouse (absolute pointer) — UMDF2 HID minidriver INF. -; Same skeleton as pf_dualsense.inx (the WDK vhidmini2 UMDF2 shape). +; Same skeleton as pf_gamepad.inx (the WDK vhidmini2 UMDF2 shape). ; Depends on MsHidUmdf.inf (build >= 22000). ; Install: devgen /add /hardwareid "root\pf_mouse" (after pnputil /add-driver /install) ;--*/ diff --git a/packaging/windows/drivers/pf-mouse/src/lib.rs b/packaging/windows/drivers/pf-mouse/src/lib.rs index dfb17656..a429caac 100644 --- a/packaging/windows/drivers/pf-mouse/src/lib.rs +++ b/packaging/windows/drivers/pf-mouse/src/lib.rs @@ -9,7 +9,7 @@ // the report path below is exercised by `punktfunk-host vmouse-spike` (validation) and is the // future higher-fidelity injection route. // -// Structure is pf-dualsense minus the identity zoo: one fixed HID identity (PF:MO, an obviously +// Structure is pf-gamepad minus the identity zoo: one fixed HID identity (PF:MO, an obviously // virtual VID/PID no software matches on), one 8-byte input report (5 buttons + absolute 15-bit // X/Y + wheel + AC-pan), no feature/output reports. The host channel is the **sealed pad channel** // (design/gamepad-channel-sealing.md) verbatim — mailbox `Global\pfmouse-boot-`, unnamed @@ -28,6 +28,7 @@ use core::sync::atomic::{AtomicPtr, AtomicU32, Ordering}; +use pf_driver_proto::gamepad::ChannelProof; use pf_driver_proto::mouse::{ MOUSE_PID, MOUSE_REPORT_ID, MOUSE_REPORT_LEN, MOUSE_VER, MOUSE_VID, MouseShm, }; @@ -173,10 +174,10 @@ fn channel_cfg() -> ChannelConfig { } } -/// Whether the world-writable bring-up file log is enabled (resolved once). OPT-IN — debug builds, -/// or the `PFMOUSE_DEBUG_LOG` (system-wide) env var — the same policy as the pad drivers (audit -/// §4.4): a RELEASE driver never writes the Public file. DebugView can't see the UMDF host across -/// session 0, so the file stays the bring-up diagnostic when enabled. +/// Whether the bring-up file log is enabled (resolved once). OPT-IN — debug builds, or the +/// `PFMOUSE_DEBUG_LOG` (system-wide) env var — the same policy as the pad drivers (audit §4.4): +/// a RELEASE driver never writes it. DebugView can't see the UMDF host across session 0, so the +/// file stays the bring-up diagnostic when enabled. fn file_log_enabled() -> bool { use std::sync::OnceLock; static ON: OnceLock = OnceLock::new(); @@ -193,10 +194,15 @@ fn file_appender() -> Option<&'static std::sync::Mutex> { if !file_log_enabled() { return None; } + // Write to the WUDFHost's own (LocalService) temp dir — NOT world-writable/readable + // `C:\Users\Public`, where any local reader gets the diagnostics and a non-admin can + // pre-create the path as a hard link to redirect this LocalService appender's writes + // (security-review 2026-07-17; pf-xusb/pf-gamepad/pf-vdisplay were moved then, this + // driver was missed — security-review 2026-07-28). Opt-in/debug only. std::fs::OpenOptions::new() .create(true) .append(true) - .open("C:\\Users\\Public\\pfmouse-driver.log") + .open(std::env::temp_dir().join("pfmouse-driver.log")) .ok() .map(std::sync::Mutex::new) }) @@ -325,7 +331,7 @@ extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INI MANUAL_QUEUE.store(manual_queue, Ordering::SeqCst); // Periodic timer (parent = manual queue): sealed-channel pump + health marks + event-driven - // READ_REPORT completion. 8 ms — the proven pf-dualsense cadence; the mouse is presence-first + // READ_REPORT completion. 8 ms — the proven pf-gamepad cadence; the mouse is presence-first // (SendInput injects), so a 125 Hz ceiling on the validation/report path is fine. // SAFETY: zeroed config then fields set. let mut tcfg: WDF_TIMER_CONFIG = unsafe { core::mem::zeroed() }; @@ -397,6 +403,9 @@ extern "C" fn evt_io_device_control( // No output reports are declared; ack a stray write instead of failing the sender. IOCTL_HID_WRITE_REPORT | IOCTL_UMDF_HID_SET_OUTPUT_REPORT => STATUS_SUCCESS, IOCTL_HID_GET_STRING => on_get_string(&request), + // The channel proof (see `pf_umdf_util::hid`): the host asks THIS devnode which process + // serves it, and duplicates the DATA section into the answer — so it never has to trust the + // LocalService-writable bootstrap mailbox to name its target. _ => STATUS_NOT_IMPLEMENTED, }; @@ -406,7 +415,7 @@ extern "C" fn evt_io_device_control( // IOCTL_HID_GET_STRING: the input is a ULONG whose low word is the string id and whose high word // is the language id. Windows polls ids 0x0E/0x0F/0x10 (manufacturer/product/serial) as well as -// the 0/1/2 HID_STRING_ID_* constants — serve both (the pf-dualsense finding). +// the 0/1/2 HID_STRING_ID_* constants — serve both (the pf-gamepad finding). fn on_get_string(request: &Request) -> NTSTATUS { let (bytes, _) = match request.input_bytes(4) { Ok(v) => v, @@ -418,10 +427,15 @@ fn on_get_string(request: &Request) -> NTSTATUS { 0 }; let string_id = id_val & 0xFFFF; - let s: &str = match string_id { - 0 | 0x000E => "punktfunk", - 2 | 0x0010 => "PFMOUSE00", - _ => "punktfunk Virtual Mouse", + let s: String = match string_id { + 0 | 0x000E => "punktfunk".into(), + // (2) The SERIAL carries the channel proof — the one transport measured to reach a UMDF HID + // minidriver from user mode (`HidD_GetSerialNumberString`, zero-access handle, verified on + // .173). Safe HERE and only here: nothing reads the virtual mouse's serial, whereas the pads' + // serials are what SDL and Steam dedup controllers on. The old value was the inert + // "PFMOUSE00"; the proof text is just as inert and does the security work. + 2 | 0x0010 => ChannelProof::new(CHANNEL.index(), std::process::id()).to_hid_string(), + _ => "punktfunk Virtual Mouse".into(), }; let mut wide: Vec = Vec::with_capacity(s.len() * 2 + 2); for u in s.encode_utf16() { diff --git a/packaging/windows/drivers/pf-umdf-util/src/channel.rs b/packaging/windows/drivers/pf-umdf-util/src/channel.rs index 03d9e984..933feb73 100644 --- a/packaging/windows/drivers/pf-umdf-util/src/channel.rs +++ b/packaging/windows/drivers/pf-umdf-util/src/channel.rs @@ -1,7 +1,7 @@ //! The sealed pad channel, driver side (`design/gamepad-channel-sealing.md`, gamepad proto v2): //! poll the named bootstrap mailbox by index, publish our pid (iff the host's proto version //! matches), adopt the host-delivered DATA-section handle, and validate the mapped section's magic -//! and `pad_index` before use. One implementation shared by `pf-xusb` and `pf-dualsense` (they used +//! and `pad_index` before use. One implementation shared by `pf-xusb` and `pf-gamepad` (they used //! to hand-duplicate it), parameterized by [`ChannelConfig`]. //! //! This module **forbids `unsafe`**: the entire state machine is safe Rust over diff --git a/packaging/windows/drivers/pf-umdf-util/src/hid.rs b/packaging/windows/drivers/pf-umdf-util/src/hid.rs new file mode 100644 index 00000000..40d5d650 --- /dev/null +++ b/packaging/windows/drivers/pf-umdf-util/src/hid.rs @@ -0,0 +1,32 @@ +//! What the HID minidrivers could and could not use to answer the **channel proof** +//! (`pf_driver_proto::gamepad::ChannelProof`) — the measured record, so nobody re-derives it. +//! +//! The proof tells the host which process to duplicate a pad's DATA section into. It cannot come +//! from the bootstrap mailbox: that must be openable by LocalService (the driver's own WUDFHost runs +//! as LocalService), so any LocalService principal could name a process of its own and be handed the +//! pad's live input surface (security-review 2026-07-28). Only the driver actually bound to the +//! devnode the host created can answer that devnode's I/O — hence "ask the device stack". +//! +//! ## Measured on .173 (Win11 26200), 2026-07-28 — three attempts, one survivor +//! +//! * ❌ **`IOCTL_HID_GET_INDEXED_STRING`** (`HidD_GetIndexedString`). Fails for EVERY index — 1, 2, +//! 4, 0x0E, 0x5046 — on the same zero-access handle where the NAMED string calls succeed and +//! return the driver's own text. hidclass does not carry an arbitrary indexed-string request to a +//! UMDF HID minidriver at all. +//! * ❌ **A private device interface** (`WdfDeviceCreateDeviceInterface` + a private IOCTL, the shape +//! `pf_xusb` uses). The interface REGISTERS and enumerates fine — +//! `\\?\SWD#punktfunk#pf_mouse_0#{32244793-…}` was present — but `CreateFile` on it is refused: +//! `ERROR_GEN_FAILURE` (31) for access 0 and GENERIC_READ, `ERROR_FILE_NOT_FOUND` (2) for +//! GENERIC_READ|WRITE. hidclass owns `IRP_MJ_CREATE` on a devnode it is the FDO for, so a second +//! interface on that devnode cannot be opened. This is why `pf_xusb` is fine and these two are not: +//! it is NOT a HID minidriver, so nothing sits above it. +//! * ✅ **The serial-number string.** `HidD_GetSerialNumberString` succeeds on a zero-access handle +//! and returns driver-authored text. Verified end to end: `pf_mouse` answered `PFCP:3:0:7296`, and +//! pid 7296 was a genuine service-spawned `WUDFHost.exe`. +//! +//! So `pf_mouse` serves its proof AS its serial (its `PFMOUSE00` was inert). The pads deliberately do +//! NOT: their serials are what SDL and Steam dedup controllers on, and Steam is already known to +//! mangle a pad's displayed name over serial FORMAT alone. `pf_gamepad` therefore has no working +//! device-stack transport on this Windows build — see `ProofTransport::MailboxUnproven` on the host +//! side for the residual that leaves, and option B (a vendor feature report, which costs a +//! report-descriptor change) if it ever needs closing. diff --git a/packaging/windows/drivers/pf-umdf-util/src/lib.rs b/packaging/windows/drivers/pf-umdf-util/src/lib.rs index 2b435355..c5992bd2 100644 --- a/packaging/windows/drivers/pf-umdf-util/src/lib.rs +++ b/packaging/windows/drivers/pf-umdf-util/src/lib.rs @@ -1,5 +1,5 @@ //! The audited unsafe-primitive layer under the punktfunk UMDF gamepad drivers (`pf-xusb`, -//! `pf-dualsense`). +//! `pf-gamepad`). //! //! A UMDF driver cannot be literally free of `unsafe` — WDF dispatch, Win32 section mapping and //! cross-process shared memory are FFI by nature. What Rust *can* buy is confining every raw @@ -15,6 +15,9 @@ //! * [`wdf`] — [`wdf::Request`] + queue/device-property helpers: each framework callback converts //! its raw `WDFREQUEST` into a token exactly once (`unsafe`, with the framework's validity as the //! contract); everything after that is safe. +//! * [`hid`] — the HID minidrivers' **channel proof** answer (also `#[forbid(unsafe_code)]`): how +//! `pf_gamepad`/`pf_mouse` tell the host, over the device stack, which process is serving this +//! devnode. That is what the host trusts instead of the LocalService-writable bootstrap mailbox. //! //! Lint gates (mirrored in every driver crate, enforced by the drivers CI clippy step): //! `unsafe_op_in_unsafe_fn` + `clippy::undocumented_unsafe_blocks` — every remaining `unsafe {}` @@ -24,6 +27,7 @@ #![deny(clippy::undocumented_unsafe_blocks)] pub mod channel; +pub mod hid; pub mod section; pub mod wdf; diff --git a/packaging/windows/drivers/pf-xusb/src/lib.rs b/packaging/windows/drivers/pf-xusb/src/lib.rs index f9fbc410..16aa0c76 100644 --- a/packaging/windows/drivers/pf-xusb/src/lib.rs +++ b/packaging/windows/drivers/pf-xusb/src/lib.rs @@ -58,6 +58,14 @@ const IOCTL_XUSB_GET_XINPUT_MANAGEMENT_DRIVER: u32 = 0x8000_6380; const IOCTL_XUSB_WAIT_FOR_INPUT: u32 = 0x8000_E3AC; const IOCTL_XUSB_GET_INFORMATION_EX: u32 = 0x8000_E3FC; +/// Our own private IOCTL (NOT part of xusb22): answer the host's **channel proof** — which process +/// is serving this devnode — so the host learns its duplication target from the device stack instead +/// of from the LocalService-writable bootstrap mailbox (see `pf_driver_proto::gamepad::ChannelProof` +/// for why that distinction is the whole security property). Any local caller may ask; the answer is +/// a pid and two version numbers, none of them secret, and it is only worth anything to a process +/// that can already duplicate handles into us. +const IOCTL_PF_GET_CHANNEL_PROOF: u32 = pf_driver_proto::gamepad::IOCTL_PF_GET_CHANNEL_PROOF; + // Xbox 360 wired identity (what GET_INFORMATION reports). 0x0103 unblocks SET_STATE (vibration). const XUSB_VID: u16 = 0x045E; const XUSB_PID: u16 = 0x028E; @@ -389,6 +397,14 @@ extern "C" fn evt_io_device_control( } } IOCTL_XUSB_GET_STATE => request.copy_to_output(&build_get_state(data)), + // The channel proof — deliberately answered from THIS process's own identity, never from + // anything a caller supplied, so the only way to make this devnode name a process is to BE + // the driver bound to it. + IOCTL_PF_GET_CHANNEL_PROOF => { + let proof = + pf_driver_proto::gamepad::ChannelProof::new(CHANNEL.index(), std::process::id()); + request.copy_to_output(&proof.to_bytes()) + } IOCTL_XUSB_GET_LED_STATE => request.copy_to_output(&[0x00, 0x00, 0x06]), IOCTL_XUSB_GET_BATTERY_INFORMATION => request.copy_to_output(&[0x00, 0x01, 0x03, 0x00]), IOCTL_XUSB_SET_STATE => on_set_state(&request, data), diff --git a/packaging/windows/pack-host-installer.ps1 b/packaging/windows/pack-host-installer.ps1 index c2fed5c4..ae31b884 100644 --- a/packaging/windows/pack-host-installer.ps1 +++ b/packaging/windows/pack-host-installer.ps1 @@ -183,7 +183,7 @@ if (-not $NoDriver) { else { Write-Host "-NoDriver: building installer WITHOUT the bundled pf-vdisplay driver" } # --- build (from source) + stage the punktfunk virtual-gamepad UMDF drivers -------------------- -# pf-dualsense (DualSense / DualShock 4) + pf-xusb (Xbox 360 / XInput) are members of the same drivers +# pf-gamepad (DualSense / DS4 / Edge / Deck) + pf-xusb (Xbox 360 / XInput) are members of the same drivers # workspace as pf-vdisplay, built from source per release (build-gamepad-drivers.ps1) - same anti-stale # reasoning as pf-vdisplay; the prior checked-in binaries under gamepad-drivers/ are retired. The # installer adds each to the store via `punktfunk-host.exe driver install --gamepad` (the host diff --git a/scripts/git-hooks/pre-push b/scripts/git-hooks/pre-push index cfa87958..bb5005be 100755 --- a/scripts/git-hooks/pre-push +++ b/scripts/git-hooks/pre-push @@ -29,9 +29,9 @@ fi # 2. UMDF driver workspace — mirrors windows-drivers.yml's gate (the shipped drivers + the # audited safe layer; pf-vdisplay is deliberately NOT gated there, so not here either). if ! (cd "$root/packaging/windows/drivers" \ - && cargo fmt -p pf-umdf-util -p pf-xusb -p pf-dualsense --check >/dev/null 2>&1); then + && cargo fmt -p pf-umdf-util -p pf-xusb -p pf-gamepad -p pf-mouse --check >/dev/null 2>&1); then fail "driver workspace is not rustfmt-clean (windows-drivers.yml would fail)" \ - "(cd packaging/windows/drivers && cargo fmt -p pf-umdf-util -p pf-xusb -p pf-dualsense)" + "(cd packaging/windows/drivers && cargo fmt -p pf-umdf-util -p pf-xusb -p pf-gamepad -p pf-mouse)" fi exit 0