Files
MicrOBU/obu-firmware/test/host/test_chain.c
T
Ashin Walpola 75d6d3b85c Receive signed ITS messages and forward each at its declared length
Signed packets. A GeoNetworking Basic Header NextHeader of 2 means a
TS 103 097 (IEEE 1609.2) envelope follows, with the Common Header
inside it. gn_unwrap_its rejected all of these, and most real traffic is
signed: the 2026-08-17 capture holds 157 signed frames from 15 source
MACs against 2 unsecured stations. It now opens a COER-encoded
signedData, or a bare unsecuredData, and parses the inner packet as
before. The inner packet comes first inside tbsData, so the certificate
and signature are never parsed, and the signature is not verified - the
firmware has no trust store. Such messages reach the phone with the new
V2X_RX flags bit1, signed but not verified. The app reads only bit0 and
is unaffected until it learns the flag. Encrypted payloads, nested
signing and the legacy v1.2.1 envelope are still rejected. All 157
recorded signed frames have the layout this reads, in all three COER
length forms, and asn1tools decodes every envelope to the same inner
packet.

Payload bounds. Every frame recorded through the ESP32-C5's promiscuous
RX, about 15 000 of them, ends in 8 bytes that are not part of the
802.11 frame and not a valid FCS. obu-firmware reads frames through the
same API and took the rest of the frame as the message, so it forwarded
those 8 bytes to the phone after every message. UPER decoders stop where
the message ends, so nothing visibly broke, but the bytes cost serial
bandwidth and 8 bytes of the DENM's headroom, and they stayed attached
wherever raw payloads were stored or passed on. The payload is now
exactly what the Common Header's payload-length field declares, which is
also what separates a signed message from its signature.

A frame longer than main.c's 800-byte capture buffer is now reported as
truncated instead of being forwarded cut off, and counted as an oversize
drop through the new serial_link_note_oversize_drop, as it was when the
cut-off frame failed serial_link's size check.

Host tests in obu-firmware/test/host build the firmware sources
unmodified with MSYS2 gcc; `make` runs all three.
- test_chain: frames from the firmware's TX code checked byte by byte
  against EN 302 636-4-1 and parsed back, including hand-built signed
  frames, the payload-length rule, the RX trailer, and every truncation
  length against a no-access guard page. 1731 checks, 0 failures.
- test_replay and check_replay.py: all 15 145 recorded frames through
  gn_unwrap_its, cut to 800 bytes as on the board, and re-derived
  independently in Python with the envelope decoded by asn1tools. They
  agree on every record; 15 131 accepted, 157 of them signed. 11 043 of
  the 11 106 distinct messages re-encode byte-identically. The other 63
  fail the same way with the old 8 bytes put back, so the boundary is
  not the cause: 5 are our own CAMs from before the 2026-08-20
  yawRateConfidence fix, and the rest, from other stations, are a
  follow-up in TODO.md.
- fuzz_gn_unwrap: random edits of every recorded frame, each run against
  the guard page. 50 000 000 iterations, no crash.

obu-firmware/test/pcap_gn_tally.py tallies GeoNetworking header fields
per station over captures; it is how the other stations' lifetimes were
measured. TODO.md collects what is still open, including the on-air
check for this change: it builds on IDF 6.1 but has not been flashed.
2026-09-11 20:19:40 +02:00

731 lines
33 KiB
C

// 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 <stdarg.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#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;
}