cat / goq
clone

turns a linux box into a private steam streaming appliance: plug in, tap your key, play from another computer

fix/hero-release-note
goq / scripts / verify-sigil-release.sh
verify-sigil-release.sh 11.1K executable
1#!/usr/bin/env bash23set -euo pipefail45usage() {6 cat <<'EOF'7Usage: scripts/verify-sigil-release.sh \8 --tag vVERSION --archive /absolute/path/sigil-vVERSION-linux-glibc2.17-x86_64.tar.gz \9 [--source-commit HEX] [--candidate | --public-key-file PATH]1011Verify the exact Sigil Linux/glibc/x86_64 release asset contract without12extracting it. This target identity does not broaden the separately validated13host-distribution support boundary.14Candidate mode verifies a clean, unsigned product candidate before it crosses15the offline signing boundary. Published mode additionally requires and verifies16ARCHIVE.minisig with the reviewed Minisign public key file.17EOF18}1920die() {21 printf 'release verification failed: %s\n' "$*" >&222 exit 123}2425script_dir="$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"26repo_dir="$(CDPATH='' cd -- "$script_dir/.." && pwd -P)"27asset_target_contract_file="$repo_dir/release/sigil-target-contract.txt"28[[ -f "$asset_target_contract_file" && ! -L "$asset_target_contract_file" ]] \29 || die "Sigil asset target contract is missing or unsafe"30[[ "$(wc -l <"$asset_target_contract_file" | tr -d ' ')" == 1 ]] \31 || die "Sigil asset target contract must contain exactly one line"32asset_target_contract="$(<"$asset_target_contract_file")"33[[ "$asset_target_contract" == linux-glibc2.17-x86_64 ]] \34 || die "Sigil asset target contract does not match the approved Linux build target"3536sha256_file() {37 if command -v sha256sum >/dev/null 2>&1; then38 sha256sum "$1" | awk '{print $1}'39 else40 shasum -a 256 "$1" | awk '{print $1}'41 fi42}4344release_tag=""45archive_path=""46source_commit=""47public_key_file=""48candidate=false4950while [[ $# -gt 0 ]]; do51 case "$1" in52 --tag)53 [[ $# -ge 2 ]] || die "--tag requires a value"54 release_tag="$2"55 shift 256 ;;57 --archive)58 [[ $# -ge 2 ]] || die "--archive requires a path"59 archive_path="$2"60 shift 261 ;;62 --source-commit)63 [[ $# -ge 2 ]] || die "--source-commit requires a value"64 source_commit="$2"65 shift 266 ;;67 --public-key-file)68 [[ $# -ge 2 ]] || die "--public-key-file requires a path"69 public_key_file="$2"70 shift 271 ;;72 --candidate)73 candidate=true74 shift75 ;;76 --help|-h)77 usage78 exit 079 ;;80 *) die "unknown argument: $1" ;;81 esac82done8384[[ "$release_tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z][0-9A-Za-z.-]*)?$ ]] \85 || die "--tag must be vVERSION"86[[ -z "$source_commit" || "$source_commit" =~ ^[0-9a-f]{40}$ ]] \87 || die "--source-commit must be a 40-character lowercase commit"88[[ "$archive_path" == /* ]] || die "--archive must be absolute"89[[ -f "$archive_path" && ! -L "$archive_path" ]] || die "archive is missing or unsafe"9091asset_name="sigil-$release_tag-$asset_target_contract.tar.gz"92[[ "$(basename -- "$archive_path")" == "$asset_name" ]] \93 || die "archive must be named $asset_name"94checksum_path="$archive_path.sha256"95signature_path="$archive_path.minisig"96[[ -f "$checksum_path" && ! -L "$checksum_path" ]] || die "checksum asset is missing or unsafe"9798for command_name in awk python3 wc; do99 command -v "$command_name" >/dev/null 2>&1 || die "$command_name is required"100done101if ! command -v sha256sum >/dev/null 2>&1 && ! command -v shasum >/dev/null 2>&1; then102 die "sha256sum or shasum is required"103fi104105archive_bytes="$(wc -c <"$archive_path" | tr -d ' ')"106[[ "$archive_bytes" =~ ^[0-9]+$ && "$archive_bytes" -gt 0 \107 && "$archive_bytes" -le 268435456 ]] || die "archive size is outside the 256 MiB release limit"108[[ "$(wc -l <"$checksum_path" | tr -d ' ')" == 1 ]] \109 || die "checksum asset must contain exactly one line"110actual_sha256="$(sha256_file "$archive_path")"111checksum_declaration="$(<"$checksum_path")"112[[ "$checksum_declaration" == "$actual_sha256 $asset_name" ]] \113 || die "checksum declaration does not exactly match the archive"114115if $candidate; then116 [[ -z "$public_key_file" ]] || die "--candidate and --public-key-file are mutually exclusive"117 [[ ! -e "$signature_path" ]] || die "unsigned candidate directory already contains a signature"118 verification_mode="candidate"119else120 [[ -n "$public_key_file" ]] || die "published verification requires --public-key-file"121 [[ "$public_key_file" == /* && -f "$public_key_file" && ! -L "$public_key_file" ]] \122 || die "public key file is missing or unsafe"123 [[ -f "$signature_path" && ! -L "$signature_path" ]] || die "detached signature is missing or unsafe"124 command -v minisign >/dev/null 2>&1 || die "minisign is required for published verification"125 publisher_key="$(awk '/^RW[A-Za-z0-9+\/=]+$/ { key=$0 } END { print key }' "$public_key_file")"126 [[ "$publisher_key" =~ ^RW[A-Za-z0-9+/=]{40,}$ ]] \127 || die "public key file is unconfigured or malformed"128 minisign -Vm "$archive_path" -x "$signature_path" -P "$publisher_key" >/dev/null \129 || die "detached publisher signature is invalid"130 verification_mode="published"131fi132133python3 - "$archive_path" "$release_tag" "$asset_name" "$source_commit" <<'PY'134import hashlib135import json136import pathlib137import re138import sys139import tarfile140141archive_path = pathlib.Path(sys.argv[1])142release_tag = sys.argv[2]143asset_name = sys.argv[3]144source_commit = sys.argv[4]145146expected_dirs = {147 "payload/release",148 "payload/release/assets",149 "payload/release/docs",150 "payload/release/tools",151}152release_files = {153 "LICENSE",154 "SHA256SUMS",155 "assets/50-sigil-spark-audio.conf",156 "assets/70-sigil-remote-input.rules",157 "assets/72-sigil-uinput.rules",158 "assets/99-sigil-uinput.rules",159 "assets/sigil-host.service",160 "docs/sigil-host-activation.md",161 "release-manifest.json",162 "sigil",163 "sigil-host",164 "sigil-probe",165 "tools/rollback-bazzite-release.sh",166}167expected_files = {168 "payload/PACKAGE-SHA256SUMS",169 "payload/install-bazzite-package.sh",170 "payload/release-id",171 "payload/stage-this-release.sh",172 *{f"payload/release/{name}" for name in release_files},173}174175def fail(message: str) -> None:176 raise SystemExit(f"release verification failed: {message}")177178def digest(data: bytes) -> str:179 return hashlib.sha256(data).hexdigest()180181def parse_sums(data: bytes, expected: set[str], label: str) -> dict[str, str]:182 try:183 text = data.decode("utf-8")184 except UnicodeDecodeError:185 fail(f"{label} is not UTF-8")186 lines = text.splitlines()187 if not lines or not text.endswith("\n"):188 fail(f"{label} is empty or lacks its final newline")189 parsed: dict[str, str] = {}190 for line in lines:191 match = re.fullmatch(r"([0-9a-f]{64}) ([A-Za-z0-9._/-]+)", line)192 if not match:193 fail(f"{label} contains a malformed line")194 sha, name = match.groups()195 if name in parsed:196 fail(f"{label} contains duplicate path {name}")197 parsed[name] = sha198 if set(parsed) != expected:199 fail(f"{label} paths do not match the release allowlist")200 return parsed201202try:203 archive = tarfile.open(archive_path, "r:gz")204except (tarfile.TarError, OSError) as error:205 fail(f"archive is unreadable: {error}")206207with archive:208 members = archive.getmembers()209 names = [member.name.rstrip("/") for member in members]210 if len(names) != len(set(names)):211 fail("archive contains duplicate members")212 if set(names) != expected_dirs | expected_files:213 fail("archive members do not match the package allowlist")214 files: dict[str, bytes] = {}215 for member, name in zip(members, names, strict=True):216 path = pathlib.PurePosixPath(name)217 if path.is_absolute() or not path.parts or any(part in {"", ".", ".."} for part in path.parts):218 fail(f"archive contains unsafe path {name!r}")219 if member.uid != 0 or member.gid != 0 or member.mtime != 0:220 fail(f"archive member metadata is not deterministic: {name}")221 if name in expected_dirs:222 if not member.isdir():223 fail(f"archive directory has unsafe type: {name}")224 continue225 if not member.isreg():226 fail(f"archive file has unsafe type: {name}")227 if member.size > 134217728:228 fail(f"archive member exceeds 128 MiB: {name}")229 stream = archive.extractfile(member)230 if stream is None:231 fail(f"archive member cannot be read: {name}")232 files[name] = stream.read()233234package_expected = {235 "release-id",236 "install-bazzite-package.sh",237 "stage-this-release.sh",238 "release/SHA256SUMS",239}240package_sums = parse_sums(files["payload/PACKAGE-SHA256SUMS"], package_expected, "PACKAGE-SHA256SUMS")241for name, sha in package_sums.items():242 if digest(files[f"payload/{name}"]) != sha:243 fail(f"package checksum mismatch for {name}")244245release_expected = release_files - {"SHA256SUMS"}246release_sums = parse_sums(files["payload/release/SHA256SUMS"], release_expected, "release SHA256SUMS")247for name, sha in release_sums.items():248 if digest(files[f"payload/release/{name}"]) != sha:249 fail(f"release checksum mismatch for {name}")250release_id = files["payload/release-id"].decode("ascii", errors="strict")251if release_id != digest(files["payload/release/SHA256SUMS"]) + "\n":252 fail("release-id does not bind the installed-file checksums")253if files["payload/release/sigil"] != files["payload/release/sigil-host"]:254 fail("sigil-host compatibility executable differs from sigil")255256try:257 manifest = json.loads(files["payload/release/release-manifest.json"])258except (UnicodeDecodeError, json.JSONDecodeError) as error:259 fail(f"release manifest is invalid: {error}")260expected_values = {261 "format": 2,262 "product": "sigil-host",263 "primary_executable": "sigil",264 "compatibility_executable": "sigil-host",265 "version": release_tag[1:],266 "target": "x86_64-unknown-linux-gnu.2.17",267 "profile": "release",268 "features": ["in-process-gstreamer"],269 "demo_auth_bypass": False,270 "demo_direct_node": False,271 "git_dirty": False,272 "binary_provenance": "self-built-clean-head",273 "binary_provenance_verified": True,274 "release_tag": release_tag,275 "asset_name": asset_name,276 "release_kind": "product-candidate",277}278for key, expected in expected_values.items():279 if manifest.get(key) != expected:280 fail(f"release manifest field {key} does not match the product contract")281commit = manifest.get("git_commit")282if not isinstance(commit, str) or re.fullmatch(r"[0-9a-f]{40}", commit) is None:283 fail("release manifest git_commit is malformed")284if source_commit and commit != source_commit:285 fail("release manifest commit does not match the release tag")286for field in ("cargo_lock_sha256", "rust_toolchain_sha256"):287 value = manifest.get(field)288 if not isinstance(value, str) or re.fullmatch(r"[0-9a-f]{64}", value) is None:289 fail(f"release manifest field {field} is malformed")290zigbuild = manifest.get("cargo_zigbuild")291if not isinstance(zigbuild, str) or not zigbuild.startswith("cargo-zigbuild "):292 fail("release manifest lacks the cargo-zigbuild version")293294print(f"release_commit={commit}")295print(f"release_id={release_id.strip()}")296PY297298printf 'release_tag=%s\n' "$release_tag"299printf 'asset_name=%s\n' "$asset_name"300printf 'archive_sha256=%s\n' "$actual_sha256"301printf 'publisher_verification=%s\n' "$verification_mode"302printf 'sigil_release_verification=ok\n'