Validate the public installer pins against the repository Minisign trust root and exact release tag. Exercise the real bootstrap across platform, download, signature, checksum, archive-safety, cleanup, and stager-only paths before website deployment.
- commit
- 8b49df3f0c264600937aa55b3f5d8d25c6cad17b
- parent
- d72ae8f3978963dccf24dc4e8ee8ef8a95f32739
- author
- FelineStateMachine <dami.etoile@gmail.com>
| M | .github/ | 4 | + |
| M | .gitignore | 4 | + |
| M | docs/ | 5 | + |
| A | scripts/ | 9 | + |
| A | scripts/ | 427 | ++++++++++++++++++++++++++++++ |
| A | scripts/ | 143 | ++++++++++ |
| M | scripts/ | 3 | + |
| M | website/ | 7 | +- |
@@ -4,6 +4,8 @@ on:
44 pull_request:55 paths:66 - website/**7+ - release/sigil-minisign.pub8+ - scripts/verify-sigil-bootstrap.py79 - scripts/verify-website.sh810 - wrangler.jsonc911 - .github/workflows/website.yml@@ -11,6 +13,8 @@ on:
1113 branches: [main]1214 paths:1315 - website/**16+ - release/sigil-minisign.pub17+ - scripts/verify-sigil-bootstrap.py1418 - scripts/verify-website.sh1519 - wrangler.jsonc1620 - .github/workflows/website.yml@@ -48,6 +48,11 @@ The bootstrap:
4848 6. Does not start or restart Sigil, overwrite identity or host configuration,4949 modify `/etc`, or guess ambiguous hardware.5050 51+`scripts/verify-sigil-bootstrap.py` keeps the channel fully closed while the52+sentinels are present. Once opened, it requires the bootstrap's embedded key to53+exactly match `release/sigil-minisign.pub` and rejects partial configuration or54+a malformed immutable release tag before the website can deploy.55+5156 A clean Bazzite image may not include Minisign. The public-alpha gate must5257 either provision a pinned verifier with its own hard-coded digest or make the5358 verifier prerequisite explicit; it must never silently downgrade to SHA-only@@ -0,0 +1,9 @@
1+2+set -euo pipefail3+4+script_dir="$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"5+repo_dir="$(CDPATH='' cd -- "$script_dir/../.." && pwd -P)"6+7+PYTHONDONTWRITEBYTECODE=1 python3 -m unittest \8+ "$repo_dir/scripts/tests/test_sigil_bootstrap.py"9+printf 'sigil_bootstrap_tests=ok\n'@@ -0,0 +1,427 @@
1+#!/usr/bin/env python32+3+from __future__ import annotations4+5+import base646+import hashlib7+import importlib.util8+import io9+import os10+import pathlib11+import re12+import subprocess13+import tarfile14+import tempfile15+import unittest16+17+18+REPO_DIR = pathlib.Path(__file__).resolve().parents[2]19+BOOTSTRAP_PATH = REPO_DIR / "website" / "install-sigil"20+MODULE_PATH = REPO_DIR / "scripts" / "verify-sigil-bootstrap.py"21+SPEC = importlib.util.spec_from_file_location("sigil_bootstrap_verifier", MODULE_PATH)22+assert SPEC is not None and SPEC.loader is not None23+VERIFIER = importlib.util.module_from_spec(SPEC)24+SPEC.loader.exec_module(VERIFIER)25+26+PUBLISHER_KEY = base64.b64encode(b"Ed" + b"A" * 8 + b"B" * 32).decode("ascii")27+OTHER_PUBLISHER_KEY = base64.b64encode(b"Ed" + b"C" * 8 + b"D" * 32).decode("ascii")28+RELEASE_TAG = "v1.2.3-alpha.1"29+ASSET_NAME = f"sigil-{RELEASE_TAG}-bazzite-x86_64.tar.gz"30+31+32+def bootstrap_with_pins(33+ source: str,34+ *,35+ publisher_key: str = PUBLISHER_KEY,36+ release_tag: str = RELEASE_TAG,37+) -> str:38+ source, publisher_replacements = re.subn(39+ r'^readonly publisher_key="[^"\n]*"$',40+ f'readonly publisher_key="{publisher_key}"',41+ source,42+ count=1,43+ flags=re.MULTILINE,44+ )45+ source, tag_replacements = re.subn(46+ r'^readonly release_tag="[^"\n]*"$',47+ f'readonly release_tag="{release_tag}"',48+ source,49+ count=1,50+ flags=re.MULTILINE,51+ )52+ if publisher_replacements != 1 or tag_replacements != 1:53+ raise AssertionError("bootstrap fixture pins must each occur exactly once")54+ return source55+56+57+class SigilBootstrapContractTests(unittest.TestCase):58+59+ def setUpClass(cls) -> None:60+ cls.bootstrap_source = BOOTSTRAP_PATH.read_text(encoding="utf-8")61+62+ def test_closed_channel_requires_all_three_sentinels(self) -> None:63+ closed = bootstrap_with_pins(64+ self.bootstrap_source,65+ publisher_key="unconfigured",66+ release_tag="unconfigured",67+ )68+ self.assertEqual(69+ VERIFIER.verify_contract(closed, "unconfigured\n"),70+ "closed",71+ )72+ partial_states = [73+ (74+ bootstrap_with_pins(self.bootstrap_source, release_tag="unconfigured"),75+ "unconfigured\n",76+ ),77+ (78+ bootstrap_with_pins(self.bootstrap_source, publisher_key="unconfigured"),79+ "unconfigured\n",80+ ),81+ (closed, f"untrusted comment: fixture\n{PUBLISHER_KEY}\n"),82+ (bootstrap_with_pins(self.bootstrap_source), "unconfigured\n"),83+ ]84+ for bootstrap, public_key in partial_states:85+ with self.subTest(bootstrap=bootstrap[:80], public_key=public_key[:30]):86+ with self.assertRaisesRegex(VERIFIER.VerificationError, "partially configured"):87+ VERIFIER.verify_contract(bootstrap, public_key)88+89+ def test_open_channel_binds_exact_repository_key_and_release_tag(self) -> None:90+ public_key = f"untrusted comment: Sigil fixture key\n{PUBLISHER_KEY}\n"91+ self.assertEqual(92+ VERIFIER.verify_contract(bootstrap_with_pins(self.bootstrap_source), public_key),93+ "open",94+ )95+ with self.assertRaisesRegex(VERIFIER.VerificationError, "does not match"):96+ VERIFIER.verify_contract(97+ bootstrap_with_pins(self.bootstrap_source),98+ f"untrusted comment: wrong\n{OTHER_PUBLISHER_KEY}\n",99+ )100+101+ def test_malformed_pins_and_repository_drift_fail_closed(self) -> None:102+ configured = bootstrap_with_pins(self.bootstrap_source)103+ public_key = f"untrusted comment: fixture\n{PUBLISHER_KEY}\n"104+ for malformed_tag in (105+ "main",106+ "v1",107+ "v1.2",108+ "v1.2.3/asset",109+ "v1.2.3+build",110+ ):111+ with self.subTest(tag=malformed_tag):112+ with self.assertRaisesRegex(VERIFIER.VerificationError, "tag is malformed"):113+ VERIFIER.verify_contract(114+ bootstrap_with_pins(self.bootstrap_source, release_tag=malformed_tag),115+ public_key,116+ )117+118+ with self.assertRaisesRegex(VERIFIER.VerificationError, "publisher key is malformed"):119+ VERIFIER.verify_contract(120+ bootstrap_with_pins(self.bootstrap_source, publisher_key="not-a-key"),121+ public_key,122+ )123+ with self.assertRaisesRegex(VERIFIER.VerificationError, "unexpected content"):124+ VERIFIER.verify_contract(configured, f"trusted comment: no\n{PUBLISHER_KEY}\n")125+ with self.assertRaisesRegex(VERIFIER.VerificationError, "release repository"):126+ VERIFIER.verify_contract(127+ configured.replace("FelineStateMachine/goq", "attacker/goq", 1),128+ public_key,129+ )130+ with self.assertRaisesRegex(VERIFIER.VerificationError, "exactly once"):131+ VERIFIER.verify_contract(132+ configured + f'\nreadonly publisher_key="{PUBLISHER_KEY}"\n',133+ public_key,134+ )135+136+ def test_rejects_structurally_invalid_minisign_public_keys(self) -> None:137+ invalid_keys = {138+ "wrong algorithm": base64.b64encode(b"ED" + b"A" * 40).decode("ascii"),139+ "short payload": base64.b64encode(b"Ed" + b"A" * 39).decode("ascii"),140+ "long payload": base64.b64encode(b"Ed" + b"A" * 41).decode("ascii"),141+ "invalid alphabet": "RW" + "!" * 54,142+ "invalid padding": PUBLISHER_KEY[:-1] + "=",143+ }144+ for name, key in invalid_keys.items():145+ with self.subTest(name=name):146+ with self.assertRaisesRegex(VERIFIER.VerificationError, "malformed"):147+ VERIFIER.verify_contract(148+ bootstrap_with_pins(self.bootstrap_source, publisher_key=key),149+ f"untrusted comment: fixture\n{key}\n",150+ )151+152+ def test_current_repository_state_is_a_complete_valid_contract(self) -> None:153+ public_key = (REPO_DIR / "release" / "sigil-minisign.pub").read_text(encoding="utf-8")154+ self.assertIn(155+ VERIFIER.verify_contract(self.bootstrap_source, public_key),156+ {"closed", "open"},157+ )158+159+160+class SigilBootstrapBehaviorTests(unittest.TestCase):161+ def setUp(self) -> None:162+ self.temporary = tempfile.TemporaryDirectory()163+ self.root = pathlib.Path(self.temporary.name)164+ self.tmp_dir = self.root / "tmp"165+ self.tmp_dir.mkdir()166+ self.fixture_dir = self.root / "assets"167+ self.fixture_dir.mkdir()168+ self.fake_bin = self.root / "bin"169+ self.fake_bin.mkdir()170+ self.curl_log = self.root / "curl.log"171+ self.stage_marker = self.root / "stage.marker"172+ self.forbidden_marker = self.root / "forbidden.marker"173+ self.bootstrap = self.root / "install-sigil"174+ self.bootstrap.write_text(175+ bootstrap_with_pins(BOOTSTRAP_PATH.read_text(encoding="utf-8")),176+ encoding="utf-8",177+ )178+ self.bootstrap.chmod(0o755)179+ self.write_command_stubs()180+ self.write_release_fixture()181+182+ def tearDown(self) -> None:183+ self.temporary.cleanup()184+185+ def write_stub(self, name: str, source: str) -> None:186+ path = self.fake_bin / name187+ path.write_text(source, encoding="utf-8")188+ path.chmod(0o755)189+190+ def write_command_stubs(self) -> None:191+ self.write_stub(192+ "uname",193+ """#!/usr/bin/env bash194+set -euo pipefail195+case "${1:-}" in196+ -s) printf '%s\n' "${BOOTSTRAP_TEST_OS:-Linux}" ;;197+ -m) printf '%s\n' "${BOOTSTRAP_TEST_ARCH:-x86_64}" ;;198+ *) exit 64 ;;199+esac200+""",201+ )202+ self.write_stub(203+ "id",204+ """#!/usr/bin/env bash205+set -euo pipefail206+[[ "${1:-}" == -u ]] || exit 64207+printf '%s\n' "${BOOTSTRAP_TEST_UID:-1000}"208+""",209+ )210+ self.write_stub(211+ "grep",212+ """#!/usr/bin/env bash213+set -euo pipefail214+if [[ "${*: -1}" == /etc/os-release ]]; then215+ [[ "${BOOTSTRAP_TEST_BAZZITE:-1}" == 1 ]]216+ exit217+fi218+exec /usr/bin/grep "$@"219+""",220+ )221+ self.write_stub(222+ "curl",223+ """#!/usr/bin/env bash224+set -euo pipefail225+output=''226+url=''227+proto=''228+tls=''229+fail=0230+silent=0231+show_error=0232+location=0233+while (( $# )); do234+ case "$1" in235+ --proto) proto="$2"; shift 2 ;;236+ --tlsv1.2) tls=1; shift ;;237+ --fail) fail=1; shift ;;238+ --silent) silent=1; shift ;;239+ --show-error) show_error=1; shift ;;240+ --location) location=1; shift ;;241+ --retry|--output) option="$1"; value="$2"; shift 2; [[ "$option" == --output ]] && output="$value" ;;242+ --retry-all-errors) shift ;;243+ https://*) url="$1"; shift ;;244+ *) exit 64 ;;245+ esac246+done247+[[ "$proto" == =https && "$tls" == 1 && "$fail" == 1 && "$silent" == 1 \248+ && "$show_error" == 1 && "$location" == 1 && -n "$output" && -n "$url" ]] || exit 64249+name="${url##*/}"250+printf '%s\n' "$name" >>"$BOOTSTRAP_CURL_LOG"251+if [[ -n "${BOOTSTRAP_TEST_CURL_PARTIAL:-}" && "$name" == *"$BOOTSTRAP_TEST_CURL_PARTIAL" ]]; then252+ printf partial >"$output"253+ exit 22254+fi255+cp -- "$BOOTSTRAP_FIXTURE_DIR/$name" "$output"256+""",257+ )258+ self.write_stub(259+ "minisign",260+ """#!/usr/bin/env bash261+set -euo pipefail262+[[ "${BOOTSTRAP_TEST_MINISIGN:-ok}" == ok ]] || exit 1263+message=''264+signature=''265+key=''266+while (( $# )); do267+ case "$1" in268+ -Vm) message="$2"; shift 2 ;;269+ -x) signature="$2"; shift 2 ;;270+ -P) key="$2"; shift 2 ;;271+ *) exit 64 ;;272+ esac273+done274+[[ -s "$message" && -s "$signature" && "$key" == "$BOOTSTRAP_EXPECTED_KEY" ]]275+""",276+ )277+ self.write_stub(278+ "systemctl",279+ """#!/usr/bin/env bash280+set -euo pipefail281+printf 'systemctl %s\n' "$*" >>"$BOOTSTRAP_FORBIDDEN_MARKER"282+exit 99283+""",284+ )285+286+ def write_release_fixture(287+ self,288+ *,289+ variant: str = "valid",290+ valid_checksum: bool = True,291+ ) -> None:292+ archive_path = self.fixture_dir / ASSET_NAME293+ with tarfile.open(archive_path, "w:gz") as archive:294+ self.add_archive_file(295+ archive,296+ "payload/stage-this-release.sh",297+ b"#!/usr/bin/env bash\nset -euo pipefail\nprintf 'staged\\n' >>\"$BOOTSTRAP_STAGE_MARKER\"\n",298+ 0o755,299+ )300+ self.add_archive_file(301+ archive,302+ "payload/install-bazzite-package.sh",303+ b"#!/usr/bin/env bash\nset -euo pipefail\nprintf 'forbidden\\n' >>\"$BOOTSTRAP_FORBIDDEN_MARKER\"\n",304+ 0o755,305+ )306+ if variant == "escape":307+ self.add_archive_file(archive, "../escape", b"no\n", 0o644)308+ if variant == "symlink":309+ link = tarfile.TarInfo("payload/unsafe-link")310+ link.type = tarfile.SYMTYPE311+ link.linkname = "stage-this-release.sh"312+ archive.addfile(link)313+314+ digest = hashlib.sha256(archive_path.read_bytes()).hexdigest()315+ if not valid_checksum:316+ digest = "0" * 64317+ (self.fixture_dir / f"{ASSET_NAME}.sha256").write_text(318+ f"{digest} {ASSET_NAME}\n", encoding="utf-8"319+ )320+ (self.fixture_dir / f"{ASSET_NAME}.minisig").write_text(321+ "fixture signature\n", encoding="utf-8"322+ )323+324+325+ def add_archive_file(326+ archive: tarfile.TarFile,327+ name: str,328+ payload: bytes,329+ mode: int,330+ ) -> None:331+ info = tarfile.TarInfo(name)332+ info.size = len(payload)333+ info.mode = mode334+ archive.addfile(info, io.BytesIO(payload))335+336+ def run_bootstrap(self, **overrides: str) -> subprocess.CompletedProcess[str]:337+ environment = os.environ.copy()338+ environment.update(339+ {340+ "PATH": f"{self.fake_bin}:{environment['PATH']}",341+ "TMPDIR": str(self.tmp_dir),342+ "BOOTSTRAP_FIXTURE_DIR": str(self.fixture_dir),343+ "BOOTSTRAP_EXPECTED_KEY": PUBLISHER_KEY,344+ "BOOTSTRAP_CURL_LOG": str(self.curl_log),345+ "BOOTSTRAP_STAGE_MARKER": str(self.stage_marker),346+ "BOOTSTRAP_FORBIDDEN_MARKER": str(self.forbidden_marker),347+ }348+ )349+ environment.update(overrides)350+ return subprocess.run(351+ [str(self.bootstrap)],352+ check=False,353+ stdout=subprocess.PIPE,354+ stderr=subprocess.PIPE,355+ text=True,356+ env=environment,357+ )358+359+ def assert_rejected(360+ self, expected: str, **overrides: str361+ ) -> subprocess.CompletedProcess[str]:362+ result = self.run_bootstrap(**overrides)363+ self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)364+ self.assertIn(expected, result.stderr)365+ self.assertFalse(self.stage_marker.exists())366+ self.assertFalse(self.forbidden_marker.exists())367+ self.assertEqual(list(self.tmp_dir.glob("goq-sigil-install.*")), [])368+ return result369+370+ def test_rejects_wrong_os_arch_root_and_non_bazzite(self) -> None:371+ cases = [372+ ("Sigil requires Linux", {"BOOTSTRAP_TEST_OS": "Darwin"}),373+ ("Sigil currently requires x86_64", {"BOOTSTRAP_TEST_ARCH": "aarch64"}),374+ ("not root", {"BOOTSTRAP_TEST_UID": "0"}),375+ ("currently supports Bazzite", {"BOOTSTRAP_TEST_BAZZITE": "0"}),376+ ]377+ for expected, overrides in cases:378+ with self.subTest(expected=expected):379+ self.assert_rejected(expected, **overrides)380+381+ def test_rejects_partial_download_bad_signature_and_bad_checksum(self) -> None:382+ self.assert_rejected(383+ "signed release asset download failed",384+ BOOTSTRAP_TEST_CURL_PARTIAL=".sha256",385+ )386+ self.assertEqual(387+ self.curl_log.read_text(encoding="utf-8").splitlines(),388+ [ASSET_NAME, f"{ASSET_NAME}.sha256"],389+ )390+ (self.fixture_dir / f"{ASSET_NAME}.sha256").write_text("", encoding="utf-8")391+ self.assert_rejected("signed release asset download is empty")392+ self.write_release_fixture()393+ self.assert_rejected(394+ "publisher signature is invalid",395+ BOOTSTRAP_TEST_MINISIGN="fail",396+ )397+ self.write_release_fixture(valid_checksum=False)398+ self.assert_rejected("release checksum does not match")399+400+ def test_rejects_malformed_checksum_declarations(self) -> None:401+ checksum_path = self.fixture_dir / f"{ASSET_NAME}.sha256"402+ valid = checksum_path.read_text(encoding="utf-8")403+ checksum_path.write_text(valid + valid, encoding="utf-8")404+ self.assert_rejected("release checksum file is malformed")405+406+ self.write_release_fixture()407+ digest = hashlib.sha256((self.fixture_dir / ASSET_NAME).read_bytes()).hexdigest()408+ checksum_path.write_text(f"{digest} wrong.tar.gz\n", encoding="utf-8")409+ self.assert_rejected("release checksum declaration is malformed")410+411+ def test_rejects_escaping_paths_and_links_before_extraction(self) -> None:412+ self.write_release_fixture(variant="escape")413+ self.assert_rejected("escapes its payload root")414+ self.write_release_fixture(variant="symlink")415+ self.assert_rejected("special file or link")416+417+ def test_success_invokes_only_the_package_stager_and_cleans_up(self) -> None:418+ result = self.run_bootstrap()419+ self.assertEqual(result.returncode, 0, result.stdout + result.stderr)420+ self.assertEqual(self.stage_marker.read_text(encoding="utf-8"), "staged\n")421+ self.assertFalse(self.forbidden_marker.exists())422+ self.assertIn(f"Sigil runtime {RELEASE_TAG} is installed.", result.stdout)423+ self.assertEqual(list(self.tmp_dir.glob("goq-sigil-install.*")), [])424+425+426+if __name__ == "__main__":427+ unittest.main()@@ -0,0 +1,143 @@
1+#!/usr/bin/env python32+"""Verify the public Sigil bootstrap's pinned release trust contract."""3+4+from __future__ import annotations5+6+import argparse7+import base648+import binascii9+import pathlib10+import re11+import sys12+13+14+REPOSITORY = "FelineStateMachine/goq"15+UNCONFIGURED = "unconfigured"16+TAG_PATTERN = re.compile(17+ r"^v[0-9]+\.[0-9]+\.[0-9]+(?:[.-][0-9A-Za-z][0-9A-Za-z.-]*)?$"18+)19+READONLY_ASSIGNMENT_PATTERN = re.compile(20+ r'^readonly (?P<name>[a-z_]+)="(?P<value>[^"\n]*)"$', re.MULTILINE21+)22+23+24+class VerificationError(RuntimeError):25+ """The bootstrap and repository trust pins are inconsistent."""26+27+28+def require(condition: bool, message: str) -> None:29+ if not condition:30+ raise VerificationError(message)31+32+33+def read_regular_file(path: pathlib.Path, description: str) -> str:34+ require(path.is_file() and not path.is_symlink(), f"{description} is missing or unsafe")35+ try:36+ return path.read_text(encoding="utf-8")37+ except (OSError, UnicodeError) as error:38+ raise VerificationError(f"{description} is unreadable: {error}") from error39+40+41+def parse_bootstrap_pins(source: str) -> dict[str, str]:42+ assignments: dict[str, list[str]] = {}43+ for match in READONLY_ASSIGNMENT_PATTERN.finditer(source):44+ assignments.setdefault(match.group("name"), []).append(match.group("value"))45+46+ pins: dict[str, str] = {}47+ for name in ("repository", "publisher_key", "release_tag"):48+ values = assignments.get(name, [])49+ require(len(values) == 1, f"bootstrap must declare readonly {name} exactly once")50+ pins[name] = values[0]51+ require(pins["repository"] == REPOSITORY, "bootstrap repository pin is not the Goq release repository")52+ return pins53+54+55+def parse_repository_public_key(source: str) -> str:56+ stripped = source.strip()57+ if stripped == UNCONFIGURED:58+ return UNCONFIGURED59+ lines = [line.strip() for line in source.splitlines() if line.strip()]60+ require(bool(lines), "repository Sigil public key is unconfigured or malformed")61+ key = lines[-1]62+ require(63+ all(line.startswith("untrusted comment:") for line in lines[:-1]),64+ "repository Sigil public key contains unexpected content",65+ )66+ validate_public_key(key, "repository Sigil public key")67+ return key68+69+70+def validate_public_key(value: str, description: str) -> None:71+ try:72+ decoded = base64.b64decode(value, validate=True)73+ except (binascii.Error, ValueError) as error:74+ raise VerificationError(f"{description} is malformed") from error75+ require(76+ len(decoded) == 42,77+ f"{description} is malformed: decoded Minisign key must be exactly 42 bytes",78+ )79+ require(80+ decoded[:2] == b"Ed",81+ f"{description} is malformed: unsupported Minisign signature algorithm",82+ )83+ require(84+ base64.b64encode(decoded).decode("ascii") == value,85+ f"{description} is malformed: public key encoding is not canonical",86+ )87+88+89+def verify_contract(bootstrap_source: str, public_key_source: str) -> str:90+ pins = parse_bootstrap_pins(bootstrap_source)91+ repository_key = parse_repository_public_key(public_key_source)92+ embedded_key = pins["publisher_key"]93+ release_tag = pins["release_tag"]94+95+ bootstrap_closed = embedded_key == UNCONFIGURED and release_tag == UNCONFIGURED96+ repository_closed = repository_key == UNCONFIGURED97+ if bootstrap_closed and repository_closed:98+ return "closed"99+100+ require(101+ embedded_key != UNCONFIGURED102+ and release_tag != UNCONFIGURED103+ and repository_key != UNCONFIGURED,104+ "Sigil bootstrap trust pins are only partially configured",105+ )106+ validate_public_key(embedded_key, "bootstrap publisher key")107+ require(108+ embedded_key == repository_key,109+ "bootstrap publisher key does not match release/sigil-minisign.pub",110+ )111+ require(112+ TAG_PATTERN.fullmatch(release_tag) is not None,113+ "bootstrap release tag is malformed",114+ )115+ return "open"116+117+118+def parse_args(argv: list[str]) -> argparse.Namespace:119+ parser = argparse.ArgumentParser(120+ description="Verify that install-sigil and the repository Minisign key agree."121+ )122+ parser.add_argument("--bootstrap", required=True, type=pathlib.Path)123+ parser.add_argument("--public-key-file", required=True, type=pathlib.Path)124+ return parser.parse_args(argv)125+126+127+def main(argv: list[str] | None = None) -> int:128+ args = parse_args(sys.argv[1:] if argv is None else argv)129+ try:130+ state = verify_contract(131+ read_regular_file(args.bootstrap, "Sigil bootstrap"),132+ read_regular_file(args.public_key_file, "repository Sigil public key"),133+ )134+ except VerificationError as error:135+ print(f"Sigil bootstrap verification failed: {error}", file=sys.stderr)136+ return 1137+ print("sigil_bootstrap_verification=ok")138+ print(f"sigil_bootstrap_channel={state}")139+ return 0140+141+142+if __name__ == "__main__":143+ raise SystemExit(main())@@ -45,6 +45,9 @@ fi
4545 node --check "$site_dir/dither.js"4646 python3 "$repo_dir/scripts/verify-portal-release.py" website \4747 --manifest "$site_dir/portal-release.json" >/dev/null48+python3 "$repo_dir/scripts/verify-sigil-bootstrap.py" \49+ --bootstrap "$site_dir/install-sigil" \50+ --public-key-file "$repo_dir/release/sigil-minisign.pub" >/dev/null4851 bash -n "$site_dir/install-sigil"4952 [[ -x "$site_dir/install-sigil" ]] || {5053 echo 'Sigil bootstrap must be executable' >&2@@ -26,7 +26,8 @@ for command_name in awk curl find grep install minisign mktemp rm sha256sum tar
2626 || die "$command_name is required"2727 done2828 29-[[ "$release_tag" =~ ^v[0-9][0-9A-Za-z._-]*$ ]] || die "the release tag is malformed"29+[[ "$release_tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z][0-9A-Za-z.-]*)?$ ]] \30+ || die "the release tag is malformed"3031 3132 asset_name="sigil-$release_tag-bazzite-x86_64.tar.gz"3233 asset_base="https://github.com/$repository/releases/download/$release_tag/$asset_name"@@ -52,7 +53,9 @@ trap cleanup EXIT INT TERM HUP
5253 5354 for suffix in '' .sha256 .minisig; do5455 curl --proto '=https' --tlsv1.2 --fail --silent --show-error --location \55- --retry 3 --retry-all-errors --output "$archive_path$suffix" "$asset_base$suffix"56+ --retry 3 --retry-all-errors --output "$archive_path$suffix" "$asset_base$suffix" \57+ || die "a signed release asset download failed"58+ [[ -s "$archive_path$suffix" ]] || die "a signed release asset download is empty"5659 done5760 5861 minisign -Vm "$archive_path" -x "$signature_path" -P "$publisher_key" \