diff --git a/tools/README-APT.md b/tools/README-APT.md new file mode 100644 index 0000000..5ff1a25 --- /dev/null +++ b/tools/README-APT.md @@ -0,0 +1,99 @@ +# Signed distribution repositories + +The canonical source is the existing GERANAS Gitea repository +\`gerardo/framebridge\`. Build all three reviewed DEBs from its release commit; +do not edit installed files to produce an update. + +One HTTPS repository at \`https://framebridge.betancourt.one/debian/\` contains +three independent suites: + +| Suite | Tested distribution profile | +| --- | --- | +| trixie | Debian 13, amd64, Python 3.13 | +| noble | Ubuntu 24.04, amd64, Python 3.12 | +| mint22 | Linux Mint 22.x, amd64, Python 3.12 | + +Select exactly the suite matching the computer. These are three builds of one +binary package, \`framebridge-original-expanded\`, with unique pool filenames. +This table does not certify untested releases or architectures. + +## Prepare and review + +Run \`python3 tools/build_apt_repository.py prepare --help\` in the source +repository. Supply the exact three version-matched DEBs, the existing official +public key and a new output directory. The command copies the artifacts, builds +indexes and by-hash paths, and prints the immutable input manifest SHA-256. +It does not open a private keyring, sign, publish or install anything. + +Review the manifest, package hashes and release identity before signing. +The public key must contain exactly one primary release key with fingerprint +\`2B1F01BC806C0B33CB6CEDDE52859CEBC91B8808\`. Official subkeys are allowed. + +## Sign in the owner's normal terminal + +Run \`python3 tools/build_apt_repository.py sign --help\`. Pass the reviewed +output directory and exact manifest SHA-256. The signer requires a non-root +interactive terminal and an owner-private existing signing home. Set +\`GPG_TTY=$(tty)\` in that normal terminal if the pinentry implementation needs it. +The owner's passphrase is entered only into normal pinentry; it is never an +argument, environment variable or chat message. + +The signer writes \`Release.gpg\` and \`InRelease\` for each suite, verifies the +signatures against the public key and exact reviewed Release plaintext, then +clears the dedicated signing agent. Existing signatures or partial results +cause refusal; inspect a partial transaction instead of blindly rerunning it. + +Run \`verify\` with the same pinned manifest hash after signing and again after +transport. Metadata expires after 30 days; publish a freshly signed Release +before expiry, without altering historical signed artifacts. + +## Publish through GERANAS + +Use the existing GERANAS web origin and the CLOUD HTTPS reverse proxy. Do not +create another Git backend or expose MCP/auth/broker services. Back up the +current public generation before activation. Preserve the existing signed +0.3.1 downloads and old repository metadata in an archive path. + +Publish only reviewed manifest files, the six verified signature files and the +signing receipt. Public directories must be \`0755\` and files \`0644\`; a private +staging umask must not leak into the served generation. Never publish the +signing home, private receipts, account configuration or operational worktree. +Activate a complete verified generation, preserving the web unit's existing +read-only bind mounts, then run isolated APT update and exact-version download +for all three suites through the final HTTPS authority. + +## Configure a client + +Install the verified official public key as +\`/etc/apt/keyrings/framebridge-archive-keyring.asc\` with mode \`0644\`. +Its fingerprint must match the value above. Add a Deb822 source file: + +\`\`\`text +Types: deb +URIs: https://framebridge.betancourt.one/debian/ +Suites: trixie +Components: main +Architectures: amd64 +Signed-By: /etc/apt/keyrings/framebridge-archive-keyring.asc +\`\`\` + +Replace \`trixie\` with \`noble\` or \`mint22\` for those tested profiles. Then use +normal APT commands: + +\`\`\`sh +sudo apt update +apt-cache policy framebridge-original-expanded +sudo apt install --only-upgrade framebridge-original-expanded +\`\`\` + +Do not use trusted=yes or disable signature/validity checks. APT update/download +verification does not itself prove runtime restart or reboot persistence. +Confirm the actual installed version and ready connection after each upgrade. + +## Tool checks + +\`python3 tools/test_build_apt_repository.py\` creates disposable packages and a +disposable signing identity in a temporary directory. It tests actual isolated +APT acceptance for each suite and rejects altered packages/indexes/signatures, +unexpected versions, path traversal and bundled extra primary trust keys. +It never uses the official private signing key or changes system APT sources. diff --git a/tools/build_apt_repository.py b/tools/build_apt_repository.py new file mode 100755 index 0000000..bb4f379 --- /dev/null +++ b/tools/build_apt_repository.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 +"""Build and verify three signed APT suites from the reviewed distribution DEBs.""" +from __future__ import annotations + +import argparse +import datetime as dt +import email.utils +import gzip +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tempfile + +PACKAGE = "framebridge-original-expanded" +FINGERPRINT = "2B1F01BC806C0B33CB6CEDDE52859CEBC91B8808" +PROFILES = { + "debian": ("trixie", "Debian 13", "debian13-amd64-py313", "3.13"), + "ubuntu": ("noble", "Ubuntu 24.04", "ubuntu24.04-amd64-py312", "3.12"), + "mint": ("mint22", "Linux Mint 22.x", "mint22-amd64-py312", "3.12"), +} + +def require(condition, message): + if not condition: + raise ValueError(message) + +def digest(data, algorithm="sha256"): + return hashlib.new(algorithm, data).hexdigest() + +def run(argv, **kwargs): + return subprocess.run([str(x) for x in argv], check=True, capture_output=True, **kwargs) + +def dump(path, value): + path.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n") + +def public_fingerprint(key): + with tempfile.TemporaryDirectory(prefix="framebridge-public-key-") as tmp: + os.chmod(tmp, 0o700) + result = run(["gpg", "--no-options", "--homedir", tmp, "--batch", + "--with-colons", "--show-keys", key]).stdout.decode() + fingerprints = [line.split(":")[9] for line in result.splitlines() + if line.startswith("fpr:")] + primary_keys = [line for line in result.splitlines() if line.startswith("pub:")] + require(len(primary_keys) == 1 and fingerprints and fingerprints[0] == FINGERPRINT, + "Expected exactly one official primary release key") + return fingerprints[0] + +def control(path): + text = run(["dpkg-deb", "--field", path]).stdout.decode() + fields = {} + for line in text.splitlines(): + if line and not line[0].isspace(): + name, separator, value = line.partition(":") + require(separator, "Invalid Debian control field") + fields[name] = value.strip() + require(not any(k in fields for k in ("Filename", "Size", "MD5sum", "SHA256", "SHA512")), + "Artifact control contains repository-generated fields") + return text.rstrip() + "\n", fields + +def release_bytes(suite, label, date, files): + lines = ["Origin: FrameBridge", "Label: FrameBridge", f"Suite: {suite}", + f"Codename: {suite}", f"Date: {email.utils.format_datetime(date)}", + f"Valid-Until: {email.utils.format_datetime(date + dt.timedelta(days=30))}", + "Architectures: amd64", "Components: main", + f"Description: FrameBridge for {label}", "Acquire-By-Hash: yes"] + for algorithm, heading in (("sha256", "SHA256"), ("sha512", "SHA512")): + lines.append(heading + ":") + lines.extend(f" {digest(data, algorithm)} {len(data):16d} {name}" + for name, data in sorted(files.items())) + return ("\n".join(lines) + "\n").encode() + +def prepare(args): + output = args.output + require(not output.exists(), "Output must be a new directory; never overwrite a published repository") + require(re.fullmatch(r"[0-9][A-Za-z0-9.+:~_-]*", args.version), "Invalid version") + public_fingerprint(args.public_key) + epoch = args.epoch if args.epoch is not None else int(dt.datetime.now(dt.timezone.utc).timestamp()) + date = dt.datetime.fromtimestamp(epoch, dt.timezone.utc) + inputs = [] + for distro, (suite, label, profile, python) in PROFILES.items(): + path = getattr(args, distro) + require(path.is_file() and not path.is_symlink(), f"{distro}: artifact is not a regular file") + text, fields = control(path) + require(fields.get("Package") == PACKAGE, f"{distro}: wrong binary package") + require(fields.get("Version") == args.version, f"{distro}: wrong version") + require(fields.get("Architecture") == "amd64", f"{distro}: wrong architecture") + require(re.search(r"python3\s*\(>=\s*" + re.escape(python), fields.get("Depends", "")), + f"{distro}: Python dependency does not match the tested profile") + data = path.read_bytes() + inputs.append((distro, suite, label, profile, text, data)) + output.mkdir(parents=True, mode=0o755) + records = [] + for distro, suite, label, profile, text, data in inputs: + relative = f"pool/main/f/framebridge/{PACKAGE}_{args.version}_{profile}.deb" + artifact = output / relative + artifact.parent.mkdir(parents=True, exist_ok=True) + artifact.write_bytes(data) + require(digest(artifact.read_bytes()) == digest(data), "Copied artifact hash mismatch") + stanza = text + f"Filename: {relative}\nSize: {len(data)}\nSHA256: {digest(data)}\nSHA512: {digest(data, 'sha512')}\n\n" + plain = stanza.encode() + indexes = {"main/binary-amd64/Packages": plain, + "main/binary-amd64/Packages.gz": gzip.compress(plain, mtime=0)} + suite_dir = output / "dists" / suite + for name, index in indexes.items(): + target = suite_dir / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(index) + for algorithm in ("sha256", "sha512"): + by_hash = target.parent / "by-hash" / algorithm.upper() / digest(index, algorithm) + by_hash.parent.mkdir(parents=True, exist_ok=True) + by_hash.write_bytes(index) + (suite_dir / "Release").write_bytes(release_bytes(suite, label, date, indexes)) + records.append({"distribution": distro, "label": label, "suite": suite, + "profile": profile, "filename": relative, + "size": len(data), "sha256": digest(data)}) + shutil.copyfile(args.public_key, output / "framebridge-archive-keyring.asc") + files = {p.relative_to(output).as_posix(): {"bytes": p.stat().st_size, + "sha256": digest(p.read_bytes())} for p in sorted(output.rglob("*")) if p.is_file()} + manifest = {"schema": 1, "package": PACKAGE, "version": args.version, + "fingerprint": FINGERPRINT, "prepared_utc": date.isoformat(), + "artifacts": records, "files": files} + dump(output / "APT-INPUT-MANIFEST.json", manifest) + manifest_hash = digest((output / "APT-INPUT-MANIFEST.json").read_bytes()) + print(json.dumps({"prepared": True, "signed": False, "directory": str(output), + "version": args.version, "manifest_sha256": manifest_hash, + "suites": [x["suite"] for x in records]}, sort_keys=True)) + +def check_inputs(output, expected): + p = output / "APT-INPUT-MANIFEST.json" + require(p.is_file() and not p.is_symlink(), "Input manifest missing or unsafe") + raw = p.read_bytes() + require(digest(raw) == expected, "Manifest hash mismatch") + manifest = json.loads(raw) + require(manifest.get("package") == PACKAGE and manifest.get("fingerprint") == FINGERPRINT, + "Wrong package or signing identity") + require(len(manifest.get("artifacts", [])) == 3, "Exactly three distribution artifacts required") + require({x["suite"] for x in manifest["artifacts"]} == {x[0] for x in PROFILES.values()}, + "Unexpected distribution suite set") + for name, metadata in manifest["files"].items(): + relative = Path(name) + require(not relative.is_absolute() and ".." not in relative.parts, "Unsafe manifest path") + path = output / relative + require(path.is_file() and not path.is_symlink(), f"Missing or unsafe input: {name}") + data = path.read_bytes() + require(len(data) == metadata["bytes"] and digest(data) == metadata["sha256"], + f"Input changed after review: {name}") + public_fingerprint(output / "framebridge-archive-keyring.asc") + return manifest + +def signature_paths(output): + for suite, *_ in PROFILES.values(): + root = output / "dists" / suite + yield root / "Release", root / "InRelease", True + yield root / "Release", root / "Release.gpg", False + +def verify_signatures(output, manifest): + with tempfile.TemporaryDirectory(prefix="framebridge-apt-verify-") as tmp: + tmp = Path(tmp) + keyring = tmp / "release.gpg" + keyring.write_bytes(run(["gpg", "--no-options", "--homedir", tmp, "--batch", "--output", "-", "--dearmor", + output / "framebridge-archive-keyring.asc"]).stdout) + for release, signed, clearsign in signature_paths(output): + require(signed.is_file() and not signed.is_symlink(), "Missing signature") + args = ["gpgv", "--homedir", tmp, "--keyring", keyring, "--status-fd", "1"] + result = run(args + ([signed] if clearsign else [signed, release])) + require(f"[GNUPG:] VALIDSIG {FINGERPRINT} ".encode() in result.stdout, + "Signature made by unexpected key") + if clearsign: + plaintext = run(["gpgv", "--homedir", tmp, "--keyring", keyring, + "--output", "-", signed]).stdout + require(plaintext == release.read_bytes(), "Clear-signed release differs from reviewed input") + return {"verified": True, "version": manifest["version"], + "suites": [x["suite"] for x in manifest["artifacts"]], "fingerprint": FINGERPRINT} + +def sign(args): + require(os.getuid() != 0 and sys.stdin.isatty() and os.isatty(1), + "Signing must run as the owner in a normal interactive terminal") + manifest = check_inputs(args.output, args.manifest_sha256) + require(args.key_home.is_dir() and not args.key_home.is_symlink(), "Unsafe signing home") + key_stat = args.key_home.stat() + require(key_stat.st_uid == os.getuid() and key_stat.st_mode & 0o077 == 0, "Signing home must be owner-private") + targets = list(signature_paths(args.output)) + require(all(not target.exists() for _, target, _ in targets), "Signatures already exist; do not overwrite") + common = ["gpg", "--no-options", "--homedir", args.key_home, "--pinentry-mode", "default", + "--local-user", FINGERPRINT, "--digest-algo", "SHA256"] + try: + for source, target, clearsign in targets: + check_inputs(args.output, args.manifest_sha256) + temporary = target.with_name(target.name + ".partial") + require(not temporary.exists(), "Incomplete previous signing transaction needs review") + subprocess.run([str(x) for x in common + ["--output", temporary, + "--clearsign" if clearsign else "--detach-sign", source]], check=True) + temporary.rename(target) + result = verify_signatures(args.output, manifest) + result["manifest_sha256"] = args.manifest_sha256 + dump(args.output / "APT-SIGNING-RECEIPT.json", result) + print(json.dumps(result, sort_keys=True)) + finally: + run(["gpgconf", "--homedir", args.key_home, "--kill", "gpg-agent"]) + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + prepare_parser = commands.add_parser("prepare") + prepare_parser.add_argument("--output", type=Path, required=True) + prepare_parser.add_argument("--version", required=True) + for distro in PROFILES: + prepare_parser.add_argument("--" + distro, type=Path, required=True) + prepare_parser.add_argument("--public-key", type=Path, required=True) + prepare_parser.add_argument("--epoch", type=int) + for name in ("sign", "verify"): + sub = commands.add_parser(name) + sub.add_argument("--output", type=Path, required=True) + sub.add_argument("--manifest-sha256", required=True) + if name == "sign": + sub.add_argument("--key-home", type=Path, + default=Path.home() / ".local/share/framebridge-release-signing") + args = parser.parse_args() + try: + if args.command == "prepare": + prepare(args) + elif args.command == "sign": + sign(args) + else: + manifest = check_inputs(args.output, args.manifest_sha256) + print(json.dumps(verify_signatures(args.output, manifest), sort_keys=True)) + except (ValueError, OSError, subprocess.CalledProcessError) as error: + print(f"FrameBridge APT repository: {error}", file=sys.stderr) + return 1 + return 0 + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/test_build_apt_repository.py b/tools/test_build_apt_repository.py new file mode 100644 index 0000000..a930113 --- /dev/null +++ b/tools/test_build_apt_repository.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +"""Integration checks with disposable DEBs and a disposable signing identity.""" +import argparse +import contextlib +import datetime as dt +import hashlib +import importlib.util +import io +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +spec = importlib.util.spec_from_file_location("apt_builder", Path(__file__).with_name("build_apt_repository.py")) +builder = importlib.util.module_from_spec(spec) +spec.loader.exec_module(builder) + +class RepositoryTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.temp = tempfile.TemporaryDirectory(prefix="framebridge-apt-tests-") + cls.base = Path(cls.temp.name) + cls.key_home = cls.base / "test-key" + cls.key_home.mkdir(mode=0o700) + subprocess.run(["gpg", "--no-options", "--homedir", str(cls.key_home), "--batch", + "--pinentry-mode", "loopback", "--passphrase", "", "--quick-generate-key", + "FrameBridge disposable test ", "ed25519", "sign", "0"], + check=True, capture_output=True) + result = subprocess.run(["gpg", "--no-options", "--homedir", str(cls.key_home), + "--with-colons", "--list-keys"], check=True, capture_output=True).stdout.decode() + cls.original_fingerprint = builder.FINGERPRINT + builder.FINGERPRINT = next(x.split(":")[9] for x in result.splitlines() if x.startswith("fpr:")) + cls.public_key = cls.base / "test.asc" + cls.public_key.write_bytes(subprocess.run( + ["gpg", "--no-options", "--homedir", str(cls.key_home), "--armor", "--export", builder.FINGERPRINT], + check=True, capture_output=True).stdout) + cls.debs = {} + for distro, (_, _, _, python) in builder.PROFILES.items(): + tree = cls.base / distro + (tree / "DEBIAN").mkdir(parents=True) + (tree / "DEBIAN").chmod(0o755) + (tree / "DEBIAN/control").write_text( + f"Package: {builder.PACKAGE}\nVersion: 0.3.3\nArchitecture: amd64\n" + f"Maintainer: FrameBridge \nDepends: python3 (>= {python})\n" + f"Description: Disposable {distro} test package\n") + (tree / "distribution.txt").write_text(distro) + deb = cls.base / (distro + ".deb") + subprocess.run(["dpkg-deb", "--build", str(tree), str(deb)], check=True, capture_output=True) + cls.debs[distro] = deb + + @classmethod + def tearDownClass(cls): + subprocess.run(["gpgconf", "--homedir", str(cls.key_home), "--kill", "gpg-agent"], capture_output=True) + builder.FINGERPRINT = cls.original_fingerprint + cls.temp.cleanup() + + def setUp(self): + self.output = Path(tempfile.mkdtemp(dir=self.base, prefix="output-")) / "repository" + + def prepare(self, **changes): + args = argparse.Namespace(output=self.output, version="0.3.3", public_key=self.public_key, + epoch=1793199600, **self.debs) + for key, value in changes.items(): + setattr(args, key, value) + with contextlib.redirect_stdout(io.StringIO()): + builder.prepare(args) + raw = (self.output / "APT-INPUT-MANIFEST.json").read_bytes() + return hashlib.sha256(raw).hexdigest(), json.loads(raw) + + def sign_test_releases(self): + for source, signed, clearsign in builder.signature_paths(self.output): + subprocess.run(["gpg", "--no-options", "--homedir", str(self.key_home), "--batch", + "--pinentry-mode", "loopback", "--passphrase", "", "--local-user", builder.FINGERPRINT, + "--digest-algo", "SHA256", "--output", str(signed), + "--clearsign" if clearsign else "--detach-sign", str(source)], + check=True, capture_output=True) + + def test_actual_apt_update_accepts_three_signed_suites(self): + expected, manifest = self.prepare() + self.sign_test_releases() + self.assertTrue(builder.verify_signatures(self.output, manifest)["verified"]) + for suite, *_ in builder.PROFILES.values(): + isolated = self.output.parent / ("apt-" + suite) + isolated.mkdir() + (isolated / "state/lists/partial").mkdir(parents=True) + (isolated / "cache/archives/partial").mkdir(parents=True) + source = isolated / "sources.list" + source.write_text(f"deb [arch=amd64 signed-by={self.public_key}] file:{self.output} {suite} main\n") + result = subprocess.run(["apt-get", "-o", f"Dir::State={isolated}/state", + "-o", f"Dir::Cache={isolated}/cache", "-o", f"Dir::Etc::sourcelist={source}", + "-o", "Dir::Etc::sourceparts=-", "-o", "Acquire::Languages=none", + "-o", "Debug::NoLocking=1", "update"], capture_output=True, text=True, timeout=35) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("Reading package lists", result.stdout) + self.assertEqual(builder.check_inputs(self.output, expected)["version"], "0.3.3") + + def test_altered_package_is_rejected_before_signing(self): + expected, manifest = self.prepare() + p = self.output / manifest["artifacts"][1]["filename"] + p.write_bytes(p.read_bytes() + b"changed") + with self.assertRaisesRegex(ValueError, "Input changed"): + builder.check_inputs(self.output, expected) + + def test_altered_index_is_rejected(self): + expected, _ = self.prepare() + (self.output / "dists/noble/main/binary-amd64/Packages").write_text("altered") + with self.assertRaisesRegex(ValueError, "Input changed"): + builder.check_inputs(self.output, expected) + + def test_wrong_version_does_not_create_repository(self): + with self.assertRaisesRegex(ValueError, "wrong version"): + self.prepare(version="0.3.4") + self.assertFalse(self.output.exists()) + + def test_existing_output_never_overwritten(self): + self.output.mkdir() + sentinel = self.output / "sentinel" + sentinel.write_text("published") + with self.assertRaisesRegex(ValueError, "never overwrite"): + self.prepare() + self.assertEqual(sentinel.read_text(), "published") + + def test_manifest_traversal_is_rejected(self): + _, manifest = self.prepare() + manifest["files"]["../outside"] = {"bytes": 0, "sha256": hashlib.sha256(b"").hexdigest()} + raw = json.dumps(manifest).encode() + (self.output / "APT-INPUT-MANIFEST.json").write_bytes(raw) + with self.assertRaisesRegex(ValueError, "Unsafe manifest path"): + builder.check_inputs(self.output, hashlib.sha256(raw).hexdigest()) + + def test_public_key_bundle_with_extra_primary_key_is_rejected(self): + bundle = self.output.parent / "extra-primary-key.asc" + bundle.write_bytes(self.public_key.read_bytes() + self.public_key.read_bytes()) + with self.assertRaisesRegex(ValueError, "exactly one official primary"): + self.prepare(public_key=bundle) + self.assertFalse(self.output.exists()) + + def test_changed_signed_release_fails_verification(self): + _, manifest = self.prepare() + self.sign_test_releases() + path = self.output / "dists/trixie/InRelease" + path.write_bytes(path.read_bytes().replace(b"Components: main", b"Components: evil")) + with self.assertRaises(subprocess.CalledProcessError): + builder.verify_signatures(self.output, manifest) + +if __name__ == "__main__": + unittest.main(verbosity=2)