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).
177 lines
8.7 KiB
Python
177 lines
8.7 KiB
Python
"""Verify the signature of secured GeoNetworking packets against configured trust anchors.
|
|
|
|
`secured.py` only unwraps the TS 103 097 envelope and says "signed, not verified". With
|
|
`--trust` the bridge also checks, per packet, whether the signature holds and whether the signer
|
|
chains to a trusted root, and the dashboard shows which. Trust comes from files the operator
|
|
names: a `VCR1` credential bundle (vanetza_idf/credentials.hpp, e.g. the MicrOBU demo chain) or
|
|
COER certificates (`.oer`). Nothing is trusted by default, so without `--trust` nothing changes.
|
|
|
|
What is checked (IEEE Std 1609.2 clause 5.3.1, ECDSA NIST P-256 with SHA-256):
|
|
* the message signature over Hash(tbsData) || Hash(signer certificate);
|
|
* a signer given as a full certificate must be signed by a trusted authority, and is then
|
|
remembered, so later messages that only carry its digest can be checked too;
|
|
* the configured chain itself at start-up: roots self-signed, authorities signed by a root,
|
|
tickets by an authority. A certificate whose signature fails is not trusted.
|
|
Not checked: validity periods, regions, permissions against the PSID, revocation. A packet this
|
|
says is "verified" was signed by a key the configured chain vouches for, nothing more.
|
|
|
|
ASN.1: IEEE1609dot2.asn / IEEE1609dot2BaseTypes.asn in asn1/, from vanetza-idf. ECDSA: the
|
|
`cryptography` package (OpenSSL).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import logging
|
|
import struct
|
|
from pathlib import Path
|
|
|
|
VERIFIED, FAILED, UNKNOWN = True, False, None
|
|
|
|
|
|
def hashed_id8(octets: bytes) -> bytes:
|
|
return hashlib.sha256(octets).digest()[-8:]
|
|
|
|
|
|
def _bundle_certificates(data: bytes) -> list[bytes]:
|
|
"""Certificates of a VCR1 bundle: records [type 1][length 2 BE][payload]; types 1-3 are certificates."""
|
|
out, i = [], 4 if data[:4] == b"VCR1" else 0
|
|
while i + 3 <= len(data):
|
|
kind, length = data[i], struct.unpack(">H", data[i + 1:i + 3])[0]
|
|
if kind in (1, 2, 3):
|
|
out.append(data[i + 3:i + 3 + length])
|
|
i += 3 + length
|
|
return out
|
|
|
|
|
|
class TrustStore:
|
|
def __init__(self, paths: list[str]):
|
|
import asn1tools
|
|
directory = Path(__file__).resolve().parent / "asn1"
|
|
spec = asn1tools.compile_files([str(directory / "IEEE1609dot2.asn"),
|
|
str(directory / "IEEE1609dot2BaseTypes.asn")], "oer")
|
|
self._m = spec.modules["IEEE1609dot2"]
|
|
certificates = []
|
|
for path in paths:
|
|
data = Path(path).read_bytes()
|
|
certificates += _bundle_certificates(data) if data[:4] == b"VCR1" else [data]
|
|
self.issuers: dict[bytes, bytes] = {} # digest -> roots and authorities whose signature held
|
|
self.signers: dict[bytes, bytes] = {} # digest -> tickets (and certificates learned on air)
|
|
pending = list(certificates)
|
|
# Roots first, then whatever they vouch for, until nothing more can be placed.
|
|
while pending:
|
|
placed = False
|
|
for cert in list(pending):
|
|
verdict = self._place(cert)
|
|
if verdict is not None:
|
|
pending.remove(cert)
|
|
placed = True
|
|
if not placed:
|
|
break
|
|
for cert in pending:
|
|
logging.warning("trust: %s not trusted (issuer unknown or signature fails)", hashed_id8(cert).hex().upper())
|
|
logging.info("trust: %d root/authority certificate(s), %d signer(s): %s", len(self.issuers), len(self.signers),
|
|
", ".join(d.hex().upper() for d in list(self.issuers) + list(self.signers)))
|
|
|
|
# ---- certificates --------------------------------------------------------------------
|
|
|
|
def _decode(self, cert: bytes) -> dict:
|
|
return self._m["Certificate"].decode(cert)
|
|
|
|
def _is_authority(self, decoded: dict) -> bool:
|
|
return "certIssuePermissions" in decoded["toBeSigned"]
|
|
|
|
def _place(self, cert: bytes):
|
|
"""Trusts cert if its signature holds under a trusted issuer (or itself, for a root). None if not yet."""
|
|
decoded = self._decode(cert)
|
|
kind, issuer = decoded["issuer"]
|
|
if kind == "self":
|
|
issuer_cert = None
|
|
elif kind in ("sha256AndDigest",):
|
|
issuer_cert = self.issuers.get(bytes(issuer))
|
|
if issuer_cert is None:
|
|
return None
|
|
else:
|
|
return None
|
|
if not self._certificate_signature_ok(cert, decoded, issuer_cert):
|
|
return False
|
|
target = self.issuers if kind == "self" or self._is_authority(decoded) else self.signers
|
|
target[hashed_id8(cert)] = cert
|
|
return True
|
|
|
|
def _public_key(self, decoded: dict):
|
|
from cryptography.hazmat.primitives.asymmetric import ec
|
|
kind, key = decoded["toBeSigned"]["verifyKeyIndicator"]
|
|
if kind != "verificationKey" or key[0] != "ecdsaNistP256":
|
|
raise ValueError("verification key is not ECDSA NIST P-256")
|
|
form, point = key[1]
|
|
if form == "compressed-y-0":
|
|
encoded = b"\x02" + point
|
|
elif form == "compressed-y-1":
|
|
encoded = b"\x03" + point
|
|
elif form == "uncompressedP256":
|
|
encoded = b"\x04" + point["x"] + point["y"]
|
|
else:
|
|
raise ValueError("unsupported point form " + form)
|
|
return ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), encoded)
|
|
|
|
@staticmethod
|
|
def _ecdsa_ok(public_key, message: bytes, signature) -> bool:
|
|
from cryptography.exceptions import InvalidSignature
|
|
from cryptography.hazmat.primitives import hashes
|
|
from cryptography.hazmat.primitives.asymmetric import ec
|
|
from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature
|
|
kind, sig = signature
|
|
if kind != "ecdsaNistP256Signature":
|
|
return False
|
|
_, r = sig["rSig"]
|
|
r_x = r if isinstance(r, (bytes, bytearray)) else r["x"] # x-only or compressed: r is x
|
|
der = encode_dss_signature(int.from_bytes(r_x, "big"), int.from_bytes(sig["sSig"], "big"))
|
|
try:
|
|
public_key.verify(der, message, ec.ECDSA(hashes.SHA256()))
|
|
return True
|
|
except InvalidSignature:
|
|
return False
|
|
|
|
def _certificate_signature_ok(self, cert: bytes, decoded: dict, issuer_cert: bytes | None) -> bool:
|
|
if "signature" not in decoded:
|
|
return False
|
|
tbs = self._m["ToBeSignedCertificate"].encode(decoded["toBeSigned"])
|
|
signer_input = hashlib.sha256(issuer_cert if issuer_cert is not None else b"").digest()
|
|
key = self._public_key(self._decode(issuer_cert) if issuer_cert is not None else decoded)
|
|
return self._ecdsa_ok(key, hashlib.sha256(tbs).digest() + signer_input, decoded["signature"])
|
|
|
|
# ---- messages ------------------------------------------------------------------------
|
|
|
|
def verify(self, envelope: bytes) -> dict:
|
|
"""{"verified": True | False | None, "reason": str, "signer_id": hex | None} for one Ieee1609Dot2Data."""
|
|
try:
|
|
data = self._m["Ieee1609Dot2Data"].decode(envelope)
|
|
kind, signed = data["content"]
|
|
if kind != "signedData":
|
|
return {"verified": UNKNOWN, "reason": "not signedData"}
|
|
signer_kind, signer = signed["signer"]
|
|
if signer_kind == "digest":
|
|
digest = bytes(signer)
|
|
cert = self.signers.get(digest)
|
|
if cert is None:
|
|
return {"verified": UNKNOWN, "reason": "signer not known here", "signer_id": digest.hex().upper()}
|
|
elif signer_kind == "certificate":
|
|
cert = self._m["Certificate"].encode(signer[0])
|
|
digest = hashed_id8(cert)
|
|
if digest not in self.signers:
|
|
placed = self._place(cert)
|
|
if placed is None:
|
|
return {"verified": UNKNOWN, "reason": "issuer not trusted here", "signer_id": digest.hex().upper()}
|
|
if placed is False:
|
|
return {"verified": FAILED, "reason": "certificate signature fails", "signer_id": digest.hex().upper()}
|
|
else:
|
|
return {"verified": UNKNOWN, "reason": "self-signed message"}
|
|
tbs = self._m["ToBeSignedData"].encode(signed["tbsData"])
|
|
message = hashlib.sha256(tbs).digest() + hashlib.sha256(cert).digest()
|
|
ok = self._ecdsa_ok(self._public_key(self._decode(cert)), message, signed["signature"])
|
|
return {"verified": VERIFIED if ok else FAILED,
|
|
"reason": "signature valid" if ok else "signature does not verify",
|
|
"signer_id": digest.hex().upper()}
|
|
except Exception as exc: # a packet this cannot decode is unknown, never "verified"
|
|
return {"verified": UNKNOWN, "reason": "not checkable: %s" % exc}
|