|
| 1 | +#Requires -Version 5.1 |
| 2 | +<# |
| 3 | +.SYNOPSIS |
| 4 | + Verify the Ed25519 signature on a StepSecurity dev-machine-guard MSI. |
| 5 | +
|
| 6 | +.DESCRIPTION |
| 7 | + Reads the MSI from disk, computes its SHA256, and verifies the |
| 8 | + accompanying .sha256.sig sidecar (an SSH-style Ed25519 signature |
| 9 | + produced by `ssh-keygen -Y sign` and base64-wrapped) against the |
| 10 | + pinned release-operator public key. |
| 11 | +
|
| 12 | + Run this BEFORE invoking `msiexec /i` to confirm the installer bytes |
| 13 | + were produced by StepSecurity and have not been tampered with in |
| 14 | + transit. This is independent of Windows Authenticode trust - it |
| 15 | + cross-checks the same artifact through a second, operator-controlled |
| 16 | + trust anchor. |
| 17 | +
|
| 18 | +.PARAMETER Msi |
| 19 | + Path to the *_signed.msi file downloaded from the GitHub release. |
| 20 | +
|
| 21 | +.PARAMETER Sig |
| 22 | + Path to the matching *.sha256.sig file. Defaults to "$Msi.sha256.sig". |
| 23 | +
|
| 24 | +.EXAMPLE |
| 25 | + .\verify-msi.ps1 -Msi .\stepsecurity-dev-machine-guard-1.11.3-x64_signed.msi |
| 26 | +
|
| 27 | +.NOTES |
| 28 | + Requires OpenSSH 8.0+ (for `ssh-keygen -Y verify`): |
| 29 | + * Windows 11 / Windows 10 2004+: built-in |
| 30 | + * Windows 10 1809-1909 / Server 2019: install via "Optional features |
| 31 | + > OpenSSH Client", or use Git for Windows' bundled ssh-keygen. |
| 32 | + * macOS / Linux: ssh-keygen ships with OpenSSH. |
| 33 | +#> |
| 34 | + |
| 35 | +param( |
| 36 | + [Parameter(Mandatory, Position = 0)] |
| 37 | + [string]$Msi, |
| 38 | + |
| 39 | + [Parameter(Position = 1)] |
| 40 | + [string]$Sig |
| 41 | +) |
| 42 | + |
| 43 | +$ErrorActionPreference = "Stop" |
| 44 | + |
| 45 | +# ===== Pinned trust anchors (must match the release-signing keypair) ===== |
| 46 | +$PUBLIC_KEY_SSH = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILN+WG4lOH/x6MysYOf1oY0PKXLLu9d3ZvQDcvq5Cboi releases@stepsecurity.io" |
| 47 | +$SIGNATURE_NAMESPACE = "stepsecurity-mdm-checksum" |
| 48 | +$SIGNATURE_IDENTITY = "releases@stepsecurity.io" |
| 49 | + |
| 50 | +function Write-Info { param([string]$m) Write-Host "[INFO] $m" -ForegroundColor Cyan } |
| 51 | +function Write-Ok { param([string]$m) Write-Host "[OK] $m" -ForegroundColor Green } |
| 52 | +function Write-Fail { param([string]$m) Write-Host "[FAIL] $m" -ForegroundColor Red } |
| 53 | + |
| 54 | +if (-not (Test-Path -LiteralPath $Msi)) { |
| 55 | + Write-Fail "MSI not found: $Msi" |
| 56 | + exit 2 |
| 57 | +} |
| 58 | +$Msi = (Resolve-Path -LiteralPath $Msi).Path |
| 59 | + |
| 60 | +if (-not $Sig) { $Sig = "$Msi.sha256.sig" } |
| 61 | +if (-not (Test-Path -LiteralPath $Sig)) { |
| 62 | + Write-Fail "Signature sidecar not found: $Sig" |
| 63 | + Write-Info "Download it from the same GitHub release alongside the MSI." |
| 64 | + exit 2 |
| 65 | +} |
| 66 | +$Sig = (Resolve-Path -LiteralPath $Sig).Path |
| 67 | + |
| 68 | +# ----- ssh-keygen probe (OpenSSH 8.0+ required for -Y verify) ----- |
| 69 | +# Probe order: PATH first, then known fallback locations (Git-for-Windows |
| 70 | +# bundles a recent ssh-keygen even on older Windows where the system one |
| 71 | +# is too old). |
| 72 | +$candidates = @() |
| 73 | +$primary = Get-Command ssh-keygen -ErrorAction SilentlyContinue |
| 74 | +if ($primary) { $candidates += $primary.Source } |
| 75 | +foreach ($p in @( |
| 76 | + "$env:ProgramFiles\Git\usr\bin\ssh-keygen.exe", |
| 77 | + "${env:ProgramFiles(x86)}\Git\usr\bin\ssh-keygen.exe", |
| 78 | + "$env:LOCALAPPDATA\Programs\Git\usr\bin\ssh-keygen.exe", |
| 79 | + "$env:SystemRoot\System32\OpenSSH\ssh-keygen.exe" |
| 80 | +)) { |
| 81 | + if ($p -and (Test-Path -LiteralPath $p) -and ($candidates -notcontains $p)) { |
| 82 | + $candidates += $p |
| 83 | + } |
| 84 | +} |
| 85 | + |
| 86 | +$sshKeygen = $null |
| 87 | +foreach ($path in $candidates) { |
| 88 | + $probe = "" |
| 89 | + try { $probe = & $path -Y verify 2>&1 | Out-String } catch { $probe = $_.Exception.Message } |
| 90 | + if ($probe -match "Too few arguments for verify" -or |
| 91 | + $probe -match "missing namespace" -or |
| 92 | + $probe -match "missing argument" -or |
| 93 | + $probe -match "-Y verify ") { |
| 94 | + $sshKeygen = $path |
| 95 | + break |
| 96 | + } |
| 97 | +} |
| 98 | + |
| 99 | +if (-not $sshKeygen) { |
| 100 | + Write-Fail "ssh-keygen with -Y verify support (OpenSSH 8.0+) not found." |
| 101 | + Write-Info "Install OpenSSH Client via 'Settings > Apps > Optional features'," |
| 102 | + Write-Info "or install Git for Windows (bundles a recent ssh-keygen)." |
| 103 | + exit 3 |
| 104 | +} |
| 105 | +Write-Info "Using ssh-keygen: $sshKeygen" |
| 106 | + |
| 107 | +# ----- Compute MSI SHA256 ----- |
| 108 | +$checksum = (Get-FileHash -LiteralPath $Msi -Algorithm SHA256).Hash.ToLower() |
| 109 | +Write-Info "MSI: $Msi" |
| 110 | +Write-Info "SHA256: $checksum" |
| 111 | + |
| 112 | +# ----- Stage temp files for ssh-keygen ----- |
| 113 | +$tmp = Join-Path ([IO.Path]::GetTempPath()) ("verify-msi-" + [IO.Path]::GetRandomFileName()) |
| 114 | +New-Item -ItemType Directory -Path $tmp -Force | Out-Null |
| 115 | +$allowedSigners = Join-Path $tmp "allowed_signers" |
| 116 | +$sigFile = Join-Path $tmp "msi.sig" |
| 117 | +$msgFile = Join-Path $tmp "msi.msg" |
| 118 | + |
| 119 | +try { |
| 120 | + # allowed_signers: identity + namespace pin + pubkey. namespaces value |
| 121 | + # MUST be double-quoted - ssh-keygen rejects unquoted values. |
| 122 | + $signersLine = "$SIGNATURE_IDENTITY namespaces=`"$SIGNATURE_NAMESPACE`" $PUBLIC_KEY_SSH`n" |
| 123 | + [IO.File]::WriteAllText($allowedSigners, $signersLine, [Text.UTF8Encoding]::new($false)) |
| 124 | + |
| 125 | + # Decode base64 envelope -> real multi-line SSH signature blob. |
| 126 | + $sigB64 = (Get-Content -LiteralPath $Sig -Raw).Trim() |
| 127 | + try { |
| 128 | + $sigBytes = [Convert]::FromBase64String($sigB64) |
| 129 | + } catch { |
| 130 | + Write-Fail "Sidecar is not valid base64: $Sig" |
| 131 | + exit 4 |
| 132 | + } |
| 133 | + [IO.File]::WriteAllBytes($sigFile, $sigBytes) |
| 134 | + if ((Get-Item -LiteralPath $sigFile).Length -eq 0) { |
| 135 | + Write-Fail "Decoded signature is empty" |
| 136 | + exit 4 |
| 137 | + } |
| 138 | + |
| 139 | + # Verified message = raw UTF-8 hex of sha256, no BOM, no trailing newline. |
| 140 | + # Must be byte-identical to what `ssh-keygen -Y sign` consumed at release time. |
| 141 | + [IO.File]::WriteAllBytes($msgFile, [Text.Encoding]::UTF8.GetBytes($checksum)) |
| 142 | + |
| 143 | + # Pipe msg over stdin. Interactive run (this script targets humans), |
| 144 | + # so the SYSTEM-context stdin-pipe bug the dev-machine-guard loader |
| 145 | + # works around does not apply here. |
| 146 | + $psi = New-Object System.Diagnostics.ProcessStartInfo |
| 147 | + $psi.FileName = $sshKeygen |
| 148 | + $psi.UseShellExecute = $false |
| 149 | + $psi.CreateNoWindow = $true |
| 150 | + $psi.RedirectStandardInput = $true |
| 151 | + $psi.RedirectStandardOutput = $true |
| 152 | + $psi.RedirectStandardError = $true |
| 153 | + # Hand-quoted Arguments string works across PS 5.1 (no ArgumentList |
| 154 | + # property on .NET Framework's ProcessStartInfo) and PS 7+. All paths |
| 155 | + # come from a temp dir under $env:TEMP + GetRandomFileName and won't |
| 156 | + # contain a literal `"` char. |
| 157 | + $psi.Arguments = '-Y verify' + |
| 158 | + ' -f "' + $allowedSigners + '"' + |
| 159 | + ' -I "' + $SIGNATURE_IDENTITY + '"' + |
| 160 | + ' -n "' + $SIGNATURE_NAMESPACE + '"' + |
| 161 | + ' -s "' + $sigFile + '"' |
| 162 | + |
| 163 | + $proc = [Diagnostics.Process]::Start($psi) |
| 164 | + $msgBytes = [IO.File]::ReadAllBytes($msgFile) |
| 165 | + $proc.StandardInput.BaseStream.Write($msgBytes, 0, $msgBytes.Length) |
| 166 | + $proc.StandardInput.Close() |
| 167 | + $stdout = $proc.StandardOutput.ReadToEnd() |
| 168 | + $stderr = $proc.StandardError.ReadToEnd() |
| 169 | + $proc.WaitForExit() |
| 170 | + |
| 171 | + if ($proc.ExitCode -eq 0) { |
| 172 | + Write-Ok "Signature VERIFIED - MSI is authentic and untampered." |
| 173 | + Write-Info "Identity: $SIGNATURE_IDENTITY" |
| 174 | + Write-Info "Namespace: $SIGNATURE_NAMESPACE" |
| 175 | + exit 0 |
| 176 | + } else { |
| 177 | + Write-Fail "Signature verification FAILED (ssh-keygen exit $($proc.ExitCode))" |
| 178 | + if ($stdout) { Write-Host $stdout } |
| 179 | + if ($stderr) { Write-Host $stderr -ForegroundColor Red } |
| 180 | + Write-Fail "DO NOT INSTALL THIS MSI. The bytes do not match the release-signed checksum." |
| 181 | + exit 1 |
| 182 | + } |
| 183 | +} |
| 184 | +finally { |
| 185 | + Remove-Item -LiteralPath $tmp -Recurse -Force -ErrorAction SilentlyContinue |
| 186 | +} |
0 commit comments