Files
framebridge/tools/build_apt_repository.py
T

300 lines
16 KiB
Python
Executable File

#!/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"
DEPENDENCY_SOURCE_ASSETS = {
"FrameBridge-0.3.1-Community-Preview1-dependency-sources.tar.gz": (82606998, "64540a85bd65eac3f394d8c0a967cacbc00e112676d57d644dcd1bb691c1926a"),
"FrameBridge-0.3.1-tunnel-client-dependency-sources.tar.gz": (26941745, "6445d4f0e773aba833fe5c610fbae54102b92de2ce636438f769f21e890c2bd9"),
}
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 verify_detached(public_key, signature, payload):
with tempfile.TemporaryDirectory(prefix="framebridge-asset-verify-") as tmp:
tmp = Path(tmp)
ring = tmp / "release.gpg"
ring.write_bytes(run(["gpg", "--no-options", "--homedir", tmp, "--batch", "--output", "-",
"--dearmor", public_key]).stdout)
result = run(["gpgv", "--homedir", tmp, "--keyring", ring, "--status-fd", "1", signature, payload])
require(f"[GNUPG:] VALIDSIG {FINGERPRINT} ".encode() in result.stdout, "Unexpected dependency-source signer")
def prepare_downloads(args, inputs):
source = getattr(args, "source", None)
dependencies = getattr(args, "dependency_source", [])
if source is None:
require(not dependencies, "Dependency assets require the corresponding project-source archive")
return []
require(source.is_file() and not source.is_symlink() and
source.name == f"FrameBridge-{args.version}-source.tar.gz", "Unexpected project-source archive")
require({p.name for p in dependencies} == set(DEPENDENCY_SOURCE_ASSETS) and
len(dependencies) == len(DEPENDENCY_SOURCE_ASSETS), "Exactly the reviewed two dependency-source archives required")
for path in dependencies:
expected_size, expected_sha = DEPENDENCY_SOURCE_ASSETS[path.name]
require(path.is_file() and not path.is_symlink(), "Unsafe dependency-source archive")
data = path.read_bytes()
require(len(data) == expected_size and digest(data) == expected_sha, "Dependency-source archive hash mismatch")
verify_detached(args.public_key, path.with_name(path.name + ".asc"), path)
directory = args.output / "downloads"
directory.mkdir()
assets = []
def add(name, data, needs_signing):
target = directory / name
require(not target.exists(), "Duplicate release filename")
target.write_bytes(data)
assets.append({"filename": name, "size_bytes": len(data), "sha256": digest(data),
"signature": name + ".asc", "needs_signing": needs_signing})
for _, _, _, profile, _, data in inputs:
add(f"FrameBridge-{args.version}-{profile}.deb", data, True)
add(source.name, source.read_bytes(), True)
for path in dependencies:
add(path.name, path.read_bytes(), False)
shutil.copyfile(path.with_name(path.name + ".asc"), directory / (path.name + ".asc"))
shutil.copyfile(args.public_key, directory / "framebridge-release-key.asc")
sums = "".join(item["sha256"] + " " + item["filename"] + "\n" for item in assets)
add("SHA256SUMS", sums.encode(), True)
return assets
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)})
downloads = prepare_downloads(args, inputs)
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, "download_assets": downloads, "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, sign_only=False):
for suite, *_ in PROFILES.values():
root = output / "dists" / suite
yield root / "Release", root / "InRelease", True
yield root / "Release", root / "Release.gpg", False
manifest = json.loads((output / "APT-INPUT-MANIFEST.json").read_text())
for asset in manifest.get("download_assets", []):
if sign_only and not asset["needs_signing"]:
continue
filename = asset["filename"]
require(Path(filename).name == filename and Path(asset["signature"]).name == asset["signature"],
"Unsafe download signature path")
yield output / "downloads" / filename, output / "downloads" / asset["signature"], 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, sign_only=True))
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)
prepare_parser.add_argument("--source", type=Path, help="Reviewed corresponding project-source tar.gz")
prepare_parser.add_argument("--dependency-source", type=Path, action="append", default=[],
help="Reviewed dependency-source archive with its unchanged sibling .asc")
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())