Files
MicrOBU/obu-firmware/main/gn_unwrap.h
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

97 lines
5.8 KiB
C

#ifndef GN_UNWRAP_H
#define GN_UNWRAP_H
#include <stdint.h>
#include <stddef.h>
#include <stdbool.h>
// 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 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:
//
// TSB/SINGLE_HOP (HT=5, HST=0) - 28 bytes: Source Position Vector (24) + Reserved (4).
// What CAM uses, and what geonet_wrap_shb() builds.
// GEOBROADCAST (HT=4) - 44 bytes: SeqNum (2) + Reserved (2) + SO PV (24) +
// GeoArea lat (4) + lon (4) + DistanceA (2) + DistanceB (2) + Angle (2) + Reserved (2).
// What DENM uses in practice - real RSUs and OBUs disseminate DENM by GeoBroadcast so it
// can be forwarded across an area, not by single-hop broadcast.
//
// Both lengths are measured facts, not spec-table guesses: verified against live air capture on
// 2026-08-17 (its-g5-receiver-firmware/recordings/capture_20260817_171055.pcap) by locating the
// BTP port and ItsPduHeader and checking they agree. An earlier version of this file used 24 for
// the SHB case, four bytes short, which read the BTP port out of the Reserved field and silently
// dropped EVERY real CAM. Do not "simplify" these constants without re-measuring.
//
// 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
// bandwidth. Adding one is a one-line change here plus a decoder on the phone - the serial
// protocol itself is already generic (see SERIAL_MSG_V2X_RX in serial_link.h).
//
// SPATEM size caveat: SERIAL_LINK_MAX_PAYLOAD is 512, so a SPATEM whose UPER exceeds 498 bytes is
// 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 main.c's RX_FRAME_MAX_LEN.
//
// 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, 2004 = SPATEM.
uint16_t btp_dest_port;
// ITS payload (UPER message bytes). Points INTO the caller's `frame` buffer - NOT a copy, so
// it is only valid while `frame` is.
const uint8_t *payload;
int payload_len;
// GeoBroadcast destination area, when this frame carried one (GEOBROADCAST only; false for
// TSB/SHB). This is the hazard's relevance area - for a DENM it says "this warning applies
// within DistanceA metres of this point", which is materially more useful on a map than the
// originator's own position.
bool has_geo_area;
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 - 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);
#endif