// 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; }