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,339 @@
"""Locate the payload of a signed GeoNetworking packet (ETSI TS 103 097 / IEEE Std 1609.2).
A GeoNetworking basic header with Next Header = 2 is followed by an `Ieee1609Dot2Data`
(COER, TS 103 097 clause 5.2 `EtsiTs103097Data-Signed`): the GN common header, extended
header, BTP header and facilities PDU are the `unsecuredData` inside
`signedData.tbsData.payload.data`. This walks exactly that path with the OER rules for the
fields in front of it and returns the payload with the header information (PSID,
generation time, signer). It does NOT verify the signature or the certificate: a decoded
VAM says what the sender claims, not that the claim was authenticated. Nothing is guessed:
any deviation from the grammar raises ValueError and the caller leaves the packet undecoded.
"""
from __future__ import annotations
import hashlib
ITS_EPOCH_UNIX = 1072915200
LEAP_SECONDS_SINCE_2004 = 5 # TAI-UTC 37 s since 2017, 32 s at the ITS epoch
class _Reader:
def __init__(self, data: bytes, at: int = 0):
self.data = data
self.at = at
def take(self, n: int) -> bytes:
if n < 0 or self.at + n > len(self.data):
raise ValueError("truncated security envelope")
chunk = self.data[self.at:self.at + n]
self.at += n
return chunk
def u8(self) -> int:
return self.take(1)[0]
def length(self) -> int:
"""OER length determinant: one octet below 128, else 0x80|n followed by n octets."""
first = self.u8()
if first < 0x80:
return first
n = first & 0x7F
if n == 0 or n > 4:
raise ValueError("unsupported length determinant")
return int.from_bytes(self.take(n), "big")
def unsigned(self) -> int:
"""Unconstrained non-negative INTEGER (Psid, quantities): length determinant then the magnitude."""
n = self.length()
if n == 0 or n > 8:
raise ValueError("bad integer length")
return int.from_bytes(self.take(n), "big")
def open_type(self) -> bytes:
"""An OER open type (extension addition, CHOICE extension alternative): length-prefixed."""
return self.take(self.length())
# ---- skipping helpers for the certificate carried inline by a signer ---------------------
def _skip_octets(r: _Reader) -> None:
r.take(r.length())
def _skip_sequence_of(r: _Reader, element) -> None:
count = r.unsigned() # OER quantity field
if count > 4096:
raise ValueError("implausible SEQUENCE OF size")
for _ in range(count):
element(r)
def _skip_extension_addition(r: _Reader) -> None:
"""OER extension part: presence bitmap (length-prefixed BIT STRING) then one open type per set bit."""
bitmap = r.take(r.length())
count = sum(bin(octet).count("1") for octet in bitmap[1:]) # bitmap[0] is the unused-bit count
for _ in range(count):
r.open_type()
def _skip_ecc_point(r: _Reader, size: int) -> None:
tag = r.u8()
if tag in (0x80, 0x82, 0x83): # x-only, compressed-y-0, compressed-y-1
r.take(size)
elif tag == 0x84: # uncompressed
r.take(2 * size)
else:
raise ValueError("unknown EccCurvePoint")
def _skip_public_verification_key(r: _Reader) -> None:
tag = r.u8()
if tag in (0x80, 0x81): # ecdsaNistP256, ecdsaBrainpoolP256r1 (root alternatives)
_skip_ecc_point(r, 32)
elif tag in (0x82, 0x83): # ecdsaBrainpoolP384r1, ecdsaNistP384: extension alternatives, open type
r.open_type()
else:
raise ValueError("unknown PublicVerificationKey")
def _skip_public_encryption_key(r: _Reader) -> None:
r.u8() # supportedSymmAlg ENUMERATED
tag = r.u8() # BasePublicEncryptionKey CHOICE
if tag in (0x80, 0x81):
_skip_ecc_point(r, 32)
else:
raise ValueError("unknown BasePublicEncryptionKey")
def _skip_signature(r: _Reader) -> None:
tag = r.u8()
if tag in (0x80, 0x81): # ecdsaNistP256Signature, ecdsaBrainpoolP256r1Signature: rSig point, sSig 32
_skip_ecc_point(r, 32)
r.take(32)
elif tag in (0x82, 0x83): # brainpoolP384r1 / nistP384: extension alternatives, open type
r.open_type()
else:
raise ValueError("unknown Signature")
def _skip_ssp(r: _Reader) -> None:
tag = r.u8()
if tag in (0x80, 0x81): # opaque / bitmapSsp: OCTET STRING
_skip_octets(r)
else:
raise ValueError("unknown ServiceSpecificPermissions")
def _skip_psid_ssp(r: _Reader) -> None:
pre = r.u8() # bit 7: ssp present
r.unsigned() # psid
if pre & 0x80:
_skip_ssp(r)
def _skip_psid_ssp_range(r: _Reader) -> None:
pre = r.u8()
r.unsigned()
if pre & 0x80:
tag = r.u8()
if tag == 0x80: # opaque: SEQUENCE OF OCTET STRING
_skip_sequence_of(r, _skip_octets)
elif tag == 0x81: # all NULL
pass
elif tag == 0x82: # bitmapSspRange: sspValue, sspBitmask
_skip_octets(r)
_skip_octets(r)
else:
raise ValueError("unknown SspRange")
def _skip_psid_group_permissions(r: _Reader) -> None:
pre = r.u8() # bit 7 minChainLength, bit 6 chainLengthRange, bit 5 eeType
tag = r.u8() # SubjectPermissions CHOICE
if tag == 0x80: # explicit: SEQUENCE OF PsidSspRange
_skip_sequence_of(r, _skip_psid_ssp_range)
elif tag == 0x81: # all NULL
pass
else:
raise ValueError("unknown SubjectPermissions")
if pre & 0x80:
r.unsigned() # minChainLength INTEGER DEFAULT 1
if pre & 0x40:
r.unsigned() # chainLengthRange INTEGER DEFAULT 0
if pre & 0x20:
r.take(1) # EndEntityType BIT STRING (SIZE 8)
def _skip_identified_region(r: _Reader) -> None:
tag = r.u8()
if tag == 0x80: # countryOnly Uint16
r.take(2)
elif tag == 0x81: # countryAndRegions: country, SEQUENCE OF Uint8
r.take(2)
_skip_sequence_of(r, lambda rr: rr.take(1))
elif tag == 0x82: # countryAndSubregions: country, SEQUENCE OF RegionAndSubregions
r.take(2)
_skip_sequence_of(r, lambda rr: (rr.take(1), _skip_sequence_of(rr, lambda x: x.take(2))))
else:
raise ValueError("unknown IdentifiedRegion")
def _skip_geographic_region(r: _Reader) -> None:
tag = r.u8()
if tag == 0x80: # circularRegion: TwoDLocation 8 + radius 2
r.take(10)
elif tag == 0x81: # rectangularRegion: SEQUENCE OF RectangularRegion (2 x TwoDLocation)
_skip_sequence_of(r, lambda rr: rr.take(16))
elif tag == 0x82: # polygonalRegion: SEQUENCE OF TwoDLocation
_skip_sequence_of(r, lambda rr: rr.take(8))
elif tag == 0x83: # identifiedRegion
_skip_sequence_of(r, _skip_identified_region)
else:
raise ValueError("unknown GeographicRegion")
def _skip_to_be_signed_certificate(r: _Reader) -> None:
# One preamble octet: bit 7 extension, then region, assuranceLevel, appPermissions,
# certIssuePermissions, certRequestPermissions, canRequestRollover, encryptionKey (bits 6..0).
pre = r.u8()
cid = r.u8() # CertificateId CHOICE
if cid == 0x80:
raise ValueError("linkageData CertificateId unsupported")
elif cid in (0x81, 0x82): # name Hostname / binaryId OCTET STRING
_skip_octets(r)
elif cid != 0x83: # none NULL
raise ValueError("unknown CertificateId")
r.take(3) # cracaId HashedId3
r.take(2) # crlSeries Uint16
r.take(4) # validityPeriod.start Time32
r.u8() # Duration CHOICE tag
r.take(2) # every Duration alternative is a Uint16
if pre & 0x40:
_skip_geographic_region(r)
if pre & 0x20:
r.take(1) # SubjectAssurance OCTET STRING (SIZE 1)
if pre & 0x10:
_skip_sequence_of(r, _skip_psid_ssp)
if pre & 0x08:
_skip_sequence_of(r, _skip_psid_group_permissions)
if pre & 0x04:
_skip_sequence_of(r, _skip_psid_group_permissions)
# bit 0x02 canRequestRollover is NULL: nothing to skip
if pre & 0x01:
_skip_public_encryption_key(r)
vki = r.u8() # VerificationKeyIndicator CHOICE
if vki == 0x80:
_skip_public_verification_key(r)
elif vki == 0x81:
_skip_ecc_point(r, 32) # reconstructionValue EccP256CurvePoint
else:
raise ValueError("unknown VerificationKeyIndicator")
if pre & 0x80:
_skip_extension_addition(r)
def _skip_certificate(r: _Reader) -> bytes:
"""Consume one COER `EtsiTs103097Certificate` and return its octets (for the HashedId8)."""
start = r.at
pre = r.u8() # bit 7: signature present
if r.u8() != 3:
raise ValueError("certificate version is not 3")
r.u8() # CertificateType ENUMERATED explicit / implicit
issuer = r.u8() # IssuerIdentifier CHOICE
if issuer in (0x80, 0x82): # sha256AndDigest / sha384AndDigest: HashedId8
r.take(8)
elif issuer == 0x81: # self: HashAlgorithm
r.take(1)
else:
raise ValueError("unknown IssuerIdentifier")
_skip_to_be_signed_certificate(r)
if pre & 0x80:
_skip_signature(r)
return r.data[start:r.at]
def hashed_id8(certificate: bytes) -> str:
return hashlib.sha256(certificate).digest()[-8:].hex()
# ---- the entry point ---------------------------------------------------------------------
def unwrap_signed(envelope: bytes) -> dict:
"""Parse an `Ieee1609Dot2Data` and return the signed payload with its header info.
Returns {"payload": bytes, "psid": int, "generation_time_ms": int | None, "hash_id": str,
"signer": "digest" | "certificate" | "self" | "unknown", "signer_id": hex str | None}.
Raises ValueError for anything but a signedData whose payload carries data.
"""
r = _Reader(envelope)
if r.u8() != 3:
raise ValueError("Ieee1609Dot2Data protocolVersion is not 3")
content = r.u8()
if content == 0x80:
raise ValueError("unsecuredData at the top level is not a signed packet")
if content != 0x81:
raise ValueError("not signedData (encrypted or unknown content)")
hash_id = {0: "sha256", 1: "sha384", 2: "sm3"}.get(r.u8())
if hash_id is None:
raise ValueError("unknown HashAlgorithm")
# ToBeSignedData ::= SEQUENCE { payload SignedDataPayload, headerInfo HeaderInfo }
payload_pre = r.u8() # SignedDataPayload: bit 7 extension, bit 6 data, bit 5 extDataHash
if not payload_pre & 0x40:
raise ValueError("signed data without a payload (extDataHash only)")
if r.u8() != 3:
raise ValueError("inner Ieee1609Dot2Data protocolVersion is not 3")
if r.u8() != 0x80:
raise ValueError("inner content is not unsecuredData")
payload = r.take(r.length())
if payload_pre & 0x20:
if r.u8() != 0x80:
raise ValueError("unknown HashedData")
r.take(32)
if payload_pre & 0x80:
_skip_extension_addition(r)
# HeaderInfo: bit 7 extension; bits 6..1 generationTime, expiryTime, generationLocation,
# p2pcdLearningRequest, missingCrlIdentifier, encryptionKey
hi = r.u8()
psid = r.unsigned()
generation_time = int.from_bytes(r.take(8), "big") if hi & 0x40 else None
result = {"payload": payload, "psid": psid, "hash_id": hash_id,
"generation_time_ms": None if generation_time is None else generation_time // 1000,
"signer": "unknown", "signer_id": None}
# The signer and the signature follow the remaining HeaderInfo fields. They are walked to the
# end so that a truncated or malformed envelope is refused as a whole: a payload from a message
# whose signature is not even structurally present is not shown as a VAM.
if True:
if hi & 0x20:
r.take(8) # expiryTime Time64
if hi & 0x10:
r.take(10) # generationLocation ThreeDLocation: lat 4, lon 4, elevation 2
if hi & 0x08:
r.take(3) # p2pcdLearningRequest HashedId3
if hi & 0x04:
mc = r.u8() # MissingCrlIdentifier (extensible): cracaId 3, crlSeries 2
r.take(5)
if mc & 0x80:
_skip_extension_addition(r)
if hi & 0x02:
_skip_public_encryption_key(r)
if hi & 0x80:
_skip_extension_addition(r)
signer_tag = r.u8() # SignerIdentifier CHOICE
if signer_tag == 0x80:
result.update(signer="digest", signer_id=r.take(8).hex())
elif signer_tag == 0x81:
certificates: list = []
_skip_sequence_of(r, lambda rr: certificates.append(_skip_certificate(rr)))
result.update(signer="certificate", signer_id=hashed_id8(certificates[0]) if certificates else None)
elif signer_tag == 0x82:
result.update(signer="self")
else:
raise ValueError("unknown SignerIdentifier")
_skip_signature(r)
result["consumed"] = r.at
return result
def generation_time_utc(generation_time_ms: int) -> float:
"""IEEE 1609.2 Time64 (TAI since the ITS epoch, here in ms) -> Unix seconds (UTC)."""
return generation_time_ms / 1000.0 + ITS_EPOCH_UNIX - LEAP_SECONDS_SINCE_2004