diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index bc8b3a35b9..3d78d068fd 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -965,6 +965,19 @@ jobs: echo "Available binaries: $BINARIES" echo "Available SBOMs: $SBOMS" + # Publish a checksum manifest alongside the assets. install.sh verifies a + # downloaded binary against this before moving it into place, so the + # manifest must name assets exactly as they are uploaded -- basenames, + # not the artifact paths they were downloaded to. + SUMS_FILE="SHA256SUMS" + : > "$SUMS_FILE" + for f in $BINARIES scripts/install.sh scripts/install.ps1; do + [ -f "$f" ] || continue + sha256sum "$f" | sed "s# .*/# #" >> "$SUMS_FILE" + done + echo "Checksum manifest:" + cat "$SUMS_FILE" + # Clean up rc pre-releases (created by the prerelease job on main pushes) gh release list --repo veryfront/veryfront --json tagName -q ".[].tagName" \ | grep "^v${VERSION}-rc\." \ @@ -984,6 +997,7 @@ jobs: npm install -g veryfront ```' \ $BINARIES \ + "$SUMS_FILE" \ scripts/install.sh \ scripts/install.ps1 \ $SBOMS diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 3f4ac4f17c..c5c41ebac8 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -73,11 +73,57 @@ function Install-Veryfront { Write-Output "" Write-Output " Downloading $downloadUrl..." + # Stage the download and verify it before it becomes the installed + # executable, so a truncated or tampered file is never left in place. + $stagingDir = Join-Path ([System.IO.Path]::GetTempPath()) ("veryfront-install-" + [guid]::NewGuid().ToString()) + New-Item -ItemType Directory -Path $stagingDir -Force | Out-Null try { - Invoke-WebRequest -Uri $downloadUrl -OutFile $binaryPath -UseBasicParsing + $stagedBinary = Join-Path $stagingDir $binaryName + + try { + Invoke-WebRequest -Uri $downloadUrl -OutFile $stagedBinary -UseBasicParsing + } + catch { + throw "Failed to download binary: $_" + } + + if ($env:VERYFRONT_INSTALL_SKIP_CHECKSUM -eq "1") { + Write-Output " Skipping checksum verification (VERYFRONT_INSTALL_SKIP_CHECKSUM=1)" + } + else { + # Fails closed: releases published before the manifest existed have no + # SHA256SUMS asset, and pinning to one of those needs the escape hatch. + $sumsUrl = "https://github.com/$Repo/releases/download/v$Version/SHA256SUMS" + $sumsPath = Join-Path $stagingDir "SHA256SUMS" + try { + Invoke-WebRequest -Uri $sumsUrl -OutFile $sumsPath -UseBasicParsing + } + catch { + throw "No SHA256SUMS published for v$Version, so the download could not be verified and was not installed. To install anyway, set VERYFRONT_INSTALL_SKIP_CHECKSUM=1." + } + + $expected = $null + foreach ($line in Get-Content -Path $sumsPath) { + $fields = $line -split '\s+', 2 + if ($fields.Count -eq 2 -and $fields[1].Trim().TrimStart('*') -eq $binaryName) { + $expected = $fields[0].Trim().ToLower() + break + } + } + if (-not $expected) { + throw "$binaryName is not listed in SHA256SUMS for v$Version, so it was not installed." + } + + $actual = (Get-FileHash -Path $stagedBinary -Algorithm SHA256).Hash.ToLower() + if ($actual -ne $expected) { + throw "Checksum mismatch for ${binaryName}: expected $expected, got $actual. The download was discarded and nothing was installed." + } + } + + Move-Item -Path $stagedBinary -Destination $binaryPath -Force } - catch { - throw "Failed to download binary: $_" + finally { + Remove-Item -Path $stagingDir -Recurse -Force -ErrorAction SilentlyContinue } Write-Output "" diff --git a/scripts/install.sh b/scripts/install.sh index 68fffe29d7..3ed9445744 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -120,6 +120,64 @@ get_latest_version() { # Download file silently # Download file silently +# Hash a file with whichever SHA-256 tool the platform ships. +sha256_of() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + else + return 1 + fi +} + +# Verify a staged download against the release's published SHA256SUMS. +# +# Fails closed: an unverified binary is not installed. Releases published before +# the manifest existed have no SHA256SUMS asset, so pinning to one of those needs +# the escape hatch, which has to be set deliberately. +verify_checksum() { + FILE="$1" + NAME="$2" + VER="$3" + WORK="$4" + + if [ "${VERYFRONT_INSTALL_SKIP_CHECKSUM:-}" = "1" ]; then + printf "\r${ORANGE}Skipping checksum verification (VERYFRONT_INSTALL_SKIP_CHECKSUM=1)${NC}\n" + return 0 + fi + + SUMS_URL="https://github.com/${REPO}/releases/download/v${VER}/SHA256SUMS" + SUMS_FILE="${WORK}/SHA256SUMS" + + if ! download "$SUMS_URL" "$SUMS_FILE" 2>/dev/null; then + printf "\r%s\n" "Install failed: no SHA256SUMS published for v${VER}." >&2 + echo " The binary was downloaded but not installed, because it could not be verified." >&2 + echo " Releases published before checksums existed have no manifest." >&2 + echo " To install anyway, re-run with VERYFRONT_INSTALL_SKIP_CHECKSUM=1." >&2 + exit 1 + fi + + EXPECTED=$(awk -v want="$NAME" '$2 == want || $2 == "*" want { print $1; exit }' "$SUMS_FILE") + if [ -z "$EXPECTED" ]; then + printf "\r%s\n" "Install failed: ${NAME} is not listed in SHA256SUMS for v${VER}." >&2 + exit 1 + fi + + ACTUAL=$(sha256_of "$FILE") || { + printf "\r%s\n" "Install failed: no sha256sum or shasum available to verify the download." >&2 + exit 1 + } + + if [ "$ACTUAL" != "$EXPECTED" ]; then + printf "\r%s\n" "Install failed: checksum mismatch for ${NAME}." >&2 + echo " expected ${EXPECTED}" >&2 + echo " actual ${ACTUAL}" >&2 + echo " The download was discarded and nothing was installed." >&2 + exit 1 + fi +} + download() { URL="$1" DEST="$2" @@ -158,12 +216,21 @@ main() { # Create install directory mkdir -p "$INSTALL_DIR" - # Download binary BINARY_PATH="${INSTALL_DIR}/veryfront" + # Download to a staging file first: a binary is only moved into place after its + # checksum matches, so a truncated or tampered download never becomes the + # installed executable. + STAGING_DIR=$(mktemp -d "${TMPDIR:-/tmp}/veryfront-install.XXXXXX") || { + echo "Error: could not create a temporary directory" >&2 + exit 1 + } + trap 'rm -rf "$STAGING_DIR"' EXIT INT TERM + STAGED_BINARY="${STAGING_DIR}/${BINARY_NAME}" + # Download with spinner printf "${ORANGE}Installing Veryfront v%s...${NC}" "$VERSION" - download "$DOWNLOAD_URL" "$BINARY_PATH" & + download "$DOWNLOAD_URL" "$STAGED_BINARY" & PID=$! SPINNER='|/-\' i=0 @@ -179,7 +246,10 @@ main() { exit 1 fi - chmod +x "$BINARY_PATH" + verify_checksum "$STAGED_BINARY" "$BINARY_NAME" "$VERSION" "$STAGING_DIR" + + chmod +x "$STAGED_BINARY" + mv -f "$STAGED_BINARY" "$BINARY_PATH" # Add to PATH if not already there NEEDS_SOURCE=""