Document the signed-ITS/VAM/BLE work and how it was verified

docs/06-signed-its-vam-ble.md: who does what between phone and ESP32-C5
(signing lives on the board), the link protocol, recovery paths (USB
heartbeat watchdog, BLE supervision timeout and auto-reconnect, board-reset
reconfiguration, app restart), the demo PKI, and what is still open.

obu-firmware/test/verify_signed_pcap.py checks the IEEE 1609.2 signatures in
a pcap with asn1tools and OpenSSL, independent of the firmware. On a capture
of the CAM pinger (2026-09-23) all 12 signed CAMs verify under the demo
ticket, whose chain verifies too. The CiT One receives the same CAMs but its
MQTT interface exposes no security information, so it cannot confirm the
signature itself. The V2X2MAP bridge on COM10 now verifies against the demo
chain as well (change in the colleague's repository); signed CAMs and VAMs
show as verified.

TODO.md: bench checks confirmed so far ticked; open are BLE/ITS-G5
coexistence, time_regression over a longer stationary run, and board reset
recovery over BLE.
This commit is contained in:
Ashin Walpola
2026-09-23 17:28:14 +02:00
parent a08494b56a
commit 2f60623e18
3 changed files with 481 additions and 0 deletions
+101
View File
@@ -5,6 +5,107 @@ Engineering to-do list. The reviewer-facing open items live in
## Waiting on hardware ## Waiting on hardware
### Signed-TX firmware (vanetza-idf port), VAM and BLE: first on-air checks (added 2026-09-23)
obu-firmware is now a port of the colleague's `microbu-esp32c5` station (vanetza-idf, TS 103 097
signing, station-link protocol, BLE GATT), built with **ESP-IDF 6.0.2**. See `obu-firmware/NOTES.md`.
The previous firmware is backed up in `firmware-backups/` (restore command in its README.txt).
The app speaks the new protocol over USB or BLE and still falls back to the old frames against the
old firmware.
Done without hardware: IDF 6.0.2 build clean (39 % app partition free); host suite (`make` in
`obu-firmware/test/host`) passes unchanged; app unit tests 103/103, including the VAM encoder
against asn1tools, the station-link codec against the colleague's Python `messages.py`, and the VAM
generation rules. Flashed to **COM3** 2026-09-23 (hash verified); boot log: IDF v6.0.2,
`BLE advertising started as 'micrOBU-4AFA'`, station task ready. The radio stays off until the app
configures the station. New app build installed on the Pixel 9 Pro (adb, `install -r`).
First phone session (user, 2026-09-23): BLE works and CAMs go out. Three faults, fixed and
reflashed/reinstalled the same day:
1. No RX until the CAM pinger ran, with ~177 RX-queue drops: the colleague's `Station::tick()`
returned before draining the radio until the first PoTi had set the clock. Now drained always.
2. "refused a request: time_regression": the loops re-send the latest fix every tick; a stale fix
timestamp read as the clock going back > 1 s, and each time the board rebuilt its stack.
`Esp32Link` now sends a PoTi only for a newer fix (or a >= 60 s real clock correction).
3. BLE reconnect loop: GATT operations with a 5 s timeout ran during Android's pairing, cut it off
and restarted it on every attempt. Encryption/pairing is now settled first (60 s), retries back
off to 30 s, and every failure reason is logged and shown; the board logs encryption changes.
Second session (user, 2026-09-23): USB, RX and signing work; BLE still prompted every time and
never connected; time_regression every ~8 s. Found and fixed, reflashed (full flash, NVS erased):
4. The board never stored a bond: NVS (24 KB, the colleague's 4 MB-board layout) was full, mostly
Wi-Fi settings the previous firmware left behind, and NimBLE's bond write failed. NVS is now
80 KB (app moved to 0x20000) and was erased; boot logs `N bonded phone(s) in NVS`.
5. The station loop waited `pdMS_TO_TICKS(5)` = 0 ticks at 100 Hz, so it spun on the single core
(task watchdog: IDLE starved). Now waits at least one tick.
6. The phone clock is ~14 min fast; GnssTimeSource fell back to it whenever GNSS time blinked out
indoors, so every transmitted timestamp (CAM generationDeltaTime too) jumped 14 min back and
forth. It now keeps the last measured error.
Watch COM3 (`idf.py -p COM3 monitor`, or `readlog.py`-style with DTR/RTS low) during these; it only
resets the board, the phone is on the other port.
- [x] **USB session.** Settings > Connection > ESP32-C5: link USB-C, transmit CAM, signing on.
Phone on the native port, Connect. Expected: the card shows "Provisioning the demo credentials"
once, then Connected and `Signing on · tickets 1 · signed N` with N rising while recording.
COM3: `radio on channel 180, transmit and receive`, `tx power: … dBm`,
`credentials provisioned: 1 roots, 1 authorities, 1 tickets`, and no `radio refused a frame`.
Confirmed by the user 2026-09-23: connects, signing works.
- [x] **Reception intact.** Same session, sim car (COM8) beaconing: its CAMs (station 195936478) on
the V2X map at ~3 Hz as before. Then put a DENM and a SPATEM on air: both show up (they come
through the raw V2X_RX path; the vanetza stack drops them because they are not demo-signed).
Confirmed 2026-09-23: sim car and the RSU's CAM/SPATEM/MAPEM arrive; DENM not yet re-tested.
- [x] **Signed CAM on air** (2026-09-23, CAM pinger over BLE, signing on). Recorded 25 s through
the V2X2MAP bridge's `/api/record` (`micrOBU_workspace/v2x-obu-esp32c5/signed-cam-check.pcap`)
and checked with the new `obu-firmware/test/verify_signed_pcap.py` (asn1tools + OpenSSL, no
vanetza code): 12/12 secured CAMs, station 999999, psid 36, signer = full certificate of the
demo AT `B80B49387A4C12EB`, **all signatures valid**, COER canonical, and the bundle's chain
(AT <- AA <- root) verifies. The CiT One (192.168.40.201) also receives them (~1 Hz on
`v2x/rx/cam`), i.e. a third-party stack unwraps our 1609.2 envelope; its MQTT API exposes no
security fields, and it forwards unsigned and unknown-root messages alike, so it cannot say
whether it verified them. The same capture showed generationTime wobbling by seconds, with
`time_regression` still firing: fixed in Esp32Link (the PoTi never moves the micrOBU's clock
back except for a >= 60 s correction). Re-check: no "restarted its stack" lines in logcat.
- [x] **Unsigned toggle.** Signing off: the same capture shows next header 1 (common header), as
the previous firmware sent. The card's `signed` count stops rising.
Confirmed 2026-09-23: unsigned pinger CAMs show on V2X2MAP as unsigned.
- [x] **Signed VAM on V2X2MAP.** Since 2026-09-23 17:16 the COM10 bridge is the colleague's
v2x2map-0.3.0 from source with a new `verify.py` and `--trust demo-chain.vcr` (launcher:
`micrOBU_workspace/v2x-obu-esp32c5/start-v2x2map-signed.bat`, replacing its-g5-bridge.exe).
Signed CAMs from the pinger already show "signature verified" live. Switch to VAM with signing
on: the VAM must be decoded (cyclist, position) and show "signature verified" too.
Confirmed by the user 2026-09-23: signed VAMs decode and verify.
- [x] **VAM.** Transmit VAM: BTP port 2018, psid 638; with `microbu-esp32c5/tools/wireshark/psid-vru.lua`
Wireshark decodes the VAM (stationType cyclist, bicyclist profile in every ~2 s VAM). Rate:
≥1 per 5 s standing still, about one per GNSS fix while riding.
Covered by the V2X2MAP check above (decoded VAM, psid 638); Wireshark not needed.
- [x] **RX without recording.** Connect only (no recording, no pinger): sim-car CAMs appear and
the RX-queue drop counter stays at 0 or near it.
Confirmed 2026-09-23: messages come in on connect alone.
- [ ] **No time_regression.** Record for a few minutes standing still indoors: no "refused" line on
the card, and COM3 never logs `ITS time moved back`.
- [x] **BLE after the NVS fix.** First forget micrOBU-4AFA in Android's Bluetooth settings (the
phone still holds the bond the board lost). Then Connect, passkey 123456 once; a second
Connect after an app restart must not prompt again, and COM3's next boot must say
`1 bonded phone(s) in NVS`.
Confirmed 2026-09-23: pairs once, reconnects after an app restart.
- [x] **BLE.** Link Bluetooth, unplug USB, Connect. Android asks to pair with micrOBU-4AFA:
passkey 123456. Expected: Connected, CAMs keep going, sim-car CAMs keep arriving.
While USB is plugged in and in use, the phone's BLE scan must not see micrOBU-4AFA
(COM3: `USB link in use: BLE advertising paused`). If it loops again, the card now says why;
"refused this phone's stored pairing" means forget micrOBU-4AFA in Android and pair again.
COM3 shows `encryption change status=...` for the board's side.
Confirmed 2026-09-23: CAMs and VAMs out, reception in, over BLE.
- [ ] **BLE/ITS-G5 coexistence (the unmeasured risk from the hardware review).** With BLE
connected, count the sim car's CAMs received per minute and ours at the sniffer; compare
with the same over USB. A clear drop, or reception stopping altogether, means the coex
arbiter takes the radio off 5900 MHz (our channel is set behind the driver's back with
`phy_change_channel`). Then BLE cannot be used while receiving, or needs a longer connection
interval.
- [ ] **Board reset recovery over BLE.** Press RST mid-session: the app reconnects by itself and
reconfigures on the first STATUS saying `not configured`, with no manual Connect. (Over USB a
reset re-enumerates the port and needs a manual Connect, as before.)
### Confirm the RX queue drop counter explains the bench-session frame drops / map flicker (added 2026-09-22) ### Confirm the RX queue drop counter explains the bench-session frame drops / map flicker (added 2026-09-22)
Investigated the user's report of "OBU mode keeps dropping a few frames" and "v2x screen comes Investigated the user's report of "OBU mode keeps dropping a few frames" and "v2x screen comes
+139
View File
@@ -0,0 +1,139 @@
# 06 – Signed ITS messages, VAM and the BLE link (2026-09-23)
What changed when the ESP32-C5 OBU moved onto the colleague's vanetza-idf station, how the pieces
fit together, how it was verified, and what is still open. Hardware checks still to do are in
`TODO.md` ("Signed-TX firmware ...").
## Summary
- **Signing lives on the ESP32-C5.** The authorization ticket's private key is in the board's NVS;
vanetza-idf's security entity signs every secured message there (IEEE 1609.2 / ETSI TS 103 097,
ECDSA NIST P-256). The phone never holds a key and never signs.
- **The phone decides what to send and when.** It builds CAM or VAM (UPER) from its own GNSS/IMU,
hands each message to the board with the flag "signed" or "unsigned", and keeps the board's clock
and position current.
- **Two links, one protocol.** USB-C (native USB Serial/JTAG) or Bluetooth LE, chosen in Settings.
Both carry the colleague's station-link protocol v1 plus one MicrOBU extension for reception.
- **Reception is unchanged for the app.** Every ITS message heard on air reaches the phone, signed
or not, verifiable or not, exactly as with the previous firmware.
- **Demo PKI, not the EU trust list.** Signed messages carry a throwaway chain. Receivers that
verify against the EU trust list drop them; unsigned sending remains available.
## Who does what
| | Phone (app) | ESP32-C5 (obu-firmware) |
|---|---|---|
| CAM / VAM content and UPER encoding | yes | – |
| Send cadence (CAM 1 Hz baseline; VAM per TS 103 300-3 clause 6.4) | yes | – |
| Pseudonym (station ID + MAC, rotated together) | yes | uses the MAC it is configured with |
| Time and position (PoTi) | yes, per new GNSS fix | keeps an ITS clock from it |
| GeoNetworking + BTP headers | – | yes |
| Signing (TS 103 097), certificate handling | – | yes |
| Credentials | ships the demo bundle, provisions it once | stores it in NVS |
| 802.11p radio at 5 900 MHz | – | yes |
| Reception: unwrap GN/BTP, forward | decodes CAM / DENM / SPATEM | yes (all frames) |
## Firmware (obu-firmware)
obu-firmware is now a port of `microbu-esp32c5/firmware` (the colleague's repository, kept beside
this one, gitignored). vanetza-idf is taken from `microbu-esp32c5/external/vanetza-idf`. It builds
with **ESP-IDF 6.0.2 only**: the raw-TX path uses private Wi-Fi driver structures that vanetza-idf
pins to that version. The previous C firmware (IDF 6.1) is backed up as a full flash image in
`firmware-backups/` (gitignored, restore command in its README.txt); its sources stay on disk,
unbuilt. Setup and flashing: `obu-firmware/FLASHING.md`. Design notes and every deviation from the
colleague's code (`MicrOBU:` in the sources): `obu-firmware/NOTES.md`.
Main changes against the colleague's firmware:
- **Raw receive path kept.** vanetza-idf decapsulates strictly and would drop unsigned frames (the
bench car) and anything not signed under the demo root (every RSU). Every captured frame also
goes through the previous firmware's `gn_unwrap.c` and reaches the phone as link opcode
`V2X_RX` (0x85), whose body is the old `SERIAL_MSG_V2X_RX` payload.
- **Unsigned sending kept.** The colleague's station refuses unsecured requests; here they go out
with the previous firmware's `geonet.c` header.
- **Console on UART0** (CH343, COM3 on the bench); the native USB port carries only link frames.
- **BLE pauses advertising while USB is in use** (BLE and ITS-G5 share one RF front end).
- **NVS 80 KB instead of 24 KB**, app at 0x20000. At 24 KB the BLE bond could not be stored and the
phone had to pair on every connection.
- Fixes found on the bench: radio queue drained before the first PoTi (no RX, ~177 queue drops
before); station loop waited 0 ticks at 100 Hz and starved the idle task; 2.4 KB RX buffer moved
off the Wi-Fi task stack; no silent truncation of BLE notifications; ATT MTU 517; serial writes
skipped when no USB host is present.
## Link protocol
Station-link v1 (`microbu-esp32c5/station-link/README.md`): `[opcode][flags][sequence LE][body]`,
little-endian, at most 512 octets. Over USB each message is one `0xAA55` frame of type `0x10`
(the old framing and CRC). Over BLE each message is one GATT value on service
`0000C175-BA5E-4C17-8000-00805F9B34FB` (the README describes a different, Nordic-UART layout; the
firmware is what counts).
| Direction | Message | Used for |
|---|---|---|
| phone → board | `STATION_CONFIGURE` | pseudonym MAC, station type, channel 180, 20 dBm; starts the radio |
| phone → board | `CREDENTIALS_PROVISION` | the demo bundle, once, when the board reports no ticket |
| phone → board | `POTI_UPDATE` | position and ITS time, once per new GNSS fix |
| phone → board | `BTP_DATA_REQUEST` | one CAM (port 2001, psid 36) or VAM (port 2018, psid 638), signed or not |
| board → phone | `RESULT` | answer to a request |
| board → phone | `STATUS` | every second: counters, tickets, signed/refused counts |
| board → phone | `V2X_RX` (0x85, MicrOBU) | every ITS message heard on air |
The app side is `Esp32Link.kt` (session), `StationLink.kt` (codec, pinned by unit tests to bytes
from the colleague's Python implementation), `UsbSerialTransport.kt` and `BleLinkTransport.kt`.
A board still on the previous firmware is recognised by its old heartbeat and keeps working for
CAM over USB.
## Redundancy and recovery
| Situation | What notices | What happens |
|---|---|---|
| USB link dead (board hung, cable) | app watchdog: no frame for 3.5 s (the board's `STATUS` comes every second) | link marked ERROR on the card |
| USB unplugged | Android detach broadcast | port closed; Connect again after re-plugging |
| BLE link lost | BLE supervision timeout (4 s) | app reconnects by itself: 1 s after a drop, then backing off to 30 s if attempts fail |
| Board reset / power cycle | first `STATUS` says "not configured" | app reconfigures (and re-provisions if needed) without user action |
| App closed and reopened | new session | app configures the board again; BLE reconnects with the stored bond, no passkey (confirmed) |
| Phone clock or GNSS time jumping | app tracks the board's clock | PoTi never moves it backwards (except a real correction of ≥ 60 s), so the board does not restart its stack |
| Board firmware wedged | ESP task watchdog (30 s, logs on COM3) | the phone sees it as a dead link (above) |
## App changes
- Settings > Connection > ESP32-C5: **link** USB-C / Bluetooth, **transmit** CAM / VAM, **sign
outgoing messages** (on by default). The connection card, top bar and dashboard show the link in
use, the pairing passkey when needed, and signing counters.
- VAM encoder (`VamUperCodec.kt`, TS 103 300-3 V2.3.1, checked against asn1tools) and the VAM
generation rules (`VamGenerationRules.kt`).
- `GnssTimeSource` keeps the last measured phone-clock error while GNSS time drops out indoors. The
bench phone's clock was 14 minutes fast; falling back to it made every transmitted timestamp
jump by 14 minutes.
- Bluetooth permissions (Android 12+) requested at start-up.
## Credentials (demo PKI)
`app/src/main/assets/demo-chain.vcr`, generated 2026-09-23 with the colleague's `vidf_issue`: root
`6E7D0374FB021901` → AA `B3312F29844299E0` → AT `B80B49387A4C12EB` (two years; psid 36 SSP `010000`,
psid 638 SSP `01`). It is throwaway and not EU-registered; its private key ships with the app on
purpose. The colleague's own demo chain only grants psid 638 and cannot sign CAMs.
## Verification
- **Unit tests** (103): VAM bytes against asn1tools, station-link messages against the colleague's
Python encoder, VAM generation rules.
- **Signatures on air**: `obu-firmware/test/verify_signed_pcap.py` checks a pcap with asn1tools
and OpenSSL, sharing no code with the firmware. Pinger capture of 2026-09-23: 12/12 signed CAMs,
psid 36, signer the demo AT, all signatures valid, chain valid.
- **Third-party stack**: the CiT One receives the signed CAMs (~1 Hz on `v2x/rx/cam`), so its
stack unwraps our envelope. Its MQTT interface exposes no security information, and it forwards
unsigned and unknown-root messages alike, so it cannot tell whether it verified the signature.
- **V2X2MAP (COM10)**: now the colleague's v2x2map 0.3.0 bridge from source with a new
`verify.py` and `--trust demo-chain.vcr`; it shows "signature verified", "SIGNATURE INVALID" or
"not verified" per packet. Signed CAMs and signed VAMs verified live; a one-bit change in a
signed CAM comes out invalid. Launcher: `micrOBU_workspace/v2x-obu-esp32c5/start-v2x2map-signed.bat`.
## Open
- BLE/ITS-G5 coexistence is not measured: does an active BLE connection cost 5.9 GHz reception?
- `time_regression` standing still indoors for several minutes, and board reset recovery over BLE,
after the last fixes.
- The signature's generationTime follows the app's UTC-based `ItsTime`; the colleague's VBS adds
the 5 leap seconds (TAI). Which is right is the open question in `ItsTime.kt`.
- Real EU PKI enrolment/authorisation (TS 102 941) instead of the demo chain.
+241
View File
@@ -0,0 +1,241 @@
#!/usr/bin/env python3
"""Verify the IEEE 1609.2 / TS 103 097 signatures of secured GeoNetworking frames in a pcap.
Written 2026-09-23 to check, independently of the firmware, that the ESP32-C5 really signs with
the demo authorization ticket the app provisions. It shares no code with vanetza-idf: the envelope
is decoded with asn1tools from the IEEE 1609.2 ASN.1 modules, and ECDSA is checked with Python's
`cryptography` (OpenSSL).
py -3.11 obu-firmware/test/verify_signed_pcap.py capture.pcap \\
--bundle app/src/main/assets/demo-chain.vcr \\
--asn1 microbu-esp32c5/external/vanetza-idf/asn1
For every frame whose GN Basic Header says "secured" it reports: the signer (digest or full
certificate), whether that signer is the bundle's ticket, the psid and generation time, and
whether the message signature verifies with the ticket's public key. It also checks the bundle's
own chain (ticket signed by AA, AA by root, root self-signed). Frames signed by anyone else (an
RSU under the EU PKI) are counted and listed, not verified: their certificates are not known here.
Signature input, IEEE 1609.2 clause 5.3.1: ECDSA over Hash(tbsData) || Hash(signer), where the
signer part is the COER of the signing certificate (the empty string for a self-signed root).
Handles linktype 105 (bare 802.11, what the V2X2MAP bridge records) and 127 (radiotap).
"""
from __future__ import annotations
import argparse
import hashlib
import struct
import sys
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
LLC_SNAP_GN = b"\xaa\xaa\x03\x00\x00\x00\x89\x47"
ITS_EPOCH_UNIX = 1072915200
def pcap_frames(path: Path):
data = path.read_bytes()
magic = struct.unpack("<I", data[:4])[0]
endian = "<" if magic in (0xA1B2C3D4, 0xA1B23C4D) else ">"
linktype = struct.unpack(endian + "I", data[20:24])[0]
i = 24
while i + 16 <= len(data):
ts_sec, ts_frac, incl, _orig = struct.unpack(endian + "IIII", data[i:i + 16])
frame = data[i + 16:i + 16 + incl]
i += 16 + incl
if linktype == 127: # radiotap: skip its own length
frame = frame[struct.unpack("<H", frame[2:4])[0]:]
elif linktype != 105:
raise SystemExit("unsupported linktype %d" % linktype)
yield ts_sec + ts_frac / 1e6, frame
def secured_payload(frame: bytes):
"""Source MAC and the bytes after the GN Basic Header, if this is a secured GN frame."""
if len(frame) < 24:
return None
fc = frame[0]
if (fc >> 2) & 0x3 != 2: # not a data frame
return None
header = 26 if (fc >> 4) & 0x8 else 24 # QoS data carries 2 more octets
at = frame.find(LLC_SNAP_GN, header, header + 16)
if at < 0:
return None
gn = frame[at + 8:]
if len(gn) < 5 or gn[0] & 0x0F != 2: # Basic Header next header 2: secured packet
return None
return frame[10:16], gn[4:]
def read_bundle(path: Path):
"""The VCR1 bundle's certificates: [type 1][length 2 BE][payload] records."""
data = path.read_bytes()
certs = {"root": [], "authority": [], "ticket": []}
kinds = {1: "root", 2: "authority", 3: "ticket"}
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 kinds:
certs[kinds[kind]].append(data[i + 3:i + 3 + length])
i += 3 + length
return certs
def its_station(gn_common_onward: bytes):
"""StationID of the ITS PDU inside a secured GN packet's payload.
The signed payload is the GN packet from the Common Header on: Common Header (8), the extended
header of the Common Header's type, BTP-B (4), then the ITS PDU, whose header is
protocolVersion (1), messageID (1), stationID (4)."""
if len(gn_common_onward) < 8:
return None
ext = {5: 28, 4: 44}.get(gn_common_onward[1] >> 4) # HT: 5 TSB/SHB, 4 GBC
if ext is None:
return None
at = 8 + ext + 4
pdu = gn_common_onward[at:at + 6]
return int.from_bytes(pdu[2:6], "big") if len(pdu) == 6 else None
def hashed_id8(octets: bytes) -> bytes:
return hashlib.sha256(octets).digest()[-8:]
class Verifier:
def __init__(self, asn1_dir: Path):
import asn1tools
spec = asn1tools.compile_files([str(asn1_dir / "IEEE1609dot2.asn"),
str(asn1_dir / "IEEE1609dot2BaseTypes.asn")], "oer")
self.m = spec.modules["IEEE1609dot2"]
def public_key(self, cert_octets: bytes):
from cryptography.hazmat.primitives.asymmetric import ec
cert = self.m["Certificate"].decode(cert_octets)
kind, key = cert["toBeSigned"]["verifyKeyIndicator"]
if kind != "verificationKey" or key[0] != "ecdsaNistP256":
raise ValueError("not an ECDSA P-256 verification key: %r" % (key[0],))
form, point = key[1]
encoded = {"compressed-y-0": b"\x02" + point, "compressed-y-1": b"\x03" + point,
"uncompressedP256": b"\x04" + point.get("x", b"") + point.get("y", b"")
if isinstance(point, dict) else None}[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":
raise ValueError("unsupported signature %s" % kind)
r_kind, r = sig["rSig"]
r_x = r if isinstance(r, (bytes, bytearray)) else r["x"] # x-only / 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_signed_by(self, cert_octets: bytes, issuer_octets: bytes | None) -> bool:
cert = self.m["Certificate"].decode(cert_octets)
tbs = self.m["ToBeSignedCertificate"].encode(cert["toBeSigned"])
signer_input = hashlib.sha256(issuer_octets if issuer_octets is not None else b"").digest()
key = self.public_key(issuer_octets if issuer_octets is not None else cert_octets)
return self._ecdsa_ok(key, hashlib.sha256(tbs).digest() + signer_input, cert["signature"])
def message(self, octets: bytes, known: dict[bytes, bytes]):
"""Decodes one Ieee1609Dot2Data; returns a result dict."""
data = self.m["Ieee1609Dot2Data"].decode(octets)
encoded = self.m["Ieee1609Dot2Data"].encode(data)
kind, signed = data["content"]
if kind != "signedData":
return {"kind": kind}
tbs = self.m["ToBeSignedData"].encode(signed["tbsData"])
header = signed["tbsData"]["headerInfo"]
signer_kind, signer = signed["signer"]
if signer_kind == "digest":
digest, cert_octets = bytes(signer), known.get(bytes(signer))
elif signer_kind == "certificate":
cert_octets = self.m["Certificate"].encode(signer[0])
digest = hashed_id8(cert_octets)
else:
return {"kind": "signedData", "signer": signer_kind}
result = {
"kind": "signedData",
"signer": signer_kind,
"digest": digest.hex().upper(),
"psid": header["psid"],
"generation_time_us": header.get("generationTime"),
# COER is canonical, so a re-encoding identical to the wire bytes means the slices
# hashed below are exactly what the sender signed.
"canonical": octets.startswith(encoded) and tbs in octets,
}
if cert_octets is None:
result["verified"] = None # unknown signer
return result
message = hashlib.sha256(tbs).digest() + hashlib.sha256(cert_octets).digest()
result["verified"] = self._ecdsa_ok(self.public_key(cert_octets), message, signed["signature"])
inner = signed["tbsData"]["payload"].get("data")
if inner and inner["content"][0] == "unsecuredData":
payload = bytes(inner["content"][1])
result["payload"] = payload
return result
def main() -> int:
p = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
p.add_argument("pcap", type=Path)
p.add_argument("--bundle", type=Path, required=True, help="VCR1 credential bundle (demo-chain.vcr)")
p.add_argument("--asn1", type=Path, required=True, help="directory with IEEE1609dot2*.asn")
args = p.parse_args()
v = Verifier(args.asn1)
certs = read_bundle(args.bundle)
root, aa, at = certs["root"][0], certs["authority"][0], certs["ticket"][0]
print("bundle: root %s, AA %s, AT %s" % (hashed_id8(root).hex().upper(), hashed_id8(aa).hex().upper(),
hashed_id8(at).hex().upper()))
print("chain: root self-signed %s, AA by root %s, AT by AA %s" % (
v.certificate_signed_by(root, None), v.certificate_signed_by(aa, root), v.certificate_signed_by(at, aa)))
known = {hashed_id8(at): at}
tally = Counter()
ours = []
for ts, frame in pcap_frames(args.pcap):
found = secured_payload(frame)
if not found:
continue
mac, octets = found
try:
r = v.message(octets, known)
except Exception as e: # noqa: BLE001 - a malformed frame is a finding, not a crash
tally["undecodable"] += 1
continue
if r.get("verified") is None:
tally["signed by an unknown signer %s (psid %s)" % (r.get("digest"), r.get("psid"))] += 1
continue
tally["demo AT, signature %s" % ("VALID" if r["verified"] else "INVALID")] += 1
ours.append((ts, mac, r))
for line, n in sorted(tally.items()):
print("%5d %s" % (n, line))
# The V2X2MAP bridge stamps records with board uptime, not wall-clock time, so the signature's
# generationTime is compared with the file's modification time (end of the recording) instead.
recorded_until = args.pcap.stat().st_mtime
for ts, mac, r in ours[:5]:
gen = r["generation_time_us"] / 1e6 + ITS_EPOCH_UNIX if r["generation_time_us"] else None
print(" %s from %s: signer %s %s, psid %d, ITS PDU station %s, generationTime %s UTC "
"(%+.0f s before the recording ended), canonical %s, signature %s" % (
f"{ts:.3f}", mac.hex(":"), r["signer"], r["digest"], r["psid"], its_station(r.get("payload", b"")),
datetime.fromtimestamp(gen, timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] if gen else "-",
(recorded_until - gen) if gen else float("nan"), r["canonical"],
"VALID" if r["verified"] else "INVALID"))
return 0 if ours and all(r["verified"] for _, _, r in ours) else 1
if __name__ == "__main__":
sys.exit(main())