Add signed APT channels for Debian, Ubuntu and Linux Mint

This commit is contained in:
Gerardo Betancourt committed 2026-10-28 11:52:35 -04:00
1 parent db7b472861
commit fb53fe2b70
3 files changed
+486

No files matched your search

+99
View File
@@ -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.
+237
View File
@@ -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())
+150
View File
@@ -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)