From 16e06793f38c35fa1b06bbd57657e46973f90525 Mon Sep 17 00:00:00 2001 From: vibesoftwarecoder Date: Sat, 24 Oct 2026 03:25:05 -0500 Subject: [PATCH 1/2] Keep USB forwarding off for seats and add an opt-in driver install step Seats keep USB forwarding off. Each seat's generated config now says so explicitly, port offset +7 is reserved for ApolloVibe's USB tunnel, and the seat firewall rules never open it. Tests prove no seat's +7 collides with any other seat port, including netplay, or with the host Apollo's own ports. Adds an opt-in step to the prerequisites installer, -WithUsbForwarding, that installs the usbip-win2 driver the host needs for USB forwarding. It is never part of a default or unattended run, and it never reboots. The installer is pinned to one release, checked against a SHA-256, and its signature and signer are verified before it runs. It is staged in a folder only SYSTEM and Administrators can write, and held open from the check until it starts, so it cannot be swapped in between. After the install, the step checks that the installed kernel drivers chain to the Microsoft Root Certificate Authority 2010 with a hardware-driver certificate; if they do not, it stops, says plainly that the driver is already installed, how to remove it, and not to reboot. It refuses to run when test signing or disabled integrity checks are on, or when that cannot be determined. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 31 + prerequisites/README.txt | 5 + prerequisites/install-prerequisites.ps1 | 47 +- prerequisites/install-usbip-driver.ps1 | 28 + prerequisites/lib/usbip-driver-lib.ps1 | 542 ++++++++++++ .../Streaming/ApolloConfigBuilder.cs | 11 + src/MultiSeat.Shared/Constants.cs | 9 +- .../Scripts/UsbipDriverScriptTests.cs | 783 ++++++++++++++++++ .../Streaming/StreamingTests.cs | 1 + .../Streaming/UsbForwardingSeatSafetyTests.cs | 134 +++ 10 files changed, 1589 insertions(+), 2 deletions(-) create mode 100644 prerequisites/install-usbip-driver.ps1 create mode 100644 prerequisites/lib/usbip-driver-lib.ps1 create mode 100644 src/MultiSeat.Tests/Scripts/UsbipDriverScriptTests.cs create mode 100644 src/MultiSeat.Tests/Streaming/UsbForwardingSeatSafetyTests.cs diff --git a/README.md b/README.md index b233f3f..cac3c08 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,37 @@ This script automatically downloads and installs everything: | .NET 9 SDK | Required only to **build from source**. Not needed with `-FromZip`, which installs a self-contained release asset. | | Node.js LTS | Required only to **build the dashboard from source**. Not needed with `-FromZip`, which ships it prebuilt. | +#### Optional: USB forwarding driver (opt-in, off by default) + +ApolloVibe can forward a USB device from a Moonlight client to the host. The host then needs the +[usbip-win2](https://github.com/vadimgrn/usbip-win2) USB/IP kernel driver. It is **never** installed by a +default or unattended run. You ask for it: + +```powershell +.\prerequisites\install-prerequisites.ps1 -WithUsbForwarding +.\prerequisites\install-usbip-driver.ps1 -WhatIf # print the steps, change nothing +``` + +- **Pinned and checked.** One release (`v.0.9.8.1`), downloaded over HTTPS, verified against a fixed SHA-256 and + its Authenticode signature before it runs, then installed silently with `/NORESTART`. +- **Microsoft-signed drivers only.** After the install the script checks every installed `usbip2` kernel driver + file. If any is not signed by "Microsoft Windows Hardware Compatibility Publisher", it stops, enables nothing + and exits with code 2. Each file must be signed by "Microsoft Windows Hardware Compatibility Publisher", chain to + Microsoft Root Certificate Authority 2010 and carry a hardware-driver usage. **Exit code 2 means the driver package + is already on the machine and will load at the next boot: do not reboot until you have removed it.** The script + prints the removal steps but never runs them, and no reboot prompt is shown. It refuses to run while Windows + test-signing mode or disabled integrity checks are on, or if that state cannot be read, and it never turns them on. +- **Private staging.** The installer is downloaded into a new folder under `%ProgramData%` that only SYSTEM and + Administrators can open, and the verified file stays locked while the installer runs. +- **A reboot is needed** before the driver works. The script never reboots the machine; that is your decision. +- **Seats keep USB forwarding off.** MultiSeat writes `usb_forwarding_enabled = disabled` into every seat's + Apollo config, because a forwarded device is machine-wide, not per session. Only the console ApolloVibe can + use it, and only when you enable it there. +- **Port reservation.** ApolloVibe's USB tunnel listens on its main port + 7 (TCP). That offset is reserved in + every seat's port block and checked against all other ports, but MultiSeat does not open it in the firewall. + That check assumes the default console Apollo port (47989) and `PortBase` (48100). If you override either in + config, re-check that the console Apollo's ports (its main port -5 to +26) stay outside the seat blocks. + It also enables Remote Desktop and opens the required firewall ports automatically. > **Reboot** when prompted — HidHide and RDPWrap require it before the service will work. diff --git a/prerequisites/README.txt b/prerequisites/README.txt index 9903e27..df2953f 100644 --- a/prerequisites/README.txt +++ b/prerequisites/README.txt @@ -26,3 +26,8 @@ Notes: To use offline/local files instead of downloading, place the installer files in this folder before running the script — it will use them as-is. + +Optional, off by default: + install-prerequisites.ps1 -WithUsbForwarding - installs the pinned usbip-win2 USB/IP driver (host side of + USB forwarding). Only if its kernel drivers are Microsoft-signed; otherwise it stops. Never reboots for you. + Preview with: install-usbip-driver.ps1 -WhatIf diff --git a/prerequisites/install-prerequisites.ps1 b/prerequisites/install-prerequisites.ps1 index 68802de..fac7666 100644 --- a/prerequisites/install-prerequisites.ps1 +++ b/prerequisites/install-prerequisites.ps1 @@ -10,6 +10,14 @@ Suppress the reboot prompt at the end. .PARAMETER SkipDownload Skip downloading missing files; only install what is already present. +.PARAMETER WithUsbForwarding + OPT-IN, off by default, never part of an unattended run. Also installs the pinned usbip-win2 + USB/IP kernel driver that the HOST needs for USB forwarding (see install-usbip-driver.ps1). + Used only while the driver is Microsoft-signed; otherwise it stops and exits non-zero. The driver + needs a reboot, which this script never performs for it - that is your decision. +.PARAMETER UsbForwardingDryRun + With -WithUsbForwarding: print the USB driver steps and do none of them. To dry-run ONLY that + step, run install-usbip-driver.ps1 -WhatIf directly. .PARAMETER Seats Number of seats to provision audio cables for. Defaults to 4. Each seat needs one VB-CABLE device. Only meaningful under SharedHost audio. @@ -30,6 +38,8 @@ param( [switch]$SkipReboot, [switch]$SkipDownload, + [switch]$WithUsbForwarding, + [switch]$UsbForwardingDryRun, [int]$Seats = 4, [ValidateSet('PerSession', 'SharedHost')] [string]$AudioMode @@ -60,6 +70,8 @@ if (-not [Environment]::Is64BitProcess -and [Environment]::Is64BitOperatingSyste $Fwd = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $PSCommandPath) if ($SkipReboot) { $Fwd += '-SkipReboot' } if ($SkipDownload) { $Fwd += '-SkipDownload' } + if ($WithUsbForwarding) { $Fwd += '-WithUsbForwarding' } + if ($UsbForwardingDryRun) { $Fwd += '-UsbForwardingDryRun' } if ($AudioMode) { $Fwd += @('-AudioMode', $AudioMode) } $Fwd += @('-Seats', $Seats) & $NativeHost @Fwd @@ -1473,6 +1485,29 @@ if (-not (Get-NetFirewallRule -DisplayName "MultiSeat Streaming" -ErrorAction Si Write-OK "Firewall: streaming ports already open" } +# ---------------------------------------------------------------- +# 10. USB forwarding driver (usbip-win2) -- OPT-IN ONLY +# ---------------------------------------------------------------- +# Runs only with -WithUsbForwarding. Never part of a default or unattended run. It does not set +# $NeedsReboot and never reboots: the driver needs one, and that is the user's decision. +$UsbipExit = 0 +if ($UsbForwardingDryRun -and -not $WithUsbForwarding) { + Write-Host "`n NOTE: -UsbForwardingDryRun does nothing without -WithUsbForwarding; the USB forwarding step was not run." -ForegroundColor Yellow +} +if ($WithUsbForwarding) { + Write-Step "USB forwarding driver (usbip-win2, opt-in)" + . (Join-Path $ScriptDir 'lib\usbip-driver-lib.ps1') + $usbip = Invoke-UsbipDriverInstall -DryRun:$UsbForwardingDryRun + $UsbipExit = $usbip.ExitCode + if ($UsbipExit -eq 0 -and $usbip.Status -eq 'Installed') { + $Installed += "usbip-win2 driver (REBOOT needed before it works; your decision)" + } elseif ($UsbipExit -eq 2) { + $Skipped += "usbip-win2 driver package is INSTALLED but NOT VERIFIED Microsoft-signed - do not reboot until it is removed (steps printed above)" + } elseif ($UsbipExit -ne 0) { + $Skipped += "usbip-win2 driver NOT installed or NOT trusted ($($usbip.Status)); see the messages above" + } +} + # ---------------------------------------------------------------- # Summary # ---------------------------------------------------------------- @@ -1490,7 +1525,16 @@ if ($Skipped.Count -gt 0) { $Skipped | ForEach-Object { Write-Host " - $_" -ForegroundColor Yellow } } -if ($NeedsReboot -and -not $SkipReboot) { +if ($UsbipExit -eq 2) { + # The USB forwarding driver package is on this machine and is NOT verified Microsoft-signed. + # Never offer a reboot here, even when another step needs one: it would load that driver. + Write-Host "`n DO NOT REBOOT yet." -ForegroundColor Red + Write-Host " The USB forwarding driver installed above is not verified as Microsoft-signed and will load at the next boot." -ForegroundColor Red + Write-Host " Remove it first (the removal steps are printed above), then reboot. No reboot prompt is shown." -ForegroundColor Red + if ($NeedsReboot) { + Write-Host " (Another step also needs a reboot; that waits until the driver is dealt with.)" -ForegroundColor Magenta + } +} elseif ($NeedsReboot -and -not $SkipReboot) { Write-Host "`n A REBOOT is required before continuing." -ForegroundColor Magenta # Read-Host THROWS under -NonInteractive, which failed the whole script at the very last step # even when every install before it succeeded — so a scripted or CI run reported failure for a @@ -1512,3 +1556,4 @@ Write-Host " Log written to: $LogFile" -ForegroundColor DarkGray Write-Host "" Stop-Transcript | Out-Null +if ($UsbipExit -ne 0) { exit $UsbipExit } diff --git a/prerequisites/install-usbip-driver.ps1 b/prerequisites/install-usbip-driver.ps1 new file mode 100644 index 0000000..16271dc --- /dev/null +++ b/prerequisites/install-usbip-driver.ps1 @@ -0,0 +1,28 @@ +<# +.SYNOPSIS + OPT-IN install of the pinned usbip-win2 USB/IP kernel driver (needed on the HOST for USB forwarding). +.DESCRIPTION + Not part of any default or unattended run. install-prerequisites.ps1 calls this only when you pass + -WithUsbForwarding. + + Pinned release: vadimgrn/usbip-win2 v.0.9.8.1, asset USBip-0.9.8.1-x64.exe, SHA-256 checked. + + RULE: usbip-win2 is used ONLY while its kernel drivers are signed by Microsoft. The script checks the + installer's hash and signature before running it, then checks the installed drivers. If any driver is + not Microsoft-signed it stops, enables nothing and exits 2. It never turns on test-signing mode and + refuses to run while test-signing is on. + + The script NEVER restarts the machine. The driver needs a reboot before it works; that is your call. +.PARAMETER WhatIf + Print every step and do nothing: no download, no install, no system query. +.NOTES + Exit codes: 0 ok (installed or already present), 1 refused or failed, 2 driver not Microsoft-signed. +#> +[CmdletBinding(SupportsShouldProcess = $true)] +param() + +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'lib\usbip-driver-lib.ps1') + +$result = Invoke-UsbipDriverInstall -DryRun:([bool]$WhatIfPreference) +exit $result.ExitCode diff --git a/prerequisites/lib/usbip-driver-lib.ps1 b/prerequisites/lib/usbip-driver-lib.ps1 new file mode 100644 index 0000000..ae1198c --- /dev/null +++ b/prerequisites/lib/usbip-driver-lib.ps1 @@ -0,0 +1,542 @@ +# usbip-driver-lib.ps1 - the logic behind prerequisites\install-usbip-driver.ps1. +# +# Dot-source this file; it defines functions and runs nothing. It is separate from the +# wrapper so tests can load it and replace every side effect (download, signature check, +# installer, driver enumeration, boot-state reads) with a fake. +# +# USER RULE (stated 2026-10-23): use usbip-win2 ONLY while its kernel drivers are signed by +# Microsoft. If they are not, STOP. The fallback is that we build and sign our own driver. +# Test-signing mode is never an option on the streaming host. Nothing in this file tries to +# work around a failed signature check, and nothing in it restarts the machine. +# +# Keep this file PowerShell 5.1 compatible (no ?. ?? or ternary) and ASCII only. + +$script:UsbipPin = @{ + Version = '0.9.8.1' + Tag = 'v.0.9.8.1' + Asset = 'USBip-0.9.8.1-x64.exe' + Url = 'https://github.com/vadimgrn/usbip-win2/releases/download/v.0.9.8.1/USBip-0.9.8.1-x64.exe' + Sha256 = '38CAD6D4432B52D5BB9409D9AD03B72FDFFC4ADA4CD3A48FBECA1A2752A8518A' + # Verified 2026-10-23: Authenticode Valid, issuer GlobalSign GCC R45 EV CodeSigning CA 2020. + # Compared with the certificate's SimpleName (X509Certificate2.GetNameInfo), never a parsed subject string. + InstallerSigner = 'Cloudyne Systems (Scheibling Consulting AB)' + # The kernel drivers (usbip2_ude.sys, usbip2_filter.sys) and their catalogs. + DriverSigner = 'Microsoft Windows Hardware Compatibility Publisher' + # Driver signatures must also chain to Microsoft Root Certificate Authority 2010 (thumbprint read from + # the real chain of a Microsoft-signed driver on a Windows 11 host, 2026-10-24) ... + DriverRootThumbprint = '3B1EFD3A66EA28B16697394703A72CA340A05BD5' + # ... and the leaf must carry a Windows hardware driver EKU: 10.3.5 WHQL, 10.3.5.1 attested, 10.3.39 extended verification. + DriverEkus = @('1.3.6.1.4.1.311.10.3.5', '1.3.6.1.4.1.311.10.3.5.1', '1.3.6.1.4.1.311.10.3.39') +} + +# Inno Setup flags. The installer has AlwaysRestart=yes; /NORESTART is what keeps it from +# rebooting the machine, so it must always be here. /LOG is appended per run. +$script:UsbipInstallerArgs = @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART') + +# Exit codes the wrapper returns. +# 0 = installed or already present, drivers verified Microsoft-signed +# 1 = refused or failed before/while installing (hash, signature, test-signing, ...) +# 2 = the user rule fired: an installed kernel driver is NOT verified Microsoft-signed +$script:UsbipExitOk = 0 +$script:UsbipExitRefused = 1 +$script:UsbipExitRule = 2 + +# --------------------------------------------------------------------------------------- +# Certificates. Never parse a subject string: an attacker controls what is inside a quoted +# RDN value. Use the certificate object. +# --------------------------------------------------------------------------------------- + +function Get-UsbipCertFacts { + param([System.Security.Cryptography.X509Certificates.X509Certificate2]$Cert) + $f = @{ SimpleName = $null; Thumbprint = $null; Ekus = @(); RootThumbprint = $null; ChainOk = $false; ChainStatus = @() } + if (-not $Cert) { return $f } + $f.SimpleName = $Cert.GetNameInfo([System.Security.Cryptography.X509Certificates.X509NameType]::SimpleName, $false) + $f.Thumbprint = $Cert.Thumbprint + $ekus = @() + foreach ($ext in $Cert.Extensions) { + if ($ext -is [System.Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension]) { + foreach ($oid in $ext.EnhancedKeyUsages) { $ekus += $oid.Value } + } + } + $f.Ekus = $ekus + + # Revocation: ONLINE, whole chain. An unreachable revocation server (OfflineRevocation / + # RevocationStatusUnknown) is tolerated so an offline host can still install, but a certificate + # that IS reported revoked fails. NotTimeValid is tolerated because driver certificates are short + # lived and Windows has already validated the signature against its timestamp (Status = Valid); + # the root pin below is what carries the trust decision here. Anything else (UntrustedRoot, + # PartialChain, NotSignatureValid, ...) fails. + $chain = New-Object System.Security.Cryptography.X509Certificates.X509Chain + try { + $chain.ChainPolicy.RevocationMode = [System.Security.Cryptography.X509Certificates.X509RevocationMode]::Online + $chain.ChainPolicy.RevocationFlag = [System.Security.Cryptography.X509Certificates.X509RevocationFlag]::EntireChain + $chain.ChainPolicy.UrlRetrievalTimeout = [TimeSpan]::FromSeconds(10) + $null = $chain.Build($Cert) + $allowed = @('NoError', 'NotTimeValid', 'OfflineRevocation', 'RevocationStatusUnknown') + $statuses = @($chain.ChainStatus | ForEach-Object { [string]$_.Status }) + $f.ChainStatus = $statuses + $bad = @($statuses | Where-Object { $allowed -notcontains $_ }) + $n = $chain.ChainElements.Count + if ($n -gt 0) { $f.RootThumbprint = $chain.ChainElements[$n - 1].Certificate.Thumbprint } + $f.ChainOk = ($bad.Count -eq 0 -and $n -ge 2) + } + catch { $f.ChainOk = $false } + finally { $chain.Reset() } + return $f +} + +function Get-UsbipSignatureFacts { + param([string]$Path) + $s = Get-AuthenticodeSignature -LiteralPath $Path + $facts = Get-UsbipCertFacts $s.SignerCertificate + $facts.Status = [string]$s.Status + return $facts +} + +function Test-UsbipSignedBy { + # $true only for status Valid + the exact signer name. Kind 'Driver' also requires the pinned + # Microsoft root and a hardware-driver EKU. + param([string]$Path, [hashtable]$Pin, [string]$Kind, [hashtable]$Seams, [ref]$Why) + $facts = & $Seams.GetSignature $Path + if (-not $facts -or $facts.Status -ne 'Valid') { + $st = 'no signature info' + if ($facts) { $st = $facts.Status } + $Why.Value = "signature status is '$st', not Valid" + return $false + } + $expected = $Pin.InstallerSigner + if ($Kind -eq 'Driver') { $expected = $Pin.DriverSigner } + if (-not $facts.SimpleName -or ([string]$facts.SimpleName) -cne $expected) { + $Why.Value = "signer is '$($facts.SimpleName)', expected exactly '$expected'" + return $false + } + if ($Kind -eq 'Driver') { + if (-not $facts.ChainOk) { + $Why.Value = "certificate chain is not acceptable ($(@($facts.ChainStatus) -join ','))" + return $false + } + if (([string]$facts.RootThumbprint).ToUpperInvariant() -ne $Pin.DriverRootThumbprint.ToUpperInvariant()) { + $Why.Value = "chain root is '$($facts.RootThumbprint)', not Microsoft Root Certificate Authority 2010" + return $false + } + $has = $false + foreach ($e in @($facts.Ekus)) { if ($Pin.DriverEkus -contains $e) { $has = $true } } + if (-not $has) { + $Why.Value = 'certificate has no Windows hardware driver EKU' + return $false + } + } + return $true +} + +# --------------------------------------------------------------------------------------- +# Staging directory: protected DACL, created BEFORE any file goes into it. +# --------------------------------------------------------------------------------------- + +function Get-UsbipDirSecurity { + param([string]$Path) + $di = New-Object System.IO.DirectoryInfo $Path + $t = [Type]::GetType('System.IO.FileSystemAclExtensions, System.IO.FileSystem.AccessControl') + if ($t) { return [System.IO.FileSystemAclExtensions]::GetAccessControl($di) } + return $di.GetAccessControl() +} + +function Set-UsbipDirSecurity { + param([string]$Path, $Security) + $di = New-Object System.IO.DirectoryInfo $Path + $t = [Type]::GetType('System.IO.FileSystemAclExtensions, System.IO.FileSystem.AccessControl') + if ($t) { [System.IO.FileSystemAclExtensions]::SetAccessControl($di, $Security) } + else { $di.SetAccessControl($Security) } +} + +function Get-UsbipStagingAclProblems { + # Empty list = the directory is only reachable by SYSTEM and Administrators. + param([string]$Path) + $problems = @() + $sec = Get-UsbipDirSecurity $Path + if (-not $sec.AreAccessRulesProtected) { $problems += 'the DACL still inherits from its parent' } + $okSids = @('S-1-5-18', 'S-1-5-32-544') + foreach ($r in $sec.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])) { + if ($r.AccessControlType -eq 'Allow' -and $okSids -notcontains $r.IdentityReference.Value) { + $problems += "allow entry for $($r.IdentityReference.Value)" + } + } + return $problems +} + +function Get-UsbipStagingRoot { + # Machine-wide, never the user profile or %TEMP% (both are writable by the user's own non-elevated processes). + return $env:ProgramData +} + +function New-UsbipStagingDir { + # A new, unpredictably named directory directly under a machine-wide location (ProgramData by + # default, never the user profile or %TEMP%), restricted to SYSTEM and Administrators with + # inheritance removed, THEN verified empty and verified protected, before the caller creates any file. + param([string]$Root) + if (-not $Root) { $Root = Get-UsbipStagingRoot } + if (-not $Root) { throw 'no machine-wide staging root (ProgramData) is available' } + $dir = Join-Path $Root ('MultiSeat-usbip-' + [guid]::NewGuid().ToString('N')) + if (Test-Path -LiteralPath $dir) { throw 'staging directory already exists' } + $null = New-Item -ItemType Directory -Path $dir + $sec = New-Object System.Security.AccessControl.DirectorySecurity + $sec.SetAccessRuleProtection($true, $false) + $inherit = [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit' + foreach ($sid in @('S-1-5-18', 'S-1-5-32-544')) { + $id = New-Object System.Security.Principal.SecurityIdentifier $sid + $rule = New-Object System.Security.AccessControl.FileSystemAccessRule($id, 'FullControl', $inherit, 'None', 'Allow') + $sec.AddAccessRule($rule) + } + Set-UsbipDirSecurity $dir $sec + $problems = @(Get-UsbipStagingAclProblems $dir) + if ($problems.Count -gt 0) { throw ('staging directory is not private: ' + ($problems -join '; ')) } + return $dir +} + +function Open-UsbipHeldFile { + # One handle that stays open from the hash check until the installer has finished: readers are + # allowed, writers and deleters are not, so the verified bytes cannot be swapped underneath us. + param([string]$Path) + return [System.IO.File]::Open($Path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::Read) +} + +function Get-UsbipStreamSha256 { + param([System.IO.Stream]$Stream) + $null = $Stream.Seek(0, [System.IO.SeekOrigin]::Begin) + $sha = [System.Security.Cryptography.SHA256]::Create() + try { return [System.BitConverter]::ToString($sha.ComputeHash($Stream)).Replace('-', '') } + finally { $sha.Dispose() } +} + +# --------------------------------------------------------------------------------------- +# Boot state: refuse when test-signing or integrity checks are off, or when we cannot tell. +# --------------------------------------------------------------------------------------- + +function Get-UsbipBootVerdict { + param([hashtable]$Seams) + $opts = $null + $bcd = $null + try { $opts = & $Seams.ReadStartOptions } catch { $opts = $null } + try { $bcd = & $Seams.ReadBcd } catch { $bcd = $null } + if ($null -eq $opts -and $null -eq $bcd) { + return @{ Refuse = $true; Reason = 'cannot determine the boot signing state (neither the running boot options nor the BCD could be read)' } + } + # The running boot's options, language independent. + if ($null -ne $opts -and ([string]$opts) -match '(?i)(^|[\s,])(TESTSIGNING|DISABLE_INTEGRITY_CHECKS|NOINTEGRITYCHECKS)(?=$|[\s,=])') { + return @{ Refuse = $true; Reason = "Windows is running with $($Matches[2]) (test-signing / integrity checks off)" } + } + # The stored (next boot) configuration. Element names are not localized, values are, so anything + # that is not clearly "no" counts as on. + if ($bcd) { + foreach ($m in [regex]::Matches([string]$bcd, '(?im)^\s*(testsigning|nointegritychecks)\s+(\S+)')) { + if ($m.Groups[2].Value -notmatch '^(?i:no|off|false|0)$') { + return @{ Refuse = $true; Reason = "the boot configuration sets $($m.Groups[1].Value) = $($m.Groups[2].Value)" } + } + } + } + return @{ Refuse = $false; Reason = $null } +} + +# --------------------------------------------------------------------------------------- +# Side effects, all replaceable. +# --------------------------------------------------------------------------------------- + +function New-UsbipDefaultSeams { + @{ + IsAdmin = { + $id = [Security.Principal.WindowsIdentity]::GetCurrent() + (New-Object Security.Principal.WindowsPrincipal($id)).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator) + } + # The running boot's options. Present in every language. $null = could not read. + ReadStartOptions = { + try { + $v = Get-ItemProperty -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control' -Name 'SystemStartOptions' -ErrorAction Stop + return [string]$v.SystemStartOptions + } catch { return $null } + } + # The stored boot configuration (next boot). $null = could not read. + ReadBcd = { + $out = & bcdedit.exe /enum '{current}' 2>&1 | Out-String + if ($LASTEXITCODE -ne 0 -or -not $out.Trim()) { return $null } + return $out + } + # Returns @{ Version = ; DriverFiles = }. Two sources, unioned: + # DISM (Get-WindowsDriver) and the driver store folders themselves. Throws only when DISM + # failed AND the store showed nothing. + GetInstalledState = { + $version = $null + $root = $env:ProgramW6432 + if (-not $root) { $root = $env:ProgramFiles } + $exe = Join-Path $root 'USBip\usbip.exe' + if (Test-Path -LiteralPath $exe) { + $version = (Get-Item -LiteralPath $exe).VersionInfo.FileVersion + } + $dirs = @() + $dismError = $null + try { + $pkgs = @(Get-WindowsDriver -Online -All -ErrorAction Stop | + Where-Object { $_.OriginalFileName -match '(?i)usbip2' }) + foreach ($p in $pkgs) { $dirs += (Split-Path -Parent $p.OriginalFileName) } + } catch { $dismError = $_.Exception.Message } + $repo = Join-Path $env:windir 'System32\DriverStore\FileRepository' + if (Test-Path -LiteralPath $repo) { + $dirs += @(Get-ChildItem -LiteralPath $repo -Directory -Filter 'usbip2*' -ErrorAction SilentlyContinue | + ForEach-Object { $_.FullName }) + } + $files = @() + foreach ($d in @($dirs | Select-Object -Unique)) { + $files += @(Get-ChildItem -LiteralPath $d -File -ErrorAction SilentlyContinue | + Where-Object { $_.Extension -in '.sys', '.cat' } | ForEach-Object { $_.FullName }) + } + $files = @($files | Select-Object -Unique) + if ($files.Count -eq 0 -and $dismError) { throw "driver enumeration failed: $dismError" } + return @{ Version = $version; DriverFiles = $files } + } + Download = { + param($Url, $Dest) + try { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 } catch { } + $ProgressPreference = 'SilentlyContinue' + # Certificate validation is left at the platform default: never disabled. + Invoke-WebRequest -Uri $Url -OutFile $Dest -UseBasicParsing + } + # Returns facts: Status, SimpleName, Ekus, RootThumbprint, ChainOk, ChainStatus. + GetSignature = { + param($Path) + return (Get-UsbipSignatureFacts $Path) + } + # Returns the installer's exit code, or $null when it did not finish within the timeout + # (it is then left running, not killed: killing an installer half way is worse). + RunInstaller = { + param($Path, $ArgList, $TimeoutSec) + $p = Start-Process -FilePath $Path -ArgumentList $ArgList -PassThru + if (-not $p.WaitForExit([int]($TimeoutSec * 1000))) { return $null } + return $p.ExitCode + } + NewStagingDir = { return (New-UsbipStagingDir) } + } +} + +function Test-UsbipInstalledDrivers { + # Fail closed: no drivers found, any exception, or any file not verified counts as a failure. + param([hashtable]$Pin, [hashtable]$Seams) + $bad = @() + $count = 0 + try { + $state = & $Seams.GetInstalledState + $files = @() + if ($state -and $state.DriverFiles) { $files = @($state.DriverFiles) } + $count = $files.Count + if ($files.Count -eq 0) { + $bad += 'no installed usbip2 driver files were found, so nothing could be verified' + } + foreach ($f in $files) { + $why = '' + if (-not (Test-UsbipSignedBy $f $Pin 'Driver' $Seams ([ref]$why))) { + $bad += ('{0}: {1}' -f (Split-Path -Leaf $f), $why) + } + } + } + catch { + $bad += "the driver check itself failed: $($_.Exception.Message)" + } + return @{ Ok = ($bad.Count -eq 0); Problems = $bad; Count = $count } +} + +function Invoke-UsbipDriverInstall { + <# + .SYNOPSIS + Opt-in install of the pinned usbip-win2 release. Returns a result object; never exits and + never reboots. -DryRun prints every step and performs none of them. + #> + param( + [hashtable]$Pin = $script:UsbipPin, + [hashtable]$Seams, + [switch]$DryRun, + [int]$InstallerTimeoutSec = 600, + [scriptblock]$Say = { param($m, $c) Write-Host $m -ForegroundColor $c } + ) + + $defaults = New-UsbipDefaultSeams + if (-not $Seams) { $Seams = @{} } + foreach ($k in @($defaults.Keys)) { if (-not $Seams.ContainsKey($k)) { $Seams[$k] = $defaults[$k] } } + + $log = New-Object System.Collections.Generic.List[string] + $emit = { + param($m, $c) + if (-not $c) { $c = 'White' } + $log.Add($m) + & $Say $m $c + } + $done = { + param($code, $status) + [pscustomobject]@{ ExitCode = $code; Status = $status; Messages = $log.ToArray(); DoNotReboot = ($code -eq 2) } + } + # The driver package is on the machine (or may be) and cannot be trusted. Say so plainly. + $unverified = { + param($problems, $justInstalled) + & $emit ' ##################################################################' 'Red' + & $emit ' STOP: the usbip-win2 kernel driver is NOT verified as Microsoft-signed.' 'Red' + foreach ($p in $problems) { & $emit " - $p" 'Red' } + if ($justInstalled) { + & $emit ' The driver package IS ALREADY INSTALLED on this machine (a virtual host controller' 'Red' + & $emit ' and a USB hub upper filter). It will LOAD at the next boot or restart, including' 'Red' + & $emit ' an automatic Windows Update restart.' 'Red' + } else { + & $emit ' A usbip-win2 driver package is on this machine and will load at the next boot.' 'Red' + } + & $emit ' Do NOT reboot until it is removed or verified. Nothing was enabled by this script.' 'Red' + & $emit ' Your rule: use usbip-win2 only while its drivers are Microsoft-signed; otherwise we' 'Red' + & $emit ' build and sign our own. This script changed nothing to make the check pass and did' 'Red' + & $emit ' not touch test-signing mode.' 'Red' + & $emit ' To remove the driver (these commands are NOT run by this script):' 'Yellow' + & $emit ' 1. Run the uninstaller: "%ProgramW6432%\USBip\unins000.exe" (or Settings > Apps > USBip)' 'Yellow' + & $emit ' 2. Or by hand, in an elevated prompt: pnputil /enum-drivers' 'Yellow' + & $emit ' find the entry whose Original Name starts with usbip2; its Published Name is oemNN.inf; then' 'Yellow' + & $emit ' pnputil /delete-driver oemNN.inf /uninstall /force' 'Yellow' + & $emit ' ##################################################################' 'Red' + } + + & $emit "[MultiSeat] USB forwarding driver: usbip-win2 $($Pin.Version) (opt-in, Microsoft-signed drivers only)" 'Cyan' + + if ($DryRun) { + & $emit ' DRY RUN: nothing below is executed.' 'Yellow' + & $emit ' 1. Refuse if Windows is in test-signing mode or has integrity checks off (running boot options and BCD), or if that cannot be determined.' 'Yellow' + & $emit ' 2. Skip if usbip-win2 is already installed at the pinned version (drivers are still checked).' 'Yellow' + & $emit ' 3. Create a private staging folder (SYSTEM and Administrators only) under ProgramData.' 'Yellow' + & $emit " 4. Download $($Pin.Url) into it over HTTPS and keep the file locked against changes." 'Yellow' + & $emit " 5. Require SHA-256 $($Pin.Sha256); delete the file and stop on a mismatch." 'Yellow' + & $emit " 6. Require a Valid Authenticode signature with signer name exactly '$($Pin.InstallerSigner)'." 'Yellow' + & $emit (' 7. Run the installer silently: ' + ($script:UsbipInstallerArgs -join ' ') + ' (it cannot restart the machine; ' + $InstallerTimeoutSec + ' s time limit).') 'Yellow' + & $emit " 8. Require every installed usbip2 kernel driver file to be signed by '$($Pin.DriverSigner)', chaining to Microsoft Root Certificate Authority 2010, with a hardware-driver EKU." 'Yellow' + & $emit ' If one is not: stop, enable nothing, say the package is installed and must not be rebooted into, print removal steps, exit non-zero.' 'Yellow' + & $emit ' 9. Tell you a REBOOT is needed. The reboot is your decision; this script never reboots.' 'Yellow' + return (& $done $script:UsbipExitOk 'DryRun') + } + + if (-not (& $Seams.IsAdmin)) { + & $emit ' REFUSED: this step needs an elevated (Administrator) shell.' 'Red' + return (& $done $script:UsbipExitRefused 'NotAdmin') + } + + # Step 1: test-signing / integrity checks. Unknown = stop. + $verdict = Get-UsbipBootVerdict $Seams + if ($verdict.Refuse) { + & $emit " REFUSED: $($verdict.Reason)." 'Red' + & $emit ' usbip-win2 is used here only with Microsoft-signed drivers. Test-signing mode weakens the' 'Red' + & $emit ' streaming host and is not an accepted workaround. Turn it off yourself and reboot, or use our own signed driver.' 'Red' + return (& $done $script:UsbipExitRefused 'TestSigning') + } + + # Step 2: already installed? + $state = $null + try { $state = & $Seams.GetInstalledState } catch { + & $emit " Could not read the installed driver state: $($_.Exception.Message)" 'Yellow' + } + $installedVersion = $null + if ($state) { $installedVersion = $state.Version } + $atPin = $false + if ($installedVersion) { + try { $atPin = ([version]([string]$installedVersion -replace '[^\d\.].*$', '') -eq [version]$Pin.Version) } catch { $atPin = $false } + } + if ($atPin -and $state.DriverFiles -and @($state.DriverFiles).Count -gt 0) { + & $emit " SKIP: usbip-win2 $installedVersion is already installed (pinned version). Checking its drivers." 'Yellow' + $chk = Test-UsbipInstalledDrivers $Pin $Seams + if (-not $chk.Ok) { + & $unverified $chk.Problems $false + return (& $done $script:UsbipExitRule 'InstalledDriverNotMicrosoftSigned') + } + & $emit " OK: $($chk.Count) driver file(s) are signed by '$($Pin.DriverSigner)' (Microsoft root, hardware-driver EKU)." 'Green' + return (& $done $script:UsbipExitOk 'AlreadyInstalled') + } + if ($installedVersion) { + & $emit " Found usbip-win2 $installedVersion; the pinned version is $($Pin.Version). Proceeding with the pinned installer." 'Yellow' + } + + if ($Pin.Url -notlike 'https://*') { + & $emit ' REFUSED: the pinned URL is not HTTPS.' 'Red' + return (& $done $script:UsbipExitRefused 'NotHttps') + } + + $stage = $null + $held = $null + try { + # Steps 3-6: private staging folder, download, lock, hash, signature on the SAME locked file. + try { $stage = & $Seams.NewStagingDir } catch { + & $emit " REFUSED: could not create a private staging folder: $($_.Exception.Message)" 'Red' + return (& $done $script:UsbipExitRefused 'StagingFailed') + } + $file = Join-Path $stage $Pin.Asset + & $emit " Downloading $($Pin.Asset) ..." 'White' + try { & $Seams.Download $Pin.Url $file } catch { + & $emit " REFUSED: download failed: $($_.Exception.Message)" 'Red' + return (& $done $script:UsbipExitRefused 'DownloadFailed') + } + if (-not (Test-Path -LiteralPath $file)) { + & $emit ' REFUSED: the download produced no file.' 'Red' + return (& $done $script:UsbipExitRefused 'DownloadFailed') + } + try { $held = Open-UsbipHeldFile $file } catch { + & $emit " REFUSED: could not lock the downloaded file: $($_.Exception.Message)" 'Red' + return (& $done $script:UsbipExitRefused 'CannotLockFile') + } + + $actual = Get-UsbipStreamSha256 $held + if ($actual -ne $Pin.Sha256.ToUpperInvariant()) { + $held.Dispose(); $held = $null + Remove-Item -LiteralPath $file -Force -ErrorAction SilentlyContinue + & $emit " REFUSED: SHA-256 mismatch. Expected $($Pin.Sha256), got $actual. The file was deleted." 'Red' + return (& $done $script:UsbipExitRefused 'HashMismatch') + } + & $emit ' OK: SHA-256 matches the pin.' 'Green' + + $why = '' + if (-not (Test-UsbipSignedBy $file $Pin 'Installer' $Seams ([ref]$why))) { + $held.Dispose(); $held = $null + Remove-Item -LiteralPath $file -Force -ErrorAction SilentlyContinue + & $emit " REFUSED: installer signature check failed: $why. The file was deleted." 'Red' + return (& $done $script:UsbipExitRefused 'InstallerSignature') + } + & $emit " OK: installer is signed by '$($Pin.InstallerSigner)'." 'Green' + + # Step 7: install, still holding the verified file. /NORESTART always. + $setupLog = Join-Path $stage 'usbip-setup.log' + $installerArgs = @($script:UsbipInstallerArgs) + ('/LOG="' + $setupLog + '"') + & $emit (' Running the installer silently (' + ($script:UsbipInstallerArgs -join ' ') + ', ' + $InstallerTimeoutSec + ' s limit) ...') 'White' + $code = & $Seams.RunInstaller $file $installerArgs $InstallerTimeoutSec + if ($null -eq $code) { + & $emit " FAILED: the installer did not finish within $InstallerTimeoutSec s. It was left running, not killed. Check it before doing anything else." 'Red' + return (& $done $script:UsbipExitRefused 'InstallerTimeout') + } + if ($code -eq 7 -or $code -eq 8) { + $extra = '' + if ($code -eq 8) { $extra = ' and a restart is needed first to clear the cause (your decision; this script will not restart anything)' } + & $emit " FAILED: the installer could not proceed (exit code $code)$extra. Nothing was installed by it. Re-run this step afterwards." 'Red' + return (& $done $script:UsbipExitRefused 'InstallerCannotProceed') + } + if ($code -ne 0) { + & $emit " FAILED: the installer exited with code $code. If it ran at all, the driver may be partly installed: check Settings > Apps > USBip and the setup log." 'Red' + return (& $done $script:UsbipExitRefused 'InstallerFailed') + } + } + finally { + if ($held) { try { $held.Dispose() } catch { } } + if ($stage -and (Test-Path -LiteralPath $stage)) { + Remove-Item -LiteralPath $stage -Recurse -Force -ErrorAction SilentlyContinue + } + } + + # Step 8: the user rule, on what actually got installed. Any failure here is exit 2. + $chk = Test-UsbipInstalledDrivers $Pin $Seams + if (-not $chk.Ok) { + & $unverified $chk.Problems $true + return (& $done $script:UsbipExitRule 'InstalledDriverNotMicrosoftSigned') + } + & $emit " OK: $($chk.Count) installed driver file(s) are signed by '$($Pin.DriverSigner)' (Microsoft root, hardware-driver EKU)." 'Green' + + # Step 9: the reboot is the user's decision. + & $emit ' A REBOOT is required before the USB/IP driver works.' 'Magenta' + & $emit ' Rebooting is your decision. This script does not and will not restart the machine.' 'Magenta' + & $emit ' Seats keep USB forwarding off; only the console ApolloVibe can use it, and only once you enable it there.' 'Cyan' + return (& $done $script:UsbipExitOk 'Installed') +} diff --git a/src/MultiSeat.Service/Streaming/ApolloConfigBuilder.cs b/src/MultiSeat.Service/Streaming/ApolloConfigBuilder.cs index a40603c..d9e69d2 100644 --- a/src/MultiSeat.Service/Streaming/ApolloConfigBuilder.cs +++ b/src/MultiSeat.Service/Streaming/ApolloConfigBuilder.cs @@ -282,6 +282,17 @@ public string BuildConfig(SeatInfo seat, string configDir) sb.AppendLine("keyboard = enabled"); sb.AppendLine(); + // ── USB forwarding: always OFF for a seat ───────────────────── + // ApolloVibe's USB tunnel (main port + 7) is off by default, but a default can change. + // An imported USB device is a machine-wide PnP device, not a per-session one, so a seat + // must never open a tunnel. Written explicitly, with the spelling the rest of this file + // uses ("disabled"; ApolloVibe's to_bool reads anything not true/yes/enable(d)/on/containing + // '1' as false). usb_forwarding_port is deliberately NOT written: with the feature off it is + // never bound, and main + 7 stays reserved (Constants.OffsetUsbForwarding), not opened. + sb.AppendLine("# USB forwarding stays off in every seat (machine-wide devices; see docs)"); + sb.AppendLine("usb_forwarding_enabled = disabled"); + sb.AppendLine(); + // ── Security ────────────────────────────────────────────────── // file_state must be an absolute path — Apollo resolves relative paths against // {exe_dir}/config/, which is shared by all instances. Each seat needs its own diff --git a/src/MultiSeat.Shared/Constants.cs b/src/MultiSeat.Shared/Constants.cs index 73ddce8..adf781b 100644 --- a/src/MultiSeat.Shared/Constants.cs +++ b/src/MultiSeat.Shared/Constants.cs @@ -16,7 +16,8 @@ public static class Constants // MultiSeat:PortBase in appsettings.json. public const int PortBase = 48100; // A seat's port offsets span -5 (GFE HTTPS) to +26 (RTSP) — a 32-position range — but only - // 8 of those positions are actually used: {-5,0,1,9,10,11,12,26}. PortsPerSeat=30 is + // 10 of those positions are reserved or used: {-5,0,1,7,9,10,11,12,13,26} (7 = USB tunnel, + // reserved and closed; 13 = netplay). PortsPerSeat=30 is // intentionally smaller than the raw 32-span: at 30-port spacing none of the *used* offsets // collide between seats, so blocks stay non-overlapping in practice. Verified by // StreamingTests.Constants_PortsPerSeat_NoUsedPortCollision — re-check it if the used-offset @@ -46,6 +47,12 @@ public static class Constants // each seat gets a unique, collision-free netplay port (seat 0 = 48113, seat 1 = 48143, …). public const int OffsetRetroArchNetplay = 13; // RetroArch netplay host port (TCP) + // Reserved for ApolloVibe's USB tunnel (main port + 7, TCP). Seats keep USB forwarding OFF + // (ApolloConfigBuilder writes it disabled), so nothing listens here and it is NOT opened in + // the firewall (FirewallManager.TcpPorts never includes it). It is in the used-offset set so + // the collision tests prove it can never land on another seat's port if that changes. + public const int OffsetUsbForwarding = 7; + // There were two aliases here, OffsetHttps and OffsetHttp, and both said the opposite of what // they pointed at: OffsetHttps was the GFE *HTTP* port (0) and OffsetHttp was the web UI, which // is *HTTPS* (1). Removed rather than corrected - an alias whose only job is to be a second diff --git a/src/MultiSeat.Tests/Scripts/UsbipDriverScriptTests.cs b/src/MultiSeat.Tests/Scripts/UsbipDriverScriptTests.cs new file mode 100644 index 0000000..0608620 --- /dev/null +++ b/src/MultiSeat.Tests/Scripts/UsbipDriverScriptTests.cs @@ -0,0 +1,783 @@ +using System.Diagnostics; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using Xunit; + +namespace MultiSeat.Tests.Scripts; + +/// +/// The opt-in usbip-win2 driver step (prerequisites\lib\usbip-driver-lib.ps1). USER RULE: the driver +/// is used only while its kernel drivers are Microsoft-signed; otherwise stop. These run the real +/// library in real PowerShell processes (Windows PowerShell 5.1, and 7 when installed) with every side +/// effect (download, signature, installer, driver listing, bcdedit, admin check) replaced by a fake. +/// NOTHING here downloads the real asset or runs an installer, and none of it needs elevation. +/// +public class UsbipDriverScriptTests +{ + private static readonly string RepoRoot = FindRepoRoot(); + private static readonly string Prereq = Path.Combine(RepoRoot, "prerequisites"); + private static readonly string Lib = Path.Combine(Prereq, "lib", "usbip-driver-lib.ps1"); + private static readonly string Wrapper = Path.Combine(Prereq, "install-usbip-driver.ps1"); + private static readonly string Main = Path.Combine(Prereq, "install-prerequisites.ps1"); + + private const string InstallerName = "USBip-0.9.8.1-x64.exe"; + private const string MsSigner = "Microsoft Windows Hardware Compatibility Publisher"; + + private static string FindRepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null && !File.Exists(Path.Combine(dir.FullName, "scripts", "install-service.ps1"))) + dir = dir.Parent; + return dir?.FullName ?? throw new InvalidOperationException("repo root not found"); + } + + public static IEnumerable Engines() + { + yield return new object[] { "powershell.exe" }; + var pwsh = FindPwsh(); + if (pwsh != null) yield return new object[] { pwsh }; + } + + private static string? FindPwsh() + { + foreach (var p in (Environment.GetEnvironmentVariable("PATH") ?? "").Split(Path.PathSeparator)) + { + try + { + var f = Path.Combine(p.Trim('"'), "pwsh.exe"); + if (File.Exists(f)) return f; + } + catch (ArgumentException) { } + } + return null; + } + + private sealed record PsResult(int ExitCode, string Stdout, string Stderr, JsonElement? Json); + + private static string Q(string s) => "'" + s.Replace("'", "''") + "'"; + + private static PsResult RunScript(string engine, string script) + { + var psi = new ProcessStartInfo(engine) + { + Arguments = "-NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand " + + Convert.ToBase64String(Encoding.Unicode.GetBytes(script)), + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + using var p = Process.Start(psi)!; + var err = p.StandardError.ReadToEndAsync(); + var outp = p.StandardOutput.ReadToEndAsync(); + Assert.True(p.WaitForExit(120_000), "PowerShell did not finish in 120 s"); + var stdout = outp.Result; + var line = stdout.Split('\n').Select(l => l.TrimEnd('\r')).LastOrDefault(l => l.StartsWith("@@JSON@@")); + JsonElement? json = line == null ? null : JsonDocument.Parse(line.Substring(8)).RootElement.Clone(); + return new PsResult(p.ExitCode, stdout, err.Result, json); + } + + private sealed class Scenario : IDisposable + { + public string Root { get; } = Directory.CreateTempSubdirectory("ms-usbip-test-").FullName; + public string Payload { get; } + public string PayloadHash { get; } + public Scenario() + { + Payload = Path.Combine(Root, "fake-installer.bin"); + File.WriteAllBytes(Payload, Encoding.ASCII.GetBytes("this is a test file, not an installer")); + PayloadHash = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(Payload))); + } + public void Dispose() + { + try { Directory.Delete(Root, true); } catch (IOException) { } catch (UnauthorizedAccessException) { } + } + } + + private const string MsRoot = "3B1EFD3A66EA28B16697394703A72CA340A05BD5"; + + // Fake world. $overrides runs after the defaults and may change $pin, $sigs, $drivers, $seams, $st. + private static PsResult Run(string engine, Scenario s, string overrides = "", bool dryRun = false) + { + var script = @" +$ErrorActionPreference = 'Stop' +. " + Q(Lib) + @" +$calls = New-Object System.Collections.ArrayList +$st = @{ Installed = $false; Downloaded = $null; Stage = $null } +$pin = @{ + Version='0.9.8.1'; Tag='v.0.9.8.1'; Asset='" + InstallerName + @"' + Url='https://example.invalid/" + InstallerName + @"' + Sha256='" + s.PayloadHash + @"' + InstallerSigner='Test Installer Signer'; DriverSigner='" + MsSigner + @"' + DriverRootThumbprint='" + MsRoot + @"'; DriverEkus=@('1.3.6.1.4.1.311.10.3.5') +} +$good = @{ Status='Valid'; SimpleName='" + MsSigner + @"'; RootThumbprint='" + MsRoot + @"'; ChainOk=$true; ChainStatus=@(); Ekus=@('1.3.6.1.4.1.311.10.3.5','1.3.6.1.5.5.7.3.3') } +$sigs = @{ + '" + InstallerName + @"' = @{ Status='Valid'; SimpleName='Test Installer Signer'; RootThumbprint='AA'; ChainOk=$true; ChainStatus=@(); Ekus=@() } + 'usbip2_ude.sys' = $good; 'usbip2_filter.sys' = $good; 'usbip2_ude.cat' = $good; 'usbip2_filter.cat' = $good +} +$drivers = @('X:\fake\usbip2_ude.sys','X:\fake\usbip2_filter.sys','X:\fake\usbip2_ude.cat','X:\fake\usbip2_filter.cat') +function Try-Swap($p, $tag) { + try { [IO.File]::WriteAllText($p, 'evil'); $calls.Add(""swap-write:${tag}:swapped"") | Out-Null } catch { $calls.Add(""swap-write:${tag}:blocked"") | Out-Null } + try { [IO.File]::Delete($p); $calls.Add(""swap-delete:${tag}:swapped"") | Out-Null } catch { $calls.Add(""swap-delete:${tag}:blocked"") | Out-Null } +} +$seams = @{ + IsAdmin = { $true } + ReadStartOptions = { ' NOEXECUTE=OPTIN HYPERVISORLAUNCHTYPE=AUTO' } + ReadBcd = { $calls.Add('bcdedit') | Out-Null; ""testsigning No`r`n"" } + GetInstalledState = { + $calls.Add('state') | Out-Null + if ($st.Installed) { @{ Version='0.9.8.1'; DriverFiles=$drivers } } else { @{ Version=$null; DriverFiles=@() } } + } + NewStagingDir = { $d = Join-Path ([IO.Path]::GetTempPath()) ('ms-usbip-stage-' + [guid]::NewGuid().ToString('N')); New-Item -ItemType Directory -Path $d | Out-Null; $st.Stage = $d; $d } + Download = { param($Url,$Dest) $calls.Add('download') | Out-Null; $st.Downloaded = $Dest; Copy-Item -LiteralPath " + Q(s.Payload) + @" -Destination $Dest } + GetSignature = { param($Path) $calls.Add('sig') | Out-Null; $k = Split-Path -Leaf $Path; if ($k -eq '" + InstallerName + @"') { Try-Swap $Path 'sig' }; if ($sigs.ContainsKey($k)) { $sigs[$k] } else { @{ Status='NotSigned'; SimpleName=$null } } } + RunInstaller = { param($Path,$ArgList,$TimeoutSec) $calls.Add('install:' + ($ArgList -join ' ')) | Out-Null; Try-Swap $Path 'install'; $st.Installed = $true; 0 } +} +" + overrides + @" +$say = { param($m,$c) } +$res = Invoke-UsbipDriverInstall -Pin $pin -Seams $seams " + (dryRun ? "-DryRun " : "") + @"-Say $say +$leftover = $false +if ($st.Downloaded) { $leftover = (Test-Path -LiteralPath $st.Downloaded) } +if ($st.Stage) { $leftover = $leftover -or (Test-Path -LiteralPath $st.Stage) } +$r = @{ ExitCode = $res.ExitCode; Status = $res.Status; DoNotReboot = $res.DoNotReboot; Messages = @($res.Messages); Calls = @($calls); Leftover = $leftover } +Write-Output ('@@JSON@@' + (ConvertTo-Json -InputObject $r -Compress -Depth 5)) +"; + return RunScript(engine, script); + } + + private static JsonElement J(PsResult r) + { + Assert.True(r.Json.HasValue, $"no JSON. exit={r.ExitCode}\nstdout:\n{r.Stdout}\nstderr:\n{r.Stderr}"); + return r.Json!.Value; + } + + private static string[] Calls(JsonElement j) => j.GetProperty("Calls").EnumerateArray().Select(x => x.GetString()!).ToArray(); + private static string Msgs(JsonElement j) => string.Join("\n", j.GetProperty("Messages").EnumerateArray().Select(x => x.GetString())); + + // -- Dry run and the default path ------------------------------------------------ + + [Theory, MemberData(nameof(Engines))] + public void DryRun_PrintsEveryStep_AndTouchesNothing(string engine) + { + using var s = new Scenario(); + // Every seam throws: a dry run that called any of them would fail the script. + var boom = string.Join("\n", new[] { "IsAdmin", "ReadStartOptions", "ReadBcd", "GetInstalledState", "Download", "GetSignature", "RunInstaller", "NewStagingDir" } + .Select(k => $"$seams['{k}'] = {{ throw 'dry run touched {k}' }}")); + var j = J(Run(engine, s, boom, dryRun: true)); + Assert.Equal(0, j.GetProperty("ExitCode").GetInt32()); + Assert.Equal("DryRun", j.GetProperty("Status").GetString()); + Assert.Empty(Calls(j)); + var m = Msgs(j); + foreach (var needle in new[] { "DRY RUN", "test-signing", "private staging folder", "SHA-256", "Authenticode", "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART", "REBOOT", "never reboots" }) + Assert.Contains(needle, m); + } + + [Theory, MemberData(nameof(Engines))] + public void Wrapper_WhatIf_PrintsStepsAndExitsZero_WithoutElevation(string engine) + { + var r = RunScript(engine, "& " + Q(Wrapper) + " -WhatIf; Write-Output ('EXIT=' + $LASTEXITCODE)"); + Assert.Contains("DRY RUN", r.Stdout); + Assert.Contains("EXIT=0", r.Stdout); + Assert.DoesNotContain("Downloading", r.Stdout); + } + + [Theory, MemberData(nameof(Engines))] + public void DefaultRun_NeverReachesTheUsbStep(string engine) + { + // install-prerequisites.ps1 needs admin and installs real things, so it cannot be run here. + // Prove it structurally instead: the switch is a plain [switch] with no default, and every + // command that mentions usbip sits inside `if ($WithUsbForwarding)`. + var script = @" +$errs = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile(" + Q(Main) + @", [ref]$null, [ref]$errs) +$p = $ast.ParamBlock.Parameters | Where-Object { $_.Name.VariablePath.UserPath -eq 'WithUsbForwarding' } +$isSwitch = [bool]($p -and $p.StaticType -eq [switch] -and $null -eq $p.DefaultValue) +$cmds = $ast.FindAll({ param($a) $a -is [System.Management.Automation.Language.CommandAst] -and $a.Extent.Text -match '(?i)usbip' }, $true) +$unguarded = @() +foreach ($c in $cmds) { + $guarded = $false + $n = $c.Parent + while ($n) { + if ($n -is [System.Management.Automation.Language.IfStatementAst]) { + foreach ($cl in $n.Clauses) { if ($cl.Item1.Extent.Text.Trim() -eq '$WithUsbForwarding') { $guarded = $true } } + } + $n = $n.Parent + } + if (-not $guarded) { $unguarded += $c.Extent.Text } +} +$r = @{ IsSwitch = $isSwitch; Count = @($cmds).Count; Unguarded = @($unguarded) } +Write-Output ('@@JSON@@' + (ConvertTo-Json -InputObject $r -Compress)) +"; + var j = J(RunScript(engine, script)); + Assert.True(j.GetProperty("IsSwitch").GetBoolean(), "-WithUsbForwarding must be a [switch] with no default"); + Assert.True(j.GetProperty("Count").GetInt32() >= 2, "expected the dot-source and the call to be found"); + Assert.Empty(j.GetProperty("Unguarded").EnumerateArray()); + } + + // -- The guarded steps ----------------------------------------------------------- + + [Theory, MemberData(nameof(Engines))] + public void HappyPath_InstallsSilentlyWithNoRestart_ThenVerifiesDrivers_AndSaysRebootIsYours(string engine) + { + using var s = new Scenario(); + var j = J(Run(engine, s)); + Assert.Equal(0, j.GetProperty("ExitCode").GetInt32()); + Assert.Equal("Installed", j.GetProperty("Status").GetString()); + Assert.False(j.GetProperty("DoNotReboot").GetBoolean()); + var calls = Calls(j); + Assert.Contains(calls, c => c.StartsWith("install:/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /LOG=")); + Assert.True(Array.IndexOf(calls, "bcdedit") < Array.IndexOf(calls, "download")); + Assert.True(Array.IndexOf(calls, "download") < Array.FindIndex(calls, c => c.StartsWith("install:"))); + var m = Msgs(j); + Assert.Contains("REBOOT is required", m); + Assert.Contains("your decision", m); + Assert.False(j.GetProperty("Leftover").GetBoolean(), "the staging folder must be cleaned up"); + } + + [Theory, MemberData(nameof(Engines))] + public void VerifiedFile_CannotBeSwappedWhileVerifyingOrInstalling(string engine) + { + // H1: the file is held open (read share only) from the hash check until the installer returns. + // The fakes try to overwrite and delete it during the signature check and during the install. + using var s = new Scenario(); + var j = J(Run(engine, s)); + var swaps = Calls(j).Where(c => c.StartsWith("swap-")).ToArray(); + Assert.Equal(4, swaps.Length); + Assert.All(swaps, c => Assert.EndsWith(":blocked", c)); + } + + [Theory, MemberData(nameof(Engines))] + public void WrongHash_IsRefused_AndTheFileIsRemoved(string engine) + { + using var s = new Scenario(); + var j = J(Run(engine, s, "$pin.Sha256 = '" + new string('0', 64) + "'")); + Assert.Equal(1, j.GetProperty("ExitCode").GetInt32()); + Assert.Equal("HashMismatch", j.GetProperty("Status").GetString()); + Assert.DoesNotContain(Calls(j), c => c.StartsWith("install:")); + Assert.False(j.GetProperty("Leftover").GetBoolean()); + } + + [Theory, MemberData(nameof(Engines))] + public void UnsignedInstaller_IsRefused(string engine) + { + using var s = new Scenario(); + var j = J(Run(engine, s, "$sigs['" + InstallerName + "'] = @{ Status='NotSigned'; SimpleName=$null }")); + Assert.Equal(1, j.GetProperty("ExitCode").GetInt32()); + Assert.Equal("InstallerSignature", j.GetProperty("Status").GetString()); + Assert.DoesNotContain(Calls(j), c => c.StartsWith("install:")); + Assert.False(j.GetProperty("Leftover").GetBoolean()); + } + + [Theory, MemberData(nameof(Engines))] + public void TamperedSignature_StatusHashMismatch_IsRefused(string engine) + { + using var s = new Scenario(); + var j = J(Run(engine, s, "$sigs['" + InstallerName + "'] = @{ Status='HashMismatch'; SimpleName='Test Installer Signer' }")); + Assert.Equal("InstallerSignature", j.GetProperty("Status").GetString()); + Assert.DoesNotContain(Calls(j), c => c.StartsWith("install:")); + } + + [Theory, MemberData(nameof(Engines))] + public void WrongInstallerSigner_IsRefused(string engine) + { + using var s = new Scenario(); + // A name that merely CONTAINS the right one, and a different case, are both wrong. + foreach (var bad in new[] { "Someone Else", "Test Installer Signer Ltd", "test installer signer" }) + { + var j = J(Run(engine, s, "$sigs['" + InstallerName + "'] = @{ Status='Valid'; SimpleName='" + bad + "' }")); + Assert.Equal(1, j.GetProperty("ExitCode").GetInt32()); + Assert.Equal("InstallerSignature", j.GetProperty("Status").GetString()); + Assert.DoesNotContain(Calls(j), c => c.StartsWith("install:")); + } + } + + // -- Test-signing / integrity checks (M2) ----------------------------------------- + + [Theory, MemberData(nameof(Engines))] + public void TestSigning_InRunningBootOptions_IsRefused_BeforeAnyDownload(string engine) + { + using var s = new Scenario(); + foreach (var opt in new[] { " NOEXECUTE=OPTIN TESTSIGNING", " DISABLE_INTEGRITY_CHECKS", "NOINTEGRITYCHECKS" }) + { + var j = J(Run(engine, s, "$seams['ReadStartOptions'] = { '" + opt + "' }")); + Assert.Equal(1, j.GetProperty("ExitCode").GetInt32()); + Assert.Equal("TestSigning", j.GetProperty("Status").GetString()); + var calls = Calls(j); + Assert.DoesNotContain("download", calls); + Assert.DoesNotContain(calls, c => c.StartsWith("install:")); + } + } + + [Theory, MemberData(nameof(Engines))] + public void TestSigning_InStoredBcd_IsRefused_EvenWhenTheRunningBootLooksClean(string engine) + { + using var s = new Scenario(); + foreach (var bcd in new[] { "testsigning Yes", "nointegritychecks Yes", "testsigning Ja" }) + { + var j = J(Run(engine, s, "$seams['ReadBcd'] = { '" + bcd + "' }")); + Assert.Equal("TestSigning", j.GetProperty("Status").GetString()); + Assert.DoesNotContain("download", Calls(j)); + } + } + + [Theory, MemberData(nameof(Engines))] + public void BootState_Unknown_IsRefused(string engine) + { + using var s = new Scenario(); + // Neither source readable -> stop. Either an empty answer or an exception counts as unreadable. + var j = J(Run(engine, s, "$seams['ReadStartOptions'] = { $null }; $seams['ReadBcd'] = { throw 'no bcdedit' }")); + Assert.Equal(1, j.GetProperty("ExitCode").GetInt32()); + Assert.Equal("TestSigning", j.GetProperty("Status").GetString()); + Assert.Contains("cannot determine", Msgs(j)); + Assert.DoesNotContain("download", Calls(j)); + } + + [Theory, MemberData(nameof(Engines))] + public void BootState_OneSourceReadable_IsEnough(string engine) + { + using var s = new Scenario(); + // bcdedit missing/localized garbage, but the running boot options (language independent) are clean. + var j = J(Run(engine, s, "$seams['ReadBcd'] = { $null }")); + Assert.Equal(0, j.GetProperty("ExitCode").GetInt32()); + } + + [Theory, MemberData(nameof(Engines))] + public void RealRegistryRead_WorksWithoutElevation(string engine) + { + var script = ". " + Q(Lib) + @" +$exists = [bool](Get-ItemProperty -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control' -Name SystemStartOptions -ErrorAction SilentlyContinue) +$ro = (New-UsbipDefaultSeams).ReadStartOptions +$v = & $ro +$verdict = Get-UsbipBootVerdict @{ ReadStartOptions = $ro; ReadBcd = { $null } } +$r = @{ Exists = $exists; IsString = ($v -is [string]); Refuse = $verdict.Refuse; Reason = $verdict.Reason } +Write-Output ('@@JSON@@' + (ConvertTo-Json -InputObject $r -Compress))"; + var j = J(RunScript(engine, script)); + if (!j.GetProperty("Exists").GetBoolean()) return; // a host without the value: nothing to prove + Assert.True(j.GetProperty("IsString").GetBoolean()); + // This host is not in test-signing mode (the user's rule), so the real read must say "not refused". + Assert.False(j.GetProperty("Refuse").GetBoolean(), j.GetProperty("Reason").GetString()); + } + + [Theory, MemberData(nameof(Engines))] + public void NotElevated_IsRefused(string engine) + { + using var s = new Scenario(); + var j = J(Run(engine, s, "$seams['IsAdmin'] = { $false }")); + Assert.Equal(1, j.GetProperty("ExitCode").GetInt32()); + Assert.Empty(Calls(j)); + } + + // -- Installer outcomes (L1) ------------------------------------------------------- + + [Theory, MemberData(nameof(Engines))] + public void InstallerFailure_IsReported_NotClaimedAsSuccess(string engine) + { + using var s = new Scenario(); + var j = J(Run(engine, s, "$seams['RunInstaller'] = { param($P,$A,$T) $calls.Add('install:x') | Out-Null; 5 }")); + Assert.Equal(1, j.GetProperty("ExitCode").GetInt32()); + Assert.Equal("InstallerFailed", j.GetProperty("Status").GetString()); + Assert.DoesNotContain("REBOOT is required", Msgs(j)); + } + + [Theory, MemberData(nameof(Engines))] + public void InstallerTimeout_IsReported_AndNothingIsClaimed(string engine) + { + using var s = new Scenario(); + var j = J(Run(engine, s, "$seams['RunInstaller'] = { param($P,$A,$T) $calls.Add('install:timeout=' + $T) | Out-Null; $null }")); + Assert.Equal(1, j.GetProperty("ExitCode").GetInt32()); + Assert.Equal("InstallerTimeout", j.GetProperty("Status").GetString()); + Assert.Contains("install:timeout=600", Calls(j)); + Assert.Contains("not killed", Msgs(j)); + } + + [Theory, MemberData(nameof(Engines))] + public void InnoExitCode8_ExplainsARestartIsNeededFirst_WithoutRestarting(string engine) + { + using var s = new Scenario(); + var j = J(Run(engine, s, "$seams['RunInstaller'] = { param($P,$A,$T) 8 }")); + Assert.Equal(1, j.GetProperty("ExitCode").GetInt32()); + Assert.Equal("InstallerCannotProceed", j.GetProperty("Status").GetString()); + Assert.Contains("restart is needed first", Msgs(j)); + Assert.Contains("will not restart anything", Msgs(j)); + } + + // -- The user rule: installed drivers must be Microsoft-signed ---------------------- + + [Theory, MemberData(nameof(Engines))] + public void InstalledDriver_NotMicrosoftSigned_StopsWithExit2_AndSaysItIsInstalled(string engine) + { + using var s = new Scenario(); + var j = J(Run(engine, s, "$sigs['usbip2_filter.sys'] = @{ Status='Valid'; SimpleName='Some Test Cert'; RootThumbprint='" + MsRoot + "'; ChainOk=$true; Ekus=@('1.3.6.1.4.1.311.10.3.5') }")); + Assert.Equal(2, j.GetProperty("ExitCode").GetInt32()); + Assert.Equal("InstalledDriverNotMicrosoftSigned", j.GetProperty("Status").GetString()); + Assert.True(j.GetProperty("DoNotReboot").GetBoolean()); + var m = Msgs(j); + Assert.Contains("STOP", m); + Assert.Contains("usbip2_filter.sys", m); + Assert.Contains("ALREADY INSTALLED", m); + Assert.Contains("LOAD at the next boot", m); + Assert.Contains("Do NOT reboot until it is removed or verified", m); + Assert.Contains("pnputil /delete-driver oemNN.inf /uninstall /force", m); + Assert.Contains("unins000.exe", m); + Assert.Contains("NOT run by this script", m); + Assert.DoesNotContain("REBOOT is required", m); + // Printed, never executed: the only calls are the normal ones, no removal. + Assert.DoesNotContain(Calls(j), c => c.Contains("delete-driver") || c.Contains("unins")); + } + + [Theory, MemberData(nameof(Engines))] + public void InstalledDriver_Unsigned_StopsWithExit2(string engine) + { + using var s = new Scenario(); + var j = J(Run(engine, s, "$sigs['usbip2_ude.sys'] = @{ Status='NotSigned'; SimpleName=$null }")); + Assert.Equal(2, j.GetProperty("ExitCode").GetInt32()); + } + + [Theory, MemberData(nameof(Engines))] + public void InstalledDriver_RightNameButWrongRoot_IsRejected(string engine) + { + using var s = new Scenario(); + var j = J(Run(engine, s, "$sigs['usbip2_ude.sys'] = @{ Status='Valid'; SimpleName='" + MsSigner + "'; RootThumbprint='" + new string('A', 40) + "'; ChainOk=$true; Ekus=@('1.3.6.1.4.1.311.10.3.5') }")); + Assert.Equal(2, j.GetProperty("ExitCode").GetInt32()); + Assert.Contains("not Microsoft Root Certificate Authority 2010", Msgs(j)); + } + + [Theory, MemberData(nameof(Engines))] + public void InstalledDriver_ChainNotOk_Or_NoHardwareEku_IsRejected(string engine) + { + using var s = new Scenario(); + var a = J(Run(engine, s, "$sigs['usbip2_ude.sys'] = @{ Status='Valid'; SimpleName='" + MsSigner + "'; RootThumbprint='" + MsRoot + "'; ChainOk=$false; ChainStatus=@('Revoked'); Ekus=@('1.3.6.1.4.1.311.10.3.5') }")); + Assert.Equal(2, a.GetProperty("ExitCode").GetInt32()); + Assert.Contains("chain is not acceptable", Msgs(a)); + var b = J(Run(engine, s, "$sigs['usbip2_ude.sys'] = @{ Status='Valid'; SimpleName='" + MsSigner + "'; RootThumbprint='" + MsRoot + "'; ChainOk=$true; Ekus=@('1.3.6.1.5.5.7.3.3') }")); + Assert.Equal(2, b.GetProperty("ExitCode").GetInt32()); + Assert.Contains("no Windows hardware driver EKU", Msgs(b)); + } + + [Theory, MemberData(nameof(Engines))] + public void NoDriverFilesFound_FailsClosed(string engine) + { + using var s = new Scenario(); + var j = J(Run(engine, s, "$drivers = @()")); + Assert.Equal(2, j.GetProperty("ExitCode").GetInt32()); + Assert.Contains("nothing could be verified", Msgs(j)); + } + + [Theory, MemberData(nameof(Engines))] + public void DriverEnumerationThrowing_AfterInstall_IsExit2_WithTheInstalledNotice(string engine) + { + // M3: a DISM failure after the installer ran must not become a bare exit 1 with no warning. + using var s = new Scenario(); + var j = J(Run(engine, s, @"$seams['GetInstalledState'] = { if ($st.Installed) { throw 'DISM exploded' } else { @{ Version=$null; DriverFiles=@() } } }")); + Assert.Equal(2, j.GetProperty("ExitCode").GetInt32()); + Assert.Equal("InstalledDriverNotMicrosoftSigned", j.GetProperty("Status").GetString()); + Assert.True(j.GetProperty("DoNotReboot").GetBoolean()); + var m = Msgs(j); + Assert.Contains("DISM exploded", m); + Assert.Contains("ALREADY INSTALLED", m); + Assert.Contains("Do NOT reboot", m); + Assert.Contains("pnputil /delete-driver", m); + } + + [Theory, MemberData(nameof(Engines))] + public void AlreadyInstalledAtPin_Skips_ButStillChecksTheDrivers(string engine) + { + using var s = new Scenario(); + var j = J(Run(engine, s, "$st.Installed = $true")); + Assert.Equal(0, j.GetProperty("ExitCode").GetInt32()); + Assert.Equal("AlreadyInstalled", j.GetProperty("Status").GetString()); + Assert.DoesNotContain("download", Calls(j)); + Assert.Contains("already installed", Msgs(j)); + } + + [Theory, MemberData(nameof(Engines))] + public void AlreadyInstalled_WithNonMicrosoftDriver_StopsWithExit2(string engine) + { + using var s = new Scenario(); + var j = J(Run(engine, s, "$st.Installed = $true; $sigs['usbip2_ude.cat'] = @{ Status='Valid'; SimpleName='Test' }")); + Assert.Equal(2, j.GetProperty("ExitCode").GetInt32()); + Assert.DoesNotContain("download", Calls(j)); + } + + // -- H2: the reboot prompt must not appear when the driver is untrusted ------------------- + + [Theory, MemberData(nameof(Engines))] + public void RebootPrompt_IsNeverOffered_WhenDriverUntrusted_EvenIfAnotherStepNeedsAReboot(string engine) + { + // Run the REAL summary block from install-prerequisites.ps1 (found by AST) with the reboot + // flag already set and exit code 2, with Read-Host and Restart-Computer replaced by recorders. + var script = @" +$errs = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile(" + Q(Main) + @", [ref]$null, [ref]$errs) +$blocks = $ast.FindAll({ param($a) $a -is [System.Management.Automation.Language.IfStatementAst] -and $a.Clauses[0].Item1.Extent.Text.Trim() -eq '$UsbipExit -eq 2' }, $true) +$text = @($blocks)[0].Extent.Text +$global:prompted = $false; $global:rebooted = $false +function Read-Host { $global:prompted = $true; 'y' } +function Restart-Computer { $global:rebooted = $true } +function Run-It($exit, $needs, $skip) { + $global:prompted = $false; $global:rebooted = $false + $UsbipExit = $exit; $NeedsReboot = $needs; $SkipReboot = $skip + $out = & { Invoke-Expression $text } 6>&1 | Out-String + @{ Prompted = $global:prompted; Rebooted = $global:rebooted; Out = $out } +} +$bad = Run-It 2 $true $false +$ok = Run-It 0 $true $false +$r = @{ Found = (@($blocks).Count -eq 1); BadPrompted = $bad.Prompted; BadRebooted = $bad.Rebooted; BadOut = $bad.Out; OkPrompted = $ok.Prompted } +Write-Output ('@@JSON@@' + (ConvertTo-Json -InputObject $r -Compress))"; + var j = J(RunScript(engine, script)); + Assert.True(j.GetProperty("Found").GetBoolean()); + Assert.False(j.GetProperty("BadPrompted").GetBoolean(), "the prompt was offered with an untrusted driver"); + Assert.False(j.GetProperty("BadRebooted").GetBoolean()); + Assert.Contains("DO NOT REBOOT", j.GetProperty("BadOut").GetString()); + // Control: the original behaviour is intact for every other case, so the test above is not vacuous. + Assert.True(j.GetProperty("OkPrompted").GetBoolean()); + } + + // -- Staging folder and held file (H1) ---------------------------------------------------- + + [Theory, MemberData(nameof(Engines))] + public void StagingDir_IsPrivate_ToSystemAndAdministratorsOnly(string engine) + { + var root = Directory.CreateTempSubdirectory("ms-usbip-root-").FullName; + try + { + var script = ". " + Q(Lib) + @" +$root = " + Q(root) + @" +$dir = New-UsbipStagingDir -Root $root +$sec = Get-UsbipDirSecurity $dir +$allow = @($sec.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier]) | Where-Object { $_.AccessControlType -eq 'Allow' } | ForEach-Object { $_.IdentityReference.Value }) +$elevated = (New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +$canWrite = $true +try { [IO.File]::WriteAllText((Join-Path $dir 'probe.txt'), 'x') } catch { $canWrite = $false } +$r = @{ Protected = $sec.AreAccessRulesProtected; Allow = @($allow); Problems = @(Get-UsbipStagingAclProblems $dir); Elevated = $elevated; CanWrite = $canWrite; UnderRoot = $dir.StartsWith($root) } +# the owner can always reset the DACL, so cleanup works without elevation +$me = [Security.Principal.WindowsIdentity]::GetCurrent().User +$fix = Get-UsbipDirSecurity $dir +$fix.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($me,'FullControl','ContainerInherit, ObjectInherit','None','Allow'))) +Set-UsbipDirSecurity $dir $fix +Write-Output ('@@JSON@@' + (ConvertTo-Json -InputObject $r -Compress))"; + var j = J(RunScript(engine, script)); + Assert.True(j.GetProperty("UnderRoot").GetBoolean()); + Assert.True(j.GetProperty("Protected").GetBoolean(), "the DACL must not inherit"); + var allow = j.GetProperty("Allow").EnumerateArray().Select(x => x.GetString()!).OrderBy(x => x).ToArray(); + Assert.Equal(new[] { "S-1-5-18", "S-1-5-32-544" }, allow); // SYSTEM, Administrators: nobody else + Assert.Empty(j.GetProperty("Problems").EnumerateArray()); + // A standard (non-elevated) account cannot write there. Only checkable from a non-elevated shell. + if (!j.GetProperty("Elevated").GetBoolean()) + Assert.False(j.GetProperty("CanWrite").GetBoolean(), "a standard user could write into the staging folder"); + } + finally { try { Directory.Delete(root, true); } catch (IOException) { } catch (UnauthorizedAccessException) { } } + } + + [Theory, MemberData(nameof(Engines))] + public void StagingRoot_IsMachineWide_NotTheUserProfileOrTemp(string engine) + { + var script = ". " + Q(Lib) + @" +$r = @{ Root = (Get-UsbipStagingRoot); ProgramData = $env:ProgramData; Temp = [IO.Path]::GetTempPath(); Profile = $env:USERPROFILE } +Write-Output ('@@JSON@@' + (ConvertTo-Json -InputObject $r -Compress))"; + var j = J(RunScript(engine, script)); + var root = j.GetProperty("Root").GetString()!; + Assert.Equal(j.GetProperty("ProgramData").GetString(), root); + Assert.False(root.StartsWith(j.GetProperty("Profile").GetString()!, StringComparison.OrdinalIgnoreCase)); + Assert.False(j.GetProperty("Temp").GetString()!.StartsWith(root, StringComparison.OrdinalIgnoreCase)); + } + + [Theory, MemberData(nameof(Engines))] + public void HeldFile_CannotBeWrittenDeletedOrMoved_ButCanBeRead(string engine) + { + using var s = new Scenario(); + var script = ". " + Q(Lib) + @" +$f = Join-Path " + Q(s.Root) + @" 'held.bin' +[IO.File]::WriteAllText($f, 'original') +$h = Open-UsbipHeldFile $f +$o = @{} +try { [IO.File]::WriteAllText($f, 'evil'); $o.Write = 'swapped' } catch { $o.Write = 'blocked' } +try { [IO.File]::Delete($f); $o.Delete = 'swapped' } catch { $o.Delete = 'blocked' } +try { [IO.File]::Move($f, $f + '.moved'); $o.Move = 'swapped' } catch { $o.Move = 'blocked' } +try { $o.Read = [IO.File]::ReadAllText($f) } catch { $o.Read = 'denied' } +$o.Hash = Get-UsbipStreamSha256 $h +$h.Dispose() +try { [IO.File]::Delete($f); $o.AfterClose = 'deleted' } catch { $o.AfterClose = 'still locked' } +Write-Output ('@@JSON@@' + (ConvertTo-Json -InputObject $o -Compress))"; + var j = J(RunScript(engine, script)); + Assert.Equal("blocked", j.GetProperty("Write").GetString()); + Assert.Equal("blocked", j.GetProperty("Delete").GetString()); + Assert.Equal("blocked", j.GetProperty("Move").GetString()); + Assert.Equal("original", j.GetProperty("Read").GetString()); + Assert.Equal(Convert.ToHexString(SHA256.HashData(Encoding.ASCII.GetBytes("original"))), j.GetProperty("Hash").GetString()); + Assert.Equal("deleted", j.GetProperty("AfterClose").GetString()); + } + + // -- M1: signer identity from the certificate object, never a parsed string ------------------- + + private static string CertScript(string subject) => ". " + Q(Lib) + @" +Add-Type -AssemblyName System.Security +$dn = New-Object System.Security.Cryptography.X509Certificates.X500DistinguishedName(" + Q(subject) + @") +$rsa = [System.Security.Cryptography.RSA]::Create(2048) +$req = New-Object System.Security.Cryptography.X509Certificates.CertificateRequest($dn, $rsa, [System.Security.Cryptography.HashAlgorithmName]::SHA256, [System.Security.Cryptography.RSASignaturePadding]::Pkcs1) +$cert = $req.CreateSelfSigned([DateTimeOffset]::UtcNow.AddDays(-1), [DateTimeOffset]::UtcNow.AddDays(30)) +$facts = Get-UsbipCertFacts $cert +"; + + [Theory, MemberData(nameof(Engines))] + public void SpoofedSubject_IsNotReadAsTheMicrosoftName(string engine) + { + // The O value is quoted and contains ', CN='. A regex over the subject string + // reads that as the CN; the certificate's SimpleName is the real CN. + var spoof = "O=\"Evil, CN=Microsoft Windows Hardware Compatibility Publisher, x\", CN=Evil Ltd"; + var j = J(RunScript(engine, CertScript(spoof) + @" +$r = @{ Simple = $facts.SimpleName; Subject = $cert.Subject } +Write-Output ('@@JSON@@' + (ConvertTo-Json -InputObject $r -Compress))")); + Assert.Equal("Evil Ltd", j.GetProperty("Simple").GetString()); + } + + [Theory, MemberData(nameof(Engines))] + public void RealSubjects_GiveTheExpectedNames(string engine) + { + var installer = "E=itsec@scheibling.se, CN=Cloudyne Systems (Scheibling Consulting AB), O=Cloudyne Systems (Scheibling Consulting AB), STREET=Fyrislundsgatan 29 a, L=Uppsala, S=Uppsala, C=SE, OID.1.3.6.1.4.1.311.60.2.1.3=SE, SERIALNUMBER=559171-6567, OID.2.5.4.15=Private Organization"; + var ms = "CN=Microsoft Windows Hardware Compatibility Publisher, O=Microsoft Corporation, L=Redmond, S=Washington, C=US"; + var a = J(RunScript(engine, CertScript(installer) + @" +$r = @{ Simple = $facts.SimpleName; Pinned = $script:UsbipPin.InstallerSigner } +Write-Output ('@@JSON@@' + (ConvertTo-Json -InputObject $r -Compress))")); + Assert.Equal("Cloudyne Systems (Scheibling Consulting AB)", a.GetProperty("Simple").GetString()); + Assert.Equal(a.GetProperty("Pinned").GetString(), a.GetProperty("Simple").GetString()); + var b = J(RunScript(engine, CertScript(ms) + @" +$r = @{ Simple = $facts.SimpleName } +Write-Output ('@@JSON@@' + (ConvertTo-Json -InputObject $r -Compress))")); + Assert.Equal(MsSigner, b.GetProperty("Simple").GetString()); + } + + [Theory, MemberData(nameof(Engines))] + public void SelfSignedCert_WithTheExactMicrosoftName_IsStillRejectedAsADriverSigner(string engine) + { + var ms = "CN=Microsoft Windows Hardware Compatibility Publisher, O=Microsoft Corporation, L=Redmond, S=Washington, C=US"; + var j = J(RunScript(engine, CertScript(ms) + @" +$facts.Status = 'Valid' +$seams = @{ GetSignature = { param($p) $facts } } +$why = '' +$ok = Test-UsbipSignedBy 'X:\fake\usbip2_ude.sys' $script:UsbipPin 'Driver' $seams ([ref]$why) +$r = @{ Ok = $ok; Why = $why; Root = $facts.RootThumbprint; ChainOk = $facts.ChainOk } +Write-Output ('@@JSON@@' + (ConvertTo-Json -InputObject $r -Compress))")); + Assert.False(j.GetProperty("Ok").GetBoolean()); + Assert.False(j.GetProperty("ChainOk").GetBoolean()); + } + + // Real files already on this machine. The positive case is a real Microsoft hardware-compatibility + // (WHQL) signed driver; negatives are unsigned and a real non-Microsoft signed file. + private const string RealFilesScript = @" +$gs = (New-UsbipDefaultSeams).GetSignature +function Check($path) { + $why = '' + $ok = Test-UsbipSignedBy $path $script:UsbipPin 'Driver' @{ GetSignature = $gs } ([ref]$why) + @{ Path = $path; Ok = $ok; Why = $why } +} +$drivers = @(Get-ChildItem $env:windir\System32\drivers -Filter *.sys -File -ErrorAction SilentlyContinue) +"; + + [Theory, MemberData(nameof(Engines))] + public void RealMicrosoftSignedDriver_IsAccepted_ByTheDefaultCheck(string engine) + { + var script = ". " + Q(Lib) + RealFilesScript + @" +$pick = $null +foreach ($d in $drivers) { + $s = Get-AuthenticodeSignature -LiteralPath $d.FullName + if ($s.Status -eq 'Valid' -and $s.SignerCertificate -and $s.SignerCertificate.GetNameInfo('SimpleName',$false) -eq $script:UsbipPin.DriverSigner) { $pick = $d.FullName; break } +} +if (-not $pick) { Write-Output ('@@JSON@@' + (ConvertTo-Json -InputObject @{ Skipped = $true } -Compress)); return } +$r = Check $pick +$r.Skipped = $false +Write-Output ('@@JSON@@' + (ConvertTo-Json -InputObject $r -Compress))"; + var j = J(RunScript(engine, script)); + if (j.GetProperty("Skipped").GetBoolean()) return; // no WHQL driver on this host: nothing to prove + Assert.True(j.GetProperty("Ok").GetBoolean(), j.GetProperty("Path").GetString() + ": " + j.GetProperty("Why").GetString()); + } + + [Theory, MemberData(nameof(Engines))] + public void RealNegatives_UnsignedFile_AndMicrosoftWindowsComponent_AndThirdParty_AreRejected(string engine) + { + using var s = new Scenario(); + var script = ". " + Q(Lib) + RealFilesScript + @" +$res = @{} +$res.Unsigned = Check " + Q(s.Payload) + @" +# A Microsoft-signed Windows component is signed as 'Microsoft Windows', not the hardware publisher. +$comp = $null +foreach ($d in $drivers | Select-Object -First 300) { + $sg = Get-AuthenticodeSignature -LiteralPath $d.FullName + if ($sg.Status -eq 'Valid' -and $sg.SignerCertificate -and $sg.SignerCertificate.GetNameInfo('SimpleName',$false) -eq 'Microsoft Windows') { $comp = $d.FullName; break } +} +if ($comp) { $res.Component = Check $comp } +# A real Valid signature from someone who is not Microsoft. +$third = $null +$cands = @($drivers | Select-Object -First 400) + @(Get-ChildItem $env:ProgramFiles -Recurse -Depth 2 -Include *.exe,*.dll -File -ErrorAction SilentlyContinue | Select-Object -First 400) +foreach ($d in $cands) { + $sg = Get-AuthenticodeSignature -LiteralPath $d.FullName + if ($sg.Status -eq 'Valid' -and $sg.SignerCertificate -and $sg.SignerCertificate.GetNameInfo('SimpleName',$false) -notmatch 'Microsoft') { $third = $d.FullName; break } +} +if ($third) { $res.Third = Check $third } +Write-Output ('@@JSON@@' + (ConvertTo-Json -InputObject $res -Compress -Depth 4))"; + var j = J(RunScript(engine, script)); + Assert.False(j.GetProperty("Unsigned").GetProperty("Ok").GetBoolean()); + if (j.TryGetProperty("Component", out var c)) Assert.False(c.GetProperty("Ok").GetBoolean(), c.GetProperty("Path").GetString()); + if (j.TryGetProperty("Third", out var t)) Assert.False(t.GetProperty("Ok").GetBoolean(), t.GetProperty("Path").GetString()); + } + + // -- Static guards --------------------------------------------------------------------- + + [Fact] + public void NewScripts_ContainNoRebootOrShutdownOrTestSigningChange() + { + // The existing install-prerequisites.ps1 has a user-answered reboot prompt for OTHER steps; + // the new files, and the USB step block inside it, must not be able to restart the machine. + var forbidden = new Regex(@"(?i)\b(Restart-Computer|Stop-Computer|shutdown(\.exe)?|Restart-Service|bcdedit(\.exe)?\s+/set|testsigning\s+on)\b"); + foreach (var f in new[] { Lib, Wrapper }) + { + // Comments may mention the words; strip them before looking. + var code = string.Join("\n", File.ReadAllLines(f).Select(l => Regex.Replace(l, @"^\s*#.*$", ""))); + code = Regex.Replace(code, @"(?m)#[^'""\r\n]*$", ""); + Assert.False(forbidden.IsMatch(code), $"{Path.GetFileName(f)} contains a reboot/shutdown/test-signing command"); + } + + var main = File.ReadAllText(Main); + var start = main.IndexOf("# 10. USB forwarding driver", StringComparison.Ordinal); + var end = main.IndexOf("# Summary", start, StringComparison.Ordinal); + Assert.True(start > 0 && end > start); + Assert.DoesNotMatch(forbidden, main.Substring(start, end - start)); + Assert.DoesNotMatch(@"\$NeedsReboot\s*=\s*\$true", main.Substring(start, end - start)); + } + + [Fact] + public void Installer_AlwaysGetsNoRestart_AndPinsAreExact() + { + var lib = File.ReadAllText(Lib); + Assert.Contains("'/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART'", lib); + Assert.Contains("38CAD6D4432B52D5BB9409D9AD03B72FDFFC4ADA4CD3A48FBECA1A2752A8518A", lib); + Assert.Contains("https://github.com/vadimgrn/usbip-win2/releases/download/v.0.9.8.1/USBip-0.9.8.1-x64.exe", lib); + Assert.Contains("Microsoft Windows Hardware Compatibility Publisher", lib); + Assert.Contains(MsRoot, lib); + } + + [Fact] + public void SignerNames_AreNeverTakenFromAParsedSubjectString() + { + var code = string.Join("\n", File.ReadAllLines(Lib).Select(l => Regex.Replace(l, @"^\s*#.*$", ""))); + Assert.DoesNotMatch(@"\.Subject\b", code); + Assert.Contains("GetNameInfo", code); + } + + [Fact] + public void NewFiles_ContainNoLocalUserPaths() + { + foreach (var f in new[] { Lib, Wrapper }) + Assert.DoesNotMatch(@"(?i)[A-Z]:\\Users\\", File.ReadAllText(f)); + } + + [Theory, MemberData(nameof(Engines))] + public void LintScript_PassesOnThePrerequisitesFolder(string engine) + { + var r = RunScript(engine, "& " + Q(Path.Combine(RepoRoot, "scripts", "lint-scripts.ps1")) + " -Path " + Q(Prereq) + "; Write-Output ('EXIT=' + $LASTEXITCODE)"); + Assert.Contains("EXIT=0", r.Stdout + r.Stderr); + } +} diff --git a/src/MultiSeat.Tests/Streaming/StreamingTests.cs b/src/MultiSeat.Tests/Streaming/StreamingTests.cs index 63856ca..0516e8c 100644 --- a/src/MultiSeat.Tests/Streaming/StreamingTests.cs +++ b/src/MultiSeat.Tests/Streaming/StreamingTests.cs @@ -812,6 +812,7 @@ public void Constants_PortsPerSeat_NoUsedPortCollision() Constants.OffsetGfeHttps, Constants.OffsetGfeHttp, Constants.OffsetWebUi, Constants.OffsetVideo, Constants.OffsetControl, Constants.OffsetAudio, Constants.OffsetMic, Constants.OffsetRtsp, Constants.OffsetRetroArchNetplay, + Constants.OffsetUsbForwarding, // reserved for ApolloVibe's USB tunnel (closed in seats) }; var allUsedPorts = new HashSet(); diff --git a/src/MultiSeat.Tests/Streaming/UsbForwardingSeatSafetyTests.cs b/src/MultiSeat.Tests/Streaming/UsbForwardingSeatSafetyTests.cs new file mode 100644 index 0000000..57c6b9c --- /dev/null +++ b/src/MultiSeat.Tests/Streaming/UsbForwardingSeatSafetyTests.cs @@ -0,0 +1,134 @@ +using Microsoft.Extensions.Options; +using MultiSeat.Service.Configuration; +using MultiSeat.Service.Streaming; +using MultiSeat.Shared; +using MultiSeat.Shared.Models; +using Xunit; + +namespace MultiSeat.Tests.Streaming; + +/// +/// ApolloVibe has a USB tunnel on main port + 7, off by default. A forwarded USB device is a +/// machine-wide PnP device, not a per-session one, so a seat must never run a tunnel. These tests +/// pin the three things that keep it that way: +7 is a reserved offset that collides with nothing, +/// every seat config says "off" explicitly, and the firewall never opens +7 for a seat. +/// +public class UsbForwardingSeatSafetyTests +{ + private static readonly int[] OtherUsedOffsets = + { + Constants.OffsetGfeHttps, Constants.OffsetGfeHttp, Constants.OffsetWebUi, + Constants.OffsetVideo, Constants.OffsetControl, Constants.OffsetAudio, + Constants.OffsetMic, Constants.OffsetRtsp, Constants.OffsetRetroArchNetplay, + }; + + [Fact] + public void Offset_IsSeven() + { + Assert.Equal(7, Constants.OffsetUsbForwarding); + } + + [Fact] + public void Offset_DoesNotEqualAnyOtherOffset_AndFitsTheBlock() + { + Assert.DoesNotContain(Constants.OffsetUsbForwarding, OtherUsedOffsets); + Assert.InRange(Constants.OffsetUsbForwarding, 0, Constants.PortsPerSeat - 1); + } + + [Fact] + public void SeatUsbPort_NeverEqualsAnyOtherUsedPortOfAnySeat() + { + for (int seat = 0; seat < Constants.MaxSeats; seat++) + { + var usb = Constants.PortBase + seat * Constants.PortsPerSeat + Constants.OffsetUsbForwarding; + for (int other = 0; other < Constants.MaxSeats; other++) + { + var otherBase = Constants.PortBase + other * Constants.PortsPerSeat; + foreach (var off in OtherUsedOffsets) + Assert.NotEqual(otherBase + off, usb); + if (other != seat) + Assert.NotEqual(otherBase + Constants.OffsetUsbForwarding, usb); + } + } + } + + [Fact] + public void HostConsoleApollo_PortsAreOutsideEverySeatBlock() + { + // Stock Apollo: main port 47989, HTTPS at -5, its own USB tunnel at +7 (47996), up to RTSP at +26. + // ASSUMPTION: the console Apollo keeps its default port (47989) and MultiSeat:PortBase keeps 48100. + // Both can be overridden by config; this test proves the shipped defaults only, it cannot see a host override. + const int hostMain = 47989; + int[] hostOffsets = OtherUsedOffsets.Append(Constants.OffsetUsbForwarding).ToArray(); + Assert.Equal(47996, hostMain + Constants.OffsetUsbForwarding); + + var seatPorts = new HashSet(); + for (int seat = 0; seat < Constants.MaxSeats; seat++) + { + var b = Constants.PortBase + seat * Constants.PortsPerSeat; + foreach (var off in hostOffsets) seatPorts.Add(b + off); + } + + var firstSeatLow = Constants.PortBase + hostOffsets.Min(); + foreach (var off in hostOffsets) + { + var p = hostMain + off; + Assert.DoesNotContain(p, seatPorts); + Assert.True(p < firstSeatLow, $"host port {p} reaches into the seat blocks (first used seat port {firstSeatLow})"); + } + } + + // ── Seat config ─────────────────────────────────────────────────── + + private static string BuildConf(MultiSeatOptions options) + { + var tempDir = Path.Combine(Path.GetTempPath(), $"multiseat-usb-{Guid.NewGuid():N}"); + try + { + var builder = new ApolloConfigBuilder( + new TestLogger(), Options.Create(options)); + var seat = new SeatInfo { AccountName = "MultiSeatSeat01", PortBase = Constants.PortBase }; + return File.ReadAllText(builder.BuildConfig(seat, tempDir)); + } + finally + { + try { Directory.Delete(tempDir, true); } catch (IOException) { } catch (UnauthorizedAccessException) { } + } + } + + public static IEnumerable OptionSets() + { + yield return new object[] { new MultiSeatOptions() }; + yield return new object[] { new MultiSeatOptions { EnableEmulatorNetplay = false } }; + yield return new object[] { new MultiSeatOptions { AudioMode = AudioMode.SharedHost } }; + } + + [Theory] + [MemberData(nameof(OptionSets))] + public void SeatConfig_KeepsUsbForwardingOff_AndWritesNoPort(MultiSeatOptions options) + { + var lines = BuildConf(options).Split('\n').Select(l => l.Trim()).ToArray(); + + Assert.Single(lines, l => l == "usb_forwarding_enabled = disabled"); + Assert.DoesNotContain(lines, l => l.StartsWith("usb_forwarding_port")); + Assert.DoesNotContain(lines, l => l.StartsWith("usb_forwarding_require_stream")); + // No other spelling of "on" for the key. + Assert.DoesNotContain(lines, l => l.StartsWith("usb_forwarding_enabled") && l != "usb_forwarding_enabled = disabled"); + } + + // ── Firewall ────────────────────────────────────────────────────── + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Firewall_NeverOpensTheUsbPort_ForAnySeat(bool netplay) + { + for (int seat = 0; seat < Constants.MaxSeats; seat++) + { + var b = Constants.PortBase + seat * Constants.PortsPerSeat; + var usb = b + Constants.OffsetUsbForwarding; + Assert.DoesNotContain(usb, FirewallManager.TcpPorts(b, netplay)); + Assert.DoesNotContain(usb, FirewallManager.UdpPorts(b)); + } + } +} From deeca7fd152369a6303bbd7bb1484140c00f027f Mon Sep 17 00:00:00 2001 From: vibesoftwarecoder Date: Sat, 24 Oct 2026 03:34:20 -0500 Subject: [PATCH 2/2] Make the real boot-options test agree with the host it runs on The test assumed no machine runs with test signing on. GitHub's Windows runner does, and the check correctly refused it, so the test failed on CI while passing locally. The test now reads the real SystemStartOptions value itself and requires the script's verdict to match it, so it passes on a clean host and on one with test signing on, and still fails if the verdict ignores the real value. Co-Authored-By: Claude Sonnet 5.5 --- .../Scripts/UsbipDriverScriptTests.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/MultiSeat.Tests/Scripts/UsbipDriverScriptTests.cs b/src/MultiSeat.Tests/Scripts/UsbipDriverScriptTests.cs index 0608620..81e4e12 100644 --- a/src/MultiSeat.Tests/Scripts/UsbipDriverScriptTests.cs +++ b/src/MultiSeat.Tests/Scripts/UsbipDriverScriptTests.cs @@ -357,8 +357,16 @@ public void RealRegistryRead_WorksWithoutElevation(string engine) var j = J(RunScript(engine, script)); if (!j.GetProperty("Exists").GetBoolean()) return; // a host without the value: nothing to prove Assert.True(j.GetProperty("IsString").GetBoolean()); - // This host is not in test-signing mode (the user's rule), so the real read must say "not refused". - Assert.False(j.GetProperty("Refuse").GetBoolean(), j.GetProperty("Reason").GetString()); + // Whether the verdict is "refuse" depends on the machine: a CI runner can be in test-signing mode, a + // normal host is not. So compare the script's real read with an independent read made here, from the + // same registry value, and require the verdict to agree on either kind of host. + var raw = Microsoft.Win32.Registry.GetValue( + @"HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control", "SystemStartOptions", null) as string; + Assert.NotNull(raw); + var expectRefuse = System.Text.RegularExpressions.Regex.IsMatch(raw, + @"(?i)(^|[\s,])(TESTSIGNING|DISABLE_INTEGRITY_CHECKS|NOINTEGRITYCHECKS)(?=$|[\s,=])"); + Assert.True(expectRefuse == j.GetProperty("Refuse").GetBoolean(), + $"verdict disagrees with the real start options (expected refuse={expectRefuse}): {j.GetProperty("Reason").GetString()}"); } [Theory, MemberData(nameof(Engines))]