mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 02:56:17 -10:00
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:
6 files changed
+379
-18
No files matched your search
+2
-1
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user