Skip to content

Commit 9d0352d

Browse files
authored
Merge pull request #112 from raysubham/subham/feat/msi-verify-script
feat(scripts): add verify-msi.ps1 for client-side MSI integrity check
2 parents a719820 + 7d487a3 commit 9d0352d

1 file changed

Lines changed: 186 additions & 0 deletions

File tree

scripts/verify-msi.ps1

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
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

Comments
 (0)