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