| 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 |