返回 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 [string]$ExpectedThumbprint,
12
13 [switch]$RequireTrusted,
14
15 [ValidateSet("canonical", "legacy-dual")]
16 [string]$PortableLayout = "canonical"
17 )
18
19 $ErrorActionPreference = "Stop"
20
21 $expectedPayload = @(
22 "reasonix-desktop.exe",
23 "reasonix-guard.exe",
24 "reasonix-launcher.exe",
25 "reasonix-update-helper.exe",
26 "reasonix-cli.exe",
27 "reasonix-uninstall.exe"
28 )
29
30 function Assert-AuthenticodeSignature {
31 param(
32 [Parameter(Mandatory = $true)]
33 [string]$Path
34 )
35
36 if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
37 throw "Signed Windows artifact is missing: $Path"
38 }
39 $signature = Get-AuthenticodeSignature -LiteralPath $Path
40 if ($null -eq $signature.SignerCertificate -or $signature.SignatureType -eq "None") {
41 throw "Authenticode signature is missing: $Path"
42 }
43 if ($RequireTrusted -and $signature.Status -ne "Valid") {
44 throw "Authenticode signature is not trusted for $Path`: $($signature.Status) $($signature.StatusMessage)"
45 }
46 if ($ExpectedThumbprint -and ($signature.SignerCertificate.Thumbprint -ne $ExpectedThumbprint -or -not $signature.TimeStamperCertificate)) {
47 throw "Unexpected signer or missing timestamp: $Path"
48 }
49 Write-Host "Authenticode $($signature.Status): $Path"
50 }
51
52 # signing-files.txt (desktop/packaging/signing-files.mjs) enumerates every PE
53 # file in the payload: the flat Go executables plus the Electron app/ tree.
54 # The SignPath artifact configuration signs exactly this set, so verify the
55 # same list instead of a hand-maintained copy.
56 $signingListPath = Join-Path $PayloadDirectory "signing-files.txt"
57 if (-not (Test-Path -LiteralPath $signingListPath -PathType Leaf)) {
58 throw "Payload signing list is missing: $signingListPath"
59 }
60 $signingFiles = @(
61 Get-Content -LiteralPath $signingListPath |
62 ForEach-Object { $_.Trim() } |
63 Where-Object { $_ -ne "" -and -not $_.StartsWith("#") }
64 )
65 if ($signingFiles.Count -eq 0) {
66 throw "Payload signing list is empty: $signingListPath"
67 }
68 foreach ($name in $expectedPayload) {
69 if ($signingFiles -notcontains $name) {
70 throw "Payload signing list does not cover $name"
71 }
72 }
73 if ($signingFiles -notcontains "app/Reasonix.exe") {
74 throw "Payload signing list does not cover the Electron shell app/Reasonix.exe"
75 }
76 if ($signingFiles -notcontains "app/resources/bin/reasonix-cli-launcher.exe") {
77 throw "Payload signing list does not cover the CLI entry app/resources/bin/reasonix-cli-launcher.exe"
78 }
79
80 $payloadFiles = @(Get-ChildItem -LiteralPath $PayloadDirectory -File -Filter "*.exe")
81 if ($payloadFiles.Count -ne $expectedPayload.Count) {
82 throw "Payload must contain exactly $($expectedPayload.Count) flat executables, found $($payloadFiles.Count)"
83 }
84 foreach ($entry in $signingFiles) {
85 Assert-AuthenticodeSignature -Path (Join-Path $PayloadDirectory ($entry -replace '/', [System.IO.Path]::DirectorySeparatorChar))
86 }
87 Assert-AuthenticodeSignature -Path $InstallerPath
88
89 $extractRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("reasonix-authenticode-" + [guid]::NewGuid().ToString("N"))
90 try {
91 Expand-Archive -LiteralPath $PortableArchivePath -DestinationPath $extractRoot
92
93 # Legacy portable releases kept all six executables at InstallRoot. The
94 # versioned-v1 layout deliberately keeps only the launcher aliases and CLI
95 # at the root, while the active Desktop, update helper, CLI and the Electron
96 # app/ tree live under versions/vX.Y.Z/. Verify the exact layout selected by
97 # current.json instead of treating the versioned executables as missing.
98 $currentPath = Join-Path $extractRoot "current.json"
99 if (-not (Test-Path -LiteralPath $currentPath -PathType Leaf)) {
100 throw "Portable archive must use the versioned layout (current.json is missing)"
101 }
102 $current = Get-Content -LiteralPath $currentPath -Raw | ConvertFrom-Json
103 if ($current.schemaVersion -ne 1) {
104 throw "Portable current.json schemaVersion must be 1"
105 }
106 $activeVersion = [string]$current.activeVersion
107 $activeDir = [string]$current.activeDir
108 if ($activeVersion -notmatch '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?$' -or
109 [string]::IsNullOrWhiteSpace($activeDir) -or
110 $activeDir.Replace("\", "/") -ne "versions/$activeVersion") {
111 throw "Portable current.json must bind activeVersion to versions/<activeVersion>"
112 }
113
114 $activePath = [System.IO.Path]::GetFullPath((Join-Path $extractRoot $activeDir))
115 $extractPrefix = [System.IO.Path]::GetFullPath($extractRoot).TrimEnd([char[]]@('\', '/')) + [System.IO.Path]::DirectorySeparatorChar
116 if (-not $activePath.StartsWith($extractPrefix, [System.StringComparison]::OrdinalIgnoreCase) -or
117 -not (Test-Path -LiteralPath $activePath -PathType Container)) {
118 throw "Portable current.json activeDir escapes or is missing: $activeDir"
119 }
120
121 # Root/versioned executables mapped back to their payload source; every PE
122 # file under the versioned app/ tree is verified from signing-files.txt.
123 $portableSources = @(
124 [pscustomobject]@{ Portable = "Reasonix.exe"; Payload = "reasonix-launcher.exe" },
125 [pscustomobject]@{ Portable = "reasonix-cli.exe"; Payload = "app/resources/bin/reasonix-cli-launcher.exe" },
126 [pscustomobject]@{ Portable = (Join-Path $activeDir "reasonix-desktop.exe"); Payload = "reasonix-desktop.exe" },
127 [pscustomobject]@{ Portable = (Join-Path $activeDir "reasonix-update-helper.exe"); Payload = "reasonix-update-helper.exe" },
128 [pscustomobject]@{ Portable = (Join-Path $activeDir "reasonix-cli.exe"); Payload = "reasonix-cli.exe" }
129 )
130 if ($PortableLayout -eq "legacy-dual") {
131 $portableSources += [pscustomobject]@{ Portable = "reasonix-launcher.exe"; Payload = "reasonix-launcher.exe" }
132 }
133 foreach ($entry in ($signingFiles | Where-Object { $_ -like "app/*" })) {
134 $portableSources += [pscustomobject]@{
135 Portable = (Join-Path $activeDir ($entry -replace '/', [System.IO.Path]::DirectorySeparatorChar))
136 Payload = ($entry -replace '/', [System.IO.Path]::DirectorySeparatorChar)
137 }
138 }
139
140 $expectedPE = @($portableSources | ForEach-Object { $_.Portable.Replace('\', '/').ToLowerInvariant() } | Sort-Object)
141 $actualPE = @(Get-ChildItem -LiteralPath $extractRoot -Recurse -File |
142 Where-Object { $_.Extension -in @('.exe', '.dll') } |
143 ForEach-Object { $_.FullName.Substring($extractPrefix.Length).Replace('\', '/').ToLowerInvariant() } | Sort-Object)
144 if (@(Compare-Object $expectedPE $actualPE).Count -ne 0) {
145 throw "Portable PE inventory does not match the exact $PortableLayout signed payload mapping"
146 }
147
148 foreach ($entry in $portableSources) {
149 $portablePath = Join-Path $extractRoot $entry.Portable
150 Assert-AuthenticodeSignature -Path $portablePath
151 $portableHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $portablePath).Hash
152 $payloadHash = (Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path $PayloadDirectory $entry.Payload)).Hash
153 if ($portableHash -ne $payloadHash) {
154 throw "Portable $($entry.Portable) does not match signed payload $($entry.Payload)"
155 }
156 }
157 }
158 finally {
159 if (Test-Path -LiteralPath $extractRoot) {
160 Remove-Item -LiteralPath $extractRoot -Recurse -Force
161 }
162 }
163
164 Write-Host "Windows Authenticode release contract verified."
165
165 lines Plain Text