Files
MicrOBU/microbu-esp32c5/pki/uml-l0-rca/reference-generator/create_new_root.py
T

409 lines
15 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""
Create a separate NEW UML L0 RCA keypair and candidate certificate
using the exact TheEnbyperor/c-its reference commit e3bb3b82480d6df4237e2a8c35ea0dd7eade25b4.
THIS DOES NOT REPLACE OR RECREATE THE EU-REGISTERED ROOT. The result is not trusted
or registered until it completes a deliberate registration/re-key process.
The new private key never leaves this machine and is permanently stored only as
encrypted PKCS#8 PEM. A 32-byte private scalar is passed to the already-built
local Rust process over stdin and is never written to disk unencrypted.
"""
from __future__ import annotations
import getpass
import hashlib
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import tarfile
import zipfile
from datetime import datetime
UPSTREAM_COMMIT = "e3bb3b82480d6df4237e2a8c35ea0dd7eade25b4"
TEMPLATE_SHA256 = "7168E341764188E140EF591634A9D2488D7FDACDA667EE53AFD566A8034ED5DB"
CERTIFICATE_ID = "0_Urban-Mobility-Lab-Root-CA_L0"
HERE = Path(__file__).resolve().parent
ROOT = HERE.parent
TEMPLATE = ROOT / "rca" / "AFD566A8034ED5DB.oer"
PATCH_SOURCE = HERE / "src" / "create_new_root.rs"
BUNDLED_SOURCE = HERE / "vendor" / "c-its-source-e3bb3b8.zip"
VENDORED_CRATES = HERE / "vendor" / "cargo-crates.tar.gz"
UPSTREAM_DIR = HERE / "_build_cits"
CARGO_VENDOR_DIR = HERE / "_cargo_vendor"
def die(msg: str) -> None:
raise SystemExit("\nERROR: " + msg)
def run(cmd, *, cwd=None, input_bytes=None, capture=False, env=None):
print("+", " ".join(str(x) for x in cmd))
return subprocess.run(
cmd,
cwd=cwd,
input=input_bytes,
check=True,
text=False,
capture_output=capture,
env=env,
)
def require_program(name: str) -> None:
if shutil.which(name) is None:
die(
f"'{name}' is not installed or not on PATH. "
"Install Git and the Rust toolchain (cargo/rustc) first. "
"On Windows, WSL2 Ubuntu is a good fallback if native compilation causes problems."
)
def validate_submission_date(s: str) -> str:
try:
datetime.strptime(s, "%Y-%m-%d")
except ValueError:
die("Submission date must be YYYY-MM-DD.")
return s
def prepare_upstream() -> None:
"""Create a disposable build tree from the bundled pinned source."""
if not BUNDLED_SOURCE.is_file() or not VENDORED_CRATES.is_file():
die("Bundled c-its source or Cargo crate archive is missing.")
if UPSTREAM_DIR.exists():
shutil.rmtree(UPSTREAM_DIR)
if CARGO_VENDOR_DIR.exists():
shutil.rmtree(CARGO_VENDOR_DIR)
UPSTREAM_DIR.mkdir()
CARGO_VENDOR_DIR.mkdir()
with zipfile.ZipFile(BUNDLED_SOURCE) as archive:
archive.extractall(UPSTREAM_DIR)
with tarfile.open(VENDORED_CRATES, "r:gz") as archive:
archive.extractall(CARGO_VENDOR_DIR)
# Replace only the root-generator utility. Security/crypto/ASN.1 implementation
# remains the pinned upstream commit.
shutil.copy2(PATCH_SOURCE, UPSTREAM_DIR / "src" / "util" / "generate_root.rs")
shutil.copy2(TEMPLATE, UPSTREAM_DIR / TEMPLATE.name)
# Host-build fix for Windows/native desktop targets:
# ieee80211 pulls embedded defmt support through default features, but a
# normal host executable has no defmt transport/logger providing symbols
# such as _defmt_panic, _defmt_acquire and _defmt_write.
cargo_toml = UPSTREAM_DIR / "Cargo.toml"
cargo_text = cargo_toml.read_text(encoding="utf-8")
original_ieee = 'ieee80211 = "0.5.9"'
patched_ieee = 'ieee80211 = { version = "0.5.9", default-features = false }'
if original_ieee in cargo_text:
cargo_text = cargo_text.replace(original_ieee, patched_ieee, 1)
cargo_toml.write_text(cargo_text, encoding="utf-8")
print("Applied host-build patch: ieee80211 default features disabled (defmt removed).")
elif patched_ieee in cargo_text:
print("Host-build patch already present.")
else:
die("Expected ieee80211 dependency line not found in pinned Cargo.toml.")
cargo_config = UPSTREAM_DIR / ".cargo" / "config.toml"
cargo_config.parent.mkdir()
cargo_config.write_text(
'[source.crates-io]\nreplace-with = "vendored-sources"\n\n'
'[source.vendored-sources]\ndirectory = "../_cargo_vendor"\n\n'
'[net]\noffline = true\n',
encoding="utf-8",
)
def build_generator() -> Path:
print("\nBuilding the pinned c-its generator BEFORE generating any private key...")
try:
run(
[
"cargo", "build", "--offline", "--release",
"--features", "build-binary",
"--bin", "c-its-generate-root",
],
cwd=UPSTREAM_DIR,
)
except subprocess.CalledProcessError as exc:
die(
"The pinned c-its host build failed. No RCA private key has been generated. "
"If the remaining linker error still mentions _defmt_*, delete the package's "
"_upstream_cits directory and rerun this V2 script. "
f"Cargo exit code: {exc.returncode}"
)
exe = UPSTREAM_DIR / "target" / "release" / (
"c-its-generate-root.exe" if os.name == "nt" else "c-its-generate-root"
)
if not exe.exists():
die(f"Build reported success but executable is missing: {exe}")
return exe
def generate_keypair_and_certificate(exe: Path) -> Path:
try:
from cryptography.hazmat.primitives.asymmetric import ec, utils
from cryptography.hazmat.primitives import hashes, serialization
except ImportError:
die("Python package 'cryptography' is required. Run: python -m pip install cryptography")
submission_date = validate_submission_date(
input("\nActual submission date (YYYY-MM-DD): ").strip()
)
while True:
pw1 = getpass.getpass("Choose NEW RCA private-key passphrase: ")
pw2 = getpass.getpass("Repeat passphrase: ")
if pw1 != pw2:
print("Passphrases do not match. Try again.")
continue
if len(pw1) < 12:
print("Use at least 12 characters.")
continue
break
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
root = HERE / f"UML_L0_RCA_NEW_{stamp}"
submit = root / "CANDIDATE_SUBMISSION_NOT_REGISTERED"
private_dir = root / "PRIVATE_DO_NOT_SHARE"
public_dir = root / "PUBLIC_AND_VALIDATION"
rust_out = root / "_rust_output"
for d in (submit, private_dir, public_dir, rust_out):
d.mkdir(parents=True, exist_ok=False)
print("\nGenerating a fresh NIST P-256 keypair locally...")
key = ec.generate_private_key(ec.SECP256R1())
encrypted_pem = key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.BestAvailableEncryption(pw1.encode("utf-8")),
)
public_pem = key.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
private_path = private_dir / "UML_L0_RCA_private_encrypted.pem"
public_path = public_dir / "UML_L0_RCA_public.pem"
private_path.write_bytes(encrypted_pem)
public_path.write_bytes(public_pem)
try:
private_path.chmod(0o600)
except OSError:
pass
# The scalar is kept in RAM and sent only over stdin to the local Rust binary.
scalar = bytearray(key.private_numbers().private_value.to_bytes(32, "big"))
env = os.environ.copy()
env["UML_TEMPLATE"] = str(UPSTREAM_DIR / TEMPLATE.name)
env["UML_OUTPUT_DIR"] = str(rust_out)
try:
proc = run(
[str(exe)],
cwd=UPSTREAM_DIR,
input_bytes=(bytes(scalar).hex().upper() + "\n").encode("ascii"),
capture=True,
env=env,
)
finally:
for i in range(len(scalar)):
scalar[i] = 0
stdout = proc.stdout.decode("utf-8", errors="replace")
stderr = proc.stderr.decode("utf-8", errors="replace")
print(stdout)
if stderr.strip():
print(stderr, file=sys.stderr)
m = re.search(r"^RESULT_ID8=([0-9A-F]{16})$", stdout, re.MULTILINE)
if not m:
die("Rust generator succeeded but RESULT_ID8 was not found.")
id8 = m.group(1)
oer_src = rust_out / f"{id8}.oer"
if not oer_src.exists():
die(f"Expected certificate not found: {oer_src}")
cert_bytes = oer_src.read_bytes()
cert_sha256 = hashlib.sha256(cert_bytes).hexdigest().upper()
if cert_sha256[-16:] != id8:
die("Independent Python HashedID8 check failed.")
# Independently reconstruct the IEEE/ETSI self-signed prehash from the TBS
# emitted by rasn and verify the c-its ECDSA signature with Python cryptography.
details = {}
for line in (rust_out / "signing_details.txt").read_text(encoding="utf-8").splitlines():
if "=" in line:
k, v = line.split("=", 1)
details[k] = v
tbs = (rust_out / "tbs.oer").read_bytes()
h_tbs = hashlib.sha256(tbs).digest()
h_empty = hashlib.sha256(b"").digest()
prehash = hashlib.sha256(h_tbs + h_empty).digest()
if details.get("TBS_SHA256") != h_tbs.hex().upper():
die("Independent TBS hash disagrees with Rust output.")
if details.get("SIGNING_PREHASH_SHA256") != prehash.hex().upper():
die("Independent IEEE/ETSI signing prehash disagrees with Rust output.")
r = int(details["ECDSA_R"], 16)
s = int(details["ECDSA_S"], 16)
der_sig = utils.encode_dss_signature(r, s)
try:
key.public_key().verify(
der_sig,
prehash,
ec.ECDSA(utils.Prehashed(hashes.SHA256())),
)
except Exception as exc:
die(f"Independent Python ECDSA verification FAILED: {exc}")
# Copy only the public submission certificate.
oer_submit = submit / f"{id8}.oer"
shutil.copy2(oer_src, oer_submit)
txt_name = f"{id8}_{CERTIFICATE_ID}_{submission_date}.txt"
txt_submit = submit / txt_name
txt_submit.write_text(f"""EU CCMS CPOC L0 ECTL — RCA descriptive information
===================================================
Full Company Name / RCA Operator:
Hochschule für Angewandte Wissenschaften Hamburg (HAW Hamburg)
Urban Mobility Lab
RCA Name:
Urban Mobility Lab L0 Root CA
RCA Distribution Centre URL:
https://cits-dc.uml-hamburg.de
Contact:
uml-haw@proton.me
CertificateID:
{CERTIFICATE_ID}
Certificate HashedID8:
{id8}
CertificateID / acronym explanation:
- 0: CPA-ID reserved for the L0 environment.
- Urban-Mobility-Lab-Root-CA: Root CA operated for the Urban Mobility Lab at HAW Hamburg.
- L0: EU CCMS L0 research/test environment.
Purpose of testing:
The RCA is used for research, field testing and proof-of-concept activities of the
Urban Mobility Lab in the context of the micrOBU project. micrOBU investigates the
integration of vulnerable road users into C-ITS through development of a lightweight,
compact and low-cost VRU ITS-S using an MCU + mobile-phone architecture.
Contextual constraints / deviations:
- This is a candidate new RCA. It is not registered and must not be deployed until
the EU CCMS CPOC has completed the intended registration or re-key procedure.
- The implementation, including the C-ITS protocol stack, signing functionality and
VRU Awareness Basic Service, is under active development. Full implementation
conformity is not claimed.
- The declared RCA Distribution Centre is the intended permanent DC URL. At the time
of this submission, the cits-dc.uml-hamburg.de subdomain and the required /getctl
and /getcrl endpoints are still being commissioned and are not yet operational.
- The RCA intentionally includes ITS-AID 638 (VRU Awareness basic service) with
bitmap SSP range 01/FF for L0 VAM/VBS research. ITS-AID 638 is a standardized ETSI
ITS-AID but is not contained in the current CPOC Protocol Release 3.3 Table 4 list;
its inclusion is therefore an intentional L0 profile deviation.
- The RCA private key is generated and maintained locally as an encrypted PKCS#8
NIST P-256 key for this L0 research environment. This software-based key storage is
not claimed to satisfy production/L2 cryptographic-module requirements.
Re-key / revocation / relationship to existing certificates:
Candidate new RCA. Coordinate its relationship to existing registrations with the
EU CCMS CPOC before submission or use.
Submission date:
{submission_date}
""", encoding="utf-8")
# Preserve public validation evidence.
shutil.copy2(rust_out / "certificate_report.json", public_dir / "certificate_report.json")
shutil.copy2(rust_out / "signing_details.txt", public_dir / "signing_details.txt")
shutil.copy2(rust_out / "tbs.oer", public_dir / "tbs.oer")
spki_der = key.public_key().public_bytes(
encoding=serialization.Encoding.DER,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
spki_sha256 = hashlib.sha256(spki_der).hexdigest().upper()
manifest = f"""UML L0 RCA generation manifest
=============================
Reference implementation:
https://github.com/TheEnbyperor/c-its
Pinned commit:
{UPSTREAM_COMMIT}
Reference root-generator behavior:
- rasn::oer encoding
- c_its::security::crypto::PrivateKey::sign()
- self-signed signer-certificate input = empty byte string
- NIST P-256 / SHA-256
Template certificate SHA-256:
{TEMPLATE_SHA256}
NEW certificate:
{id8}.oer
Certificate SHA-256:
{cert_sha256}
HashedID8:
{id8}
New public-key SPKI SHA-256:
{spki_sha256}
Validation:
PASS - c-its parsed final certificate
PASS - c-its self-signature verification
PASS - c-its parse/re-encode stability
PASS - Python independently recomputed HashedID8
PASS - Python independently recomputed IEEE/ETSI signing prehash
PASS - Python cryptography independently verified ECDSA signature over that prehash
PRIVATE KEY:
{private_path.name}
Encrypted PKCS#8 PEM. DO NOT SUBMIT OR UPLOAD.
"""
(public_dir / "generation_manifest.txt").write_text(manifest, encoding="utf-8")
# Remove duplicate Rust certificate; the canonical submission copy is in SUBMIT.
shutil.rmtree(rust_out)
print("\nSUCCESS")
print("A NEW, UNREGISTERED keypair and candidate certificate were generated locally.")
print("Do not deploy it as the registered root. EU registration/re-keying is required.")
print(f"\nCANDIDATE SUBMISSION FILES (NOT REGISTERED):\n {oer_submit}\n {txt_submit}")
print(f"\nKEEP PRIVATE:\n {private_path}")
print(f"\nSAFE PUBLIC/VALIDATION FILES:\n {public_dir}")
return root
def main() -> None:
require_program("cargo")
if not TEMPLATE.exists():
die(f"Missing bundled template: {TEMPLATE}")
got = hashlib.sha256(TEMPLATE.read_bytes()).hexdigest().upper()
if got != TEMPLATE_SHA256:
die(f"Bundled template hash mismatch: {got}")
prepare_upstream()
exe = build_generator()
root = generate_keypair_and_certificate(exe)
print(f"\nResult folder: {root}")
if __name__ == "__main__":
main()