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:])