Keep the colleague's microbu-esp32c5 tree in this repository

obu-firmware builds against vanetza-idf from microbu-esp32c5/external, but
that tree was gitignored, so a clone of this repository could not build the
firmware it ships. It is now committed here as ordinary files in its own
folder, microbu-esp32c5/: the colleague's commit cf4b99f plus the V2X2MAP
bridge's signature verification (--trust) used on the bench. Nothing is
fetched from or pushed to the colleague's repository; this repository and
its remotes carry everything. The folder's own .gitignore keeps build output,
downloaded components and private key material out, as it did there; the
committed file set is identical to that repository's tracked files.

The ESP32-C5 is still flashed from obu-firmware/, which only takes
vanetza-idf from microbu-esp32c5/, so the two stay separate folders.
FLASHING.md says how to take a newer version of the colleague's tree (copy
it over the folder, rebuild, test, commit).
This commit is contained in:
Ashin Walpola
2026-09-23 17:46:40 +02:00
parent 2f60623e18
commit 0e9525162d
9881 changed files with 1582523 additions and 17 deletions
@@ -0,0 +1,28 @@
Reference notes
===============
Pinned upstream commit:
e3bb3b82480d6df4237e2a8c35ea0dd7eade25b4
The upstream commit's root generator:
1. constructs a ToBeSignedCertificate;
2. serializes it using rasn::oer::encode();
3. calls PrivateKey::sign(hash_alg, &tbs_bytes, None);
4. inserts the returned IEEE 1609.2 Signature into an ExplicitCertificate.
The pinned c-its PrivateKey::sign() implementation for SHA-256 computes:
SHA256(
SHA256(tbs_bytes)
||
SHA256(signer_info)
)
For a self-signed root, signer_info is the empty byte string because the root
generator passes None.
This bundle uses that exact upstream code for the cryptographic operation.
`rebuild_registered_rca.py` keeps the registered TBS/public key unchanged and uses
the registered encrypted key. `create_new_root.py` creates a new encrypted key and
replaces the template public key for a separate, unregistered candidate root. In
both cases the scalar reaches Rust only through standard input.
@@ -0,0 +1,36 @@
# UML L0 RCA generation tools
Both tools use the Rust/c-its certificate path pinned to commit
`e3bb3b82480d6df4237e2a8c35ea0dd7eade25b4`in https://github.com/TheEnbyperor/c-its. Requirements are Python 3.10+,
`cryptography`, Git, Rust/Cargo, and internet access for the first build.
## Rebuild the registered root
```text
python rebuild\_registered\_rca.py
```
Uses `private/UML\_L0\_RCA\_private\_encrypted.pem`. It succeeds only when the result is
byte-for-byte identical to the registered `AFD566A8034ED5DB.oer`. Use this to prove
how the registered certificate was made or to verify the registered key. It never
creates a key and does not alter the registered files.
## Create a separate new root
```text
python create\_new\_root.py
```
Creates a new P-256 key and candidate root certificate. It copies the registered
root's reviewed TBS profile, including CertificateID, permissions, region and
validity, but replaces the public key and signature. The result therefore has a
different HashedId8 and is **not EU-registered**.
Use this only for an isolated test root or a deliberate EU registration/re-key
process. Before submission, review the inherited validity/profile and coordinate
revocation or registration with the EU CCMS CPOC. Files appear under a directory
named `CANDIDATE\_SUBMISSION\_NOT\_REGISTERED`; that name is a warning, not approval.
In both workflows the private scalar is passed to the local Rust process through
standard input and is never stored unencrypted by these tools.
@@ -0,0 +1,408 @@
#!/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()
@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""Rebuild and verify the registered UML L0 RCA with its existing private key."""
from __future__ import annotations
import getpass
import hashlib
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tarfile
import zipfile
UPSTREAM_COMMIT = "e3bb3b82480d6df4237e2a8c35ea0dd7eade25b4"
REGISTERED_SHA256 = "7168E341764188E140EF591634A9D2488D7FDACDA667EE53AFD566A8034ED5DB"
REGISTERED_ID8 = "AFD566A8034ED5DB"
HERE = Path(__file__).resolve().parent
ROOT = HERE.parent
REGISTERED_CERT = ROOT / "rca" / f"{REGISTERED_ID8}.oer"
REGISTERED_PUBLIC_KEY = ROOT / "rca" / "UML_L0_RCA_public.pem"
REGISTERED_PRIVATE_KEY = ROOT / "private" / "UML_L0_RCA_private_encrypted.pem"
PATCH_SOURCE = HERE / "src" / "generate_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"
OUTPUT_DIR = HERE / "rebuild-output"
TEMPLATE_NAME = "registered_rca.oer"
def die(message: str) -> None:
raise SystemExit(f"\nERROR: {message}")
def run(command: list[str], *, cwd: Path, input_bytes: bytes | None = None,
capture: bool = False, env: dict[str, str] | None = None):
print("+", " ".join(command))
return subprocess.run(
command, cwd=cwd, input=input_bytes, check=True, capture_output=capture, env=env
)
def require_program(name: str) -> None:
if shutil.which(name) is None:
die(f"'{name}' is required and was not found on PATH.")
def verify_inputs() -> None:
for path in (REGISTERED_CERT, REGISTERED_PUBLIC_KEY, REGISTERED_PRIVATE_KEY, PATCH_SOURCE):
if not path.is_file():
die(f"Required file is missing: {path}")
actual = hashlib.sha256(REGISTERED_CERT.read_bytes()).hexdigest().upper()
if actual != REGISTERED_SHA256:
die(f"Registered certificate hash mismatch: {actual}")
def prepare_upstream() -> None:
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)
shutil.copy2(PATCH_SOURCE, UPSTREAM_DIR / "src" / "util" / "generate_root.rs")
shutil.copy2(REGISTERED_CERT, UPSTREAM_DIR / TEMPLATE_NAME)
cargo_toml = UPSTREAM_DIR / "Cargo.toml"
text = cargo_toml.read_text(encoding="utf-8")
old = 'ieee80211 = "0.5.9"'
new = 'ieee80211 = { version = "0.5.9", default-features = false }'
if old in text:
cargo_toml.write_text(text.replace(old, new, 1), encoding="utf-8")
elif new not in text:
die("Expected ieee80211 dependency was not found in the pinned source.")
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:
run(
["cargo", "build", "--offline", "--release", "--features", "build-binary", "--bin", "c-its-generate-root"],
cwd=UPSTREAM_DIR,
)
executable = UPSTREAM_DIR / "target" / "release" / (
"c-its-generate-root.exe" if os.name == "nt" else "c-its-generate-root"
)
if not executable.is_file():
die(f"Generator executable is missing: {executable}")
return executable
def load_registered_scalar() -> bytearray:
try:
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec
except ImportError:
die("Install the Python package 'cryptography'.")
password = getpass.getpass("Registered RCA private-key passphrase: ").encode("utf-8")
try:
key = serialization.load_pem_private_key(REGISTERED_PRIVATE_KEY.read_bytes(), password=password)
except Exception as exc:
die(f"Could not unlock the registered private key: {exc}")
if not isinstance(key, ec.EllipticCurvePrivateKey) or not isinstance(key.curve, ec.SECP256R1):
die("Registered key is not NIST P-256.")
expected_public = serialization.load_pem_public_key(REGISTERED_PUBLIC_KEY.read_bytes())
if key.public_key().public_numbers() != expected_public.public_numbers():
die("Private key does not match the registered public key.")
return bytearray(key.private_numbers().private_value.to_bytes(32, "big"))
def rebuild(executable: Path) -> None:
scalar = load_registered_scalar()
if OUTPUT_DIR.exists():
shutil.rmtree(OUTPUT_DIR)
OUTPUT_DIR.mkdir()
env = os.environ.copy()
env["UML_TEMPLATE"] = str(UPSTREAM_DIR / TEMPLATE_NAME)
env["UML_OUTPUT_DIR"] = str(OUTPUT_DIR)
try:
result = run(
[str(executable)], cwd=UPSTREAM_DIR,
input_bytes=(bytes(scalar).hex().upper() + "\n").encode("ascii"),
capture=True, env=env,
)
finally:
for index in range(len(scalar)):
scalar[index] = 0
sys.stdout.write(result.stdout.decode("utf-8", errors="replace"))
sys.stderr.write(result.stderr.decode("utf-8", errors="replace"))
rebuilt = OUTPUT_DIR / f"{REGISTERED_ID8}.oer"
if not rebuilt.is_file():
die(f"Expected rebuilt certificate is missing: {rebuilt}")
if rebuilt.read_bytes() != REGISTERED_CERT.read_bytes():
die("Rebuilt certificate is not byte-for-byte identical to the EU-registered certificate.")
print("\nPASS: rebuilt certificate exactly matches the registered certificate.")
print(f"Validation output: {OUTPUT_DIR}")
def main() -> None:
require_program("cargo")
verify_inputs()
prepare_upstream()
rebuild(build_generator())
if __name__ == "__main__":
main()
@@ -0,0 +1,179 @@
use std::io::{self, Read};
use std::path::PathBuf;
use sha2::{Digest, Sha256};
const EXPECTED_TEMPLATE_SHA256: &str =
"7168E341764188E140EF591634A9D2488D7FDACDA667EE53AFD566A8034ED5DB";
fn err(msg: impl Into<String>) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, msg.into())
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let template_path = PathBuf::from(
std::env::var("UML_TEMPLATE")
.unwrap_or_else(|_| "registered_rca.oer".to_string()),
);
let output_dir = PathBuf::from(
std::env::var("UML_OUTPUT_DIR").unwrap_or_else(|_| "uml_output".to_string()),
);
let template_bytes = std::fs::read(&template_path)?;
let template_sha256 = hex::encode_upper(Sha256::digest(&template_bytes));
if template_sha256 != EXPECTED_TEMPLATE_SHA256 {
return Err(err(format!(
"Template SHA-256 mismatch. Expected {}, got {}",
EXPECTED_TEMPLATE_SHA256, template_sha256
))
.into());
}
// Decode the registered certificate only as the reviewed profile template.
let template: rasn_its::ts103097::EtsiTs103097Certificate =
rasn::oer::decode(&template_bytes)?;
// The wrapper sends a newly generated P-256 scalar over stdin. It is never
// written to disk unencrypted.
let mut scalar_hex = String::new();
std::io::stdin().read_to_string(&mut scalar_hex)?;
let scalar = hex::decode(scalar_hex.trim())?;
if scalar.len() != 32 {
return Err(err(format!(
"Expected a 32-byte NIST P-256 private scalar, received {} bytes",
scalar.len()
))
.into());
}
let secret_key = p256::SecretKey::from_slice(&scalar)
.map_err(|_| err("Invalid NIST P-256 private scalar"))?;
let private_key =
c_its::security::crypto::PrivateKey::P256(secret_key.into());
let mut tbs_cert = template.to_be_signed.clone();
tbs_cert.verify_key_indicator =
rasn_its::ieee1609dot2::VerificationKeyIndicator::VerificationKey(
private_key.public_key().encode(),
);
// This is deliberately the exact encoding/signing route introduced by
// TheEnbyperor/c-its commit e3bb3b82480d6df4237e2a8c35ea0dd7eade25b4.
let tbs_bytes = rasn::oer::encode(&tbs_cert)?;
let hash_alg = c_its::security::crypto::HashAlgorithm::Sha256;
let signature = private_key
.sign(hash_alg, &tbs_bytes, None)
.ok_or_else(|| err("c-its signing failed"))?;
let (r_hex, s_hex) = match &signature {
c_its::security::crypto::Signature::P256(sig) => (
hex::encode_upper(sig.r().to_bytes()),
hex::encode_upper(sig.s().to_bytes()),
),
_ => return Err(err("Unexpected signature algorithm").into()),
};
// Construct the certificate exactly like the upstream root generator.
let raw_cert = rasn_its::ts103097::EtsiTs103097Certificate::try_from(
rasn_its::ieee1609dot2::Certificate::from(
rasn_its::ieee1609dot2::ExplicitCertificate::new(
rasn_its::ieee1609dot2::CertificateBase {
version: 3,
r#type: rasn_its::ieee1609dot2::CertificateType::Explicit,
issuer: rasn_its::ieee1609dot2::IssuerIdentifier::VSelf(
hash_alg.into(),
),
to_be_signed: tbs_cert,
signature: Some(signature.as_signature()),
},
)
.map_err(|_| err("Could not construct ExplicitCertificate"))?,
),
)
.map_err(|_| err("Certificate does not satisfy EtsiTs103097Certificate constraints"))?;
let cert = c_its::security::certs::Certificate::new(&raw_cert);
let encoded = cert.to_bytes();
// Parse the actual final bytes from scratch and make the upstream c-its verifier
// validate the self-signature.
let reparsed = c_its::security::certs::Certificate::parse(&encoded)
.map_err(err)?;
let report = reparsed.report().map_err(err)?;
match report.signature() {
c_its::security::certs::CertificateSignature::SelfSigned { verifies: true } => {}
other => {
return Err(err(format!(
"FINAL CERTIFICATE SELF-SIGNATURE DID NOT VERIFY: {:?}",
other
))
.into())
}
}
let reencoded = reparsed.to_bytes();
if reencoded != encoded {
return Err(err("Final certificate is not stable across parse/re-encode").into());
}
let id8 = hex::encode_upper(report.id8());
let cert_sha256 = hex::encode_upper(Sha256::digest(&encoded));
if &cert_sha256[cert_sha256.len() - 16..] != id8 {
return Err(err("HashedID8 does not match low-order 8 bytes of SHA-256").into());
}
// Compute the IEEE/ETSI self-signed certificate signing prehash independently
// from the signature object:
// SHA256( SHA256(COER/OER-TBS) || SHA256(empty signer certificate) )
let tbs_hash = Sha256::digest(&tbs_bytes);
let empty_hash = Sha256::digest([]);
let mut outer = Sha256::new();
outer.update(&tbs_hash);
outer.update(&empty_hash);
let signing_prehash = outer.finalize();
std::fs::create_dir_all(&output_dir)?;
std::fs::write(output_dir.join(format!("{}.oer", id8)), &encoded)?;
std::fs::write(output_dir.join("tbs.oer"), &tbs_bytes)?;
std::fs::write(
output_dir.join("certificate_report.json"),
serde_json::to_vec_pretty(&report)?,
)?;
let signing_details = format!(
concat!(
"UPSTREAM_COMMIT=e3bb3b82480d6df4237e2a8c35ea0dd7eade25b4\n",
"TEMPLATE_SHA256={}\n",
"CERTIFICATE_SHA256={}\n",
"HASHEDID8={}\n",
"TBS_LENGTH={}\n",
"TBS_SHA256={}\n",
"EMPTY_SHA256={}\n",
"SIGNING_PREHASH_SHA256={}\n",
"ECDSA_R={}\n",
"ECDSA_S={}\n",
"CITS_SELF_SIGNATURE_VERIFIES=true\n",
"PARSE_REENCODE_STABLE=true\n"
),
template_sha256,
cert_sha256,
id8,
tbs_bytes.len(),
hex::encode_upper(tbs_hash),
hex::encode_upper(empty_hash),
hex::encode_upper(signing_prehash),
r_hex,
s_hex,
);
std::fs::write(output_dir.join("signing_details.txt"), signing_details)?;
println!("RESULT_ID8={}", id8);
println!(
"RESULT_CERT={}",
output_dir.join(format!("{}.oer", id8)).display()
);
println!("CITS_SELF_SIGNATURE_VERIFIES=true");
Ok(())
}
@@ -0,0 +1,176 @@
use std::io::{self, Read};
use std::path::PathBuf;
use sha2::{Digest, Sha256};
const EXPECTED_TEMPLATE_SHA256: &str =
"7168E341764188E140EF591634A9D2488D7FDACDA667EE53AFD566A8034ED5DB";
fn err(msg: impl Into<String>) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, msg.into())
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let template_path = PathBuf::from(
std::env::var("UML_TEMPLATE")
.unwrap_or_else(|_| "registered_rca.oer".to_string()),
);
let output_dir = PathBuf::from(
std::env::var("UML_OUTPUT_DIR").unwrap_or_else(|_| "uml_output".to_string()),
);
let template_bytes = std::fs::read(&template_path)?;
let template_sha256 = hex::encode_upper(Sha256::digest(&template_bytes));
if template_sha256 != EXPECTED_TEMPLATE_SHA256 {
return Err(err(format!(
"Template SHA-256 mismatch. Expected {}, got {}",
EXPECTED_TEMPLATE_SHA256, template_sha256
))
.into());
}
// Decode the registered certificate. Its TBS, including its public key, is kept
// unchanged so this tool cannot silently create a different root identity.
let template: rasn_its::ts103097::EtsiTs103097Certificate =
rasn::oer::decode(&template_bytes)?;
// The wrapper unlocks the registered P-256 key and sends its scalar over stdin.
// It is never written to disk unencrypted.
let mut scalar_hex = String::new();
std::io::stdin().read_to_string(&mut scalar_hex)?;
let scalar = hex::decode(scalar_hex.trim())?;
if scalar.len() != 32 {
return Err(err(format!(
"Expected a 32-byte NIST P-256 private scalar, received {} bytes",
scalar.len()
))
.into());
}
let secret_key = p256::SecretKey::from_slice(&scalar)
.map_err(|_| err("Invalid NIST P-256 private scalar"))?;
let private_key =
c_its::security::crypto::PrivateKey::P256(secret_key.into());
let tbs_cert = template.to_be_signed.clone();
// This is deliberately the exact encoding/signing route introduced by
// TheEnbyperor/c-its commit e3bb3b82480d6df4237e2a8c35ea0dd7eade25b4.
let tbs_bytes = rasn::oer::encode(&tbs_cert)?;
let hash_alg = c_its::security::crypto::HashAlgorithm::Sha256;
let signature = private_key
.sign(hash_alg, &tbs_bytes, None)
.ok_or_else(|| err("c-its signing failed"))?;
let (r_hex, s_hex) = match &signature {
c_its::security::crypto::Signature::P256(sig) => (
hex::encode_upper(sig.r().to_bytes()),
hex::encode_upper(sig.s().to_bytes()),
),
_ => return Err(err("Unexpected signature algorithm").into()),
};
// Construct the certificate exactly like the upstream root generator.
let raw_cert = rasn_its::ts103097::EtsiTs103097Certificate::try_from(
rasn_its::ieee1609dot2::Certificate::from(
rasn_its::ieee1609dot2::ExplicitCertificate::new(
rasn_its::ieee1609dot2::CertificateBase {
version: 3,
r#type: rasn_its::ieee1609dot2::CertificateType::Explicit,
issuer: rasn_its::ieee1609dot2::IssuerIdentifier::VSelf(
hash_alg.into(),
),
to_be_signed: tbs_cert,
signature: Some(signature.as_signature()),
},
)
.map_err(|_| err("Could not construct ExplicitCertificate"))?,
),
)
.map_err(|_| err("Certificate does not satisfy EtsiTs103097Certificate constraints"))?;
let cert = c_its::security::certs::Certificate::new(&raw_cert);
let encoded = cert.to_bytes();
// Parse the actual final bytes from scratch and make the upstream c-its verifier
// validate the self-signature.
let reparsed = c_its::security::certs::Certificate::parse(&encoded)
.map_err(err)?;
let report = reparsed.report().map_err(err)?;
match report.signature() {
c_its::security::certs::CertificateSignature::SelfSigned { verifies: true } => {}
other => {
return Err(err(format!(
"FINAL CERTIFICATE SELF-SIGNATURE DID NOT VERIFY: {:?}",
other
))
.into())
}
}
let reencoded = reparsed.to_bytes();
if reencoded != encoded {
return Err(err("Final certificate is not stable across parse/re-encode").into());
}
let id8 = hex::encode_upper(report.id8());
let cert_sha256 = hex::encode_upper(Sha256::digest(&encoded));
if &cert_sha256[cert_sha256.len() - 16..] != id8 {
return Err(err("HashedID8 does not match low-order 8 bytes of SHA-256").into());
}
// Compute the IEEE/ETSI self-signed certificate signing prehash independently
// from the signature object:
// SHA256( SHA256(COER/OER-TBS) || SHA256(empty signer certificate) )
let tbs_hash = Sha256::digest(&tbs_bytes);
let empty_hash = Sha256::digest([]);
let mut outer = Sha256::new();
outer.update(&tbs_hash);
outer.update(&empty_hash);
let signing_prehash = outer.finalize();
std::fs::create_dir_all(&output_dir)?;
std::fs::write(output_dir.join(format!("{}.oer", id8)), &encoded)?;
std::fs::write(output_dir.join("tbs.oer"), &tbs_bytes)?;
std::fs::write(
output_dir.join("certificate_report.json"),
serde_json::to_vec_pretty(&report)?,
)?;
let signing_details = format!(
concat!(
"UPSTREAM_COMMIT=e3bb3b82480d6df4237e2a8c35ea0dd7eade25b4\n",
"TEMPLATE_SHA256={}\n",
"CERTIFICATE_SHA256={}\n",
"HASHEDID8={}\n",
"TBS_LENGTH={}\n",
"TBS_SHA256={}\n",
"EMPTY_SHA256={}\n",
"SIGNING_PREHASH_SHA256={}\n",
"ECDSA_R={}\n",
"ECDSA_S={}\n",
"CITS_SELF_SIGNATURE_VERIFIES=true\n",
"PARSE_REENCODE_STABLE=true\n"
),
template_sha256,
cert_sha256,
id8,
tbs_bytes.len(),
hex::encode_upper(tbs_hash),
hex::encode_upper(empty_hash),
hex::encode_upper(signing_prehash),
r_hex,
s_hex,
);
std::fs::write(output_dir.join("signing_details.txt"), signing_details)?;
println!("RESULT_ID8={}", id8);
println!(
"RESULT_CERT={}",
output_dir.join(format!("{}.oer", id8)).display()
);
println!("CITS_SELF_SIGNATURE_VERIFIES=true");
Ok(())
}