From 75d6d3b85cf5d61596deb0b5d410e3f140057305 Mon Sep 17 00:00:00 2001 From: Ashin Walpola Date: Fri, 11 Sep 2026 20:19:40 +0200 Subject: [PATCH] Receive signed ITS messages and forward each at its declared length Signed packets. A GeoNetworking Basic Header NextHeader of 2 means a TS 103 097 (IEEE 1609.2) envelope follows, with the Common Header inside it. gn_unwrap_its rejected all of these, and most real traffic is signed: the 2026-08-17 capture holds 157 signed frames from 15 source MACs against 2 unsecured stations. It now opens a COER-encoded signedData, or a bare unsecuredData, and parses the inner packet as before. The inner packet comes first inside tbsData, so the certificate and signature are never parsed, and the signature is not verified - the firmware has no trust store. Such messages reach the phone with the new V2X_RX flags bit1, signed but not verified. The app reads only bit0 and is unaffected until it learns the flag. Encrypted payloads, nested signing and the legacy v1.2.1 envelope are still rejected. All 157 recorded signed frames have the layout this reads, in all three COER length forms, and asn1tools decodes every envelope to the same inner packet. Payload bounds. Every frame recorded through the ESP32-C5's promiscuous RX, about 15 000 of them, ends in 8 bytes that are not part of the 802.11 frame and not a valid FCS. obu-firmware reads frames through the same API and took the rest of the frame as the message, so it forwarded those 8 bytes to the phone after every message. UPER decoders stop where the message ends, so nothing visibly broke, but the bytes cost serial bandwidth and 8 bytes of the DENM's headroom, and they stayed attached wherever raw payloads were stored or passed on. The payload is now exactly what the Common Header's payload-length field declares, which is also what separates a signed message from its signature. A frame longer than main.c's 800-byte capture buffer is now reported as truncated instead of being forwarded cut off, and counted as an oversize drop through the new serial_link_note_oversize_drop, as it was when the cut-off frame failed serial_link's size check. Host tests in obu-firmware/test/host build the firmware sources unmodified with MSYS2 gcc; `make` runs all three. - test_chain: frames from the firmware's TX code checked byte by byte against EN 302 636-4-1 and parsed back, including hand-built signed frames, the payload-length rule, the RX trailer, and every truncation length against a no-access guard page. 1731 checks, 0 failures. - test_replay and check_replay.py: all 15 145 recorded frames through gn_unwrap_its, cut to 800 bytes as on the board, and re-derived independently in Python with the envelope decoded by asn1tools. They agree on every record; 15 131 accepted, 157 of them signed. 11 043 of the 11 106 distinct messages re-encode byte-identically. The other 63 fail the same way with the old 8 bytes put back, so the boundary is not the cause: 5 are our own CAMs from before the 2026-08-20 yawRateConfidence fix, and the rest, from other stations, are a follow-up in TODO.md. - fuzz_gn_unwrap: random edits of every recorded frame, each run against the guard page. 50 000 000 iterations, no crash. obu-firmware/test/pcap_gn_tally.py tallies GeoNetworking header fields per station over captures; it is how the other stations' lifetimes were measured. TODO.md collects what is still open, including the on-air check for this change: it builds on IDF 6.1 but has not been flashed. --- TODO.md | 103 ++++ obu-firmware/main/gn_unwrap.c | 146 ++++- obu-firmware/main/gn_unwrap.h | 49 +- obu-firmware/main/main.c | 10 +- obu-firmware/main/serial_link.c | 11 +- obu-firmware/main/serial_link.h | 13 +- obu-firmware/test/host/Makefile | 58 ++ obu-firmware/test/host/README.md | 151 +++++ obu-firmware/test/host/check_replay.py | 253 ++++++++ obu-firmware/test/host/fuzz_gn_unwrap.c | 218 +++++++ obu-firmware/test/host/test_chain.c | 730 ++++++++++++++++++++++++ obu-firmware/test/host/test_replay.c | 99 ++++ obu-firmware/test/host/test_util.c | 249 ++++++++ obu-firmware/test/host/test_util.h | 36 ++ obu-firmware/test/pcap_gn_tally.py | 104 ++++ 15 files changed, 2196 insertions(+), 34 deletions(-) create mode 100644 TODO.md create mode 100644 obu-firmware/test/host/Makefile create mode 100644 obu-firmware/test/host/README.md create mode 100644 obu-firmware/test/host/check_replay.py create mode 100644 obu-firmware/test/host/fuzz_gn_unwrap.c create mode 100644 obu-firmware/test/host/test_chain.c create mode 100644 obu-firmware/test/host/test_replay.c create mode 100644 obu-firmware/test/host/test_util.c create mode 100644 obu-firmware/test/host/test_util.h create mode 100644 obu-firmware/test/pcap_gn_tally.py diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..566940a --- /dev/null +++ b/TODO.md @@ -0,0 +1,103 @@ +# TODO + +Engineering to-do list. The reviewer-facing open items live in +`docs/01-requirements-traceability.md` ("Open items"); this file is the working list behind them. + +## Waiting on hardware + +### Over-the-air check of the GN lifetime fix (added 2026-09-11) + +`geonet.c` now writes GN lifetime `0x05` (1 s) instead of `0x83`, which decoded to 3200 s. Changed +in both `obu-firmware` and `obu-cam-transmistter`. Both still build (IDF 6.1 / 5.5.4), and the +compiled `geonet_wrap_shb` stores the new byte, but it has not been seen on air yet. Nothing else +reads this byte (`gn_unwrap.c` ignores it, the app never sees GN headers), so the app does not +need updating alongside the firmware. + +Needs: the phone with the app, the OBU ESP32-C5, and a **second** ESP32-C5 running +`its-g5-receiver-firmware` to capture with. + +- [ ] Flash `obu-firmware` (see `obu-firmware/FLASHING.md`). +- [ ] Connect the phone, let it send CAMs, and confirm the CAM Pinger's `tx fail` counter stays 0. +- [ ] Capture with the receiver into `its-g5-receiver-firmware/recordings/`. +- [ ] Run `python obu-firmware/test/pcap_gn_tally.py its-g5-receiver-firmware/recordings/.pcap`. + The rows for the phone's pseudonym MACs must show SHB, port 2001, lifetime `0x05`, exactly + like every other station's CAMs. +- [ ] While the phone is connected: real-station CAMs/DENMs still reach the app (RX path unchanged). + +Partial check possible with one board and no phone: flash it, `idf.py -p COMx monitor`, and look +for `OCB @ 5900 MHz - TX/RX armed`. That proves the new build boots and brings the radio up, not +that it transmits correctly. + +### obu-cam-transmistter yawRateConfidence fix (added 2026-09-11) + +Its `cam.c` (compiled into that firmware) wrote `yawRateConfidence` as 3 bits / 7 instead of +4 bits / unavailable(8), the bug the app fixed on 2026-08-20. Fixed in it and in obu-firmware's +reference copy; asn1tools now decodes the CAM and re-encodes it byte-identically, and it builds on +IDF 5.5.4. No board runs this firmware right now (the production OBU runs obu-firmware), so this +only matters if it is flashed again: + +- [ ] After flashing it: capture, run `pcap_gn_tally.py`, and decode the CAM payload with + asn1tools (`py -3.11`, modules in `asn1/`). + +### Signed-message reception and exact payloads (added 2026-09-11) + +obu-firmware's `gn_unwrap.c` now unwraps TS 103 097 signed packets (signature not verified, +reported as V2X_RX flags bit1) and cuts every message to the length its header declares, dropping +the 8 bytes the chip's RX appends to each frame, which were forwarded to the phone until now. +Verified on the host (`obu-firmware/test/host`: chain, replay of all recordings against asn1tools, +50M-iteration fuzz) and built on IDF 6.1, but not flashed: the production OBU still runs the +2026-09-10 build. Needs the OBU with this build, the phone, and signed traffic - real vehicles or +RSUs, since the bench CiT One sends unsigned. A second ESP32 running the receiver firmware is +optional, but shows what was on air at the time. + +- [ ] Flash obu-firmware (this also carries the GN lifetime fix above). +- [ ] Near signed traffic: signed CAMs/DENMs appear in the app, and a simultaneous capture shows + them on air (`pcap_gn_tally.py` lists them as `secured`). +- [ ] Unsigned bench traffic still decodes in the app as before (messages now arrive 8 bytes + shorter). +- [ ] The heartbeat's oversize counter still counts over-long messages (e.g. road SPATEMs). + +## Set up host testing + +- [x] Install MSYS2 UCRT64 gcc (done 2026-09-11: gcc 16.2.0, GNU Make 4.4.1; chosen over WSL, + vanetza is not going to be built). Setup and the PATH gotcha: `obu-firmware/test/host/README.md`. +- [x] Host round-trip test `obu-firmware/test/host/test_chain.c` (`geonet_wrap_shb` -> + `dot11p_build_frame` -> `gn_unwrap_its`, byte-checked against the standard). Done + 2026-09-11: 491 checks, 0 failed. Run `make` in that folder before flashing any firmware fix. +- [x] Replay of the recorded captures (`test_replay.c` + `check_replay.py`, independent asn1tools + check). Done 2026-09-11: C and Python agree on all 15 145 records. +- [x] Mutation fuzzer `fuzz_gn_unwrap.c`, inputs against a no-access guard page. Done 2026-09-11: + 50 000 000 iterations, no crash. `make` runs a 2 000 000-iteration pass every time. + +## Firmware ideas from the vanetza review (2026-09-11, not started) + +Suggested order after the host tests exist: + +- [x] **Read secured packets (GN NextHeader=2) without verifying them.** Done 2026-09-11 in + `gn_unwrap.c`, host-verified; flagged to the phone as V2X_RX flags bit1. On-air check under + "Waiting on hardware". +- [ ] **Forward the full GeoBroadcast area**: shape (circle/rectangle/ellipse), DistanceB, angle, + appended to the V2X_RX prefix behind a capability bit. Port vanetza's `geonet/areas.cpp` + `inside_or_at_border` to the app, which currently treats every area as a circle. +- [ ] **RX filtering before the serial link**: duplicate detection for GBC (last 8 sequence numbers + per source, as vanetza does), drop our own frames, reject GN version != 1. +- [ ] **Read the DCC-MCO field** (the 4 "reserved" bytes of an SHB header): neighbours' channel + busy ratio for free. +- [ ] **Minimum TX gap in firmware** as a DCC safety net (vanetza reactive table: 60 ms relaxed ... + 460 ms restrictive), with a CBR estimate in the heartbeat. +- [ ] **Generic V2X_TX message** (BTP port, SHB/GBC, traffic class, lifetime, area) so the phone can + send DENM and VAM without reflashing. Consider QoS Data frames: vanetza's Cohda receive path + drops non-QoS ones. + +Dropped: building vanetza as a GN/BTP oracle. Real captures (`pcap_gn_tally.py`), the host +round-trip test and `asn1tools` for UPER cover what it would have checked. + +## Follow-ups found 2026-09-11 + +- [ ] **App: show the signed flag.** `V2xRxFrame.parse` in `SerialFrame.kt` only reads bit0 of + the flags byte; read bit1 (signed, not verified) and show it where messages are listed. +- [ ] **Messages that do not decode with asn1tools.** In the recordings, 56 from the CiT One + (`aa:f8:76:7d:bd:ad`: 54 CAMs of 245 bytes, 2 DENMs of 402 bytes) and one 218-byte CAM from + `6e:94:03:1b:05:26` fail against `cam_1_4_1`/`denm_1_3_1` + `cdd_1_3_1_1`, with or without the + old trailing bytes. A newer module version on the sender, or a sender bug; check what the + app's decoders make of them (`check_replay.py` lists the records). diff --git a/obu-firmware/main/gn_unwrap.c b/obu-firmware/main/gn_unwrap.c index 9f0342a..76b1060 100644 --- a/obu-firmware/main/gn_unwrap.c +++ b/obu-firmware/main/gn_unwrap.c @@ -28,9 +28,20 @@ #define GN_HEADER_TYPE_TSB (5) // Topologically-Scoped Broadcast #define GN_HEADER_SUBTYPE_SINGLE_HOP (0) -#define GN_NEXT_HEADER_COMMON (1) // unsecured; 2 would be a secured packet +#define GN_NEXT_HEADER_COMMON (1) // unsecured: the Common Header follows +#define GN_NEXT_HEADER_SECURED (2) // a TS 103 097 envelope follows, Common Header inside it #define GN_COMMON_NEXT_HEADER_BTP_B (2) +// Common Header field (clause 9.7): length of everything after the GeoNetworking headers, i.e. +// the BTP-B header plus the ITS payload. +#define GN_COMMON_PAYLOAD_LEN_OFFSET (4) + +// IEEE 1609.2 / TS 103 097 envelope, COER encoded - see unwrap_secured(). +#define IEEE1609DOT2_VERSION (3) +#define CONTENT_TAG_UNSECURED_DATA (0x80) // Ieee1609Dot2Content CHOICE, context tag 0 +#define CONTENT_TAG_SIGNED_DATA (0x81) // context tag 1 +#define SIGNED_PAYLOAD_HAS_DATA (0x40) // SignedDataPayload preamble: `data` present + #define BTP_DEST_PORT_CAM (2001) // ETSI TS 103 248 #define BTP_DEST_PORT_DENM (2002) // NOTE the crossover: SPATEM is BTP port 2004 but ItsPduHeader messageID 4, while MAPEM is port @@ -51,6 +62,90 @@ static uint16_t be16(const uint8_t *p) return (uint16_t)(((uint16_t)p[0] << 8) | (uint16_t)p[1]); } +// COER length determinant (ITU-T X.696): a first byte below 0x80 is the length itself; otherwise +// its low 7 bits count the big-endian length bytes that follow. Two of them cover anything this +// radio can deliver. Returns how many bytes the determinant occupies, or 0 if it does not fit in +// `avail` or uses a form this does not read. +static int coer_length(const uint8_t *p, int avail, int *len) +{ + if (avail < 1) { + return 0; + } + if (p[0] < 0x80) { + *len = p[0]; + return 1; + } + const int n = p[0] & 0x7F; + if (n < 1 || n > 2 || avail < 1 + n) { + return 0; + } + int v = 0; + for (int i = 1; i <= n; i++) { + v = (v << 8) | p[i]; + } + *len = v; + return 1 + n; +} + +// Locates the GeoNetworking packet inside a secured one. `offset` points just past the Basic +// Header. Returns the offset of the inner Common Header and sets *inner_end to where the envelope +// says the inner packet ends - which lies beyond frame_len if the capture was cut short - or +// returns -1 for anything this does not unwrap. +// +// The envelope is an Ieee1609Dot2Data (IEEE 1609.2, profiled by TS 103 097 v1.3.1 and later), +// COER encoded. A signed message starts: +// +// 03 protocolVersion 3 +// 81 content = signedData +// 00 hashId (sha256; any one-byte value is accepted - the hash is not checked) +// 40 tbsData.payload preamble: `data` present (bit 6) +// 03 80 payload.data: an Ieee1609Dot2Data holding unsecuredData of bytes, which +// are the Common Header, extended header, BTP-B header and ITS payload +// ... headerInfo, signer, signature: not read +// +// The inner packet comes first inside tbsData, so it is found without parsing the certificate +// or the signature, and its explicit length is what separates it from them. The shape is +// measured, not only read from the standard: all 157 signed frames in +// capture_20260817_171055.pcap have it (150 CAM, 7 GeoBroadcast DENM; in all three COER +// forms), and asn1tools decodes every one of them to the same unsecuredData. A top-level +// unsecuredData (03 80 , no signature at all) is accepted too. +static int unwrap_secured(const uint8_t *frame, int offset, int frame_len, + int *inner_end, bool *is_signed) +{ + const uint8_t *p = frame + offset; + const int avail = frame_len - offset; + int i; + + if (avail < 2 || p[0] != IEEE1609DOT2_VERSION) { + return -1; // includes the legacy TS 103 097 v1.2.1 envelope, protocolVersion 2 + } + if (p[1] == CONTENT_TAG_SIGNED_DATA) { + if (avail < 6 || + p[2] >= 0x80 || // hashId: a one-byte enumerated value + !(p[3] & SIGNED_PAYLOAD_HAS_DATA) || // signs only a hash of data sent elsewhere + p[4] != IEEE1609DOT2_VERSION || + p[5] != CONTENT_TAG_UNSECURED_DATA) { // nested signing or encryption + return -1; + } + i = 6; + *is_signed = true; + } else if (p[1] == CONTENT_TAG_UNSECURED_DATA) { + i = 2; + *is_signed = false; + } else { + return -1; // encryptedData, certificate requests + } + + int len; + const int used = coer_length(p + i, avail - i, &len); + if (used == 0) { + return -1; + } + i += used; + *inner_end = offset + i + len; + return offset + i; +} + bool gn_unwrap_its(const uint8_t *frame, int frame_len, gn_rx_t *out) { if (!frame || !out || frame_len < IEEE80211_HEADER_LEN) { @@ -91,22 +186,33 @@ bool gn_unwrap_its(const uint8_t *frame, int frame_len, gn_rx_t *out) if (frame_len < offset + GN_BASIC_HEADER_LEN) { return false; } - // NextHeader distinguishes an unsecured packet (1 = Common Header follows) from a secured one - // (2 = a TS 103 097 SecuredMessage follows, with the Common Header buried inside it at a - // variable offset). Checking this rather than blindly skipping means a secured packet is - // rejected cleanly instead of having its security envelope misread as a Common Header. - if ((frame[offset] & 0x0F) != GN_NEXT_HEADER_COMMON) { - return false; - } + const uint8_t basic_next_header = frame[offset] & 0x0F; offset += GN_BASIC_HEADER_LEN; + // The headers from here on must end before `limit`: the end of the frame, or for a secured + // packet the end of the envelope's inner packet if that comes first. + int limit = frame_len; + int envelope_end = -1; + if (basic_next_header == GN_NEXT_HEADER_SECURED) { + offset = unwrap_secured(frame, offset, frame_len, &envelope_end, &out->signed_unverified); + if (offset < 0) { + return false; + } + if (envelope_end < limit) { + limit = envelope_end; + } + } else if (basic_next_header != GN_NEXT_HEADER_COMMON) { + return false; + } + // ---- GN Common Header (8 bytes) ---- - if (frame_len < offset + GN_COMMON_HEADER_LEN) { + if (limit < offset + GN_COMMON_HEADER_LEN) { return false; } uint8_t next_header = (frame[offset + 0] >> 4) & 0x0F; uint8_t header_type = (frame[offset + 1] >> 4) & 0x0F; uint8_t header_subtype = frame[offset + 1] & 0x0F; + const int gn_payload_len = be16(frame + offset + GN_COMMON_PAYLOAD_LEN_OFFSET); if (next_header != GN_COMMON_NEXT_HEADER_BTP_B) { return false; } @@ -126,7 +232,7 @@ bool gn_unwrap_its(const uint8_t *frame, int frame_len, gn_rx_t *out) } else { return false; // Beacon / GeoUnicast / GeoAnycast / multi-hop TSB - see header comment } - if (frame_len < offset + ext_len) { + if (limit < offset + ext_len) { return false; } if (is_gbc) { @@ -138,7 +244,7 @@ bool gn_unwrap_its(const uint8_t *frame, int frame_len, gn_rx_t *out) offset += ext_len; // ---- BTP-B header (4 bytes) ---- - if (frame_len < offset + BTP_B_HEADER_LEN) { + if (limit < offset + BTP_B_HEADER_LEN) { return false; } uint16_t dest_port = be16(frame + offset); @@ -146,16 +252,26 @@ bool gn_unwrap_its(const uint8_t *frame, int frame_len, gn_rx_t *out) dest_port != BTP_DEST_PORT_SPATEM) { return false; } - offset += BTP_B_HEADER_LEN; - // ---- Whatever's left is the ITS UPER payload ---- - int payload_len = frame_len - offset; + // ---- ITS payload: exactly as long as the Common Header declares ---- + // Not "whatever is left of the frame": see "Payload bounds" in gn_unwrap.h for the 8 trailing + // bytes every received frame carries and the signature that follows a secured packet. + if (gn_payload_len <= BTP_B_HEADER_LEN) { + return false; // no ITS payload at all + } + const int payload_start = offset + BTP_B_HEADER_LEN; + const int payload_end = offset + gn_payload_len; + if (envelope_end >= 0 && payload_end > envelope_end) { + return false; // the inner packet claims more than its envelope holds + } + out->truncated = payload_end > frame_len; + const int payload_len = (out->truncated ? frame_len : payload_end) - payload_start; if (payload_len <= 0) { return false; } out->btp_dest_port = dest_port; - out->payload = frame + offset; + out->payload = frame + payload_start; out->payload_len = payload_len; return true; } diff --git a/obu-firmware/main/gn_unwrap.h b/obu-firmware/main/gn_unwrap.h index 88864fd..c0d155c 100644 --- a/obu-firmware/main/gn_unwrap.h +++ b/obu-firmware/main/gn_unwrap.h @@ -6,8 +6,9 @@ // Inverse of geonet_wrap_shb() + dot11p_build_frame(): takes a raw 802.11 frame as delivered by // the WiFi driver's promiscuous RX callback and strips 802.11 header -> LLC/SNAP -> -// GeoNetworking Basic/Common/extended header -> BTP-B header, leaving the ITS payload (a UPER -// message) plus the metadata the phone needs to know what it received. +// GeoNetworking Basic Header -> [security envelope] -> Common/extended header -> BTP-B header, +// leaving the ITS payload (a UPER message) plus the metadata the phone needs to know what it +// received. // // ---- Supported GeoNetworking header types -------------------------------------------------- // Two shapes, chosen by the Common Header's HeaderType, with DIFFERENT extended-header lengths: @@ -28,6 +29,23 @@ // Beacon, GeoUnicast, GeoAnycast and multi-hop TSB are still rejected - nothing this project // talks to sends them, and each has its own extended-header length that would need measuring. // +// ---- Secured packets ----------------------------------------------------------------------- +// A Basic Header NextHeader of 2 means an ETSI TS 103 097 (IEEE 1609.2) envelope follows, with +// the Common Header onward inside it. Signed messages are unwrapped WITHOUT verifying the +// signature or the certificate - this firmware has no trust store - and are reported with +// signed_unverified set so the phone can tell. Most real traffic is signed: the 2026-08-17 +// capture held 157 signed frames from 15 source MACs. Encrypted payloads, nested signing and the +// legacy v1.2.1 envelope are rejected. The layout is documented at unwrap_secured() in +// gn_unwrap.c. Before 2026-09-11 every secured packet was rejected. +// +// ---- Payload bounds ------------------------------------------------------------------------ +// The payload is exactly as long as the Common Header's payload-length field says, minus the +// BTP-B header - not "the rest of the frame". After the message comes, in a signed packet, the +// signature; and every frame recorded through this chip's promiscuous RX API (~15 000 of them) +// ends in 8 more bytes that are not part of the 802.11 frame and not a valid FCS. Until +// 2026-09-11 those 8 bytes were forwarded to the phone as the tail of every message. UPER +// decoders stop where the message ends, which is why nothing visibly broke. +// // ---- Accepted BTP-B ports (ETSI TS 103 248) ------------------------------------------------ // 2001 (CAM), 2002 (DENM) and 2004 (SPATEM). MAPEM (2003) and the rest are deliberately not // accepted yet: the phone has no decoder for them, so forwarding would just burn serial @@ -38,17 +56,11 @@ // counted as an oversize drop rather than forwarded. The bench RSU trigger emits ~58-byte SPATEMs // and is unaffected, but real road RSUs measured 555 bytes median and 1243 max (2026-03-18 drive, // 79k messages), i.e. roughly 70% would be dropped. Raising the cap is deliberately deferred: it -// also requires enlarging RX_FRAME_MAX_LEN and moving rx_item_t off the WiFi callback stack, -// which at that size would overflow it. +// also requires enlarging main.c's RX_FRAME_MAX_LEN. // -// ---- What is NOT handled ------------------------------------------------------------------- -// Secured packets (GN Basic Header NextHeader=2, i.e. ETSI TS 103 097 signed messages). The -// units on this bench run with ItsGnSecurity=0 so everything observed is unsecured; a secured -// packet is rejected rather than mis-parsed. -// -// No FCS/CRC check: the WiFi driver has already validated and stripped it. +// No FCS/CRC check here: the WiFi driver has already validated the frame. typedef struct { - // BTP-B destination port, identifying the service: 2001 = CAM, 2002 = DENM. + // BTP-B destination port, identifying the service: 2001 = CAM, 2002 = DENM, 2004 = SPATEM. uint16_t btp_dest_port; // ITS payload (UPER message bytes). Points INTO the caller's `frame` buffer - NOT a copy, so @@ -64,11 +76,20 @@ typedef struct { int32_t geo_area_lat_tenmicrodeg; int32_t geo_area_lon_tenmicrodeg; uint16_t geo_area_distance_a_m; + + // The packet arrived inside a TS 103 097 signed envelope. The signature was NOT checked. + bool signed_unverified; + + // The frame ended before the payload its headers declare. On the board only main.c's + // RX_FRAME_MAX_LEN capture limit causes this (the driver drops frames that fail their FCS). + // payload/payload_len then cover just the part that arrived, so it must not be forwarded. + bool truncated; } gn_rx_t; -// Returns true and fills *out if this was a well-formed, supported ITS frame. Returns false -// otherwise (wrong ethertype, secured, unsupported header type, unaccepted BTP port, truncated, -// or promiscuous-capture garbage) - all common and expected on an open-air capture, so the caller +// Returns true and fills *out if this was a well-formed, supported ITS frame - check `truncated` +// before using the payload. Returns false otherwise (wrong ethertype, encrypted or unsupported +// envelope, unsupported header type, unaccepted BTP port, headers cut short, or +// promiscuous-capture garbage) - all common and expected on an open-air capture, so the caller // should treat false as "not for us", not as an error worth logging per frame. bool gn_unwrap_its(const uint8_t *frame, int frame_len, gn_rx_t *out); diff --git a/obu-firmware/main/main.c b/obu-firmware/main/main.c index 3263176..dcf3ced 100644 --- a/obu-firmware/main/main.c +++ b/obu-firmware/main/main.c @@ -280,8 +280,16 @@ static void rx_forward_task(void *arg) // returning false here is the common case, not an error, so it isn't logged per frame. gn_rx_t rx; if (gn_unwrap_its(item.data, item.len, &rx)) { + if (rx.truncated) { + // Longer than the RX_FRAME_MAX_LEN bytes captured above, so it cannot be forwarded + // whole - and at that size it could not cross the serial link either. Counted as + // an oversize drop, as it was when the cut-off frame still reached + // serial_link_send_v2x_rx() and failed the size check there. + serial_link_note_oversize_drop(rx.btp_dest_port); + continue; + } serial_link_send_v2x_rx(rx.btp_dest_port, item.rssi, - rx.has_geo_area, + rx.has_geo_area, rx.signed_unverified, rx.geo_area_lat_tenmicrodeg, rx.geo_area_lon_tenmicrodeg, rx.geo_area_distance_a_m, diff --git a/obu-firmware/main/serial_link.c b/obu-firmware/main/serial_link.c index a1be9ba..1723ee6 100644 --- a/obu-firmware/main/serial_link.c +++ b/obu-firmware/main/serial_link.c @@ -38,6 +38,13 @@ void serial_link_note_tx_failure(void) bump(&s_tx_failures); } +void serial_link_note_oversize_drop(uint16_t btp_dest_port) +{ + bump(&s_oversize_drops); + ESP_LOGW(TAG, "port %u message larger than the RX capture buffer, total oversize drops %u", + btp_dest_port, s_oversize_drops); +} + // ---- CRC-16/CCITT-FALSE (poly 0x1021, init 0xFFFF, no reflect, no xorout) ---- // Bytewise (no table) - frames here are at most SERIAL_LINK_MAX_PAYLOAD + 3 bytes, so table // lookup isn't worth the flash/RAM tradeoff. MUST match the Kotlin-side implementation exactly @@ -114,7 +121,7 @@ static bool send_frame(uint8_t type, const uint8_t *payload, int len) } bool serial_link_send_v2x_rx(uint16_t btp_dest_port, int8_t rssi, - bool has_geo_area, + bool has_geo_area, bool signed_unverified, int32_t geo_area_lat_tenmicrodeg, int32_t geo_area_lon_tenmicrodeg, uint16_t geo_area_distance_a_m, @@ -138,7 +145,7 @@ bool serial_link_send_v2x_rx(uint16_t btp_dest_port, int8_t rssi, s_v2x_payload[0] = (uint8_t)(btp_dest_port & 0xFF); s_v2x_payload[1] = (uint8_t)((btp_dest_port >> 8) & 0xFF); s_v2x_payload[2] = (uint8_t)rssi; - s_v2x_payload[3] = has_geo_area ? 0x01 : 0x00; + s_v2x_payload[3] = (uint8_t)((has_geo_area ? 0x01 : 0x00) | (signed_unverified ? 0x02 : 0x00)); uint32_t lat = (uint32_t)geo_area_lat_tenmicrodeg; uint32_t lon = (uint32_t)geo_area_lon_tenmicrodeg; s_v2x_payload[4] = (uint8_t)(lat & 0xFF); diff --git a/obu-firmware/main/serial_link.h b/obu-firmware/main/serial_link.h index 87fa6fb..2b58385 100644 --- a/obu-firmware/main/serial_link.h +++ b/obu-firmware/main/serial_link.h @@ -37,10 +37,13 @@ // [0..1] btp_dest_port uint16 LE 2001 = CAM, 2002 = DENM (ETSI TS 103 248) // [2] rssi int8 dBm, from the promiscuous RX metadata // [3] flags uint8 bit0: geo area fields below are valid +// bit1: arrived signed (TS 103 097), signature NOT +// verified. An app that tests only bit0 ignores it. // [4..7] geo_area_lat int32 LE 1/10 microdegree, GeoBroadcast destination area // [8..11] geo_area_lon int32 LE 1/10 microdegree // [12..13] geo_area_dist uint16 LE Distance A, metres (relevance radius for a circle) -// [14..] UPER message bytes +// [14..] UPER message bytes - exactly the message. Before 2026-09-11 they were followed by +// the 8 bytes the chip's promiscuous RX appends (gn_unwrap.h, "Payload bounds"). // // All prefix fields are LITTLE-endian, matching this framing's own length field - note the // GeoNetworking wire format they came from is big-endian, so gn_unwrap.c converts. @@ -138,10 +141,11 @@ void serial_link_init(serial_link_cam_tx_cb_t on_cam_tx, // Sends a SERIAL_MSG_V2X_RX frame: the metadata prefix plus the UPER bytes gn_unwrap.c extracted // from an over-the-air frame. Pass has_geo_area=false and zeroes for the area fields when the // source frame carried no destination area (i.e. it was single-hop broadcast, not GeoBroadcast). +// signed_unverified is gn_rx_t's flag of the same name; it sets bit1 of the prefix flags. // Returns true if the frame was written to the USB endpoint - not an end-to-end ack, the phone // may still drop it. bool serial_link_send_v2x_rx(uint16_t btp_dest_port, int8_t rssi, - bool has_geo_area, + bool has_geo_area, bool signed_unverified, int32_t geo_area_lat_tenmicrodeg, int32_t geo_area_lon_tenmicrodeg, uint16_t geo_area_distance_a_m, @@ -156,4 +160,9 @@ bool serial_link_send_status(uint8_t status); // otherwise indistinguishable, from the phone's side, from one that transmitted fine. void serial_link_note_tx_failure(void); +// Counts an ITS message that cannot be forwarded because it is too large, in the same heartbeat +// counter serial_link_send_v2x_rx() uses for its own size check. For main.c's rx_forward_task, +// whose capture buffer is smaller than the largest frames on air. +void serial_link_note_oversize_drop(uint16_t btp_dest_port); + #endif diff --git a/obu-firmware/test/host/Makefile b/obu-firmware/test/host/Makefile new file mode 100644 index 0000000..a9c6e91 --- /dev/null +++ b/obu-firmware/test/host/Makefile @@ -0,0 +1,58 @@ +# Host-side tests for obu-firmware. See README.md: needs gcc and make on PATH (MSYS2 UCRT64), and +# asn1tools under `py -3.11` for the replay check. +# +# make build and run everything: chain, replay, fuzz +# make chain | replay | fuzz one of them +# make fuzz FUZZ_ITER=50000000 FUZZ_SEED=7 a longer or different fuzz run +# make clean remove build/ +# +# The firmware sources are compiled straight from ../../main, never copied. + +CC = gcc +PYTHON = python +PYTHON_ASN1 = py -3.11 +FW = ../../main +BUILD = build +EXE = $(if $(filter Windows_NT,$(OS)),.exe,) +RECORDINGS = $(wildcard ../../../its-g5-receiver-firmware/recordings/*.pcap) +FUZZ_ITER = 2000000 +FUZZ_SEED = 1 + +# -Werror: these sources must stay warning-free on the host compiler too. +# UBSan in trap mode needs no runtime library, so it works on MinGW; a trap shows up as a crash. +CFLAGS = -std=c11 -O2 -g -Wall -Wextra -Wpedantic -Werror -I$(FW) \ + -fsanitize=undefined -fsanitize-undefined-trap-on-error + +FW_SRCS = $(FW)/geonet.c $(FW)/dot11p.c $(FW)/gn_unwrap.c +DEPS = test_util.c test_util.h $(FW_SRCS) $(wildcard $(FW)/*.h) + +.PHONY: all check chain replay fuzz clean + +all: check + +check: chain replay fuzz + +$(BUILD): + mkdir -p $@ + +$(BUILD)/%$(EXE): %.c $(DEPS) | $(BUILD) + $(CC) $(CFLAGS) -o $@ $< test_util.c $(FW_SRCS) + +# The pcap goes through pcap_gn_tally.py as a second, independent parser of the same frames. +chain: $(BUILD)/test_chain$(EXE) + $(BUILD)/test_chain$(EXE) $(BUILD)/test_chain.pcap + $(PYTHON) ../pcap_gn_tally.py $(BUILD)/test_chain.pcap + +replay: $(BUILD)/test_replay$(EXE) +ifeq ($(RECORDINGS),) + @echo "replay: no recordings in ../../../its-g5-receiver-firmware/recordings, skipped" +else + $(BUILD)/test_replay$(EXE) $(BUILD)/replay.tsv $(RECORDINGS) + $(PYTHON_ASN1) check_replay.py $(BUILD)/replay.tsv $(RECORDINGS) +endif + +fuzz: $(BUILD)/fuzz_gn_unwrap$(EXE) + $(BUILD)/fuzz_gn_unwrap$(EXE) $(FUZZ_ITER) $(FUZZ_SEED) $(RECORDINGS) + +clean: + rm -rf $(BUILD) diff --git a/obu-firmware/test/host/README.md b/obu-firmware/test/host/README.md new file mode 100644 index 0000000..9a222b9 --- /dev/null +++ b/obu-firmware/test/host/README.md @@ -0,0 +1,151 @@ +# Host-side tests for obu-firmware + +**Status (2026-09-11):** the chain test, the capture replay and the fuzzer are written and pass; +results under "Running". Toolchain: MSYS2 UCRT64 gcc, Option B below (chosen and +installed 2026-09-11). + +`geonet.c`, `dot11p.c` and `gn_unwrap.c` include nothing but standard C headers, so they compile +unmodified on a PC. That makes three things possible without a board: a TX -> RX round trip +through our own code, replaying real captures through the RX parser, and fuzzing the parser that +reads untrusted radio bytes. ESP-IDF only builds `main/` (and `components/`), so nothing under +`test/` ever affects the firmware image. + +## Layout + +``` +obu-firmware/ +├── main/ firmware sources; the tests compile these directly, never copies +└── test/ + ├── pcap_gn_tally.py GN header fields per station over .pcap captures (Python only) + └── host/ + ├── README.md this file + ├── Makefile `make`: builds ../../main/{geonet,dot11p,gn_unwrap}.c + tests, runs them + ├── test_chain.c geonet_wrap_shb -> dot11p_build_frame -> gn_unwrap_its, byte-checked + ├── test_util.c/.h shared: guard page, crash report, pcap read/write + ├── test_replay.c every recorded frame through gn_unwrap_its, results to a TSV + ├── check_replay.py re-derives each result independently, then asn1tools on the messages + ├── fuzz_gn_unwrap.c mutation fuzzer; each input ends against a no-access guard page, so + │ an over-read faults (no ASan on MinGW) + └── build/ compiler output; already ignored by the repo's `build/` rule +``` + +The repo's `vanetza/` folder is a gitignored reading copy only; it is not built. `check_replay.py` +reads the IEEE 1609.2 ASN.1 modules from it (they carry no licence header, so they are not copied +into `asn1/`). Without it, signed frames are still replayed but not checked independently. + +## Running + +From PowerShell, with MSYS2 on PATH for the session (Option B step 3): + +```powershell +$env:PATH = "C:\msys64\ucrt64\bin;C:\msys64\usr\bin;$env:PATH" +cd C:\Users\Ashin\AndroidStudioProjects\MicrOBU\obu-firmware\test\host +make +``` + +`make` runs three things (`make chain`, `make replay`, `make fuzz` run one). A non-zero exit, or a +`CRASH ... during: ` line (from the fuzzer, followed by the input as hex), is a failure. + +- **chain** (`test_chain.c`): frames built by the firmware's own TX code, checked byte by byte + against EN 302 636-4-1 and parsed back. Covers the CAM layout (non-QoS and QoS), Source Position + Vector edges, output-buffer bounds, a 512-byte payload through `main.c`'s buffer sizes, + hand-built GeoBroadcast frames in all three shapes, signed frames in all three COER length + forms plus a top-level unsecuredData, one-byte mutations that must be rejected or accepted, the + Common Header's payload length as the message boundary, the 8-byte RX trailer, and every + truncation length of each frame against the guard page. `pcap_gn_tally.py` then reads the + frames back as a second parser; `build/test_chain.pcap` opens in Wireshark too. + 2026-09-11: 1731 checks, 0 failed. +- **replay** (`test_replay.c` + `check_replay.py`): every record in + `its-g5-receiver-firmware/recordings/*.pcap` through `gn_unwrap_its`, each cut to `main.c`'s + 800-byte capture buffer as on the board. `check_replay.py` re-derives each result on its own + (its own GN/BTP parse; the security envelope decoded by asn1tools from the IEEE 1609.2 modules), + compares record by record, then decodes and re-encodes every distinct message with asn1tools - + a byte-identical re-encode is only possible when the message was cut at exactly the right byte. + Needs `py -3.11` with asn1tools. 2026-09-11: 15 145 records, 15 131 accepted (10 831 CAM, + 4 300 DENM; 157 of them signed); C and Python agree on every record; 11 043 of the 11 106 + distinct messages re-encode byte-identically. The other 63 fail the same way with the old 8 + trailing bytes put back, so the boundary is not the cause: 5 are our own CAMs from before the + 2026-08-20 yawRateConfidence fix, 56 come from the CiT One and 1 from another station (see + `TODO.md`), and 1 uses an extension asn1tools cannot re-encode. +- **fuzz** (`fuzz_gn_unwrap.c`): random edits of every recorded frame, each run against the guard + page; an over-read crashes, an accepted payload outside its input fails. Default 2 000 000 + iterations (about 2 s); `make fuzz FUZZ_ITER=50000000 FUZZ_SEED=7` for a longer run. + 2026-09-11: 50 000 000 iterations, no crash. + +Not covered: `main.c` (serial prefix parsing, queues) and `serial_link.c`, which need ESP-IDF; +and the phone's encoder, whose bytes are opaque here (asn1tools and the app's golden test cover +it). + +## Option A (not used): WSL2 + Ubuntu 24.04 + +Kept as the fallback if AddressSanitizer or libFuzzer are ever needed; Option B has neither. + +1. In **PowerShell as Administrator**: + + ```powershell + wsl --install -d Ubuntu-24.04 + ``` + + Reboot if it asks. Ubuntu then opens and asks for a Linux username and password (separate from + the Windows account). Checked 2026-09-11: Hyper-V is already running on this PC, so no BIOS + change should be needed. If the install says virtualization is disabled, enable Intel VT-x / + AMD SVM in the BIOS. + +2. Confirm it is WSL **2**: `wsl -l -v` should list `Ubuntu-24.04` with VERSION `2`. + +3. Inside Ubuntu, the compilers: + + ```bash + sudo apt update + sudo apt install -y build-essential clang cmake ninja-build git pkg-config python3 + ``` + +4. Smoke test: the firmware sources compile on the host (expect no output): + + ```bash + cd /mnt/c/Users/Ashin/AndroidStudioProjects/MicrOBU/obu-firmware/test/host + cc -std=c11 -Wall -Wextra -fsyntax-only ../../main/geonet.c ../../main/dot11p.c ../../main/gn_unwrap.c + ``` + +## Option B (chosen): native Windows gcc via MSYS2 + +Enough for the round-trip test, the capture replay and the guard-page fuzzer. No libFuzzer and no +AddressSanitizer with MinGW gcc, which is why the fuzzer uses a guard page instead. + +1. In PowerShell: `winget install -e --id MSYS2.MSYS2` (installs to `C:\msys64`). +2. Open **MSYS2 UCRT64** from the Start menu and run `pacman -Syu`. If the window closes, reopen + it and run `pacman -Syu` again. Then: + + ```bash + pacman -S --needed mingw-w64-ucrt-x86_64-gcc make + ``` + +3. **`C:\msys64\ucrt64\bin` must be on PATH.** Calling `C:\msys64\ucrt64\bin\gcc.exe` by its full + path alone exits 1 with no message, because gcc's compiler stages load their DLLs from that + folder. `make` lives on the MSYS side, in `C:\msys64\usr\bin`. Either work inside the + **MSYS2 UCRT64** shell, which has both, or put them on PATH for the current session: + + ```powershell + $env:PATH = "C:\msys64\ucrt64\bin;C:\msys64\usr\bin;$env:PATH" # PowerShell + ``` + + ```bash + export PATH=/c/msys64/ucrt64/bin:/c/msys64/usr/bin:$PATH # Git Bash + ``` + + Adding them to the user PATH permanently also works; it was deliberately not done by setup. +4. Smoke test (expect no output): + + ```powershell + cd C:\Users\Ashin\AndroidStudioProjects\MicrOBU\obu-firmware\test\host + gcc -std=c11 -Wall -Wextra -fsyntax-only ../../main/geonet.c ../../main/dot11p.c ../../main/gn_unwrap.c + ``` + +Installed on this PC 2026-09-11: MSYS2 20260611, gcc 16.2.0 (UCRT64), GNU Make 4.4.1. All three +firmware sources compile with `-std=c11 -O2 -Wall -Wextra -Wpedantic` and no warnings. + +## Line endings + +The repo runs with `core.autocrlf=true`, so Windows checkouts have CRLF line endings. C compilers +don't care; shell scripts run from WSL do (`bash: $'\r': command not found`). When the first `.sh` +file lands here, add `*.sh text eol=lf` to a root `.gitattributes` (none exists yet). diff --git a/obu-firmware/test/host/check_replay.py b/obu-firmware/test/host/check_replay.py new file mode 100644 index 0000000..471ca8c --- /dev/null +++ b/obu-firmware/test/host/check_replay.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 +"""Independent check of test_replay's output. See README.md. + +For every recorded frame this works out on its own what gn_unwrap_its should have produced - +GeoNetworking and BTP parsed here from EN 302 636-4-1, the TS 103 097 security envelope decoded by +asn1tools from the IEEE 1609.2 ASN.1 modules rather than by hand - and compares that with what the +C code did, record by record. Then it decodes every distinct extracted message with asn1tools and +re-encodes it. Only a message cut at exactly the right byte re-encodes to the same bytes, so this +is what proves that no trailer or signature bytes came along with it. + + py -3.11 check_replay.py replay.tsv capture.pcap [capture.pcap ...] + +Needs asn1tools (installed for Python 3.11 on this PC). Message modules come from the repo's +asn1/; the IEEE 1609.2 ones from asn1/ or, failing that, the gitignored vanetza/ checkout. +Exit status 1 if the C code and this disagree about any record. +""" +import collections +import pathlib +import struct +import sys + +import asn1tools + +ROOT = pathlib.Path(__file__).resolve().parents[3] +PORT_NAMES = {2001: "CAM", 2002: "DENM", 2004: "SPATEM"} +# main.c's RX_FRAME_MAX_LEN: the most of any frame the board hands to gn_unwrap_its. test_replay +# cuts frames to it, so this does too. +RX_FRAME_MAX_LEN = 800 + + +def compile_specs(): + a = ROOT / "asn1" + uper = { + 2001: asn1tools.compile_files([str(a / "cam_1_4_1.asn"), str(a / "cdd_1_3_1_1.asn")], "uper"), + 2002: asn1tools.compile_files([str(a / "denm_1_3_1.asn"), str(a / "cdd_1_3_1_1.asn")], "uper"), + 2004: asn1tools.compile_files( + [str(a / n) for n in ("spatem_2_2_1.asn", "mapem_2_2_1.asn", "dsrc_2_2_1.asn", "cdd_2_2_1.asn")], + "uper"), + } + names = ("IEEE1609dot2BaseTypes.asn", "IEEE1609dot2.asn") + for d in (a, ROOT / "vanetza" / "asn1"): + if all((d / n).exists() for n in names): + return uper, asn1tools.compile_files([str(d / n) for n in names], "oer"), d + return uper, None, None + + +def frames(path): + """(record index, 802.11 frame) for every record, numbered the way test_util.c numbers them.""" + d = pathlib.Path(path).read_bytes() + if len(d) < 24: + return + magic = struct.unpack("" + link = struct.unpack(e + "I", d[20:24])[0] + if link not in (105, 127): + return + off, index = 24, 0 + while off + 16 <= len(d): + incl = struct.unpack(e + "I", d[off + 8:off + 12])[0] + if incl > len(d) - off - 16: + break + pkt = d[off + 16:off + 16 + incl] + off += 16 + incl + if link == 127: + rl = pkt[2] | pkt[3] << 8 if len(pkt) >= 4 else len(pkt) + 1 + if rl > len(pkt): + index += 1 + continue + pkt = pkt[rl:] + yield index, pkt + index += 1 + + +def open_envelope(sec, env): + """(inner GeoNetworking packet, signed) of a TS 103 097 envelope per asn1tools, or None.""" + try: + m = sec.decode("Ieee1609Dot2Data", env) + except Exception: + return None + if m["protocolVersion"] != 3: + return None + kind, content = m["content"] + if kind == "unsecuredData": + return content, False + if kind == "signedData": + data = content["tbsData"]["payload"].get("data") + if data and data["protocolVersion"] == 3 and data["content"][0] == "unsecuredData": + return data["content"][1], True + return None + + +def u16(b): + return int.from_bytes(b, "big") + + +def s32(b): + return int.from_bytes(b, "big", signed=True) + + +def expect(f, sec): + """What gn_unwrap_its should report for frame f: None, or a dict matching test_replay's row. + Second value: how many bytes after the message the pre-2026-09-11 code would have forwarded.""" + if len(f) < 24 or (f[0] >> 2) & 3 != 2 or f[1] & 3 == 3: + return None, None + o = 24 + (2 if f[0] & 0x80 else 0) + if f[o:o + 8] != b"\xaa\xaa\x03\x00\x00\x00\x89\x47" or len(f) < o + 12: + return None, None + nh = f[o + 8] & 0x0F + o += 12 + if nh == 2: + if sec is None: + return "unchecked", None + opened = open_envelope(sec, f[o:]) + if opened is None: + return None, None + region, signed = opened + elif nh == 1: + region, signed = f[o:], False + else: + return None, None + + if len(region) < 8 or region[0] >> 4 != 2: + return None, None + ht, hst, pl = region[1] >> 4, region[1] & 0x0F, u16(region[4:6]) + if ht == 5 and hst == 0: + ext, area = 28, None + elif ht == 4: + ext = 44 + else: + return None, None + if len(region) < 8 + ext + 4: + return None, None + if ht == 4: + a = 8 + 28 + area = (s32(region[a:a + 4]), s32(region[a + 4:a + 8]), u16(region[a + 8:a + 10])) + port = u16(region[8 + ext:8 + ext + 2]) + if port not in PORT_NAMES or pl <= 4: + return None, None + start, end = 8 + ext + 4, 8 + ext + pl + if signed is not None and nh == 2 and end > len(region): + return None, None # the inner packet claims more than its envelope holds + payload = region[start:min(end, len(region))] + if not payload: + return None, None + old_extra = len(region) - end if nh == 1 else None + return dict(port=port, signed=signed, truncated=end > len(region), area=area, payload=payload), old_extra + + +def read_tsv(path): + rows = {} + with open(path, encoding="ascii") as fh: + next(fh) + for line in fh: + c = line.rstrip("\n").split("\t") + key = (c[0], int(c[1])) + if c[2] == "0": + rows[key] = None + else: + rows[key] = dict(port=int(c[3]), signed=c[4] == "1", truncated=c[5] == "1", + area=(int(c[7]), int(c[8]), int(c[9])) if c[6] == "1" else None, + payload=bytes.fromhex(c[10])) + return rows + + +def describe(r): + if r is None: + return "rejected" + return "port %d signed %s truncated %s area %s %d bytes" % ( + r["port"], r["signed"], r["truncated"], r["area"], len(r["payload"])) + + +def main(argv): + if len(argv) < 2: + sys.exit(__doc__) + got = read_tsv(argv[0]) + uper, sec, sec_dir = compile_specs() + if sec is None: + print("WARNING: IEEE 1609.2 modules not found; secured frames are not checked independently") + + stats, extra, disagreements, messages = collections.Counter(), collections.Counter(), [], {} + for path in argv[1:]: + for index, f in frames(path): + key = (path, index) + stats["records"] += 1 + stats["capped"] += len(f) > RX_FRAME_MAX_LEN + want, old_extra = expect(f[:RX_FRAME_MAX_LEN], sec) + if key not in got: + disagreements.append((key, "no row from test_replay")) + continue + have = got.pop(key) + if want == "unchecked": + stats["secured, unchecked"] += 1 + continue + if want != have: + disagreements.append((key, "C: %s | independent: %s" % (describe(have), describe(want)))) + continue + if want: + stats["accepted"] += 1 + stats[PORT_NAMES[want["port"]]] += 1 + stats["signed"] += want["signed"] + stats["truncated"] += want["truncated"] + if old_extra is not None: + extra[old_extra] += 1 + messages.setdefault((want["port"], want["payload"]), key) + for key in got: + disagreements.append((key, "row from test_replay for a record this did not see")) + + print("check_replay: %d records (%d cut to %d bytes), %d accepted (CAM %d, DENM %d, SPATEM %d; " + "%d signed, %d truncated)" + % (stats["records"], stats["capped"], RX_FRAME_MAX_LEN, stats["accepted"], stats["CAM"], + stats["DENM"], stats["SPATEM"], stats["signed"], stats["truncated"])) + if sec_dir: + print(" envelopes decoded with asn1tools using %s" % sec_dir.relative_to(ROOT)) + print(" C vs independent parse: %s" % ("agree on every record" if not disagreements + else "%d DISAGREEMENTS" % len(disagreements))) + for key, why in disagreements[:15]: + print(" %s record %d: %s" % (pathlib.Path(key[0]).name, key[1], why)) + + outcome, failures = collections.Counter(), [] + for (port, payload), key in messages.items(): + name, spec = PORT_NAMES[port], uper[port] + try: + decoded = spec.decode(name, payload) + except Exception as e: + outcome[(name, "does not decode")] += 1 + failures.append((key, name, len(payload), str(e)[:110])) + continue + try: + encoded = spec.encode(name, decoded) + except Exception as e: + # asn1tools decodes an alternative from a later module version (a CHOICE extension) + # as (None, None) and then cannot encode it back. Says nothing about where the message + # was cut, so it is reported apart from real mismatches. + outcome[(name, "decodes; uses a newer extension")] += 1 + failures.append((key, name, len(payload), "cannot re-encode: " + str(e)[:90])) + continue + if encoded == payload: + outcome[(name, "re-encodes byte-identically")] += 1 + else: + outcome[(name, "re-encodes differently")] += 1 + failures.append((key, name, len(payload), "re-encoded to different bytes")) + print(" asn1tools on the %d distinct extracted messages:" % len(messages)) + for (name, what), n in sorted(outcome.items()): + print(" %-6s %-28s %d" % (name, what, n)) + for key, name, n, why in failures[:15]: + print(" %s record %d, %s %d bytes: %s" % (pathlib.Path(key[0]).name, key[1], name, n, why)) + print(" bytes the pre-2026-09-11 code forwarded after each unsecured message: %s" + % dict(sorted(extra.items()))) + return 1 if disagreements else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/obu-firmware/test/host/fuzz_gn_unwrap.c b/obu-firmware/test/host/fuzz_gn_unwrap.c new file mode 100644 index 0000000..780e388 --- /dev/null +++ b/obu-firmware/test/host/fuzz_gn_unwrap.c @@ -0,0 +1,218 @@ +// Mutation fuzzer for gn_unwrap_its, the one function in this firmware that parses bytes from the +// air. See README.md. +// +// Seeds are every recorded frame in the pcaps given, plus frames built by the firmware's own TX +// code. Each iteration takes a seed, applies 1-4 random edits - bit flips, random bytes, boundary +// bytes such as COER length markers, 16-bit length fields, truncation, insertion, deletion, +// appended bytes - mostly within the first 128 bytes where the headers are, and runs gn_unwrap_its +// on the result placed against the guard page. A read past the end crashes and prints the input; +// an accepted frame whose payload is not inside the input is reported the same way. MinGW has +// neither libFuzzer nor AddressSanitizer, hence this rather than coverage guidance. The same seed +// gives the same run. +// +// Usage: fuzz_gn_unwrap iterations seed [capture.pcap ...] + +#include +#include +#include +#include +#include + +#include "dot11p.h" +#include "geonet.h" +#include "gn_unwrap.h" +#include "test_util.h" + +#define MAX_FRAME 2048 // within the guard page's one page, and above any 802.11 frame + +static uint8_t **s_seeds; +static int *s_seed_len; +static int s_nseeds; +static int s_seed_cap; + +static void add_seed(const uint8_t *f, int len) +{ + if (len <= 0 || len > MAX_FRAME) { + return; + } + if (s_nseeds == s_seed_cap) { + s_seed_cap = s_seed_cap ? 2 * s_seed_cap : 1024; + s_seeds = realloc(s_seeds, (size_t)s_seed_cap * sizeof *s_seeds); + s_seed_len = realloc(s_seed_len, (size_t)s_seed_cap * sizeof *s_seed_len); + if (!s_seeds || !s_seed_len) { + fprintf(stderr, "out of memory\n"); + exit(2); + } + } + s_seeds[s_nseeds] = malloc((size_t)len); + if (!s_seeds[s_nseeds]) { + fprintf(stderr, "out of memory\n"); + exit(2); + } + memcpy(s_seeds[s_nseeds], f, (size_t)len); + s_seed_len[s_nseeds++] = len; +} + +static void on_frame(const uint8_t *f, int len, int index, void *ctx) +{ + (void)index; + (void)ctx; + add_seed(f, len); +} + +static void add_own_seeds(void) +{ + static const uint8_t cam[] = {0x02, 0x02, 0x00, 0x0f, 0x42, 0x3f, 0x37, 0x00, 0x40, 0x2a, 0xb2}; + gn_lpv_t lpv; + memset(&lpv, 0, sizeof lpv); + lpv.mac[0] = 0x02; + lpv.station_type = 2; + uint8_t gn[256]; + uint8_t f[512]; + const int gn_len = geonet_wrap_shb(cam, (int)sizeof cam, &lpv, 2001, gn, sizeof gn); + for (int qos = 0; qos <= 1; qos++) { + add_seed(f, dot11p_build_frame(gn, gn_len, lpv.mac, f, sizeof f, qos)); + } +} + +static uint64_t s_rng; + +static uint64_t rnd(void) +{ + s_rng ^= s_rng << 13; + s_rng ^= s_rng >> 7; + s_rng ^= s_rng << 17; + return s_rng; +} + +static int below(int n) +{ + return n <= 0 ? 0 : (int)(rnd() % (uint64_t)n); +} + +// Three times in four inside the headers, otherwise anywhere. +static int pick_pos(int len) +{ + return below(4) ? below(len < 128 ? len : 128) : below(len); +} + +static const uint8_t k_bytes[] = {0x00, 0x01, 0x02, 0x03, 0x05, 0x10, 0x12, 0x20, + 0x40, 0x50, 0x7F, 0x80, 0x81, 0x82, 0x83, 0xFF}; +static const uint16_t k_words[] = {0x0000, 0x0001, 0x0003, 0x0004, 0x0005, 0x007F, + 0x0080, 0x00FF, 0x0100, 0x7FFF, 0x8000, 0xFFFF}; + +static void mutate(uint8_t *b, int *len) +{ + const int edits = 1 + below(4); + for (int e = 0; e < edits; e++) { + const int n = *len; + switch (below(8)) { + case 0: // flip a bit + if (n) { + b[pick_pos(n)] ^= (uint8_t)(1u << below(8)); + } + break; + case 1: // random byte + if (n) { + b[pick_pos(n)] = (uint8_t)rnd(); + } + break; + case 2: // boundary byte + if (n) { + b[pick_pos(n)] = k_bytes[below((int)sizeof k_bytes)]; + } + break; + case 3: // truncate + *len = below(n + 1); + break; + case 4: { // append + const int add = 1 + below(16); + if (n + add <= MAX_FRAME) { + for (int i = 0; i < add; i++) { + b[n + i] = (uint8_t)rnd(); + } + *len = n + add; + } + break; + } + case 5: // insert a byte + if (n < MAX_FRAME) { + const int p = below(n + 1); + memmove(b + p + 1, b + p, (size_t)(n - p)); + b[p] = (uint8_t)rnd(); + *len = n + 1; + } + break; + case 6: // delete a byte + if (n) { + const int p = below(n); + memmove(b + p, b + p + 1, (size_t)(n - p - 1)); + *len = n - 1; + } + break; + default: // boundary 16-bit big-endian value, e.g. a length field + if (n >= 2) { + const int p = pick_pos(n - 1); + const uint16_t v = k_words[below((int)(sizeof k_words / sizeof k_words[0]))]; + b[p] = (uint8_t)(v >> 8); + b[p + 1] = (uint8_t)v; + } + break; + } + } +} + +int main(int argc, char **argv) +{ + if (argc < 3) { + fprintf(stderr, "usage: fuzz_gn_unwrap iterations seed [capture.pcap ...]\n"); + return 2; + } + const unsigned long long iterations = strtoull(argv[1], NULL, 10); + s_rng = (strtoull(argv[2], NULL, 10) * 0x9E3779B97F4A7C15ull) | 1; + + tu_install_crash_handler(); + tu_guard_init(); + for (int i = 3; i < argc; i++) { + if (tu_pcap_foreach(argv[i], on_frame, NULL) < 0) { + fprintf(stderr, "cannot read %s as a pcap\n", argv[i]); + return 2; + } + } + add_own_seeds(); + + static uint8_t buf[MAX_FRAME]; + int len = 0; + tu_set_crash_input(buf, &len); + unsigned long long accepted = 0, signed_frames = 0, truncated = 0; + for (unsigned long long it = 0; it < iterations; it++) { + const int s = below(s_nseeds); + len = s_seed_len[s]; + memcpy(buf, s_seeds[s], (size_t)len); + mutate(buf, &len); + tu_set_context("fuzz iteration %llu (seed %s), mutated from seed frame %d", it, argv[2], s); + + const uint8_t *g = tu_guarded(buf, len); + gn_rx_t rx; + if (gn_unwrap_its(g, len, &rx)) { + accepted++; + signed_frames += rx.signed_unverified; + truncated += rx.truncated; + const uintptr_t lo = (uintptr_t)g; + const uintptr_t p = (uintptr_t)rx.payload; + if (p < lo || rx.payload_len <= 0 || p + (uintptr_t)rx.payload_len > lo + (uintptr_t)len) { + fprintf(stderr, "FAIL during %s: accepted payload lies outside the input\ninput (%d bytes): ", + tu_context(), len); + for (int i = 0; i < len; i++) { + fprintf(stderr, "%02x", buf[i]); + } + fputc('\n', stderr); + return 1; + } + } + } + printf("fuzz_gn_unwrap: %llu iterations from %d seed frames, %llu accepted (%llu signed, " + "%llu truncated), no crash\n", + iterations, s_nseeds, accepted, signed_frames, truncated); + return 0; +} diff --git a/obu-firmware/test/host/test_chain.c b/obu-firmware/test/host/test_chain.c new file mode 100644 index 0000000..61cdc1a --- /dev/null +++ b/obu-firmware/test/host/test_chain.c @@ -0,0 +1,730 @@ +// Host-side chain test for obu-firmware. See README.md in this folder. +// +// Builds frames with the firmware's own TX code (geonet_wrap_shb -> dot11p_build_frame) and parses +// them back with its own RX code (gn_unwrap_its), all compiled from ../../main unmodified. +// +// Two kinds of check, on purpose. The round trip proves TX and RX agree with each other. The byte +// checks at fixed offsets prove they agree with EN 302 636-4-1, and only those catch a mistake made +// the same way on both sides: the missing 4-byte SHB field (fixed 2026-08-13) round-tripped fine +// between two ESP32s and was wrong against every other station. So expected values here come from +// the standard, vanetza's serializers and real captures - never from reading geonet.c. +// +// Every frame handed to gn_unwrap_its is first copied so that its last byte sits right before a +// no-access page (test_util.c): reading even one byte past a frame crashes the test instead of +// passing quietly. MinGW has no AddressSanitizer; this covers what matters for a radio parser. +// +// Usage: test_chain [out.pcap] +// With a path, also writes every test frame to a pcap (linktype 105, bare 802.11) so a second, +// independent parser can read them: ../pcap_gn_tally.py, or Wireshark. The GeoBroadcast and +// signed frames carry stub payloads/signatures; their headers are real. + +#include +#include +#include +#include +#include +#include + +#include "dot11p.h" +#include "geonet.h" +#include "gn_unwrap.h" +#include "serial_link.h" // SERIAL_LINK_MAX_PAYLOAD only; serial_link.c itself needs ESP-IDF +#include "test_util.h" + +// Same buffer sizes as tx_radio_task in main.c. Keep them in step with it. +#define GN_BUF_LEN (SERIAL_LINK_MAX_PAYLOAD + 64) +#define FRAME_BUF_LEN (SERIAL_LINK_MAX_PAYLOAD + 192) + +// ---- Checks --------------------------------------------------------------------------------- + +static int s_checks; +static int s_failures; + +static void check(bool ok, int line, const char *fmt, ...) +{ + s_checks++; + if (ok) { + return; + } + s_failures++; + va_list ap; + va_start(ap, fmt); + fprintf(stderr, "FAIL line %d [%s]: ", line, tu_context()); + vfprintf(stderr, fmt, ap); + fputc('\n', stderr); + va_end(ap); +} +#define CHECK(cond, ...) check((cond), __LINE__, __VA_ARGS__) + +// ---- Test data ------------------------------------------------------------------------------ + +static uint16_t rd16(const uint8_t *p) +{ + return (uint16_t)((p[0] << 8) | p[1]); +} + +static uint32_t rd32(const uint8_t *p) +{ + return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) | ((uint32_t)p[2] << 8) | p[3]; +} + +// The app's reference CAM: the expected bytes of CamEncodeGoldenTest.kt (stationID 999999), which +// asn1tools decodes and re-encodes byte-identically. The chain treats it as opaque bytes. Not taken +// from the old captures on purpose: our own CAMs there predate the 2026-08-20 yawRateConfidence fix +// and do not decode. +static const uint8_t k_cam[] = { + 0x02, 0x02, 0x00, 0x0f, 0x42, 0x3f, 0x37, 0x00, 0x40, 0x2a, 0xb2, 0x15, 0xaf, 0x6e, 0x28, 0x64, + 0x77, 0xdf, 0xff, 0xff, 0xfc, 0x23, 0xb7, 0x74, 0x3e, 0x00, 0x27, 0xff, 0xc0, 0xd0, 0xfe, 0x01, + 0x18, 0x32, 0x93, 0x37, 0xfe, 0xeb, 0xff, 0xf6, 0x00, 0x00, 0x00, +}; +#define CAM_LEN ((int)sizeof k_cam) + +static const uint8_t k_mac[6] = {0x02, 0x11, 0x22, 0x33, 0x44, 0x55}; +static const uint8_t k_bcast[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; +static const uint8_t k_llc_snap_gn[8] = {0xAA, 0xAA, 0x03, 0x00, 0x00, 0x00, 0x89, 0x47}; + +// The 8 bytes that follow the 802.11 frame in every frame recorded through the ESP32-C5's +// promiscuous RX (these from capture_20260817_171055.pcap). See gn_unwrap.h, "Payload bounds". +static const uint8_t k_rx_trailer[8] = {0xc8, 0x01, 0x00, 0x00, 0x88, 0x00, 0x00, 0x00}; + +// Header lengths from EN 302 636-4-1 / IEEE 802.11, used to compute every offset below. +enum { + MAC_HDR = 24, QOS_CTRL = 2, LLC_SNAP = 8, GN_BASIC = 4, GN_COMMON = 8, + SHB_EXT = 28, // Source Position Vector (24) + DCC-MCO / reserved (4) + GBC_EXT = 44, // SN (2) + reserved (2) + SO PV (24) + area (12) + reserved (4) + BTP_B = 4, + GN_COMMON_PAYLOAD_LEN_FIELD = 4, // offset of the payload-length field in the Common Header +}; +// Where the ITS payload starts in a non-QoS SHB frame. +#define SHB_PAYLOAD_OFFSET (MAC_HDR + LLC_SNAP + GN_BASIC + GN_COMMON + SHB_EXT + BTP_B) + +static gn_lpv_t test_lpv(void) +{ + gn_lpv_t lpv; + memset(&lpv, 0, sizeof lpv); + memcpy(lpv.mac, k_mac, sizeof lpv.mac); + lpv.station_type = 2; // cyclist + lpv.pai = true; + lpv.tst_ms = 0x89ABCDEFu; + lpv.lat_tenmicrodeg = 535546667; // the bench, 53.5546667 N + lpv.lon_tenmicrodeg = -10022389; // negative on purpose: the sign has to survive + lpv.speed_cms = 1234; + lpv.heading_decideg = 2700; + return lpv; +} + +// The TX path exactly as tx_radio_task runs it. Returns the frame length, or <= 0 on failure. +static int build(const uint8_t *payload, int len, const gn_lpv_t *lpv, uint16_t port, bool qos, + uint8_t *frame, size_t frame_size) +{ + static uint8_t gn[GN_BUF_LEN]; + int gn_len = geonet_wrap_shb(payload, len, lpv, port, gn, sizeof gn); + if (gn_len <= 0) { + return gn_len; + } + return dot11p_build_frame(gn, gn_len, lpv->mac, frame, frame_size, qos); +} + +// Every prefix of a frame, each ending against the guard page. Up to the end of the BTP-B header +// nothing may be accepted; a frame cut inside the payload is accepted as truncated with what +// arrived; from `payload_end` on, the payload is exactly the declared one whatever follows it. +static void sweep(const char *name, const uint8_t *f, int len, int payload_start, int payload_end) +{ + for (int n = 0; n <= len; n++) { + tu_set_context("truncation sweep, %s, %d of %d bytes", name, n, len); + const uint8_t *g = tu_guarded(f, n); + gn_rx_t rx; + const bool ok = gn_unwrap_its(g, n, &rx); + if (n <= payload_start) { + CHECK(!ok, "%s cut to %d bytes was accepted", name, n); + } else if (n < payload_end) { + CHECK(ok && rx.truncated && rx.payload == g + payload_start && + rx.payload_len == n - payload_start, + "%s cut to %d bytes: ok=%d truncated=%d payload_len=%d", name, n, ok, + ok && rx.truncated, ok ? rx.payload_len : -1); + } else { + CHECK(ok && !rx.truncated && rx.payload == g + payload_start && + rx.payload_len == payload_end - payload_start, + "%s at %d bytes: ok=%d truncated=%d payload_len=%d", name, n, ok, + ok && rx.truncated, ok ? rx.payload_len : -1); + } + } +} + +// ---- SHB: layout, position vector, bounds --------------------------------------------------- + +// Every byte of a CAM frame against the standard, then back through the RX path. +static void test_shb_layout(bool qos) +{ + tu_set_context("SHB layout, qos=%d", qos); + const gn_lpv_t lpv = test_lpv(); + uint8_t f[FRAME_BUF_LEN]; + const int len = build(k_cam, CAM_LEN, &lpv, 2001, qos, f, sizeof f); + + const int llc = MAC_HDR + (qos ? QOS_CTRL : 0); + const int basic = llc + LLC_SNAP; + const int common = basic + GN_BASIC; + const int shb = common + GN_COMMON; + const int btp = shb + SHB_EXT; + const int pay = btp + BTP_B; + CHECK(len == pay + CAM_LEN, "frame length %d, expected %d", len, pay + CAM_LEN); + if (len != pay + CAM_LEN) { + return; + } + + // 802.11 MAC header + CHECK(f[0] == (qos ? 0x88 : 0x08) && f[1] == 0x00, "frame control %02x %02x", f[0], f[1]); + CHECK(memcmp(f + 4, k_bcast, 6) == 0, "addr1 must be broadcast"); + CHECK(memcmp(f + 10, k_mac, 6) == 0, "addr2 must be the pseudonym"); + CHECK(memcmp(f + 16, k_bcast, 6) == 0, "addr3 (BSSID) must be the OCB wildcard"); + if (qos) { + CHECK(f[24] == 0 && f[25] == 0, "QoS control %02x %02x", f[24], f[25]); + } + CHECK(memcmp(f + llc, k_llc_snap_gn, 8) == 0, "LLC/SNAP with EtherType 0x8947"); + + // GN Basic Header + CHECK(f[basic + 0] == 0x11, "basic: version 1 + NextHeader 1 (Common, unsecured), got %02x", + f[basic + 0]); + CHECK(f[basic + 1] == 0x00, "basic: reserved, got %02x", f[basic + 1]); + // Multiplier 1 in the upper 6 bits, base 1 (= 1 s) in the lower 2: 1 s, what every real station + // in the recordings sends its CAMs with. Was 0x83 = 3200 s until 2026-09-11. + CHECK(f[basic + 2] == 0x05, "basic: lifetime 0x05 (1 s), got 0x%02x", f[basic + 2]); + CHECK(f[basic + 3] == 1, "basic: remaining hop limit 1, got %d", f[basic + 3]); + + // GN Common Header + CHECK(f[common + 0] == 0x20, "common: NextHeader 2 (BTP-B), got %02x", f[common + 0]); + CHECK(f[common + 1] == 0x50, "common: HeaderType 5 (TSB) / subtype 0 (single hop), got %02x", + f[common + 1]); + CHECK(f[common + 2] == 0x02, "common: traffic class TC-ID 2 (DP2, CAM), got %02x", f[common + 2]); + CHECK(f[common + 3] == 0x80, "common: flags = mobile, got %02x", f[common + 3]); + CHECK(rd16(f + common + 4) == BTP_B + CAM_LEN, + "common: payload length must count BTP-B + payload only, got %d", rd16(f + common + 4)); + CHECK(f[common + 6] == 1, "common: maximum hop limit 1, got %d", f[common + 6]); + CHECK(f[common + 7] == 0, "common: reserved, got %02x", f[common + 7]); + + // SHB extended header: Source Position Vector, then 4 bytes DCC-MCO / reserved + // GN_ADDR: M flag bit 15, station type bits 14..10 (vanetza geonet/address.cpp), MID. + CHECK(rd16(f + shb) == (2u << 10), "GN_ADDR: M=0, station type 2, got %04x", rd16(f + shb)); + CHECK(memcmp(f + shb + 2, k_mac, 6) == 0, "GN_ADDR MID must equal the 802.11 source address"); + CHECK(rd32(f + shb + 8) == 0x89ABCDEFu, "TST, got %08lx", (unsigned long)rd32(f + shb + 8)); + CHECK((int32_t)rd32(f + shb + 12) == 535546667, "latitude, got %ld", + (long)(int32_t)rd32(f + shb + 12)); + CHECK((int32_t)rd32(f + shb + 16) == -10022389, "longitude, got %ld", + (long)(int32_t)rd32(f + shb + 16)); + CHECK(rd16(f + shb + 20) == (0x8000 | 1234), "PAI bit 15 + speed 1234, got %04x", + rd16(f + shb + 20)); + CHECK(rd16(f + shb + 22) == 2700, "heading, got %d", rd16(f + shb + 22)); + CHECK(rd32(f + shb + 24) == 0, "DCC-MCO / reserved: present and zero, got %08lx", + (unsigned long)rd32(f + shb + 24)); + + // BTP-B, payload + CHECK(rd16(f + btp) == 2001, "BTP-B destination port, got %d", rd16(f + btp)); + CHECK(rd16(f + btp + 2) == 0, "BTP-B destination port info, got %d", rd16(f + btp + 2)); + CHECK(memcmp(f + pay, k_cam, (size_t)CAM_LEN) == 0, "payload bytes"); + + // ...and back through the RX path + const uint8_t *g = tu_guarded(f, len); + gn_rx_t rx; + const bool ok = gn_unwrap_its(g, len, &rx); + CHECK(ok, "gn_unwrap_its rejected our own frame"); + if (ok) { + CHECK(rx.btp_dest_port == 2001, "RX port %d", rx.btp_dest_port); + CHECK(!rx.has_geo_area, "RX reports a geo area for an SHB frame"); + CHECK(!rx.signed_unverified, "RX reports an unsecured frame as signed"); + CHECK(!rx.truncated, "RX reports a whole frame as truncated"); + CHECK(rx.payload == g + pay, "RX payload offset %d, expected %d", (int)(rx.payload - g), pay); + CHECK(rx.payload_len == CAM_LEN, "RX payload length %d, expected %d", rx.payload_len, CAM_LEN); + CHECK(rx.payload_len == CAM_LEN && memcmp(rx.payload, k_cam, (size_t)CAM_LEN) == 0, + "RX payload bytes differ from what was sent"); + } + tu_pcap_add(f, len); +} + +// The Source Position Vector's packed fields at their edges. +static void test_pv_encoding(void) +{ + // Offsets inside the GeoNetworking packet (no 802.11 header): SO PV starts after Basic + Common. + const int pv = GN_BASIC + GN_COMMON; + uint8_t gn[GN_BUF_LEN]; + + // Speed is 15-bit signed (geonet.h), clamped rather than wrapped; PAI is bit 15. + static const struct { int16_t speed; bool pai; uint16_t expect; } speeds[] = { + {0, false, 0x0000}, {1234, true, 0x84D2}, {16383, false, 0x3FFF}, + {16384, false, 0x3FFF}, {32767, false, 0x3FFF}, {-100, false, 0x7F9C}, + {-16384, false, 0x4000}, {-32768, true, 0xC000}, + }; + for (size_t i = 0; i < sizeof speeds / sizeof speeds[0]; i++) { + tu_set_context("PV speed %d pai %d", speeds[i].speed, speeds[i].pai); + gn_lpv_t lpv = test_lpv(); + lpv.speed_cms = speeds[i].speed; + lpv.pai = speeds[i].pai; + const int n = geonet_wrap_shb(k_cam, CAM_LEN, &lpv, 2001, gn, sizeof gn); + CHECK(n > 0 && rd16(gn + pv + 20) == speeds[i].expect, "speed %d pai %d: got %04x, expected %04x", + speeds[i].speed, speeds[i].pai, rd16(gn + pv + 20), speeds[i].expect); + } + + // Heading is 0..3599 tenths of a degree, wrapped. + static const struct { uint16_t in, expect; } headings[] = { + {0, 0}, {2700, 2700}, {3599, 3599}, {3600, 0}, {3601, 1}, {65535, 735}, + }; + for (size_t i = 0; i < sizeof headings / sizeof headings[0]; i++) { + tu_set_context("PV heading %d", headings[i].in); + gn_lpv_t lpv = test_lpv(); + lpv.heading_decideg = headings[i].in; + const int n = geonet_wrap_shb(k_cam, CAM_LEN, &lpv, 2001, gn, sizeof gn); + CHECK(n > 0 && rd16(gn + pv + 22) == headings[i].expect, "heading %d: got %d, expected %d", + headings[i].in, rd16(gn + pv + 22), headings[i].expect); + } + + // Station type has 5 bits; anything larger must not spill into the M flag. + static const struct { uint8_t in; uint16_t expect; } types[] = { + {2, 0x0800}, {15, 0x3C00}, {0xFF, 0x7C00}, + }; + for (size_t i = 0; i < sizeof types / sizeof types[0]; i++) { + tu_set_context("PV station type %d", types[i].in); + gn_lpv_t lpv = test_lpv(); + lpv.station_type = types[i].in; + const int n = geonet_wrap_shb(k_cam, CAM_LEN, &lpv, 2001, gn, sizeof gn); + CHECK(n > 0 && rd16(gn + pv) == types[i].expect, "station type %d: GN_ADDR %04x, expected %04x", + types[i].in, rd16(gn + pv), types[i].expect); + } +} + +// Output buffers: an exact fit works and writes nothing past its end; one byte less is refused. +static void test_bounds(void) +{ + const gn_lpv_t lpv = test_lpv(); + const int gn_len = GN_BASIC + GN_COMMON + SHB_EXT + BTP_B + CAM_LEN; + + tu_set_context("geonet_wrap_shb bounds"); + CHECK(geonet_wrap_shb(k_cam, CAM_LEN, &lpv, 2001, tu_guard_buf(gn_len), (size_t)gn_len) == gn_len, + "geonet_wrap_shb: exact-size buffer must fit"); + CHECK(geonet_wrap_shb(k_cam, CAM_LEN, &lpv, 2001, tu_guard_buf(gn_len - 1), (size_t)gn_len - 1) == -1, + "geonet_wrap_shb: one byte short must be refused"); + + uint8_t gn[GN_BUF_LEN]; + geonet_wrap_shb(k_cam, CAM_LEN, &lpv, 2001, gn, sizeof gn); + for (int qos = 0; qos <= 1; qos++) { + tu_set_context("dot11p_build_frame bounds, qos=%d", qos); + const int frame_len = MAC_HDR + (qos ? QOS_CTRL : 0) + LLC_SNAP + gn_len; + CHECK(dot11p_build_frame(gn, gn_len, k_mac, tu_guard_buf(frame_len), (size_t)frame_len, qos) == + frame_len, + "dot11p_build_frame qos=%d: exact-size buffer must fit", qos); + CHECK(dot11p_build_frame(gn, gn_len, k_mac, tu_guard_buf(frame_len - 1), (size_t)frame_len - 1, + qos) == -1, + "dot11p_build_frame qos=%d: one byte short must be refused", qos); + } +} + +// The largest CAM the serial link can carry must survive the whole chain in main.c's buffers. +static void test_max_payload(void) +{ + static uint8_t big[SERIAL_LINK_MAX_PAYLOAD]; + for (int i = 0; i < SERIAL_LINK_MAX_PAYLOAD; i++) { + big[i] = (uint8_t)(i * 7 + 3); + } + for (int qos = 0; qos <= 1; qos++) { + tu_set_context("max payload %d, qos=%d", SERIAL_LINK_MAX_PAYLOAD, qos); + const gn_lpv_t lpv = test_lpv(); + uint8_t f[FRAME_BUF_LEN]; + const int hdr = MAC_HDR + (qos ? QOS_CTRL : 0) + LLC_SNAP + GN_BASIC + GN_COMMON + SHB_EXT + BTP_B; + const int len = build(big, SERIAL_LINK_MAX_PAYLOAD, &lpv, 2001, qos, f, sizeof f); + CHECK(len == hdr + SERIAL_LINK_MAX_PAYLOAD, "qos=%d: frame length %d, expected %d", qos, len, + hdr + SERIAL_LINK_MAX_PAYLOAD); + if (len <= 0) { + continue; + } + gn_rx_t rx; + const uint8_t *g = tu_guarded(f, len); + const bool ok = gn_unwrap_its(g, len, &rx); + CHECK(ok && !rx.truncated && rx.payload_len == SERIAL_LINK_MAX_PAYLOAD && + memcmp(rx.payload, big, SERIAL_LINK_MAX_PAYLOAD) == 0, + "qos=%d: max-size payload did not round-trip", qos); + tu_pcap_add(f, len); + } +} + +// ---- GeoBroadcast --------------------------------------------------------------------------- + +// A GeoBroadcast DENM frame laid out by hand from EN 302 636-4-1 clause 9.8.5, since the firmware +// has no GBC builder and real RSUs send DENM this way. QoS Data, lifetime 0x79 (30 s) and the +// 44-byte extended header all as seen from the CiT One in the recordings. +static const uint8_t k_rsu_mac[6] = {0x02, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE}; +static const uint8_t k_denm_stub[] = {0x02, 0x01, 0x00, 0x00, 0x30, 0x39, 0xDE, 0xAD, 0xBE, + 0xEF, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}; +#define DENM_STUB_LEN ((int)sizeof k_denm_stub) +#define GBC_PAYLOAD_OFFSET (MAC_HDR + QOS_CTRL + LLC_SNAP + GN_BASIC + GN_COMMON + GBC_EXT + BTP_B) + +static uint8_t *put(uint8_t *p, const void *src, int n) +{ + memcpy(p, src, (size_t)n); + return p + n; +} + +static uint8_t *put16(uint8_t *p, uint16_t v) +{ + *p++ = (uint8_t)(v >> 8); + *p++ = (uint8_t)v; + return p; +} + +static uint8_t *put32(uint8_t *p, uint32_t v) +{ + p = put16(p, (uint16_t)(v >> 16)); + return put16(p, (uint16_t)v); +} + +static int build_gbc(uint8_t subtype, int32_t area_lat, int32_t area_lon, uint16_t dist_a, uint8_t *f) +{ + uint8_t *p = f; + // 802.11 QoS Data: FC, duration, addr1..3, sequence control, QoS control + p = put16(p, 0x8800); + p = put16(p, 0); + p = put(p, k_bcast, 6); + p = put(p, k_rsu_mac, 6); + p = put(p, k_bcast, 6); + p = put16(p, 0x1000); + p = put16(p, 0); + p = put(p, k_llc_snap_gn, 8); + // Basic: version 1 / NH common, reserved, lifetime 30 s, RHL 10 + const uint8_t basic[4] = {0x11, 0x00, 0x79, 10}; + p = put(p, basic, 4); + // Common: NH BTP-B, HT 4 (GBC) / subtype = area shape, TC 1, flags 0 (stationary), PL, MHL, reserved + const uint8_t common[4] = {0x20, (uint8_t)(0x40 | subtype), 0x01, 0x00}; + p = put(p, common, 4); + p = put16(p, (uint16_t)(BTP_B + DENM_STUB_LEN)); + *p++ = 10; + *p++ = 0; + // GBC extended header: SN, reserved, SO PV (GN_ADDR with station type 15 = RSU, MID, TST, lat, + // lon, PAI/speed, heading), area centre lat/lon, DistanceA, DistanceB, angle, reserved + p = put16(p, 0x1234); + p = put16(p, 0); + p = put16(p, 15u << 10); + p = put(p, k_rsu_mac, 6); + p = put32(p, 1000); + p = put32(p, 535540100); + p = put32(p, 100220100); + p = put16(p, 0); + p = put16(p, 0); + p = put32(p, (uint32_t)area_lat); + p = put32(p, (uint32_t)area_lon); + p = put16(p, dist_a); + p = put16(p, subtype ? 40 : 0); + p = put16(p, subtype ? 900 : 0); + p = put16(p, 0); + // BTP-B: DENM + p = put16(p, 2002); + p = put16(p, 0); + p = put(p, k_denm_stub, DENM_STUB_LEN); + return (int)(p - f); +} + +static void test_gbc_rx(void) +{ + static const struct { uint8_t subtype; int32_t lat, lon; uint16_t dist_a; } areas[] = { + {0, 535540000, 100220000, 250}, // circle around the bench + {1, -338600000, 1512100000, 1000}, // rectangle; southern and far-eastern: signs and range + {2, 535540000, -100220000, 65535}, // ellipse; western, largest DistanceA + }; + for (size_t i = 0; i < sizeof areas / sizeof areas[0]; i++) { + tu_set_context("GBC subtype %d", areas[i].subtype); + uint8_t f[256]; + const int len = build_gbc(areas[i].subtype, areas[i].lat, areas[i].lon, areas[i].dist_a, f); + CHECK(len == GBC_PAYLOAD_OFFSET + DENM_STUB_LEN, "GBC frame length %d", len); + gn_rx_t rx; + const uint8_t *g = tu_guarded(f, len); + const bool ok = gn_unwrap_its(g, len, &rx); + CHECK(ok, "GBC subtype %d rejected", areas[i].subtype); + if (ok) { + CHECK(rx.btp_dest_port == 2002, "GBC port %d", rx.btp_dest_port); + CHECK(rx.has_geo_area, "GBC area missing"); + CHECK(!rx.signed_unverified && !rx.truncated, "GBC flags signed=%d truncated=%d", + rx.signed_unverified, rx.truncated); + CHECK(rx.geo_area_lat_tenmicrodeg == areas[i].lat, "area lat %ld, expected %ld", + (long)rx.geo_area_lat_tenmicrodeg, (long)areas[i].lat); + CHECK(rx.geo_area_lon_tenmicrodeg == areas[i].lon, "area lon %ld, expected %ld", + (long)rx.geo_area_lon_tenmicrodeg, (long)areas[i].lon); + CHECK(rx.geo_area_distance_a_m == areas[i].dist_a, "DistanceA %d, expected %d", + rx.geo_area_distance_a_m, areas[i].dist_a); + CHECK(rx.payload == g + GBC_PAYLOAD_OFFSET && rx.payload_len == DENM_STUB_LEN && + memcmp(rx.payload, k_denm_stub, (size_t)DENM_STUB_LEN) == 0, + "GBC payload offset %d length %d", (int)(rx.payload - g), rx.payload_len); + } + tu_pcap_add(f, len); + } +} + +// ---- What gets rejected, and where the payload ends ----------------------------------------- + +// One-byte changes to a good CAM frame: what must be dropped, and what must still pass. +static void test_rejections(void) +{ + const gn_lpv_t lpv = test_lpv(); + uint8_t good[FRAME_BUF_LEN]; + const int len = build(k_cam, CAM_LEN, &lpv, 2001, false, good, sizeof good); + + // Offsets into that non-QoS frame: 802.11 0, LLC/SNAP 24, Basic 32, Common 36, SHB 44, BTP-B 72. + static const struct { + const char *what; + int offset; + uint8_t value; + bool accept; + uint16_t port; + } cases[] = { + {"management frame (beacon)", 0, 0x80, false, 0}, + {"WDS (ToDS and FromDS)", 1, 0x03, false, 0}, + {"LLC DSAP not 0xAA", 24, 0xAB, false, 0}, + {"EtherType not 0x8947", 30, 0x08, false, 0}, + {"Basic NextHeader 2 with no security envelope", 32, 0x12, false, 0}, + {"Basic NextHeader 0 (any)", 32, 0x10, false, 0}, + {"Common NextHeader 1 (BTP-A)", 36, 0x10, false, 0}, + {"Beacon (HeaderType 1)", 37, 0x10, false, 0}, + {"GeoUnicast (HeaderType 2)", 37, 0x20, false, 0}, + {"multi-hop TSB (HeaderType 5, subtype 1)", 37, 0x51, false, 0}, + {"BTP port 2003 (MAPEM, not accepted yet)", 73, 0xD3, false, 0}, + {"BTP port 2018 (VAM, not accepted yet)", 73, 0xE2, false, 0}, + {"BTP port 2002 (DENM)", 73, 0xD2, true, 2002}, + {"BTP port 2004 (SPATEM)", 73, 0xD4, true, 2004}, + }; + for (size_t i = 0; i < sizeof cases / sizeof cases[0]; i++) { + tu_set_context("mutation: %s", cases[i].what); + uint8_t f[FRAME_BUF_LEN]; + memcpy(f, good, (size_t)len); + f[cases[i].offset] = cases[i].value; + gn_rx_t rx; + const bool ok = gn_unwrap_its(tu_guarded(f, len), len, &rx); + CHECK(ok == cases[i].accept, "%s: %s", cases[i].what, ok ? "accepted" : "rejected"); + if (ok && cases[i].accept) { + CHECK(rx.btp_dest_port == cases[i].port, "%s: port %d", cases[i].what, rx.btp_dest_port); + } + } +} + +// The Common Header's payload length, not the end of the frame, decides where the message stops. +static void test_payload_length(void) +{ + const gn_lpv_t lpv = test_lpv(); + uint8_t good[FRAME_BUF_LEN]; + const int len = build(k_cam, CAM_LEN, &lpv, 2001, false, good, sizeof good); + const int pl = MAC_HDR + LLC_SNAP + GN_BASIC + GN_COMMON_PAYLOAD_LEN_FIELD; + + static const struct { const char *what; uint16_t value; bool accept; bool truncated; int payload_len; } + cases[] = { + {"one byte less than sent", BTP_B + CAM_LEN - 1, true, false, CAM_LEN - 1}, + {"BTP-B header only, no payload", BTP_B, false, false, 0}, + {"shorter than the BTP-B header", BTP_B - 1, false, false, 0}, + {"zero", 0, false, false, 0}, + {"far more than arrived", 0xFFFF, true, true, CAM_LEN}, + }; + for (size_t i = 0; i < sizeof cases / sizeof cases[0]; i++) { + tu_set_context("Common payload length: %s", cases[i].what); + uint8_t f[FRAME_BUF_LEN]; + memcpy(f, good, (size_t)len); + f[pl] = (uint8_t)(cases[i].value >> 8); + f[pl + 1] = (uint8_t)cases[i].value; + gn_rx_t rx; + const uint8_t *g = tu_guarded(f, len); + const bool ok = gn_unwrap_its(g, len, &rx); + CHECK(ok == cases[i].accept, "%s: %s", cases[i].what, ok ? "accepted" : "rejected"); + if (ok && cases[i].accept) { + CHECK(rx.truncated == cases[i].truncated && rx.payload == g + SHB_PAYLOAD_OFFSET && + rx.payload_len == cases[i].payload_len, + "%s: truncated=%d payload_len=%d", cases[i].what, rx.truncated, rx.payload_len); + } + } +} + +// The 8 bytes the chip's promiscuous RX appends to every frame must not reach the phone. +static void test_trailer(void) +{ + uint8_t f[FRAME_BUF_LEN]; + for (int qos = 0; qos <= 1; qos++) { + tu_set_context("RX trailer after an SHB frame, qos=%d", qos); + const gn_lpv_t lpv = test_lpv(); + const int len = build(k_cam, CAM_LEN, &lpv, 2001, qos, f, sizeof f); + memcpy(f + len, k_rx_trailer, sizeof k_rx_trailer); + gn_rx_t rx; + const bool ok = gn_unwrap_its(tu_guarded(f, len + 8), len + 8, &rx); + CHECK(ok && !rx.truncated && rx.payload_len == CAM_LEN && + memcmp(rx.payload, k_cam, (size_t)CAM_LEN) == 0, + "qos=%d: payload_len %d with the trailer, expected %d", qos, ok ? rx.payload_len : -1, + CAM_LEN); + } + + tu_set_context("RX trailer after a GBC frame"); + const int len = build_gbc(0, 535540000, 100220000, 250, f); + memcpy(f + len, k_rx_trailer, sizeof k_rx_trailer); + gn_rx_t rx; + const bool ok = gn_unwrap_its(tu_guarded(f, len + 8), len + 8, &rx); + CHECK(ok && !rx.truncated && rx.payload_len == DENM_STUB_LEN, + "GBC: payload_len %d with the trailer, expected %d", ok ? rx.payload_len : -1, DENM_STUB_LEN); +} + +// ---- Secured (TS 103 097) ------------------------------------------------------------------- + +// Stand-in for what follows the inner packet in a real signed frame: headerInfo, signer and +// signature, 94 bytes with a digest signer. Its content is never read. +#define SIG_STUB_LEN 94 + +static int put_coer_length(int len, uint8_t *out) +{ + if (len < 0x80) { + out[0] = (uint8_t)len; + return 1; + } + if (len <= 0xFF) { + out[0] = 0x81; + out[1] = (uint8_t)len; + return 2; + } + out[0] = 0x82; + out[1] = (uint8_t)(len >> 8); + out[2] = (uint8_t)len; + return 3; +} + +// A secured CAM frame shaped like the recorded ones: our own SHB packet with the Basic Header's +// NextHeader set to 2 and everything after the Basic Header wrapped as `prefix` + COER length + +// inner packet, then the signature stand-in. Sets where the ITS payload starts and ends. +static int build_secured(const uint8_t *prefix, int prefix_len, const uint8_t *payload, + int payload_len, uint8_t *f, int *payload_start, int *payload_end) +{ + const gn_lpv_t lpv = test_lpv(); + uint8_t plain[FRAME_BUF_LEN]; + const int plain_len = build(payload, payload_len, &lpv, 2001, false, plain, sizeof plain); + const int basic = MAC_HDR + LLC_SNAP; + const int inner = basic + GN_BASIC; // the Common Header onward + const int inner_len = plain_len - inner; + + int n = inner; + memcpy(f, plain, (size_t)inner); + f[basic] = (uint8_t)((f[basic] & 0xF0) | 2); // Basic Header NextHeader: secured + memcpy(f + n, prefix, (size_t)prefix_len); + n += prefix_len; + n += put_coer_length(inner_len, f + n); + memcpy(f + n, plain + inner, (size_t)inner_len); + *payload_start = n + GN_COMMON + SHB_EXT + BTP_B; + n += inner_len; + *payload_end = n; + for (int i = 0; i < SIG_STUB_LEN; i++) { + f[n++] = (uint8_t)(0xA5 ^ i); + } + return n; +} + +static const uint8_t k_signed_prefix[] = {0x03, 0x81, 0x00, 0x40, 0x03, 0x80}; +static const uint8_t k_unsecured_prefix[] = {0x03, 0x80}; + +static void test_secured(void) +{ + static uint8_t big[300]; + for (int i = 0; i < (int)sizeof big; i++) { + big[i] = (uint8_t)(i * 13 + 1); + } + static const struct { const char *what; bool is_signed; int payload_len; } cases[] = { + {"signed, short length form", true, CAM_LEN}, // inner packet 83 bytes + {"signed, 1-byte long length form", true, 100}, // 140 + {"signed, 2-byte long length form", true, 300}, // 340 + {"top-level unsecuredData", false, CAM_LEN}, + }; + for (size_t c = 0; c < sizeof cases / sizeof cases[0]; c++) { + tu_set_context("secured: %s", cases[c].what); + const uint8_t *payload = cases[c].payload_len == CAM_LEN ? k_cam : big; + uint8_t f[FRAME_BUF_LEN]; + int start, end; + const int len = cases[c].is_signed + ? build_secured(k_signed_prefix, (int)sizeof k_signed_prefix, payload, + cases[c].payload_len, f, &start, &end) + : build_secured(k_unsecured_prefix, (int)sizeof k_unsecured_prefix, payload, + cases[c].payload_len, f, &start, &end); + gn_rx_t rx; + const uint8_t *g = tu_guarded(f, len); + const bool ok = gn_unwrap_its(g, len, &rx); + CHECK(ok, "%s: rejected", cases[c].what); + if (ok) { + CHECK(rx.signed_unverified == cases[c].is_signed, "%s: signed_unverified %d", + cases[c].what, rx.signed_unverified); + CHECK(!rx.truncated && rx.btp_dest_port == 2001, "%s: truncated %d port %d", + cases[c].what, rx.truncated, rx.btp_dest_port); + CHECK(rx.payload == g + start && rx.payload_len == cases[c].payload_len && + memcmp(rx.payload, payload, (size_t)cases[c].payload_len) == 0, + "%s: payload offset %d length %d, expected %d / %d", cases[c].what, + (int)(rx.payload - g), rx.payload_len, start, cases[c].payload_len); + } + tu_pcap_add(f, len); + sweep(cases[c].what, f, len, start, end); + } + + // One-byte changes to the signed, short-form frame. `env` is where the envelope starts. + uint8_t good[FRAME_BUF_LEN]; + int start, end; + const int len = build_secured(k_signed_prefix, (int)sizeof k_signed_prefix, k_cam, CAM_LEN, + good, &start, &end); + const int env = MAC_HDR + LLC_SNAP + GN_BASIC; + const int inner = env + (int)sizeof k_signed_prefix + 1; // + one length byte + static const struct { const char *what; int offset; uint8_t value; } bad[] = { + {"legacy envelope, protocolVersion 2", 0, 0x02}, + {"encryptedData", 1, 0x82}, + {"hashId not a one-byte value", 2, 0x80}, + {"no data, only extDataHash (preamble 0x20)", 3, 0x20}, + {"inner protocolVersion 2", 4, 0x02}, + {"nested signedData", 5, 0x81}, + {"length form with no length bytes (0x80)", 6, 0x80}, + {"length form with 3 length bytes (0x83)", 6, 0x83}, + {"envelope too short for the Common Header", 6, 0x05}, + }; + for (size_t i = 0; i < sizeof bad / sizeof bad[0]; i++) { + tu_set_context("secured mutation: %s", bad[i].what); + uint8_t f[FRAME_BUF_LEN]; + memcpy(f, good, (size_t)len); + f[env + bad[i].offset] = bad[i].value; + gn_rx_t rx; + CHECK(!gn_unwrap_its(tu_guarded(f, len), len, &rx), "%s: accepted", bad[i].what); + } + + tu_set_context("secured mutation: inner packet claims more than its envelope holds"); + uint8_t f[FRAME_BUF_LEN]; + memcpy(f, good, (size_t)len); + f[inner + GN_COMMON_PAYLOAD_LEN_FIELD + 1]++; // Common Header payload length, low byte + gn_rx_t rx; + CHECK(!gn_unwrap_its(tu_guarded(f, len), len, &rx), "payload overrunning its envelope: accepted"); +} + +static void test_truncation(void) +{ + const gn_lpv_t lpv = test_lpv(); + uint8_t f[FRAME_BUF_LEN]; + for (int qos = 0; qos <= 1; qos++) { + const int len = build(k_cam, CAM_LEN, &lpv, 2001, qos, f, sizeof f); + const int start = MAC_HDR + (qos ? QOS_CTRL : 0) + LLC_SNAP + GN_BASIC + GN_COMMON + SHB_EXT + BTP_B; + sweep(qos ? "SHB QoS" : "SHB", f, len, start, len); + } + const int len = build_gbc(0, 535540000, 100220000, 250, f); + sweep("GBC", f, len, GBC_PAYLOAD_OFFSET, len); +} + +int main(int argc, char **argv) +{ + tu_install_crash_handler(); + tu_guard_init(); + if (argc > 1 && !tu_pcap_open(argv[1])) { + fprintf(stderr, "cannot write %s\n", argv[1]); + return 2; + } + + test_shb_layout(false); + test_shb_layout(true); + test_pv_encoding(); + test_bounds(); + test_max_payload(); + test_gbc_rx(); + test_rejections(); + test_payload_length(); + test_trailer(); + test_secured(); + test_truncation(); + + tu_pcap_close(); + printf("test_chain: %d checks, %d failed\n", s_checks, s_failures); + return s_failures ? 1 : 0; +} diff --git a/obu-firmware/test/host/test_replay.c b/obu-firmware/test/host/test_replay.c new file mode 100644 index 0000000..ff9583d --- /dev/null +++ b/obu-firmware/test/host/test_replay.c @@ -0,0 +1,99 @@ +// Replays recorded air traffic through gn_unwrap_its, one pcap record at a time and exactly as the +// promiscuous RX callback hands each frame over (the recordings come through the same API), and +// writes what came out to a TSV that check_replay.py verifies independently. See README.md. +// +// Usage: test_replay out.tsv capture.pcap [capture.pcap ...] +// +// One row per pcap record: +// file, record, accepted, port, signed, truncated, area, area_lat, area_lon, area_dist, payload +// with everything after `accepted` empty for rejected frames and the payload in hex. Each frame +// is placed against the guard page, and an accepted payload that is not inside its frame is a +// failure here already. + +#include +#include +#include + +#include "gn_unwrap.h" +#include "test_util.h" + +// main.c's RX_FRAME_MAX_LEN: the promiscuous callback copies at most this many bytes of a frame, +// so it is the most gn_unwrap_its ever sees on the board. Keep in step with main.c. +#define RX_FRAME_MAX_LEN 800 + +typedef struct { + FILE *out; + const char *path; + long records, capped, accepted, cam, denm, spatem, signed_frames, truncated, bad; +} replay_t; + +static void on_frame(const uint8_t *frame, int len, int index, void *ctx) +{ + replay_t *r = ctx; + r->records++; + if (len > RX_FRAME_MAX_LEN) { + len = RX_FRAME_MAX_LEN; + r->capped++; + } + tu_set_context("replay %s record %d (%d bytes)", r->path, index, len); + const uint8_t *g = tu_guarded(frame, len); + gn_rx_t rx; + const bool ok = gn_unwrap_its(g, len, &rx); + fprintf(r->out, "%s\t%d\t%d", r->path, index, ok); + if (ok) { + const uintptr_t lo = (uintptr_t)g; + const uintptr_t p = (uintptr_t)rx.payload; + if (p < lo || rx.payload_len <= 0 || p + (uintptr_t)rx.payload_len > lo + (uintptr_t)len) { + fprintf(stderr, "FAIL %s: payload outside the frame\n", tu_context()); + r->bad++; + } else { + fprintf(r->out, "\t%u\t%d\t%d\t%d\t%ld\t%ld\t%u\t", rx.btp_dest_port, rx.signed_unverified, + rx.truncated, rx.has_geo_area, (long)rx.geo_area_lat_tenmicrodeg, + (long)rx.geo_area_lon_tenmicrodeg, rx.geo_area_distance_a_m); + for (int i = 0; i < rx.payload_len; i++) { + fprintf(r->out, "%02x", rx.payload[i]); + } + } + r->accepted++; + r->cam += rx.btp_dest_port == 2001; + r->denm += rx.btp_dest_port == 2002; + r->spatem += rx.btp_dest_port == 2004; + r->signed_frames += rx.signed_unverified; + r->truncated += rx.truncated; + } + fputc('\n', r->out); +} + +int main(int argc, char **argv) +{ + if (argc < 3) { + fprintf(stderr, "usage: test_replay out.tsv capture.pcap [capture.pcap ...]\n"); + return 2; + } + tu_install_crash_handler(); + tu_guard_init(); + + replay_t r = {0}; + r.out = fopen(argv[1], "w"); + if (!r.out) { + fprintf(stderr, "cannot write %s\n", argv[1]); + return 2; + } + fprintf(r.out, "file\trecord\taccepted\tport\tsigned\ttruncated\tarea\tarea_lat\tarea_lon\tarea_dist\tpayload\n"); + + int unreadable = 0; + for (int i = 2; i < argc; i++) { + r.path = argv[i]; + if (tu_pcap_foreach(argv[i], on_frame, &r) < 0) { + fprintf(stderr, "cannot read %s as a pcap\n", argv[i]); + unreadable++; + } + } + fclose(r.out); + + printf("test_replay: %ld records (%ld cut to %d bytes as main.c does), %ld accepted (CAM %ld, " + "DENM %ld, SPATEM %ld; %ld signed, %ld truncated), %ld bad\n", + r.records, r.capped, RX_FRAME_MAX_LEN, r.accepted, r.cam, r.denm, r.spatem, + r.signed_frames, r.truncated, r.bad); + return (r.bad || unreadable) ? 1 : 0; +} diff --git a/obu-firmware/test/host/test_util.c b/obu-firmware/test/host/test_util.c new file mode 100644 index 0000000..9d85867 --- /dev/null +++ b/obu-firmware/test/host/test_util.c @@ -0,0 +1,249 @@ +#ifndef _WIN32 +#define _DEFAULT_SOURCE // MAP_ANONYMOUS under -std=c11 +#endif + +#include "test_util.h" + +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#else +#include +#include +#include +#endif + +// ---- Crash reporting ------------------------------------------------------------------------ + +static char s_context[200] = "startup"; +static const uint8_t *s_crash_bytes; +static const int *s_crash_len; + +void tu_set_context(const char *fmt, ...) +{ + va_list ap; + va_start(ap, fmt); + vsnprintf(s_context, sizeof s_context, fmt, ap); + va_end(ap); +} + +const char *tu_context(void) +{ + return s_context; +} + +void tu_set_crash_input(const uint8_t *bytes, const int *len) +{ + s_crash_bytes = bytes; + s_crash_len = len; +} + +static void report_crash(const char *what) +{ + fprintf(stderr, "CRASH (%s) during: %s\n", what, s_context); + if (s_crash_bytes && s_crash_len) { + fprintf(stderr, "input (%d bytes): ", *s_crash_len); + for (int i = 0; i < *s_crash_len; i++) { + fprintf(stderr, "%02x", s_crash_bytes[i]); + } + fputc('\n', stderr); + } + fflush(stderr); +} + +#ifdef _WIN32 +static LONG WINAPI on_crash(EXCEPTION_POINTERS *info) +{ + char what[40]; + snprintf(what, sizeof what, "exception 0x%08lx", + (unsigned long)info->ExceptionRecord->ExceptionCode); + report_crash(what); + return EXCEPTION_EXECUTE_HANDLER; // terminate, with the exception code as the exit status +} + +void tu_install_crash_handler(void) +{ + SetUnhandledExceptionFilter(on_crash); +} +#else +static void on_crash(int sig) +{ + char what[40]; + snprintf(what, sizeof what, "signal %d", sig); + report_crash(what); // not async-signal-safe, but the process is ending anyway + _exit(2); +} + +void tu_install_crash_handler(void) +{ + signal(SIGSEGV, on_crash); + signal(SIGBUS, on_crash); + signal(SIGILL, on_crash); // -fsanitize-undefined-trap-on-error traps with an illegal instruction +} +#endif + +// ---- Guard page ----------------------------------------------------------------------------- + +static uint8_t *s_guard_end; // first byte of the no-access page +static size_t s_page_size; + +void tu_guard_init(void) +{ +#ifdef _WIN32 + SYSTEM_INFO si; + GetSystemInfo(&si); + s_page_size = si.dwPageSize; + uint8_t *base = VirtualAlloc(NULL, 2 * s_page_size, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE); + DWORD old; + if (!base || !VirtualProtect(base + s_page_size, s_page_size, PAGE_NOACCESS, &old)) { + fprintf(stderr, "guard page setup failed\n"); + exit(2); + } +#else + s_page_size = (size_t)sysconf(_SC_PAGESIZE); + uint8_t *base = mmap(NULL, 2 * s_page_size, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (base == MAP_FAILED || mprotect(base + s_page_size, s_page_size, PROT_NONE) != 0) { + fprintf(stderr, "guard page setup failed\n"); + exit(2); + } +#endif + s_guard_end = base + s_page_size; +} + +uint8_t *tu_guard_buf(int len) +{ + if (len < 0 || (size_t)len > s_page_size) { + fprintf(stderr, "tu_guard_buf(%d) exceeds one page\n", len); + exit(2); + } + return s_guard_end - len; +} + +const uint8_t *tu_guarded(const uint8_t *frame, int len) +{ + uint8_t *dst = tu_guard_buf(len); + if (len > 0) { + memcpy(dst, frame, (size_t)len); + } + return dst; +} + +// ---- pcap ----------------------------------------------------------------------------------- + +static uint32_t rd_le32(const uint8_t *p) +{ + return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24); +} + +static uint32_t rd_be32(const uint8_t *p) +{ + return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) | ((uint32_t)p[2] << 8) | (uint32_t)p[3]; +} + +int tu_pcap_foreach(const char *path, tu_frame_fn fn, void *ctx) +{ + FILE *f = fopen(path, "rb"); + if (!f) { + return -1; + } + fseek(f, 0, SEEK_END); + const long size = ftell(f); + fseek(f, 0, SEEK_SET); + if (size < 24) { + fclose(f); + return size == 0 ? 0 : -1; // the recordings include empty files + } + uint8_t *d = malloc((size_t)size); + if (!d || fread(d, 1, (size_t)size, f) != (size_t)size) { + fclose(f); + free(d); + return -1; + } + fclose(f); + + const uint32_t magic = rd_le32(d); + const bool swapped = magic == 0xD4C3B2A1u || magic == 0x4D3CB2A1u; + if (!swapped && magic != 0xA1B2C3D4u && magic != 0xA1B23C4Du) { + free(d); + return -1; + } + uint32_t (*u32)(const uint8_t *) = swapped ? rd_be32 : rd_le32; + const uint32_t linktype = u32(d + 20); + if (linktype != 127 && linktype != 105) { + free(d); + return -1; + } + + long off = 24; + int index = 0; + while (off + 16 <= size) { + const uint32_t incl = u32(d + off + 8); + if (incl > (uint32_t)(size - off - 16)) { + break; // last record cut off + } + const uint8_t *pkt = d + off + 16; + int len = (int)incl; + off += 16 + (long)incl; + if (linktype == 127) { + const int rt_len = len >= 4 ? (pkt[2] | (pkt[3] << 8)) : len + 1; // always little-endian + if (rt_len > len) { + index++; + continue; + } + pkt += rt_len; + len -= rt_len; + } + fn(pkt, len, index++, ctx); + } + free(d); + return index; +} + +static FILE *s_pcap_out; + +static void put_le32(uint8_t *p, uint32_t v) +{ + p[0] = (uint8_t)v; + p[1] = (uint8_t)(v >> 8); + p[2] = (uint8_t)(v >> 16); + p[3] = (uint8_t)(v >> 24); +} + +bool tu_pcap_open(const char *path) +{ + // Global header: magic, version 2.4, zone 0, sigfigs 0, snaplen 65535, linktype 105. + static const uint8_t hdr[24] = { + 0xD4, 0xC3, 0xB2, 0xA1, 0x02, 0x00, 0x04, 0x00, 0, 0, 0, 0, 0, 0, 0, 0, + 0xFF, 0xFF, 0x00, 0x00, 105, 0, 0, 0, + }; + s_pcap_out = fopen(path, "wb"); + return s_pcap_out && fwrite(hdr, 1, sizeof hdr, s_pcap_out) == sizeof hdr; +} + +void tu_pcap_add(const uint8_t *frame, int len) +{ + static uint32_t seq; + if (!s_pcap_out || len <= 0) { + return; + } + uint8_t rec[16]; + put_le32(rec + 0, seq++); // timestamp seconds: just the frame's sequence number + put_le32(rec + 4, 0); + put_le32(rec + 8, (uint32_t)len); + put_le32(rec + 12, (uint32_t)len); + fwrite(rec, 1, sizeof rec, s_pcap_out); + fwrite(frame, 1, (size_t)len, s_pcap_out); +} + +void tu_pcap_close(void) +{ + if (s_pcap_out) { + fclose(s_pcap_out); + s_pcap_out = NULL; + } +} diff --git a/obu-firmware/test/host/test_util.h b/obu-firmware/test/host/test_util.h new file mode 100644 index 0000000..dc2f6fa --- /dev/null +++ b/obu-firmware/test/host/test_util.h @@ -0,0 +1,36 @@ +#ifndef TEST_UTIL_H +#define TEST_UTIL_H +// Shared by the host tests: crash reporting, the guard page, and pcap reading and writing. +#include +#include + +// What the test is doing right now, printed if it crashes. printf-style. +void tu_set_context(const char *fmt, ...); +const char *tu_context(void); +// Also print these bytes as hex if it crashes (the fuzzer's current input). NULL to clear. +void tu_set_crash_input(const uint8_t *bytes, const int *len); +void tu_install_crash_handler(void); + +// One read-write page followed by one no-access page. +void tu_guard_init(void); +// A buffer of exactly `len` bytes (at most one page) whose end touches the no-access page. +uint8_t *tu_guard_buf(int len); +// Copy of `frame` whose last byte is the last readable one: reading past it crashes. +const uint8_t *tu_guarded(const uint8_t *frame, int len); + +// Calls `fn` for every record of a pcap file, with its 802.11 frame. Linktype 127 (radiotap, +// what its-g5-receiver-firmware records) has the radiotap header removed; linktype 105 is passed +// as is. The frame is otherwise exactly as captured, including the 8 bytes the ESP32-C5's +// promiscuous RX appends - which is what obu-firmware's callback receives through the same API. +// `index` counts every record, including ones skipped for a malformed radiotap header. Returns +// the number of records (0 for an empty file), or -1 if the file can't be read or isn't a pcap +// of those linktypes. +typedef void (*tu_frame_fn)(const uint8_t *frame, int len, int index, void *ctx); +int tu_pcap_foreach(const char *path, tu_frame_fn fn, void *ctx); + +// Writes frames to a pcap, linktype 105 (bare 802.11). +bool tu_pcap_open(const char *path); +void tu_pcap_add(const uint8_t *frame, int len); +void tu_pcap_close(void); + +#endif diff --git a/obu-firmware/test/pcap_gn_tally.py b/obu-firmware/test/pcap_gn_tally.py new file mode 100644 index 0000000..2391294 --- /dev/null +++ b/obu-firmware/test/pcap_gn_tally.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Tally GeoNetworking header fields per sending station across .pcap captures. + +Written to check the GN lifetime byte on air (see TODO.md), and it answers the general question +"what do real stations put in this header" too: one row per source MAC, packet type, BTP port and +lifetime byte, with a frame count. + + python obu-firmware/test/pcap_gn_tally.py its-g5-receiver-firmware/recordings/*.pcap + +Handles linktype 127 (radiotap, what its-g5-receiver-firmware records) and 105 (bare 802.11). +Standard library only. Pseudonym MACs rotate, so one vehicle can appear as several rows. The +pcap-over-serial dump path corrupts roughly 0.3% of frames, so a stray odd row is tooling noise. +""" +import collections +import glob +import struct +import sys + +LLC_SNAP_GN = b"\xaa\xaa\x03\x00\x00\x00\x89\x47" + +# (HeaderType, HeaderSubtype) from the GN Common Header -> name, EN 302 636-4-1 table 9. +HEADER_TYPES = { + (1, 0): "beacon", + (4, 0): "GBC-circle", + (4, 1): "GBC-rect", + (4, 2): "GBC-ellipse", + (5, 0): "SHB", +} +# Extended header length, i.e. the distance from the end of the Common Header to BTP-B. +EXT_LEN = {"SHB": 28, "GBC-circle": 44, "GBC-rect": 44, "GBC-ellipse": 44} + + +def lifetime_seconds(raw): + # Multiplier in the upper 6 bits, base in the lower 2: 50 ms, 1 s, 10 s, 100 s. + return (raw >> 2) * (0.05, 1, 10, 100)[raw & 3] + + +def frames(path): + with open(path, "rb") as f: + data = f.read() + if len(data) < 24: + return + magic = struct.unpack("" + linktype = struct.unpack(endian + "I", data[20:24])[0] + off = 24 + while off + 16 <= len(data): + incl = struct.unpack(endian + "I", data[off + 8:off + 12])[0] + pkt = data[off + 16:off + 16 + incl] + off += 16 + incl + if linktype == 127: + if len(pkt) < 4: + continue + pkt = pkt[struct.unpack("> 2) & 3 != 2: + return None # Data frames only + o = 24 + (2 if f[0] & 0x80 else 0) # QoS Data carries a 2-byte QoS Control field + if f[o:o + 8] != LLC_SNAP_GN or len(f) < o + 12: + return None + o += 8 + src = f[10:16].hex(":") + version, next_header, lifetime = f[o] >> 4, f[o] & 0x0F, f[o + 2] + port = "-" + if next_header == 2: + kind = "secured" # Common Header is inside the security envelope + elif next_header == 1 and len(f) >= o + 12: + c = o + 4 + ht = (f[c + 1] >> 4, f[c + 1] & 0x0F) + kind = HEADER_TYPES.get(ht, "type %d/%d" % ht) + ext = EXT_LEN.get(kind) + btp = c + 8 + (ext or 0) + if ext and len(f) >= btp + 2: + port = str(struct.unpack(">H", f[btp:btp + 2])[0]) + else: + kind = "nh=%d" % next_header + return src, version, kind, port, lifetime + + +def main(argv): + # PowerShell does not expand wildcards itself, so do it here. + paths = [p for arg in argv for p in (glob.glob(arg) or [arg])] + if not paths: + sys.exit(__doc__) + tally = collections.Counter() + for path in paths: + for frame in frames(path): + fields = gn_fields(frame) + if fields: + tally[fields] += 1 + print("%-17s %3s %-11s %5s %8s %8s %7s" + % ("source", "ver", "packet", "port", "lifetime", "seconds", "frames")) + for (src, ver, kind, port, lt), n in sorted(tally.items()): + print("%-17s %3d %-11s %5s %8s %8g %7d" + % (src, ver, kind, port, "0x%02x" % lt, lifetime_seconds(lt), n)) + + +if __name__ == "__main__": + main(sys.argv[1:])