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:
@@ -0,0 +1,176 @@
|
||||
"""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}
|
||||
Reference in New Issue
Block a user