diff --git a/TODO.md b/TODO.md index d727e78..db9c8a3 100644 --- a/TODO.md +++ b/TODO.md @@ -5,6 +5,107 @@ Engineering to-do list. The reviewer-facing open items live in ## 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) Investigated the user's report of "OBU mode keeps dropping a few frames" and "v2x screen comes diff --git a/docs/06-signed-its-vam-ble.md b/docs/06-signed-its-vam-ble.md new file mode 100644 index 0000000..ba9ffa7 --- /dev/null +++ b/docs/06-signed-its-vam-ble.md @@ -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. diff --git a/obu-firmware/test/verify_signed_pcap.py b/obu-firmware/test/verify_signed_pcap.py new file mode 100644 index 0000000..03b23b8 --- /dev/null +++ b/obu-firmware/test/verify_signed_pcap.py @@ -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("" + 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("> 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())