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..81e4e12 --- /dev/null +++ b/src/MultiSeat.Tests/Scripts/UsbipDriverScriptTests.cs @@ -0,0 +1,791 @@ +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()); + // 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))] + 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)); + } + } +}