#!/usr/bin/env python3 """Generate an independently held Ed25519 assessment-authority keypair.""" from __future__ import annotations import argparse import base64 from datetime import UTC, datetime, timedelta import json import os from pathlib import Path import re import stat from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey KEY_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$") PROOF_SCOPES = ( "target_environment", "external_providers", "accessibility", "privacy", "security", "operations", "recovery", "production_approval", ) PURPOSES = { "proof": ( "govoplan.capability-fit-proof-authorities", "./capability-fit-proof-authority-keyring.schema.json", ), "installer": ( "govoplan.installer-receipt-authorities", "./installer-receipt-authority-keyring.schema.json", ), } def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--purpose", choices=tuple(PURPOSES), required=True) parser.add_argument("--key-id", required=True) parser.add_argument( "--scope", action="append", choices=PROOF_SCOPES, default=[], help="Authorized proof scope; repeat as needed. Not used for installer keys.", ) parser.add_argument("--private-key", type=Path, required=True) parser.add_argument("--keyring", type=Path, required=True) parser.add_argument( "--valid-days", type=int, default=365, help="Validity from generation time (default: 365 days).", ) parser.add_argument( "--status", choices=("active", "next"), default="active", ) args = parser.parse_args(argv) if not KEY_ID_PATTERN.fullmatch(args.key_id): parser.error("--key-id must be a valid opaque identifier") if args.valid_days < 1 or args.valid_days > 3660: parser.error("--valid-days must be between 1 and 3660") scopes = _resolve_scopes(parser, purpose=args.purpose, scopes=args.scope) private_path = args.private_key.expanduser().resolve() keyring_path = args.keyring.expanduser().resolve() _require_fresh_output(parser, private_path, label="private key") _require_fresh_output(parser, keyring_path, label="keyring") _require_private_directory(parser, private_path.parent) _require_output_directory(parser, keyring_path.parent) private_key = Ed25519PrivateKey.generate() private_bytes = private_key.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.PKCS8, encryption_algorithm=serialization.NoEncryption(), ) public_bytes = private_key.public_key().public_bytes( encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw, ) public_base64 = base64.b64encode(public_bytes).decode("ascii") now = datetime.now(UTC).replace(microsecond=0) not_after = now + timedelta(days=args.valid_days) purpose, schema = PURPOSES[args.purpose] keyring = { "$schema": schema, "schema_version": "0.1.0", "purpose": purpose, "keys": [ { "key_id": args.key_id, "status": args.status, "public_key": public_base64, "allowed_scopes": scopes, "not_before": _rfc3339(now), "not_after": _rfc3339(not_after), } ], } _write_new_private_file(private_path, private_bytes) try: _write_new_private_file( keyring_path, (json.dumps(keyring, indent=2, sort_keys=True) + "\n").encode("utf-8"), ) except BaseException: private_path.unlink(missing_ok=True) keyring_path.unlink(missing_ok=True) raise print(f"private_key={private_path}") print(f"keyring={keyring_path}") print(f"key_id={args.key_id}") print(f"allowed_scopes={','.join(scopes)}") return 0 def _resolve_scopes( parser: argparse.ArgumentParser, *, purpose: str, scopes: list[str] ) -> list[str]: if purpose == "installer": if scopes: parser.error("installer authorities do not accept --scope") return ["installed_release_origin"] unique = list(dict.fromkeys(scopes)) if not unique: parser.error("proof authorities require at least one --scope") return unique def _require_fresh_output( parser: argparse.ArgumentParser, path: Path, *, label: str ) -> None: if path.exists() or path.is_symlink(): parser.error(f"{label.capitalize()} output already exists: {path}") def _require_private_directory( parser: argparse.ArgumentParser, directory: Path ) -> None: _require_output_directory(parser, directory) mode = stat.S_IMODE(directory.stat().st_mode) if mode & (stat.S_IRWXG | stat.S_IRWXO): parser.error( "Private-key parent directory must not be accessible by group or others" ) def _require_output_directory( parser: argparse.ArgumentParser, directory: Path ) -> None: try: metadata = directory.lstat() except OSError as exc: parser.error(f"Output parent directory is unavailable: {directory}") raise AssertionError from exc if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISDIR(metadata.st_mode): parser.error(f"Output parent must be a real directory: {directory}") def _write_new_private_file(path: Path, payload: bytes) -> None: flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL if hasattr(os, "O_NOFOLLOW"): flags |= os.O_NOFOLLOW descriptor = os.open(path, flags, 0o600) try: with os.fdopen(descriptor, "wb", closefd=False) as handle: handle.write(payload) handle.flush() os.fsync(handle.fileno()) metadata = os.fstat(descriptor) if not stat.S_ISREG(metadata.st_mode) or stat.S_IMODE(metadata.st_mode) != 0o600: raise OSError("Authority output could not be secured") finally: os.close(descriptor) def _rfc3339(value: datetime) -> str: return value.isoformat().replace("+00:00", "Z") if __name__ == "__main__": raise SystemExit(main())