#!/usr/bin/env python3 """Sign a reviewed, packaged release using a separate offline GnuPG home. This command is never invoked by controller builds or runtime. Keep the signing key and its GnuPG home outside this checkout and Docker context. """ import argparse from pathlib import Path import re import subprocess import tempfile from package_release import manifest_bytes def reviewed_manifest(release): manifest = (release / "manifest.json").read_bytes() expected = manifest_bytes((release / "ec-agent.py").read_bytes(), (release / "ec-agent-update.py").read_bytes(), (release / "install.sh").read_bytes()) if manifest != expected: raise ValueError("Release manifest does not match packaged artifacts; package and review the release again") return manifest def sign_release(release, gnupghome, fingerprint): """Sign exact manifest bytes, without importing any private key material.""" release, gnupghome = Path(release).resolve(), Path(gnupghome).resolve() repository = Path(__file__).resolve().parent.parent if not re.fullmatch(r"(?:[A-Fa-f0-9]{40}|[A-Fa-f0-9]{64})", fingerprint): raise ValueError("Signing requires a full OpenPGP key fingerprint (40 or 64 hexadecimal characters)") if gnupghome.is_relative_to(repository) or gnupghome.is_relative_to(release): raise ValueError("Keep the signing GnuPG home outside the repository and release directory") if not gnupghome.is_dir(): raise ValueError("The offline signing GnuPG home must already exist") manifest = reviewed_manifest(release) with tempfile.TemporaryDirectory(prefix=".sign-release-", dir=release) as directory: staged_manifest = Path(directory) / "manifest.json" signature = Path(directory) / "manifest.json.sig" staged_manifest.write_bytes(manifest) subprocess.run([ "gpg", "--no-options", "--homedir", str(gnupghome), "--batch", "--yes", "--armor", "--detach-sign", "--digest-algo", "SHA512", "--local-user", fingerprint.upper() + "!", "--output", str(signature), str(staged_manifest), ], check=True) if not signature.read_bytes().startswith(b"-----BEGIN PGP SIGNATURE-----\n"): raise ValueError("GnuPG did not produce an ASCII-armored detached signature") if reviewed_manifest(release) != manifest: raise ValueError("Release changed during signing; review and sign the release again") signature.chmod(0o644) signature.replace(release / "manifest.json.sig") def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--release-dir", type=Path, default=Path(__file__).resolve().parent.parent / "static" / "agent") parser.add_argument("--gnupghome", required=True, type=Path, help="Existing offline GnuPG home outside the checkout/build context") parser.add_argument("--fingerprint", required=True, help="Full fingerprint of the exact signing key (not a key ID or email)") args = parser.parse_args() try: sign_release(args.release_dir, args.gnupghome, args.fingerprint) except (OSError, ValueError, SyntaxError, subprocess.CalledProcessError) as cause: parser.exit(1, f"Release signing failed: {cause}\n") print(f"Signed {args.release_dir / 'manifest.json'}") if __name__ == "__main__": main()