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,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
|
||||
Reference in New Issue
Block a user