300 lines
16 KiB
Python
Executable File
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())
|