cat / goq
clone

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

feat: make Sigil bootstrap self-contained (#31)

FelineStateMachine committed ef404d7
* feat: provision pinned Sigil verifier

* fix: accept official GNU tar metadata
commit
ef404d700d7d66be5be86a27a2a91a8c9ff31e75
parent
be30ff3ea199e808e72ab966446b7ebf35e56a98
author
FelineStateMachine <dami.etoile@gmail.com>
4 files changed, +320 -25
Mdocs/public-release-delivery.md16+-
Mscripts/tests/test_sigil_bootstrap.py228++++++++++++++++++++++++++++--
Mscripts/verify-sigil-bootstrap.py36++++-
Mwebsite/install-sigil65++++++++-
M docs/public-release-delivery.md +11 -5
@@ -36,7 +36,7 @@ general deployment environment.
3636 The bootstrap:3737 3838 1. Refuses root, non-Bazzite systems, unsupported architectures, and missing39- verification tools.39+ bootstrap tools.4040 2. Uses the exact immutable release tag pinned beside the publisher key in the4141 reviewed bootstrap source.4242 3. Downloads the versioned archive, checksum, and detached signature from that
@@ -53,10 +53,16 @@ sentinels are present. Once opened, it requires the bootstrap's embedded key to
5353 exactly match `release/sigil-minisign.pub` and rejects partial configuration or5454 a malformed immutable release tag before the website can deploy.5555 56-A clean Bazzite image may not include Minisign. The public-alpha gate must57-either provision a pinned verifier with its own hard-coded digest or make the58-verifier prerequisite explicit; it must never silently downgrade to SHA-only59-verification.56+A clean Bazzite image does not need a preinstalled Minisign package. The57+bootstrap downloads the exact official Minisign 0.12 Linux archive and checks58+its hard-coded SHA-256 before archive inspection, extraction, or execution. It59+accepts only the reviewed archive layout (including its inert AppleDouble60+metadata members), extracts only the x86_64 verifier into private temporary61+state, and invokes that verifier by absolute path rather than trusting `PATH`.62+Changing the verifier version, URL, or independently computed content digest63+requires a reviewed website commit. The Sigil publisher key and release tag64+remain separate pins and keep the release channel closed while either is65+`unconfigured`.6066 6167 ## Sigil candidate and offline signing ceremony6268
M scripts/tests/test_sigil_bootstrap.py +213 -15
@@ -27,6 +27,34 @@ PUBLISHER_KEY = base64.b64encode(b"Ed" + b"A" * 8 + b"B" * 32).decode("ascii")
2727 OTHER_PUBLISHER_KEY = base64.b64encode(b"Ed" + b"C" * 8 + b"D" * 32).decode("ascii")2828 RELEASE_TAG = "v1.2.3-alpha.1"2929 ASSET_NAME = f"sigil-{RELEASE_TAG}-bazzite-x86_64.tar.gz"30+ASSET_BASE = (31+ "https://github.com/FelineStateMachine/goq/releases/download/"32+ f"{RELEASE_TAG}/{ASSET_NAME}"33+)34+MINISIGN_VERSION = "0.12"35+MINISIGN_ASSET_NAME = f"minisign-{MINISIGN_VERSION}-linux.tar.gz"36+MINISIGN_URL = (37+ "https://github.com/jedisct1/minisign/releases/download/0.12/"38+ f"{MINISIGN_ASSET_NAME}"39+)40+MINISIGN_SHA256 = (41+ "9a599b48ba6eb7b1e80f12f36b94ceca7c00b7a5173c95c3efc88d9822957e73"42+)43+ZERO_SHA256 = "0" * 6444+MINISIGN_CORE_MEMBERS = {45+ "minisign-linux/",46+ "minisign-linux/aarch64/",47+ "minisign-linux/x86_64/",48+ "minisign-linux/aarch64/minisign",49+ "minisign-linux/x86_64/minisign",50+}51+MINISIGN_APPLEDOUBLE_MEMBERS = {52+ "._minisign-linux",53+ "minisign-linux/._aarch64",54+ "minisign-linux/._x86_64",55+ "minisign-linux/aarch64/._minisign",56+ "minisign-linux/x86_64/._minisign",57+}3058 3159 3260 def bootstrap_with_pins(
@@ -34,6 +62,7 @@ def bootstrap_with_pins(
3462 *,3563 publisher_key: str = PUBLISHER_KEY,3664 release_tag: str = RELEASE_TAG,65+ minisign_sha256: str | None = None,3766 ) -> str:3867 source, publisher_replacements = re.subn(3968 r'^readonly publisher_key="[^"\n]*"$',
@@ -49,7 +78,21 @@ def bootstrap_with_pins(
4978 count=1,5079 flags=re.MULTILINE,5180 )52- if publisher_replacements != 1 or tag_replacements != 1:81+ if minisign_sha256 is not None:82+ source, minisign_replacements = re.subn(83+ r'^readonly minisign_sha256="[^"\n]*"$',84+ f'readonly minisign_sha256="{minisign_sha256}"',85+ source,86+ count=1,87+ flags=re.MULTILINE,88+ )89+ else:90+ minisign_replacements = 191+ if (92+ publisher_replacements != 193+ or tag_replacements != 194+ or minisign_replacements != 195+ ):5396 raise AssertionError("bootstrap fixture pins must each occur exactly once")5497 return source5598
@@ -133,6 +176,35 @@ class SigilBootstrapContractTests(unittest.TestCase):
133176 public_key,134177 )135178 179+ def test_verifier_provisioning_is_bound_to_reviewed_exact_pins(self) -> None:180+ pins = VERIFIER.parse_bootstrap_pins(self.bootstrap_source)181+ self.assertEqual(pins["minisign_version"], MINISIGN_VERSION)182+ self.assertEqual(pins["minisign_url"], MINISIGN_URL)183+ self.assertEqual(pins["minisign_sha256"], MINISIGN_SHA256)184+185+ public_key = "unconfigured\n"186+ replacements = {187+ 'readonly minisign_version="0.12"': 'readonly minisign_version="0.13"',188+ f'readonly minisign_url="{MINISIGN_URL}"': (189+ 'readonly minisign_url="https://example.invalid/minisign.tar.gz"'190+ ),191+ f'readonly minisign_sha256="{MINISIGN_SHA256}"': (192+ f'readonly minisign_sha256="{ZERO_SHA256}"'193+ ),194+ }195+ for original, replacement in replacements.items():196+ with self.subTest(pin=original.split("=", 1)[0]):197+ mutated = self.bootstrap_source.replace(original, replacement, 1)198+ with self.assertRaises(VERIFIER.VerificationError):199+ VERIFIER.verify_contract(mutated, public_key)200+201+ with self.assertRaisesRegex(VERIFIER.VerificationError, "exactly once"):202+ VERIFIER.verify_contract(203+ self.bootstrap_source204+ + f'\nreadonly minisign_sha256="{MINISIGN_SHA256}"\n',205+ public_key,206+ )207+136208 def test_rejects_structurally_invalid_minisign_public_keys(self) -> None:137209 invalid_keys = {138210 "wrong algorithm": base64.b64encode(b"ED" + b"A" * 40).decode("ascii"),
@@ -170,14 +242,19 @@ class SigilBootstrapBehaviorTests(unittest.TestCase):
170242 self.curl_log = self.root / "curl.log"171243 self.stage_marker = self.root / "stage.marker"172244 self.forbidden_marker = self.root / "forbidden.marker"245+ self.minisign_marker = self.root / "minisign.marker"173246 self.bootstrap = self.root / "install-sigil"247+ self.write_command_stubs()248+ minisign_digest = self.write_minisign_fixture()249+ self.write_release_fixture()174250 self.bootstrap.write_text(175- bootstrap_with_pins(BOOTSTRAP_PATH.read_text(encoding="utf-8")),251+ bootstrap_with_pins(252+ BOOTSTRAP_PATH.read_text(encoding="utf-8"),253+ minisign_sha256=minisign_digest,254+ ),176255 encoding="utf-8",177256 )178257 self.bootstrap.chmod(0o755)179- self.write_command_stubs()180- self.write_release_fixture()181258 182259 def tearDown(self) -> None:183260 self.temporary.cleanup()
@@ -247,7 +324,7 @@ done
247324 [[ "$proto" == =https && "$tls" == 1 && "$fail" == 1 && "$silent" == 1 \248325 && "$show_error" == 1 && "$location" == 1 && -n "$output" && -n "$url" ]] || exit 64249326 name="${url##*/}"250-printf '%s\n' "$name" >>"$BOOTSTRAP_CURL_LOG"327+printf '%s\n' "$url" >>"$BOOTSTRAP_CURL_LOG"251328 if [[ -n "${BOOTSTRAP_TEST_CURL_PARTIAL:-}" && "$name" == *"$BOOTSTRAP_TEST_CURL_PARTIAL" ]]; then252329 printf partial >"$output"253330 exit 22
@@ -259,6 +336,24 @@ cp -- "$BOOTSTRAP_FIXTURE_DIR/$name" "$output"
259336 "minisign",260337 """#!/usr/bin/env bash261338 set -euo pipefail339+printf 'PATH minisign invoked\n' >>"$BOOTSTRAP_FORBIDDEN_MARKER"340+exit 99341+""",342+ )343+ self.write_stub(344+ "systemctl",345+ """#!/usr/bin/env bash346+set -euo pipefail347+printf 'systemctl %s\n' "$*" >>"$BOOTSTRAP_FORBIDDEN_MARKER"348+exit 99349+""",350+ )351+352+ def write_minisign_fixture(self, *, variant: str = "valid") -> str:353+ archive_path = self.fixture_dir / MINISIGN_ASSET_NAME354+ verifier = b"""#!/usr/bin/env bash355+set -euo pipefail356+printf 'invoked\\n' >>"$BOOTSTRAP_MINISIGN_MARKER"262357 [[ "${BOOTSTRAP_TEST_MINISIGN:-ok}" == ok ]] || exit 1263358 message=''264359 signature=''
@@ -272,16 +367,67 @@ while (( $# )); do
272367 esac273368 done274369 [[ -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-""",370+"""371+ with tarfile.open(archive_path, "w:gz") as archive:372+ self.add_archive_directory(archive, "minisign-linux/")373+ self.add_archive_directory(archive, "minisign-linux/aarch64/")374+ self.add_archive_directory(archive, "minisign-linux/x86_64/")375+ self.add_archive_file(archive, "._minisign-linux", b"metadata\n", 0o644)376+ self.add_archive_file(377+ archive, "minisign-linux/._aarch64", b"metadata\n", 0o644378+ )379+ self.add_archive_file(380+ archive, "minisign-linux/._x86_64", b"metadata\n", 0o644381+ )382+ self.add_archive_file(383+ archive,384+ "minisign-linux/aarch64/._minisign",385+ b"metadata\n",386+ 0o644,387+ )388+ self.add_archive_file(389+ archive,390+ "minisign-linux/x86_64/._minisign",391+ b"metadata\n",392+ 0o644,393+ )394+ self.add_archive_file(395+ archive,396+ "minisign-linux/aarch64/minisign",397+ b"fixture aarch64 binary\n",398+ 0o755,399+ )400+ if variant == "symlink":401+ link = tarfile.TarInfo("minisign-linux/x86_64/minisign")402+ link.type = tarfile.SYMTYPE403+ link.linkname = "../aarch64/minisign"404+ archive.addfile(link)405+ else:406+ self.add_archive_file(407+ archive,408+ "minisign-linux/x86_64/minisign",409+ verifier,410+ 0o755,411+ )412+ if variant == "unexpected":413+ self.add_archive_file(414+ archive,415+ "minisign-linux/README",416+ b"unexpected\n",417+ 0o644,418+ )419+ return hashlib.sha256(archive_path.read_bytes()).hexdigest()420+421+ def repin_minisign_fixture(self, *, variant: str) -> None:422+ digest = self.write_minisign_fixture(variant=variant)423+ self.bootstrap.write_text(424+ bootstrap_with_pins(425+ BOOTSTRAP_PATH.read_text(encoding="utf-8"),426+ minisign_sha256=digest,427+ ),428+ encoding="utf-8",284429 )430+ self.bootstrap.chmod(0o755)285431 286432 def write_release_fixture(287433 self,
@@ -321,6 +467,13 @@ exit 99
321467 "fixture signature\n", encoding="utf-8"322468 )323469 470+ @staticmethod471+ def add_archive_directory(archive: tarfile.TarFile, name: str) -> None:472+ info = tarfile.TarInfo(name)473+ info.type = tarfile.DIRTYPE474+ info.mode = 0o755475+ archive.addfile(info)476+324477 @staticmethod325478 def add_archive_file(326479 archive: tarfile.TarFile,
@@ -344,6 +497,7 @@ exit 99
344497 "BOOTSTRAP_CURL_LOG": str(self.curl_log),345498 "BOOTSTRAP_STAGE_MARKER": str(self.stage_marker),346499 "BOOTSTRAP_FORBIDDEN_MARKER": str(self.forbidden_marker),500+ "BOOTSTRAP_MINISIGN_MARKER": str(self.minisign_marker),347501 }348502 )349503 environment.update(overrides)
@@ -378,6 +532,48 @@ exit 99
378532 with self.subTest(expected=expected):379533 self.assert_rejected(expected, **overrides)380534 535+ def test_verifier_download_and_digest_fail_before_any_verifier_executes(self) -> None:536+ self.assert_rejected(537+ "Minisign verifier download failed",538+ BOOTSTRAP_TEST_CURL_PARTIAL=MINISIGN_ASSET_NAME,539+ )540+ self.assertFalse(self.minisign_marker.exists())541+ self.assertEqual(542+ self.curl_log.read_text(encoding="utf-8").splitlines(),543+ [MINISIGN_URL],544+ )545+546+ (self.fixture_dir / MINISIGN_ASSET_NAME).write_bytes(b"")547+ self.assert_rejected("Minisign verifier download is empty")548+ self.assertFalse(self.minisign_marker.exists())549+550+ digest = self.write_minisign_fixture()551+ self.assertNotEqual(digest, ZERO_SHA256)552+ with (self.fixture_dir / MINISIGN_ASSET_NAME).open("ab") as archive:553+ archive.write(b"corrupt")554+ self.assert_rejected("Minisign verifier checksum does not match")555+ self.assertFalse(self.minisign_marker.exists())556+557+ def test_verifier_archive_shape_and_types_fail_before_execution(self) -> None:558+ self.repin_minisign_fixture(variant="unexpected")559+ self.assert_rejected("Minisign verifier archive has unexpected contents")560+ self.assertFalse(self.minisign_marker.exists())561+562+ self.repin_minisign_fixture(variant="symlink")563+ self.assert_rejected("Minisign verifier archive contains a special file or link")564+ self.assertFalse(self.minisign_marker.exists())565+566+ def test_verifier_fixture_models_the_full_official_archive_layout(self) -> None:567+ with tarfile.open(self.fixture_dir / MINISIGN_ASSET_NAME, "r:gz") as archive:568+ self.assertEqual(569+ {member.name.rstrip("/") for member in archive.getmembers()},570+ {571+ member.rstrip("/")572+ for member in MINISIGN_CORE_MEMBERS573+ | MINISIGN_APPLEDOUBLE_MEMBERS574+ },575+ )576+381577 def test_rejects_partial_download_bad_signature_and_bad_checksum(self) -> None:382578 self.assert_rejected(383579 "signed release asset download failed",
@@ -385,7 +581,7 @@ exit 99
385581 )386582 self.assertEqual(387583 self.curl_log.read_text(encoding="utf-8").splitlines(),388- [ASSET_NAME, f"{ASSET_NAME}.sha256"],584+ [MINISIGN_URL, ASSET_BASE, f"{ASSET_BASE}.sha256"],389585 )390586 (self.fixture_dir / f"{ASSET_NAME}.sha256").write_text("", encoding="utf-8")391587 self.assert_rejected("signed release asset download is empty")
@@ -394,6 +590,7 @@ exit 99
394590 "publisher signature is invalid",395591 BOOTSTRAP_TEST_MINISIGN="fail",396592 )593+ self.assertEqual(self.minisign_marker.read_text(encoding="utf-8"), "invoked\n")397594 self.write_release_fixture(valid_checksum=False)398595 self.assert_rejected("release checksum does not match")399596
@@ -418,6 +615,7 @@ exit 99
418615 result = self.run_bootstrap()419616 self.assertEqual(result.returncode, 0, result.stdout + result.stderr)420617 self.assertEqual(self.stage_marker.read_text(encoding="utf-8"), "staged\n")618+ self.assertEqual(self.minisign_marker.read_text(encoding="utf-8"), "invoked\n")421619 self.assertFalse(self.forbidden_marker.exists())422620 self.assertIn(f"Sigil runtime {RELEASE_TAG} is installed.", result.stdout)423621 self.assertEqual(list(self.tmp_dir.glob("goq-sigil-install.*")), [])
M scripts/verify-sigil-bootstrap.py +33 -3
@@ -13,11 +13,19 @@ import sys
1313 1414 REPOSITORY = "FelineStateMachine/goq"1515 UNCONFIGURED = "unconfigured"16+MINISIGN_VERSION = "0.12"17+MINISIGN_URL = (18+ "https://github.com/jedisct1/minisign/releases/download/0.12/"19+ "minisign-0.12-linux.tar.gz"20+)21+MINISIGN_SHA256 = (22+ "9a599b48ba6eb7b1e80f12f36b94ceca7c00b7a5173c95c3efc88d9822957e73"23+)1624 TAG_PATTERN = re.compile(1725 r"^v[0-9]+\.[0-9]+\.[0-9]+(?:[.-][0-9A-Za-z][0-9A-Za-z.-]*)?$"1826 )1927 READONLY_ASSIGNMENT_PATTERN = re.compile(20- r'^readonly (?P<name>[a-z_]+)="(?P<value>[^"\n]*)"$', re.MULTILINE28+ r'^readonly (?P<name>[a-z_][a-z0-9_]*)="(?P<value>[^"\n]*)"$', re.MULTILINE2129 )2230 2331
@@ -44,11 +52,33 @@ def parse_bootstrap_pins(source: str) -> dict[str, str]:
4452 assignments.setdefault(match.group("name"), []).append(match.group("value"))4553 4654 pins: dict[str, str] = {}47- for name in ("repository", "publisher_key", "release_tag"):55+ for name in (56+ "repository",57+ "publisher_key",58+ "release_tag",59+ "minisign_version",60+ "minisign_url",61+ "minisign_sha256",62+ ):4863 values = assignments.get(name, [])4964 require(len(values) == 1, f"bootstrap must declare readonly {name} exactly once")5065 pins[name] = values[0]51- require(pins["repository"] == REPOSITORY, "bootstrap repository pin is not the Goq release repository")66+ require(67+ pins["repository"] == REPOSITORY,68+ "bootstrap repository pin is not the Goq release repository",69+ )70+ require(71+ pins["minisign_version"] == MINISIGN_VERSION,72+ "bootstrap Minisign version is not the reviewed verifier version",73+ )74+ require(75+ pins["minisign_url"] == MINISIGN_URL,76+ "bootstrap Minisign URL is not the reviewed verifier asset",77+ )78+ require(79+ pins["minisign_sha256"] == MINISIGN_SHA256,80+ "bootstrap Minisign checksum is not the reviewed verifier digest",81+ )5282 return pins5383 5484
M website/install-sigil +63 -2
@@ -6,6 +6,9 @@ umask 077
66 readonly repository="FelineStateMachine/goq"77 readonly publisher_key="unconfigured"88 readonly release_tag="unconfigured"9+readonly minisign_version="0.12"10+readonly minisign_url="https://github.com/jedisct1/minisign/releases/download/0.12/minisign-0.12-linux.tar.gz"11+readonly minisign_sha256="9a599b48ba6eb7b1e80f12f36b94ceca7c00b7a5173c95c3efc88d9822957e73"912 1013 die() {1114 printf 'Sigil setup failed: %s\n' "$*" >&2
@@ -21,7 +24,7 @@ fi
2124 [[ "$(id -u)" -ne 0 ]] || die "run this command as the dedicated gaming user, not root"2225 grep -Eq '^ID="?bazzite"?$' /etc/os-release || die "Sigil currently supports Bazzite"2326 24-for command_name in awk curl find grep install minisign mktemp rm sha256sum tar tr wc; do27+for command_name in awk curl find grep install mktemp rm sha256sum tar tr wc; do2528 command -v "$command_name" >/dev/null 2>&1 \2629 || die "$command_name is required"2730 done
@@ -40,6 +43,9 @@ archive_path="$temp_root/$asset_name"
4043 checksum_path="$archive_path.sha256"4144 signature_path="$archive_path.minisig"4245 unpack_path="$temp_root/unpack"46+minisign_archive="$temp_root/minisign-$minisign_version-linux.tar.gz"47+minisign_unpack_path="$temp_root/minisign-unpack"48+minisign_binary="$minisign_unpack_path/minisign-linux/x86_64/minisign"4349 4450 cleanup() {4551 local status=$?
@@ -51,6 +57,60 @@ cleanup() {
5157 }5258 trap cleanup EXIT INT TERM HUP5359 60+curl --proto '=https' --tlsv1.2 --fail --silent --show-error --location \61+ --retry 3 --retry-all-errors --output "$minisign_archive" "$minisign_url" \62+ || die "the Minisign verifier download failed"63+[[ -s "$minisign_archive" ]] || die "the Minisign verifier download is empty"64+actual_minisign_hash="$(sha256sum "$minisign_archive" | awk '{ print $1 }')"65+[[ "$actual_minisign_hash" == "$minisign_sha256" ]] \66+ || die "the Minisign verifier checksum does not match"67+68+minisign_entries="$(tar --list --gzip --file "$minisign_archive" 2>/dev/null)" \69+ || die "the Minisign verifier archive is unreadable"70+if ! printf '%s\n' "$minisign_entries" | awk '71+ BEGIN {72+ required["minisign-linux/"] = 173+ required["minisign-linux/aarch64/"] = 174+ required["minisign-linux/x86_64/"] = 175+ required["minisign-linux/aarch64/minisign"] = 176+ required["minisign-linux/x86_64/minisign"] = 177+ metadata["._minisign-linux"] = 178+ metadata["minisign-linux/._aarch64"] = 179+ metadata["minisign-linux/._x86_64"] = 180+ metadata["minisign-linux/aarch64/._minisign"] = 181+ metadata["minisign-linux/x86_64/._minisign"] = 182+ }83+ {84+ if ($0 in required) {85+ if (seen[$0]++) exit 186+ required_count++87+ } else if ($0 in metadata) {88+ if (seen[$0]++) exit 189+ } else {90+ exit 191+ }92+ }93+ END {94+ if (required_count != 5) exit 195+ for (name in required) if (!seen[name]) exit 196+ }97+'; then98+ die "the Minisign verifier archive has unexpected contents"99+fi100+if ! LC_ALL=C tar --list --verbose --gzip --file "$minisign_archive" 2>/dev/null \101+ | awk '{ type = substr($1, 1, 1); if (type != "-" && type != "d") exit 1 }'102+then103+ die "the Minisign verifier archive contains a special file or link"104+fi105+106+install -d -m 0700 "$minisign_unpack_path"107+tar --extract --gzip --file "$minisign_archive" \108+ --directory "$minisign_unpack_path" --no-same-owner --no-same-permissions \109+ minisign-linux/x86_64/minisign 2>/dev/null \110+ || die "the Minisign verifier could not be extracted"111+[[ -f "$minisign_binary" && ! -L "$minisign_binary" && -x "$minisign_binary" ]] \112+ || die "the Minisign verifier is missing or unsafe"113+54114 for suffix in '' .sha256 .minisig; do55115 curl --proto '=https' --tlsv1.2 --fail --silent --show-error --location \56116 --retry 3 --retry-all-errors --output "$archive_path$suffix" "$asset_base$suffix" \
@@ -58,7 +118,8 @@ for suffix in '' .sha256 .minisig; do
58118 [[ -s "$archive_path$suffix" ]] || die "a signed release asset download is empty"59119 done60120 61-minisign -Vm "$archive_path" -x "$signature_path" -P "$publisher_key" \121+"$minisign_binary" -Vm "$archive_path" -x "$signature_path" -P "$publisher_key" \122+ >/dev/null 2>&1 \62123 || die "the publisher signature is invalid"63124 64125 [[ "$(wc -l <"$checksum_path" | tr -d ' ')" == 1 ]] \