Merge pull request #11186 from flexeykinDev/install-ps1-signature

🤖 Agent: install.ps1 now verifies the release signature with the release-keys.txt key, the same check install.sh already does.
This commit is contained in:
Ludovic Marie authored and GitHub committed 2026-11-07 10:12:13 +01:00
commit f4858e8a25
6 files changed
+379 -18

No files matched your search

+2 -1
View File
@@ -54,7 +54,8 @@ irm https://raw.githubusercontent.com/horizon-rust/horizon/main/packaging/server
The script picks the machine's x64 or ARM64 archive, including when PowerShell runs under x64 emulation. It downloads
the current Horizon Nightly from GitHub (the only build Horizon ships;
`--version` picks another), checks it against the SHA-256 GitHub publishes, and never asks for sudo or administrator rights. It installs:
`--version` picks another), checks it against the release's signature when OpenSSL 3 is installed (on Windows, Git
for Windows includes it), else against the SHA-256 GitHub publishes, and never asks for sudo or administrator rights. It installs:
- the release in `~/.local/share/horizon/releases/<version>` (Windows: `%LOCALAPPDATA%\Horizon`), with
`current` pointing at the one in use;
+6 -5
View File
@@ -115,11 +115,12 @@ and some are waiting for Ludovic:
(#1024), which removes anything a pull request left in the work folder by accident, but
not a deliberate attack. The protection is review: only agents and maintainers push to this repository, and a
maintainer reads a fork's change before labelling it.
- **The first install** with `install.sh` checks the signature when OpenSSL 3 is installed: it verifies
`SHA256SUMS.sig` (a Nightly's `SHA256SUMS.v2.sig`, for its own version) against the release key built into the
script, then the archive against `SHA256SUMS`. With
an older OpenSSL, LibreSSL (what macOS ships) or none, it says so and checks only the SHA-256 GitHub
publishes. `install.ps1` still checks only that SHA-256. Every update after that checks the signature.
- **The first install** with `install.sh` or `install.ps1` checks the signature when OpenSSL 3 is installed: it
verifies `SHA256SUMS.sig` (a Nightly's `SHA256SUMS.v2.sig`, for its own version) against the release key built
into the script, then the archive against `SHA256SUMS`. `install.ps1` looks for OpenSSL on the PATH and in Git
for Windows, which ships it; Windows itself can't check Ed25519. With an older OpenSSL, LibreSSL (what macOS
ships) or none, the script says so and checks only the SHA-256 GitHub publishes. Every update after that checks
the signature.
## Reporting a security problem
+5 -3
View File
@@ -502,8 +502,9 @@ comes out of the headless-engine work.
- Linux and macOS:
`curl -fsSL https://raw.githubusercontent.com/horizon-rust/horizon/main/packaging/server/install.sh | sh`
The script picks the release archive for the OS and architecture (on Linux x86_64 the server archive,
on macOS arm64 the game's), downloads it from GitHub Releases, and checks it against the SHA-256 GitHub
publishes for each asset. It never asks for sudo. Options: `--version vX.Y.Z`, `--dir` (default
on macOS arm64 the game's), downloads it from GitHub Releases, and checks it against the release's signed
`SHA256SUMS` when OpenSSL 3 is installed, else against the SHA-256 GitHub publishes for each asset
(docs/security.md). It never asks for sudo. Options: `--version vX.Y.Z`, `--dir` (default
`~/.local/share/horizon`), `--bin` (default `~/.local/bin`), and `--archive FILE` to install an archive
already on the machine (a build of your own; no download and no checksum).
A Linux release from before the server archive installs the full game, which links ALSA's and udev's
@@ -514,7 +515,8 @@ comes out of the headless-engine work.
- Windows:
`irm https://raw.githubusercontent.com/horizon-rust/horizon/main/packaging/server/install.ps1 | iex`
The script does the same into `%LOCALAPPDATA%\Horizon` (`-Version`, `-Dir`), with junctions instead of
links (no admin rights), and adds `bin` to the user's PATH.
links (no admin rights), and adds `bin` to the user's PATH. It finds OpenSSL 3 on the PATH or in Git for
Windows to check the signature.
- **Layout.** Each release goes to `<dir>/releases/<tag>` and `<dir>/current` points at the one in use, so an
update or a rollback is one link. `<bin>/horizon-server` and `<bin>/horizon-prepare` are two-line launchers
that run `current`. The prepared game files live in `<dir>/game-files/horizon-client` and each release's
+103 -9
View File
@@ -1,6 +1,7 @@
# Installs horizon-server (docs/server.md) on Windows: the current Horizon Nightly (the only build Horizon ships;
# GitHub's latest release while there is none), downloaded from GitHub Releases and checked against the SHA-256
# GitHub publishes for it.
# GitHub's latest release while there is none), downloaded from GitHub Releases and checked against the release's
# signed SHA256SUMS when OpenSSL 3 is found (docs/release-flow.md, "Signed checksums"), else against the SHA-256
# GitHub publishes for it. OpenSSL is looked for on the PATH and in Git for Windows, which ships it.
#
# irm https://raw.githubusercontent.com/horizon-rust/horizon/main/packaging/server/install.ps1 | iex
# & ([scriptblock]::Create((irm .../install.ps1))) -Version v0.5.0 -Dir D:\Horizon
@@ -16,6 +17,9 @@ $ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue"
$Repo = "horizon-rust/horizon"
# Horizon's release public keys, base64: copy of crates/horizon-release-sign/release-keys.txt (rotation adds a key
# there first; keep the two in step, and install.sh's RELEASE_KEYS too).
$ReleaseKeys = @("MU4oiIQcCxkZvbwSjjXs7SySCuM6ywRQ3nGaJYZG0gw=")
$Fallback = $null
$Architecture = $env:PROCESSOR_ARCHITEW6432
if (-not $Architecture) { $Architecture = $env:PROCESSOR_ARCHITECTURE }
@@ -35,11 +39,18 @@ if ($Version -eq "latest") {
$Release = Get-Release "tags/$Version"
}
$Tag = $Release.tag_name
$RawTag = $Tag
# A Nightly installs under its date and commit, as `horizon-server update` names it:
# "Nightly 2026-10-28 <middle dot> 0c02067" is nightly-2026-10-28-0c02067 ([char]0xB7: this file stays ASCII).
if (($Tag -eq "nightly" -or $Tag.StartsWith("nightly-")) -and
$Release.name -match "^Nightly (\d{4}-\d{2}-\d{2}) $([char]0xB7) ([0-9a-f]{1,40})$") {
$Tag = "nightly-$($Matches[1])-$($Matches[2])"
# Its signature is checked for exactly that version, so the title must name it (as
# horizon_release_sign::nightly_title_version reads it, strictly).
$Nightly = $null
if ($Tag -eq "nightly" -or $Tag.StartsWith("nightly-")) {
if ($Release.name -cnotmatch "^Nightly (\d{4}-\d{2}-\d{2}) $([char]0xB7) ([0-9a-f]{7,40})$") {
throw "This Nightly's title doesn't name its date and commit as Horizon signs them, so it can't be checked: not installed"
}
$Nightly = "nightly-$($Matches[1])-$($Matches[2])"
$Tag = $Nightly
}
$Item = $Release.assets | Where-Object { $_.name -eq $Asset } | Select-Object -First 1
if (-not $Item -and $Fallback) {
@@ -48,18 +59,101 @@ if (-not $Item -and $Fallback) {
$Item = $Release.assets | Where-Object { $_.name -eq $Asset } | Select-Object -First 1
}
if (-not $Item) { throw "Release $Tag has no $Asset" }
if (-not $Item.digest -or -not $Item.digest.StartsWith("sha256:")) { throw "Release $Tag publishes no SHA-256 for $Asset" }
$Digest = $Item.digest.Substring(7)
$Digest = $null
if ($Item.digest -and $Item.digest.StartsWith("sha256:")) { $Digest = $Item.digest.Substring(7) }
# The first OpenSSL 3 on the PATH or in Git for Windows (its usr\bin and mingw64\bin), else what was found instead.
function Find-OpenSsl {
$Candidates = @()
$OnPath = Get-Command openssl -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
if ($OnPath) { $Candidates += $OnPath.Source }
$Git = Get-Command git -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
if ($Git) {
# git.exe is in Git\cmd, Git\bin or Git\mingw64\bin.
$Folder = Split-Path $Git.Source
foreach ($Up in 1, 2) {
$Folder = Split-Path $Folder
if (-not $Folder) { break }
foreach ($Sub in "usr\bin\openssl.exe", "mingw64\bin\openssl.exe") {
$Path = Join-Path $Folder $Sub
if (Test-Path -LiteralPath $Path -PathType Leaf) { $Candidates += $Path }
}
}
}
$Other = $null
foreach ($Path in $Candidates) {
$ErrorActionPreference = "Continue"
$Said = (& $Path version 2>$null | Select-Object -First 1)
if ("$Said" -match "^OpenSSL ([3-9]|[1-9][0-9])\.") { return @{ Path = $Path } }
if (-not $Other -and $Said) { $Other = ("$Said" -split " ")[0..1] -join " " }
}
return @{ Path = $null; Other = $Other }
}
$Tmp = Join-Path ([IO.Path]::GetTempPath()) ("horizon-install-" + [Guid]::NewGuid())
New-Item -ItemType Directory -Path $Tmp | Out-Null
try {
# The signature (docs/release-flow.md, "Signed checksums"): SHA256SUMS.sig holds one base64 Ed25519 signature
# per line over "horizon release <tag>\n" + SHA256SUMS. A Nightly is checked against SHA256SUMS.v2.sig instead,
# signed for its own version (nightly-<date>-<commit>, from its title), never the channel's SHA256SUMS.sig: an
# older Nightly can't be passed off as the current one (docs/security.md, "Nightly signatures"). Windows can't
# check Ed25519 by itself, so this needs OpenSSL 3; without it the install says so and keeps GitHub's SHA-256.
$Expect = $Digest
if ($Nightly) { $SigTag = $Nightly; $SigFile = "SHA256SUMS.v2.sig" } else { $SigTag = $RawTag; $SigFile = "SHA256SUMS.sig" }
$OpenSsl = Find-OpenSsl
if (-not $OpenSsl.Path -and $OpenSsl.Other) {
Write-Host "Note: $($OpenSsl.Other) can't check Ed25519 signatures (OpenSSL 3 is needed), so the release signature was not checked; checking GitHub's SHA-256 only."
} elseif (-not $OpenSsl.Path) {
Write-Host "Note: OpenSSL isn't installed (Git for Windows includes it), so the release signature was not checked; checking GitHub's SHA-256 only."
} else {
$SumsItem = $Release.assets | Where-Object { $_.name -eq "SHA256SUMS" } | Select-Object -First 1
$SigItem = $Release.assets | Where-Object { $_.name -eq $SigFile } | Select-Object -First 1
if ($Nightly -and (-not $SumsItem -or -not $SigItem)) {
throw "$Tag has no $SigFile (its signature for this exact build): it was published before Nightlies were signed for their own version, so it can't be checked. Install the current Nightly (-Version latest) instead."
} elseif (-not $SumsItem -or -not $SigItem) {
Write-Host "Note: release $Tag has no signed checksums (it was published before signing began), so the signature was not checked; checking GitHub's SHA-256 only."
} else {
$Sums = Join-Path $Tmp "SHA256SUMS"
$Sigs = Join-Path $Tmp "SHA256SUMS.sig"
Invoke-WebRequest -UseBasicParsing -Uri $SumsItem.browser_download_url -OutFile $Sums
Invoke-WebRequest -UseBasicParsing -Uri $SigItem.browser_download_url -OutFile $Sigs
$Message = Join-Path $Tmp "message"
[IO.File]::WriteAllBytes($Message, [byte[]]([Text.Encoding]::ASCII.GetBytes("horizon release $SigTag`n") + [IO.File]::ReadAllBytes($Sums)))
$KeyDer = Join-Path $Tmp "key.der"
$Signature = Join-Path $Tmp "signature"
$Signed = $false
foreach ($Key in $ReleaseKeys) {
# The raw 32-byte key in an Ed25519 SubjectPublicKeyInfo, the form OpenSSL reads.
$Prefix = [byte[]](0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00)
[IO.File]::WriteAllBytes($KeyDer, [byte[]]($Prefix + [Convert]::FromBase64String($Key)))
foreach ($Line in [IO.File]::ReadAllLines($Sigs)) {
if (-not $Line.Trim()) { continue }
try { [IO.File]::WriteAllBytes($Signature, [Convert]::FromBase64String($Line.Trim())) } catch { continue }
$ErrorActionPreference = "Continue"
& $OpenSsl.Path pkeyutl -verify -rawin -pubin -keyform DER -inkey $KeyDer -in $Message -sigfile $Signature 2>&1 | Out-Null
if ($LASTEXITCODE -eq 0) { $Signed = $true }
$ErrorActionPreference = "Stop"
}
}
if (-not $Signed) {
throw "$Tag's SHA256SUMS signature doesn't match Horizon's release key: not installed. The download may have been tampered with; please report it on Discord or GitHub."
}
$Expect = $null
foreach ($Line in [IO.File]::ReadAllLines($Sums)) {
if ($Line -match "^([0-9a-fA-F]{64}) [ *](.+)$" -and $Matches[2] -eq $Asset) { $Expect = $Matches[1]; break }
}
if (-not $Expect) { throw "The signed SHA256SUMS doesn't list ${Asset}: not installed" }
Write-Host "Release signature OK (Horizon release key)."
}
}
if (-not $Expect) { throw "Release $Tag publishes no SHA-256 for $Asset" }
Write-Host "Downloading Horizon $Tag ($Asset)..."
$Zip = Join-Path $Tmp $Asset
Invoke-WebRequest -Uri $Item.browser_download_url -OutFile $Zip
$Got = (Get-FileHash -Algorithm SHA256 $Zip).Hash.ToLowerInvariant()
if ($Got -ne $Digest.ToLowerInvariant()) {
throw "$Asset doesn't match its SHA-256 (got $Got, expected $Digest): not installed"
if ($Got -ne $Expect.ToLowerInvariant()) {
throw "$Asset doesn't match its SHA-256 (got $Got, expected $Expect): not installed"
}
$Unpacked = Join-Path $Tmp "x"
Expand-Archive -Path $Zip -DestinationPath $Unpacked
+228
View File
@@ -0,0 +1,228 @@
# PURPOSE: proves packaging/server/install.ps1 checks the release signature (docs/release-flow.md, "Signed checksums").
# WHY: the first install on Windows used to trust only the SHA-256 GitHub publishes (docs/security.md).
# REFS: tests/install_sh_signature.sh (the same cases for install.sh), tools/ci/sign_release.sh (the format signed here).
# LOGS: prints one ok/FAIL line per case; exit 1 when any case fails.
# READS-BY: tests/test_install_ps1_signature.py (unittest wrapper), by hand: pwsh -File tests/install_ps1_signature.ps1
# ENDS-WHEN: it ends by itself; leaves nothing outside its own temporary folder.
# Windows only, Windows PowerShell 5.1 or PowerShell 7. Needs OpenSSL 3 (to sign the throwaway releases); skips
# (exit 77) without it. Invoke-RestMethod and Invoke-WebRequest are replaced by fakes that serve local files, and the
# user's PATH is never written, so nothing touches the network or the account.
$ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue"
$Root = Split-Path -Parent $PSScriptRoot
$Installer = Join-Path $Root "packaging\server\install.ps1"
function Find-TestOpenSsl {
$Paths = @()
$OnPath = Get-Command openssl -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
if ($OnPath) { $Paths += $OnPath.Source }
$Git = Get-Command git -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
if ($Git) { $Paths += Join-Path (Split-Path (Split-Path $Git.Source)) "usr\bin\openssl.exe" }
foreach ($Path in $Paths) {
if (-not (Test-Path -LiteralPath $Path)) { continue }
if ("$(& $Path version 2>$null)" -match "^OpenSSL [3-9]\.") { return $Path }
}
return $null
}
$Ssl = Find-TestOpenSsl
if (-not $Ssl) { Write-Output "skip: OpenSSL 3 needed"; exit 77 }
$W = Join-Path ([IO.Path]::GetTempPath()) ("horizon-ps1-test-" + [Guid]::NewGuid())
New-Item -ItemType Directory -Path $W | Out-Null
# The installer always picks the x64 archive, the one these releases hold, whatever this machine is.
$env:PROCESSOR_ARCHITECTURE = "AMD64"
$env:PROCESSOR_ARCHITEW6432 = $null
$SavedPath = $env:PATH
$Asset = "horizon-windows-x64.zip"
$RealKey = "MU4oiIQcCxkZvbwSjjXs7SySCuM6ywRQ3nGaJYZG0gw="
$script:Fails = 0
$global:HorizonTestRel = $null
# The user's PATH stays as it is: the copies of install.ps1 write through this class instead of [Environment].
Add-Type -TypeDefinition @"
public static class FakeEnvironment {
public static string GetEnvironmentVariable(string name, string scope) { return ""; }
public static void SetEnvironmentVariable(string name, string value, string scope) { }
}
"@
# The fakes: the API answers with the case's release, any download with the case's file of that name.
function Invoke-RestMethod { param($Uri, $Headers) $global:HorizonTestRel.Release }
function Invoke-WebRequest {
param($Uri, $OutFile, [switch]$UseBasicParsing)
$Source = Join-Path $global:HorizonTestRel.Files ($Uri -split "/")[-1]
if (-not (Test-Path -LiteralPath $Source)) { throw "404 $Uri" }
Copy-Item -LiteralPath $Source -Destination $OutFile
}
function Write-Ascii([string]$Path, [string]$Text) { [IO.File]::WriteAllText($Path, $Text, [Text.Encoding]::ASCII) }
function Get-Sha([string]$Path) { (Get-FileHash -Algorithm SHA256 -LiteralPath $Path).Hash.ToLowerInvariant() }
function Get-Title([string]$Tag, [string]$Also) {
if ($Also) { "Nightly $($Also.Substring(8, 10)) $([char]0xB7) $($Also.Substring(19))" } else { $Tag }
}
# One base64 signature line over "horizon release <tag>\n" + SHA256SUMS, as tools/ci/sign_release.sh writes it.
function New-SignatureLine([string]$Dir, [string]$Tag) {
$Message = Join-Path $Dir "message"
[IO.File]::WriteAllBytes($Message, [byte[]]([Text.Encoding]::ASCII.GetBytes("horizon release $Tag`n") + [IO.File]::ReadAllBytes((Join-Path $Dir "files\SHA256SUMS"))))
& $Ssl pkeyutl -sign -rawin -inkey (Join-Path $Dir "key") -keyform DER -in $Message -out (Join-Path $Dir "sig.bin")
[Convert]::ToBase64String([IO.File]::ReadAllBytes((Join-Path $Dir "sig.bin"))) + "`n"
}
# A fake release: a zip holding horizon-server.exe, signed for Tag with a throwaway key. A Nightly (Tag nightly) is
# also signed for its own version Also (nightly-<date>-<commit>) in SHA256SUMS.v2.sig, titled as nightly-build.yml
# titles it.
function New-Release([string]$Dir, [string]$Tag, [string]$Also = "") {
New-Item -ItemType Directory -Force -Path (Join-Path $Dir "src\horizon-server-x"), (Join-Path $Dir "files") | Out-Null
Write-Ascii (Join-Path $Dir "src\horizon-server-x\horizon-server.exe") "fake"
Compress-Archive -Path (Join-Path $Dir "src\horizon-server-x") -DestinationPath (Join-Path $Dir "files\$Asset")
Write-Ascii (Join-Path $Dir "files\SHA256SUMS") "$(Get-Sha (Join-Path $Dir "files\$Asset")) $Asset`n"
& $Ssl genpkey -algorithm ed25519 -outform DER -out (Join-Path $Dir "key")
& $Ssl pkey -inform DER -in (Join-Path $Dir "key") -pubout -outform DER -out (Join-Path $Dir "pub.der")
$Pub = [IO.File]::ReadAllBytes((Join-Path $Dir "pub.der"))
$script:Key = [Convert]::ToBase64String($Pub[($Pub.Length - 32)..($Pub.Length - 1)])
Write-Ascii (Join-Path $Dir "files\SHA256SUMS.sig") (New-SignatureLine $Dir $Tag)
if ($Also) { Write-Ascii (Join-Path $Dir "files\SHA256SUMS.v2.sig") (New-SignatureLine $Dir $Also) }
Set-Release $Dir $Tag $true (Get-Title $Tag $Also)
Set-Installer $Dir $script:Key
}
# GitHub's release, as Invoke-RestMethod returns it: the archive with its digest, and the checksum files.
function Set-Release([string]$Dir, [string]$Tag, [bool]$Sums, [string]$Title = $Tag) {
$Assets = @([pscustomobject]@{
name = $Asset; digest = "sha256:$(Get-Sha (Join-Path $Dir "files\$Asset"))"
browser_download_url = "https://dl.test/$Asset"
})
if ($Sums) {
foreach ($Name in "SHA256SUMS", "SHA256SUMS.sig", "SHA256SUMS.v2.sig") {
if (Test-Path -LiteralPath (Join-Path $Dir "files\$Name")) {
$Assets += [pscustomobject]@{ name = $Name; browser_download_url = "https://dl.test/$Name" }
}
}
}
$script:Releases[$Dir] = [pscustomobject]@{ tag_name = $Tag; name = $Title; assets = $Assets }
}
$script:Releases = @{}
# install.ps1 with the throwaway public key in place of the release key, and the fake environment.
function Set-Installer([string]$Dir, [string]$Key) {
$Text = [IO.File]::ReadAllText($Installer)
$Patched = $Text -replace '(?m)^\$ReleaseKeys = .*$', "`$ReleaseKeys = @(`"$Key`")" -replace '\[Environment\]::', '[FakeEnvironment]::'
if (-not $Patched.Contains($Key) -or $Patched.Contains("[Environment]::")) { throw "could not patch install.ps1" }
Write-Ascii (Join-Path $Dir "install.ps1") $Patched
}
# Runs the case's install.ps1 with PATH set to Path; returns @{ Ok; Out }.
function Invoke-Case([string]$Dir, [string]$Path, [string[]]$Arguments = @()) {
$global:HorizonTestRel = [pscustomobject]@{ Release = $script:Releases[$Dir]; Files = Join-Path $Dir "files" }
$env:PATH = $Path
$Ok = $true
$Out = try {
& (Join-Path $Dir "install.ps1") -Dir (Join-Path $Dir "inst") @Arguments 6>&1 | Out-String
} catch { $Ok = $false; $_.Exception.Message }
$env:PATH = $SavedPath
@{ Ok = $Ok; Out = "$Out" }
}
function Test-Case([string]$Name, [bool]$WantOk, [string]$Pattern, $Result) {
if ($Result.Ok -eq $WantOk -and (-not $Pattern -or $Result.Out.Contains($Pattern))) {
Write-Output "ok $Name"
} else {
Write-Output "FAIL $Name (installed: $($Result.Ok), wanted $WantOk, pattern '$Pattern')"
Write-Output (($Result.Out -split "`n" | ForEach-Object { " $_" }) -join "`n")
$script:Fails++
}
}
function Test-That([string]$Name, [bool]$Condition) {
if (-not $Condition) { Write-Output "FAIL $Name"; $script:Fails++ }
}
try {
# The cases get the OpenSSL 3 found above, alone on their PATH.
$P = Split-Path $Ssl
$Empty = Join-Path $W "empty"; New-Item -ItemType Directory -Path $Empty | Out-Null
$Nightly = "nightly-2026-11-03-0c02067"
# 1. Good signature, a Nightly: SHA256SUMS.v2.sig signed for the version its title names.
$d = Join-Path $W "good"; New-Release $d "nightly" $Nightly
$r = Invoke-Case $d $P; Test-Case "good signature installs (Nightly, signed for its own version)" $true "Release signature OK" $r
Test-That "nothing installed" (Test-Path (Join-Path $d "inst\current\horizon-server.exe"))
Test-That "not installed as $Nightly" ($r.Out.Contains("Downloading Horizon $Nightly "))
# 1b. A Nightly with only the channel's signature (published before v2): refused, never the channel's signature.
$d = Join-Path $W "channel"; New-Release $d "nightly"; Set-Release $d "nightly" $true (Get-Title "nightly" $Nightly)
$r = Invoke-Case $d $P; Test-Case "Nightly without SHA256SUMS.v2.sig refused" $false "has no SHA256SUMS.v2.sig" $r
Test-That "a Nightly without v2 was installed" (-not (Test-Path (Join-Path $d "inst\current")))
# 1c. An older Nightly's files and signature retitled as a newer one: its v2 signature names the old version.
$d = Join-Path $W "rollback"; New-Release $d "nightly" "nightly-2026-11-01-1111111"
Set-Release $d "nightly" $true (Get-Title "nightly" $Nightly)
$r = Invoke-Case $d $P; Test-Case "older Nightly passed off as a newer one refused" $false "signature doesn't match" $r
# 1d. A Nightly whose title doesn't name its date and commit: refused, not dated some other way.
$d = Join-Path $W "title"; New-Release $d "nightly" $Nightly; Set-Release $d "nightly" $true "Nightly"
$r = Invoke-Case $d $P; Test-Case "Nightly with an unreadable title refused" $false "title doesn't name its date and commit" $r
# 2. Good signature, a normal tag.
$d = Join-Path $W "tag"; New-Release $d "v1.2.3"
$r = Invoke-Case $d $P @("-Version", "v1.2.3"); Test-Case "good signature installs (release tag)" $true "Release signature OK" $r
# 3. Tampered archive: GitHub's digest is made to agree with the swapped archive, only SHA256SUMS catches it.
$d = Join-Path $W "arc"; New-Release $d "v1.2.3"
Add-Content -LiteralPath (Join-Path $d "files\$Asset") -Value "x"; Set-Release $d "v1.2.3" $true
$r = Invoke-Case $d $P @("-Version", "v1.2.3"); Test-Case "tampered archive refused" $false "doesn't match its SHA-256" $r
Test-That "tampered archive was installed" (-not (Test-Path (Join-Path $d "inst\current")))
# 4. Tampered SHA256SUMS (the archive's line swapped for the hash of the tampered archive).
$d = Join-Path $W "sums"; New-Release $d "v1.2.3"
Add-Content -LiteralPath (Join-Path $d "files\$Asset") -Value "x"
Write-Ascii (Join-Path $d "files\SHA256SUMS") "$(Get-Sha (Join-Path $d "files\$Asset")) $Asset`n"; Set-Release $d "v1.2.3" $true
$r = Invoke-Case $d $P @("-Version", "v1.2.3"); Test-Case "tampered SHA256SUMS refused" $false "signature doesn't match" $r
# 5. A signature made for another tag does not pass (a release's list replayed under another tag).
$d = Join-Path $W "replay"; New-Release $d "v1.2.3"; Set-Release $d "v9.9.9" $true
$r = Invoke-Case $d $P @("-Version", "v9.9.9"); Test-Case "signature replayed under another tag refused" $false "signature doesn't match" $r
# 6. Signed by a key that is not the release key.
$d = Join-Path $W "wrong"; New-Release $d "v1.2.3"; Set-Installer $d $RealKey
$r = Invoke-Case $d $P @("-Version", "v1.2.3"); Test-Case "wrong key refused" $false "signature doesn't match" $r
# 7. No OpenSSL: says so, keeps the SHA-256 check.
$d = Join-Path $W "none"; New-Release $d "v1.2.3"
$r = Invoke-Case $d $Empty @("-Version", "v1.2.3"); Test-Case "no OpenSSL: installs, says it was not checked" $true "OpenSSL isn't installed" $r
$d = Join-Path $W "none2"; New-Release $d "v1.2.3"; $script:Releases[$d].assets[0].digest = "sha256:0000"
$r = Invoke-Case $d $Empty @("-Version", "v1.2.3"); Test-Case "no OpenSSL: a wrong GitHub SHA-256 still refused" $false "doesn't match its SHA-256" $r
# 8. Old OpenSSL and LibreSSL: says so, keeps the SHA-256 check.
foreach ($v in "OpenSSL 1.1.1w 11 Sep 2023", "LibreSSL 3.3.6") {
$d = Join-Path $W ("old-" + $v.Split(" ")[0]); New-Release $d "v1.2.3"
$Bin = Join-Path $d "old-bin"; New-Item -ItemType Directory -Path $Bin | Out-Null
Write-Ascii (Join-Path $Bin "openssl.cmd") "@echo $v`r`n"
$r = Invoke-Case $d $Bin @("-Version", "v1.2.3"); Test-Case "$($v.Split(' ')[0]): installs, says it can't check signatures" $true "can't check Ed25519" $r
}
# 9. A release published before signing (no SHA256SUMS): says so, keeps the SHA-256 check.
$d = Join-Path $W "legacy"; New-Release $d "v1.2.3"; Set-Release $d "v1.2.3" $false
$r = Invoke-Case $d $P @("-Version", "v1.2.3"); Test-Case "unsigned release: installs, says so" $true "no signed checksums" $r
# 10. OpenSSL found in Git for Windows, not on the PATH: git.exe in Git\cmd, OpenSSL in Git\usr\bin (or
# Git\mingw64\bin). Only when this machine's OpenSSL is such a copy, which the fake Git folder links to.
$Up = Split-Path $P
if ((Split-Path $P -Leaf) -eq "bin" -and (Split-Path $Up -Leaf) -in "usr", "mingw64") {
$d = Join-Path $W "git"; New-Release $d "v1.2.3"
$GitDir = Join-Path $d "Git"; New-Item -ItemType Directory -Path (Join-Path $GitDir "cmd") | Out-Null
Write-Ascii (Join-Path $GitDir "cmd\git.exe") ""
New-Item -ItemType Junction -Path (Join-Path $GitDir (Split-Path $Up -Leaf)) -Target $Up | Out-Null
$r = Invoke-Case $d (Join-Path $GitDir "cmd") @("-Version", "v1.2.3"); Test-Case "OpenSSL from Git for Windows checks the signature" $true "Release signature OK" $r
(Get-Item (Join-Path $GitDir (Split-Path $Up -Leaf))).Delete()
} else {
Write-Output "skip OpenSSL from Git for Windows (this OpenSSL isn't Git's)"
}
} finally {
$env:PATH = $SavedPath
Remove-Item -Recurse -Force $W -ErrorAction SilentlyContinue
}
Write-Output "$($script:Fails) case(s) failed"
if ($script:Fails -ne 0) { exit 1 }
exit 0
+35
View File
@@ -0,0 +1,35 @@
"""Runs tests/install_ps1_signature.ps1: packaging/server/install.ps1 checks the release signature.
PURPOSE: keep install.ps1's Ed25519 check from regressing (docs/release-flow.md, "Signed checksums").
REFS: tests/install_ps1_signature.ps1, tests/test_install_sh_signature.py (the same for install.sh).
Skips quietly off Windows and without PowerShell or OpenSSL 3. Headless, no network.
"""
import os
import shutil
import subprocess
import unittest
from pathlib import Path
SCRIPT = Path(__file__).with_name("install_ps1_signature.ps1")
class InstallPs1Signature(unittest.TestCase):
# install.ps1 installs the Windows server: junctions, %LOCALAPPDATA%, the user's PATH.
@unittest.skipUnless(os.name == "nt", "install.ps1 is for Windows")
@unittest.skipUnless(shutil.which("pwsh") or shutil.which("powershell"), "PowerShell needed")
def test_signature_cases(self):
shell = shutil.which("pwsh") or shutil.which("powershell")
run = subprocess.run(
[shell, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(SCRIPT)],
capture_output=True,
text=True,
timeout=300,
)
if run.returncode == 77:
self.skipTest(run.stdout.strip())
self.assertEqual(run.returncode, 0, run.stdout + run.stderr)
if __name__ == "__main__":
unittest.main()