|
| 1 | +#!/usr/bin/env python3 |
| 2 | +""" |
| 3 | +Create a self-signed, ETSI-compliant X.509 certificate for LoTL/Trusted List signing. |
| 4 | +
|
| 5 | +Per ETSI TS 119 612 clause 5.7.1 and Annex B: |
| 6 | +- Self-signed (TLSO as issuer) |
| 7 | +- Subject DN: C=SchemeTerritory, O=SchemeOperatorName |
| 8 | +- KeyUsage: digitalSignature, nonRepudiation |
| 9 | +- ExtendedKeyUsage: id-tsl-kp-tslSigning (0.4.0.2231.3.0) |
| 10 | +- SubjectKeyIdentifier: present |
| 11 | +- BasicConstraints: CA=false |
| 12 | +- Key: ECDSA P-256 (minimum 3 years usable per ETSI TS 119 312) |
| 13 | +""" |
| 14 | + |
| 15 | +import argparse |
| 16 | +import subprocess |
| 17 | +import sys |
| 18 | +from datetime import datetime, timezone, timedelta |
| 19 | +from pathlib import Path |
| 20 | + |
| 21 | +from cryptography import x509 |
| 22 | +from cryptography.hazmat.backends import default_backend |
| 23 | +from cryptography.hazmat.primitives import hashes, serialization |
| 24 | +from cryptography.hazmat.primitives.asymmetric import ec |
| 25 | +from cryptography.x509.oid import ExtensionOID, ObjectIdentifier |
| 26 | + |
| 27 | +# ETSI id-tsl-kp-tslSigning OID (TS 119 612 clause 5.7.1) |
| 28 | +ID_TSL_KP_TSL_SIGNING = ObjectIdentifier("0.4.0.2231.3.0") |
| 29 | + |
| 30 | + |
| 31 | +def create_lotl_signing_cert( |
| 32 | + scheme_territory: str, |
| 33 | + scheme_operator_name: str, |
| 34 | + output_dir: Path, |
| 35 | + validity_days: int = 365 * 3, # 3 years per ETSI TS 119 312 |
| 36 | + key_path: Path | None = None, |
| 37 | + cert_path: Path | None = None, |
| 38 | +) -> tuple[bytes, bytes]: |
| 39 | + """Generate ETSI-compliant self-signed cert and private key. Returns (key_pem, cert_pem).""" |
| 40 | + output_dir.mkdir(parents=True, exist_ok=True) |
| 41 | + |
| 42 | + # ECDSA P-256 for minimum 3 years usable key (ETSI TS 119 312) |
| 43 | + key = ec.generate_private_key(ec.SECP256R1()) |
| 44 | + |
| 45 | + subject = issuer = x509.Name( |
| 46 | + [ |
| 47 | + x509.NameAttribute(x509.oid.NameOID.COUNTRY_NAME, scheme_territory), |
| 48 | + x509.NameAttribute(x509.oid.NameOID.ORGANIZATION_NAME, scheme_operator_name), |
| 49 | + ] |
| 50 | + ) |
| 51 | + |
| 52 | + now = datetime.now(timezone.utc) |
| 53 | + cert = ( |
| 54 | + x509.CertificateBuilder() |
| 55 | + .subject_name(subject) |
| 56 | + .issuer_name(issuer) |
| 57 | + .public_key(key.public_key()) |
| 58 | + .serial_number(x509.random_serial_number()) |
| 59 | + .not_valid_before(now) |
| 60 | + .not_valid_after(now + timedelta(days=validity_days)) |
| 61 | + .add_extension( |
| 62 | + x509.KeyUsage( |
| 63 | + digital_signature=True, |
| 64 | + content_commitment=True, # nonRepudiation |
| 65 | + key_encipherment=False, |
| 66 | + data_encipherment=False, |
| 67 | + key_agreement=False, |
| 68 | + key_cert_sign=False, |
| 69 | + crl_sign=False, |
| 70 | + encipher_only=False, |
| 71 | + decipher_only=False, |
| 72 | + ), |
| 73 | + critical=True, |
| 74 | + ) |
| 75 | + .add_extension( |
| 76 | + x509.ExtendedKeyUsage([ID_TSL_KP_TSL_SIGNING]), |
| 77 | + critical=False, |
| 78 | + ) |
| 79 | + .add_extension( |
| 80 | + x509.BasicConstraints(ca=False, path_length=None), |
| 81 | + critical=True, |
| 82 | + ) |
| 83 | + .add_extension( |
| 84 | + x509.SubjectKeyIdentifier.from_public_key(key.public_key()), |
| 85 | + critical=False, |
| 86 | + ) |
| 87 | + .sign(key, hashes.SHA256()) |
| 88 | + ) |
| 89 | + |
| 90 | + key_pem = key.private_bytes( |
| 91 | + encoding=serialization.Encoding.PEM, |
| 92 | + format=serialization.PrivateFormat.TraditionalOpenSSL, |
| 93 | + encryption_algorithm=serialization.NoEncryption(), |
| 94 | + ) |
| 95 | + cert_pem = cert.public_bytes(serialization.Encoding.PEM) |
| 96 | + |
| 97 | + if key_path: |
| 98 | + key_path.write_bytes(key_pem) |
| 99 | + if cert_path: |
| 100 | + cert_path.write_bytes(cert_pem) |
| 101 | + |
| 102 | + return key_pem, cert_pem |
| 103 | + |
| 104 | + |
| 105 | +def _print_cert_diagnostic(cert_path: Path, cert_pem: bytes) -> None: |
| 106 | + """Print certificate diagnostic (ASN.1 / OpenSSL text) and raw PEM.""" |
| 107 | + print("--- Certificate (diagnostic ASN / OpenSSL text) ---") |
| 108 | + result = subprocess.run( |
| 109 | + ["openssl", "x509", "-in", str(cert_path), "-text", "-noout"], |
| 110 | + capture_output=True, |
| 111 | + text=True, |
| 112 | + ) |
| 113 | + if result.returncode == 0: |
| 114 | + print(result.stdout) |
| 115 | + else: |
| 116 | + # Fallback: basic Python representation |
| 117 | + cert = x509.load_pem_x509_certificate(cert_pem, default_backend()) |
| 118 | + print(f"Certificate:") |
| 119 | + print(f" Subject: {cert.subject.rfc4514_string()}") |
| 120 | + print(f" Issuer: {cert.issuer.rfc4514_string()}") |
| 121 | + print(f" Serial: {cert.serial_number}") |
| 122 | + print(f" Not Before: {cert.not_valid_before_utc}") |
| 123 | + print(f" Not After: {cert.not_valid_after_utc}") |
| 124 | + print(f" Signature: {getattr(cert.signature_algorithm_oid, '_name', cert.signature_algorithm_oid.dotted_string)}") |
| 125 | + for ext in cert.extensions: |
| 126 | + oid_name = getattr(ext.oid, "_name", ext.oid.dotted_string) |
| 127 | + print(f" {oid_name}: {ext.value}") |
| 128 | + |
| 129 | + print("--- Certificate (raw PEM) ---") |
| 130 | + print(cert_pem.decode()) |
| 131 | + |
| 132 | + |
| 133 | +def main() -> int: |
| 134 | + parser = argparse.ArgumentParser( |
| 135 | + description="Create ETSI-compliant self-signed X.509 certificate for LoTL signing", |
| 136 | + ) |
| 137 | + parser.add_argument( |
| 138 | + "--output-dir", |
| 139 | + "-o", |
| 140 | + type=Path, |
| 141 | + default=Path("lotl/certs"), |
| 142 | + help="Output directory for key.pem and cert.pem (default: lotl/certs)", |
| 143 | + ) |
| 144 | + parser.add_argument( |
| 145 | + "--scheme-territory", |
| 146 | + "-t", |
| 147 | + default="EU", |
| 148 | + help="Scheme territory (ISO 3166-1 alpha-2, e.g. EU, IT) (default: EU)", |
| 149 | + ) |
| 150 | + parser.add_argument( |
| 151 | + "--scheme-operator-name", |
| 152 | + "-n", |
| 153 | + default="WP4 Trust Registry Group", |
| 154 | + help="Scheme operator name for Subject/Issuer O (default: WP4 Trust Registry Group)", |
| 155 | + ) |
| 156 | + parser.add_argument( |
| 157 | + "--validity-days", |
| 158 | + type=int, |
| 159 | + default=365 * 3, |
| 160 | + help="Certificate validity in days (default: 1095 = 3 years)", |
| 161 | + ) |
| 162 | + parser.add_argument( |
| 163 | + "--key-file", |
| 164 | + default="lotl_signing_key.pem", |
| 165 | + help="Output key filename (default: lotl_signing_key.pem)", |
| 166 | + ) |
| 167 | + parser.add_argument( |
| 168 | + "--cert-file", |
| 169 | + default="lotl_signing_cert.pem", |
| 170 | + help="Output cert filename (default: lotl_signing_cert.pem)", |
| 171 | + ) |
| 172 | + |
| 173 | + args = parser.parse_args() |
| 174 | + |
| 175 | + key_path = args.output_dir / args.key_file |
| 176 | + cert_path = args.output_dir / args.cert_file |
| 177 | + |
| 178 | + try: |
| 179 | + _, cert_pem = create_lotl_signing_cert( |
| 180 | + scheme_territory=args.scheme_territory, |
| 181 | + scheme_operator_name=args.scheme_operator_name, |
| 182 | + output_dir=args.output_dir, |
| 183 | + validity_days=args.validity_days, |
| 184 | + key_path=key_path, |
| 185 | + cert_path=cert_path, |
| 186 | + ) |
| 187 | + except OSError as e: |
| 188 | + print(f"Error writing files: {e}", file=sys.stderr) |
| 189 | + return 1 |
| 190 | + |
| 191 | + print(f"Created ETSI-compliant LoTL signing certificate:") |
| 192 | + print(f" Key: {key_path}") |
| 193 | + print(f" Cert: {cert_path}") |
| 194 | + print(f" Subject: C={args.scheme_territory}, O={args.scheme_operator_name}") |
| 195 | + print() |
| 196 | + print("Use with LoTL producer:") |
| 197 | + print(f" export LOTL_SIGNING_KEY=$(cat {key_path})") |
| 198 | + print(f" export LOTL_SIGNING_CERT=$(cat {cert_path})") |
| 199 | + print(f" python -m tools.lotl --tl-entries-dir lotl/tl_entries/ --output-dir lotl/") |
| 200 | + print() |
| 201 | + print("Or with inline paths:") |
| 202 | + print(f" python -m tools.lotl --signing-key {key_path} --signing-cert {cert_path} \\") |
| 203 | + print(" --tl-entries-dir lotl/tl_entries/ --output-dir lotl/") |
| 204 | + print() |
| 205 | + _print_cert_diagnostic(cert_path, cert_pem) |
| 206 | + |
| 207 | + return 0 |
| 208 | + |
| 209 | + |
| 210 | +if __name__ == "__main__": |
| 211 | + sys.exit(main()) |
0 commit comments