73 lines
3.4 KiB
Python
73 lines
3.4 KiB
Python
#!/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()
|