Files
rippled/package/sign_rpm.py
2026-09-02 21:18:13 +00:00

131 lines
4.4 KiB
Python
Executable File

#!/usr/bin/env python3
"""Sign the RPMs built by build_pkg.py.
Nexus signs the yum repository metadata (via the 'rpm-<channel>' group
repository), but never the packages themselves, so they carry their own
signature. Clients verify the packages with gpgcheck=1 and the metadata with
repo_gpgcheck=1.
The DEBs are deliberately not signed: embedded DEB signatures exist (debsigs),
but apt does not verify them by default and trusts the repository metadata,
which Nexus signs, instead.
PKG_SIGNING_KEY is read from the environment, so the key never reaches the
process list.
"""
from __future__ import annotations
import argparse
import os
import subprocess
import tempfile
from pathlib import Path
# An RSA signature lands in the RSAHEADER tag, a DSA or EdDSA one in DSAHEADER,
# so both are queried; checking only the first would reject a signed package.
SIGNATURE_QUERY = "%{RSAHEADER:pgpsig}%{DSAHEADER:pgpsig}"
UNSIGNED = "(none)(none)"
def gpg(gnupghome: Path, *args: str, stdin: str | None = None) -> str:
"""Run gpg against a throwaway keyring and return its stdout."""
return subprocess.run(
["gpg", "--batch", "--quiet", *args],
input=stdin,
# stderr is left alone so a failing gpg explains itself.
stdout=subprocess.PIPE,
text=True,
check=True,
env={**os.environ, "GNUPGHOME": str(gnupghome)},
).stdout
def import_key(gnupghome: Path, key: str) -> str:
"""Import the armoured private key and return its fingerprint."""
gpg(gnupghome, "--import", stdin=key)
records = [
line.split(":")
for line in gpg(gnupghome, "--list-secret-keys", "--with-colons").splitlines()
]
# Exactly one, so the fingerprint picked below is not a guess.
secrets = [record for record in records if record[0] == "sec"]
assert (
len(secrets) == 1
), f"PKG_SIGNING_KEY must hold exactly one secret key, found {len(secrets)}"
# The first fingerprint belongs to the primary key; subkeys follow.
fingerprints = [record[9] for record in records if record[0] == "fpr"]
assert fingerprints, "PKG_SIGNING_KEY holds a secret key with no fingerprint"
return fingerprints[0]
def sign(gnupghome: Path, rpms: list[Path], fingerprint: str) -> None:
"""Attach a signature to every RPM in one rpmsign invocation."""
subprocess.run(
[
"rpmsign",
"--define",
f"_gpg_name {fingerprint}",
# Loopback pinentry: the key is unattended, so there is no tty to
# prompt on.
"--define",
"_gpg_sign_cmd_extra_args --pinentry-mode loopback --batch --yes",
"--addsign",
*(str(rpm) for rpm in rpms),
],
check=True,
env={**os.environ, "GNUPGHOME": str(gnupghome)},
)
def verify(rpms: list[Path]) -> None:
"""Fail unless every RPM now carries a signature.
rpmsign can exit 0 having attached nothing, and an unsigned package is only
rejected later, on the installing machine.
"""
for rpm in rpms:
signature = subprocess.run(
["rpm", "--query", "--queryformat", SIGNATURE_QUERY, "--package", str(rpm)],
stdout=subprocess.PIPE,
text=True,
check=True,
).stdout.strip()
assert signature != UNSIGNED, f"{rpm} is unsigned after rpmsign"
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--package-dir",
type=Path,
default=Path("build"),
help="searched recursively for *.rpm (default: %(default)s)",
)
args = parser.parse_args()
package_dir: Path = args.package_dir
# Deliberately not shared with publish_pkg.py, which ships standalone in the
# packaging image.
rpms = sorted(path for path in package_dir.rglob("*.rpm") if path.is_file())
# Signing nothing would otherwise look like a successful signing.
assert rpms, f"no RPMs found in {package_dir}"
key = os.environ.get("PKG_SIGNING_KEY")
assert key, "PKG_SIGNING_KEY is required"
# The keyring holds an unencrypted private key, so it goes even if signing
# fails.
with tempfile.TemporaryDirectory() as tmp:
gnupghome = Path(tmp)
fingerprint = import_key(gnupghome, key)
print(f"Signing {len(rpms)} RPM(s) with {fingerprint}.")
sign(gnupghome, rpms, fingerprint)
verify(rpms)
if __name__ == "__main__":
main()