Add signed APT channels for Debian, Ubuntu and Linux Mint
This commit is contained in:
1 parent
db7b472861
commit
fb53fe2b70
3 files changed
+486
No files matched your search
@@ -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.
|
||||||
Executable
+237
@@ -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())
|
||||||
@@ -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 <test@invalid>", "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 <test@invalid>\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)
|
||||||
Reference in new issue
Block a user