返回 DeepSeek-Reasonix
verify-windows-authenticode.ps1
根目录 / scripts / verify-windows-authenticode.ps1
1 param(
2 [Parameter(Mandatory = $true)]
3 [string]$PayloadDirectory,
4
5 [Parameter(Mandatory = $true)]
6 [string]$InstallerPath,
7
8 [Parameter(Mandatory = $true)]
9 [string]$PortableArchivePath,
10
11 [switch]$RequireTrusted
12 )
13
14 $ErrorActionPreference = "Stop"
15
16 $expectedPayload = @(
17 "reasonix-desktop.exe",
18 "reasonix-guard.exe",
19 "reasonix-launcher.exe",
20 "reasonix-update-helper.exe",
21 "reasonix-cli.exe",
22 "reasonix-uninstall.exe"
23 )
24
25 function Assert-AuthenticodeSignature {
26 param(
27 [Parameter(Mandatory = $true)]
28 [string]$Path
29 )
30
31 if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
32 throw "Signed Windows artifact is missing: $Path"
33 }
34 $signature = Get-AuthenticodeSignature -LiteralPath $Path
35 if ($null -eq $signature.SignerCertificate -or $signature.SignatureType -eq "None") {
36 throw "Authenticode signature is missing: $Path"
37 }
38 if ($RequireTrusted -and $signature.Status -ne "Valid") {
39 throw "Authenticode signature is not trusted for $Path`: $($signature.Status) $($signature.StatusMessage)"
40 }
41 Write-Host "Authenticode $($signature.Status): $Path"
42 }
43
44 $payloadFiles = @(Get-ChildItem -LiteralPath $PayloadDirectory -File -Filter "*.exe")
45 if ($payloadFiles.Count -ne $expectedPayload.Count) {
46 throw "Payload must contain exactly $($expectedPayload.Count) executables, found $($payloadFiles.Count)"
47 }
48 foreach ($name in $expectedPayload) {
49 Assert-AuthenticodeSignature -Path (Join-Path $PayloadDirectory $name)
50 }
51 Assert-AuthenticodeSignature -Path $InstallerPath
52
53 $extractRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("reasonix-authenticode-" + [guid]::NewGuid().ToString("N"))
54 try {
55 Expand-Archive -LiteralPath $PortableArchivePath -DestinationPath $extractRoot
56
57 # Legacy portable releases kept all six executables at InstallRoot. The
58 # versioned-v1 layout deliberately keeps only the launcher aliases and CLI
59 # at the root, while the active Desktop, update helper, and CLI live under
60 # versions/vX.Y.Z/. Verify the exact layout selected by current.json instead
61 # of treating the three versioned executables as missing.
62 $currentPath = Join-Path $extractRoot "current.json"
63 if (Test-Path -LiteralPath $currentPath -PathType Leaf) {
64 $current = Get-Content -LiteralPath $currentPath -Raw | ConvertFrom-Json
65 if ($current.schemaVersion -ne 1) {
66 throw "Portable current.json schemaVersion must be 1"
67 }
68 $activeVersion = [string]$current.activeVersion
69 $activeDir = [string]$current.activeDir
70 if ($activeVersion -notmatch '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?$' -or
71 [string]::IsNullOrWhiteSpace($activeDir) -or
72 $activeDir.Replace("\", "/") -ne "versions/$activeVersion") {
73 throw "Portable current.json must bind activeVersion to versions/<activeVersion>"
74 }
75
76 $activePath = [System.IO.Path]::GetFullPath((Join-Path $extractRoot $activeDir))
77 $extractPrefix = [System.IO.Path]::GetFullPath($extractRoot).TrimEnd([char[]]@('\', '/')) + [System.IO.Path]::DirectorySeparatorChar
78 if (-not $activePath.StartsWith($extractPrefix, [System.StringComparison]::OrdinalIgnoreCase) -or
79 -not (Test-Path -LiteralPath $activePath -PathType Container)) {
80 throw "Portable current.json activeDir escapes or is missing: $activeDir"
81 }
82
83 $portableSources = @(
84 [pscustomobject]@{ Portable = "reasonix-launcher.exe"; Payload = "reasonix-launcher.exe" },
85 [pscustomobject]@{ Portable = "Reasonix.exe"; Payload = "reasonix-launcher.exe" },
86 [pscustomobject]@{ Portable = "reasonix-cli.exe"; Payload = "reasonix-cli.exe" },
87 [pscustomobject]@{ Portable = (Join-Path $activeDir "reasonix-desktop.exe"); Payload = "reasonix-desktop.exe" },
88 [pscustomobject]@{ Portable = (Join-Path $activeDir "reasonix-update-helper.exe"); Payload = "reasonix-update-helper.exe" },
89 [pscustomobject]@{ Portable = (Join-Path $activeDir "reasonix-cli.exe"); Payload = "reasonix-cli.exe" }
90 )
91 }
92 else {
93 $portableSources = @(
94 [pscustomobject]@{ Portable = "reasonix-desktop.exe"; Payload = "reasonix-desktop.exe" },
95 [pscustomobject]@{ Portable = "reasonix-guard.exe"; Payload = "reasonix-guard.exe" },
96 [pscustomobject]@{ Portable = "reasonix-launcher.exe"; Payload = "reasonix-launcher.exe" },
97 [pscustomobject]@{ Portable = "Reasonix.exe"; Payload = "reasonix-launcher.exe" },
98 [pscustomobject]@{ Portable = "reasonix-update-helper.exe"; Payload = "reasonix-update-helper.exe" },
99 [pscustomobject]@{ Portable = "reasonix-cli.exe"; Payload = "reasonix-cli.exe" }
100 )
101 }
102
103 $portableFiles = @(Get-ChildItem -LiteralPath $extractRoot -Recurse -File -Filter "*.exe")
104 if ($portableFiles.Count -ne 6) {
105 throw "Portable archive must contain exactly 6 executables, found $($portableFiles.Count)"
106 }
107
108 foreach ($entry in $portableSources) {
109 $portablePath = Join-Path $extractRoot $entry.Portable
110 Assert-AuthenticodeSignature -Path $portablePath
111 $portableHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $portablePath).Hash
112 $payloadHash = (Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path $PayloadDirectory $entry.Payload)).Hash
113 if ($portableHash -ne $payloadHash) {
114 throw "Portable $($entry.Portable) does not match signed payload $($entry.Payload)"
115 }
116 }
117 }
118 finally {
119 if (Test-Path -LiteralPath $extractRoot) {
120 Remove-Item -LiteralPath $extractRoot -Recurse -Force
121 }
122 }
123
124 Write-Host "Windows Authenticode release contract verified."
125
125 lines Plain Text