Keep vanetza-idf in obu-firmware, so a plain clone builds the firmware

obu-firmware builds against the vanetza-idf C-ITS library, which until now
came from the colleague's microbu-esp32c5 tree beside the repository and was
not tracked here, so a clone of this repository could not build the firmware
it ships. The library alone is now part of obu-firmware, as
obu-firmware/external/vanetza-idf: their external/vanetza-idf at commit
cf4b99f, unchanged (9775 files; see its PROVENANCE.md). CMake takes it from
there by default; -DVANETZA_IDF_DIR still points the build elsewhere.

The rest of the colleague's tree (their own VAM firmware, PKI tooling,
station-link Python tools, the V2X2MAP bridge) stays out of this repository
and gitignored; nothing is pushed to their repository. NOTES.md, docs/06,
TODO.md and the pcap verifier's usage line point at the new location.
This commit is contained in:
Ashin Walpola
2026-09-24 10:56:05 +02:00
parent 2f60623e18
commit d107534eb2
9781 changed files with 1560475 additions and 17 deletions
@@ -0,0 +1,356 @@
# Running external test campaigns
Install and build the official ETSI TTCN-3 framework separately. This library
does not install TITAN, supply the ETSI suites or replace their verdict logic.
The adapter build helper links the user's compiled BTP suite with this library's
test-port adapter; it preserves the official testcase objects.
## BTP
`ports/esp_idf/tools/run_etsi.py` accepts the external TITAN installation,
compiled suite and configuration. On Linux, for example:
```sh
python3 ports/esp_idf/tools/run_etsi.py \
--titan /path/to/titan/Install \
--binary /path/to/adapted/AtsBTP \
--config ports/esp_idf/tests/etsi_btp.cfg \
--expected-cases ports/esp_idf/tests/etsi_btp_cases.json \
--sut /path/to/vidf_sut \
--out /path/to/new-result-directory
```
The host SUT and device SUT use `NF_SAP::BTP_DATA_request` and map reception
through `BTP_DATA_indication`. Lower test records come from the real router.
For hardware, select a Python executable as `--sut`, the absolute
`ports/esp_idf/tools/serial_sut.py` path as `--sut-script`, and the DUT's
serial port as `--port`. The example `esp_idf_test` firmware must already be
built, flashed and running its HIL server. WSL requires Windows Python and a
Windows script path to access a Windows COM port.
Retain `result.json`, `test.cfg`, `expected-cases.json`, the MTC log and console
log. The result includes executable/configuration hashes and individual
verdicts. A complete campaign has exactly one verdict per expected testcase.
The provided five-case campaign establishes only its selected BTP behavior.
It does not validate GeoNetworking routing, facilities service behavior,
security, DCC or the physical radio. Those need their own applicable suites,
PICS/PIXIT and test-port implementations. Never acknowledge an unsupported
upper-tester command merely to let a suite advance.
## GeoNetworking
`ports/esp_idf/tools/build_etsi_geonetworking_adapter.py` builds
`ports/esp_idf/tests/etsi_geonetworking_adapter.cpp` against a separately
compiled `AtsGeoNetworking`, the same way as the BTP adapter. It implements
`GeoNetworkingPort`, `UpperTesterPort` and `AdapterControlPort`; only SHB
triggering/observation is wired up (GAP-GN-001), so
[etsi_geonetworking.cfg](../../ports/esp_idf/tests/etsi_geonetworking.cfg)
disables every other PICS the ATS defaults to "supported".
```sh
python3 ports/esp_idf/tools/build_etsi_geonetworking_adapter.py \
--etsi /path/to/TS.ITS --titan /path/to/titan/Install \
--out /path/to/new-build-directory
python3 ports/esp_idf/tools/run_etsi.py \
--titan /path/to/titan/Install \
--binary /path/to/new-build-directory/AtsGeoNetworking \
--config ports/esp_idf/tests/etsi_geonetworking.cfg \
--expected-cases ports/esp_idf/tests/etsi_geonetworking_cases.json \
--sut /path/to/vidf_sut \
--out /path/to/new-result-directory
```
**The host SUT must be a native Linux build**, unlike BTP's Windows
`vidf_sut.exe`: piping this adapter's stdin/stdout to a Windows executable
launched through WSL interop silently drops the piped input (the SUT process
reads EOF immediately and exits), which is indistinguishable from a crash
until you check for it directly. Configure and build `ports/esp_idf` with
CMake/Ninja inside WSL against the distribution's own Boost dev package
(`apt install cmake ninja-build libboost-dev libboost-date-time-dev`); do
this from a native Linux path (e.g. `/root/...`), not `/mnt/c/...` — building
from the Windows-mounted path works but is dramatically slower (large,
many-small-file compiles can stall for many minutes) because every header
read crosses the 9P filesystem boundary. Apt's Boost is missing
`boost/core/invoke_swap.hpp`, which `boost/geometry/formulas/karney_inverse.hpp`
needs; naively copying it from `external/esp-boost` redefines symbols already
in apt Boost's `boost/core/swap.hpp` and fails to compile for a different
reason. `ports/esp_idf/compat/include/boost/core/invoke_swap.hpp`
is a from-scratch compatibility shim (not a copy of upstream Boost) that
builds `invoke_swap` on top of the already-present `boost::swap()` instead of
redefining anything, so it works when combined with either Boost source. The
`compat/include` directory is appended after every real Boost include root,
so the shim is only reached when the selected Boost lacks the header; with
`VIDF_BOOST_ROOT` pointing at a complete Boost it is never used.
`build_etsi_geonetworking_adapter.py` also applies a disposable build overlay
for a null-pointer bug in the external framework's own
`geonetworking_codec.cc` (decoding any `GeoNetworkingPdu` through the
official `fx__dec__GeoNetworkingPdu` wrapper segfaults, since it always
decodes with `params=nullptr` and one payload-decode branch dereferences
that unconditionally). The pinned source on disk is read but never written;
see docs/idf/validation.md for the full diagnosis and
[the recorded before/after hashes](evidence/geonetworking-host-01/adapter-build.json).
Device execution has not been attempted for this suite.
## Security
`ports/esp_idf/tools/build_etsi_security_adapter.py` links
`ports/esp_idf/tests/etsi_security_adapter.cpp` with a separately compiled
`AtsSecurity` (official testcase objects unchanged; the framework's own
GeoNetworking/CAM/DENM port objects are replaced, the same disposable
`geonetworking_codec.cc` overlay as for GeoNetworking is applied to this build
only). The adapter covers the sending side: GN-MGMT beacons through the GN core,
CAM and DENM carriers from the test application behind the upper tester ports,
and the receiving side through the third configuration below.
Generate an isolated test trust domain in the framework's certificate pool
layout, then run the two configurations (the GN-MGMT cases must run without the
CAM carrier, which restarts the beacon timer, TS 103 836-4-1 clause 10.3.5):
```sh
cmake -S ports/esp_idf -B build-host -DVIDF_TESTS=ON -DVIDF_SECURITY=ON
cmake --build build-host
./build-host/vidf_test_pool /path/to/new-pool-directory # <name>.oer, <name>.vkey, index.lst
python3 ports/esp_idf/tools/build_etsi_security_adapter.py \
--etsi /path/to/TS.ITS --titan /path/to/titan/Install \
--out /path/to/new-build-directory
python3 ports/esp_idf/tools/run_etsi.py \
--titan /path/to/titan/Install \
--binary /path/to/new-build-directory/AtsSecurity \
--config ports/esp_idf/tests/etsi_security_gn.cfg \
--expected-cases ports/esp_idf/tests/etsi_security_gn_cases.json \
--sut ./build-host/vidf_sut --sut-args "--security-pool ./certificates" \
--pool /path/to/new-pool-directory \
--out /path/to/new-result-directory-gn
python3 ports/esp_idf/tools/run_etsi.py \
--titan /path/to/titan/Install \
--binary /path/to/new-build-directory/AtsSecurity \
--config ports/esp_idf/tests/etsi_security_facilities.cfg \
--expected-cases ports/esp_idf/tests/etsi_security_facilities_cases.json \
--sut ./build-host/vidf_sut --sut-args "--security-pool ./certificates" \
--pool /path/to/new-pool-directory \
--out /path/to/new-result-directory-facilities
```
`--pool` copies the pool to `<out>/certificates` (the SUT reads it from there
through `--sut-args`, the testcases through `PX_CERTIFICATE_POOL_PATH`/
`PX_IUT_SEC_CONFIG_NAME`) and records each file's hash. The SUT must be a
native Linux build, as for GeoNetworking. Pool tickets start one minute before
generation and last 24 hours; regenerate the pool for a campaign run later
than that.
The `[TESTPORT_PARAMETERS]` of both configurations set
`enable_security_checks=1` on the GeoNetworking port: the framework's own
security services verify every transmission of the SUT against the pool and a
failed verification discards the packet. `system.utPort.params` selects the
CAM carrier period of the test application (`cam_carrier_ms`), the only
stimulus difference between the two configurations. `AcEnableSecurity` names
the certificate the *test system* would sign with; the adapter accepts it when
it exists in the pool and applies the enforcement flag, but signs no
test-system packets.
A third configuration, `etsi_security_receive.cfg` with
`etsi_security_receive_cases.json` (26 receiving-side cases), needs a SUT built
with `VIDF_SECURITY_VERIFY` (the default). The test system signs its CAMs and
DENMs in TTCN-3 with `CERT_TS_A_AT`, or with `CERT_TS_B_AT` (a ticket restricted
to a 5 km circle around the SUT position) where the testcase uses
`PX_AT_CERTIFICATE`; the adapter injects them and reports what the SUT passes up
after SN-DECAP as `UtGnEventInd`. The SUT trusts both AAs of the pool
(`--aa` is repeatable; the defaults are `CERT_IUT_A_AA` and `CERT_TS_A_AA`).
Expected outcome with this library (retained in `docs/idf/evidence/security-host-09`,
`-10` and `-08`; earlier -06/-07/-05 identical): sending side 14 of 15
(`TC_SEC_ITSS_SND_GENMSG_05_BV` fails on a unit defect of the testcase), receiving
side 24 of 26 (`TC_SEC_ITSS_RCV_DENM_01_BV` and `DENM_02_BV_XX` error on a
declaration-order defect of the testcases); docs/idf/validation.md has both
analyses. Device execution of this suite has not been attempted; the device runs
the same security entity in the component tests (`security-device-06`).
### Issuing credentials outside the test pool: `vidf_issue`
`vidf_test_pool` writes the ETSI-named pool with fresh keys. `vidf_issue` (built
with the tests, host OpenSSL) issues the same certificate profiles for keys and
names of your choosing, so a chain under a real root can be produced and the
station's signed frames checked by tools that know nothing of this library:
```sh
vidf_issue root --key root.pem --name "Example Root CA" --id ROOT --out chain # self-signed (clause 7.2.3)
vidf_issue authority --issuer chain/ROOT.oer --issuer-key root.pem --name "Example AA" --id AA --out chain
vidf_issue ticket --issuer chain/AA.oer --issuer-key chain/AA.vkey --root chain/ROOT.oer --id AT \
--permission 36:01FFFC --permission 141 --permission 638:01 --out chain
vidf_issue show chain/AT.oer # digest, issuer, validity, permissions, region
vidf_issue verify chain/AT.oer chain/AA.oer chain/ROOT.oer # signatures, validity/region nesting, permission consistency
```
`--key`/`--issuer-key` accept a PEM private key (an encrypted PKCS#8 file is
opened with OpenSSL's pass-phrase prompt; the pass phrase is never an argument)
or a raw 32-octet `.vkey` as the pool writes it; `--start`/`--years`/`--hours`
set the validity (the default start is an hour ago but never before the
issuer's own start), `--region LAT,LON,RADIUS_M` (1/10 microdegrees, metres) a
circular ticket region. `root` writes the EU CCMS CPOC Protocol Release 3.0
profile (certIssuePermissions with minChainLength 2 and eeType app+enrol for
CA/DEN/VRU/GN-MGMT and the end-entity part of psid 623, a second group for the
authorities' psid 623 SSPs; CRL/CTL appPermissions), or with `--like
OTHER.oer` the permissions and region of an existing root (a rehearsal twin of
a real root with a throwaway key). `authority` derives its issuing
permissions from the issuer (every group that reaches two certificates down,
IEEE Std 1609.2 6.4.28, with chain length 1) and inherits the issuer's region
(6.4.17); `ticket` inherits the issuer's region unless `--region` is given and
carries the SSPs of TS 102 941 V2.2.1 Table B.6 for psid 623 where relevant.
Every command checks the result the way the receive-side verifier will
(signatures, validity nesting, region nesting, permission consistency, with
`--root` over the full chain) and writes nothing that would fail. The output
directory gets `<id>.oer`, `<id>.vkey` (except for `root`, whose key stays
where it was) and an `index.lst`, i.e. a pool `vidf_sut --security-pool`
loads with `--root ROOT --aa AA --at AT`.
Rules for a production root: run the tool where the root key lives, never copy
the encrypted PEM or its pass phrase anywhere, and keep the generated `.vkey`
files with the same care as the PEM. Nothing in the tests uses a project key;
the component tests and the pool generate throwaway keys per run.
### A distribution centre on localhost (CTL and CRL)
Receivers that take their AA certificates from the root's CTL rather than from
P2P distribution need the root's distribution centre (TS 102 941 V2.2.1 clause
6.3, Annex D). Until the real DC is up, the same interface runs on localhost:
```sh
vidf_issue ctl --issuer chain/ROOT.oer --issuer-key root.pem --aa chain/AA.oer=http://aa.example/ \
--dc http://127.0.0.1:8080/ --sequence 1 --out lists/ctl-<HASHEDID8>.oer
vidf_issue crl --issuer chain/ROOT.oer --issuer-key root.pem [--revoke <HASHEDID8>]... --out lists/crl-<HASHEDID8>.oer
python3 ports/esp_idf/tools/local_dc.py --dir lists --port 8080 # GET /getctl/<HASHEDID8>, /getcrl/<HASHEDID8>
python3 ports/esp_idf/tools/fetch_trust_lists.py --dc http://127.0.0.1:8080/ --root chain/ROOT.oer --out fetched
vidf_issue inspect fetched/ctl-<HASHEDID8>.oer --root chain/ROOT.oer # clause 6.3.6 checks, then the entries
```
The station applies what `fetch_trust_lists.py` fetched through
`pki::parse_rca_ctl`/`parse_crl` and `pki::apply` (the fetch itself is the
application's transport). c-its' `c-its-download-int-certs` is the
independent consumer: with `ctl/root-<HASHEDID8>.oer` and a
`root-<HASHEDID8>.json` naming the DC it fetches, validates and extracts the
AA certificates (retained run: `docs/idf/evidence/trust-lists-01`).
### Enrolment and authorization on localhost (EA/AA)
TS 102 941 V2.2.1 clause 6.2.3: an EC from the EA, then an AT from the AA
(which validates the EC's entitlement with the EA). `pki.hpp` builds and
parses every message; the HTTP transport between two separate processes is
the test tools' job, the same division of labour as the DC above:
```sh
vidf_issue root --key root.pem --name "lab root" --id ROOT --out chain
vidf_issue authority --issuer chain/ROOT.oer --issuer-key root.pem --name "lab EA" --id EA --out chain
vidf_issue authority --issuer chain/ROOT.oer --issuer-key root.pem --name "lab AA" --id AA --out chain
# authority also writes <id>.ekey: the ECIES private key needed to decrypt real requests
python3 ports/esp_idf/tools/local_pki.py --issue-tool build/vidf_issue \
--ea chain/EA.oer --ea-key chain/EA.vkey --ea-enc-key chain/EA.ekey --canonical-key canonical.pem \
--aa chain/AA.oer --aa-key chain/AA.vkey --aa-enc-key chain/AA.ekey \
--dir issued --port 8090 # POST /ea/enrolment, POST /aa/authorization
python3 ports/esp_idf/tools/pki_client.py --issue-tool build/vidf_issue --pki http://127.0.0.1:8090/ enrol \
--ea chain/EA.oer --canonical-key canonical.pem --its-id my-station --out EC.oer --out-key EC.vkey
python3 ports/esp_idf/tools/pki_client.py --issue-tool build/vidf_issue --pki http://127.0.0.1:8090/ authorize \
--ea chain/EA.oer --aa chain/AA.oer --ec EC.oer --ec-key EC.vkey --out AT.oer --out-key AT.vkey
vidf_issue verify EC.oer chain/EA.oer chain/ROOT.oer
vidf_issue verify AT.oer chain/AA.oer chain/ROOT.oer
```
`--canonical-key` on both the client and `local_pki.py`/`ea-respond` side is
the same private key file: a lab stand-in for the manufacturer's out-of-band
canonical-key registry a real EA consults, not something this tool invents.
`ea-respond`/`aa-respond` are a lab authority (decrypt, verify, issue, sign,
encrypt with the same building blocks the ITS-S side already uses), not a
production PKI: no replay protection, no butterfly keys, no revocation.
Retained run: `docs/idf/evidence/enrolment-authorization-01`.
### Independent verification of signed frames
`ports/esp_idf/tools/capture_pcap.py` drives `vidf_sut` with such a pool,
triggers the CAM and DENM carriers and writes the transmissions as an IEEE
802.11 pcap (linktype 105, wildcard BSSID, LLC/SNAP 0x8947). Any verifier that
reads pcaps can then judge them; the retained run uses c-its
(https://github.com/TheEnbyperor/c-its, `c-its-pcap`, roots as
`./ctl/root-*.oer`, AAs as `./ctl/aa-*.oer`):
```sh
python3 ports/esp_idf/tools/capture_pcap.py --sut ./build-host/vidf_sut --pool chain \
--root ROOT --aa AA --at AT --out sut.pcap
mkdir ctl && cp chain/ROOT.oer ctl/root-ROOT.oer && cp chain/AA.oer ctl/aa-AA.oer
RUST_LOG=info c-its-pcap sut.pcap # one JSON line per frame: signature, chain, security_authorized
```
The same from an ESP32-C5 running the test application: pack the chain into a
credential bundle (`vanetza_idf/credentials.hpp` format, keys in the clear:
keep the file like the keys) and let the capture provision the board over the
USB serial diagnostic channel (command 9) before it resets and signs:
```sh
python3 ports/esp_idf/tools/credential_bundle.py build --pool chain --root ROOT --aa AA --at AT --out chain.vcr
python3 ports/esp_idf/tools/capture_pcap.py --port COM11 --bundle chain.vcr --out device.pcap
```
`vidf_sut --security-bundle chain.vcr` (or `capture_pcap.py --sut ... --bundle`)
takes the same bundle on the host; `serial_sut.py --bundle chain.vcr` provisions
a device before relaying an ETSI campaign's commands. The device keeps the
bundle in RAM for the session; persisting it is the application's
`CredentialStore` (the component tests exercise the NVS store).
Retained in `docs/idf/evidence/independent-verifier-01` (analysis there): with
a ticket carrying CAM, DENM and VRU permissions c-its reports the message
signature, the chain to the root and the CAM/DENM authorisation as valid; with
the standard ticket that also lists psid 141 without an SSP, c-its verifies
every signature but does not validate the permissions because it treats an
omitted SSP as unsupported (its limitation, not a standard's requirement).
`independent-verifier-02` repeats the run under a twin of a real EU CCMS L0
root (`root --like`), i.e. with the CPOC permission profile and the EU
identified region.
## Independent radio reception
After flashing the test application on two C5 boards, use:
```sh
python ports/esp_idf/tools/radio_pair.py \
--dut COM20 --receiver COM11 --channel 180 \
--allow-transmission --out /path/to/new-radio-result-directory
```
This integration test explicitly enables laboratory transmission. It checks
that receive-only mode rejects transmission, injects a unique payload at BTP,
sends the resulting GN packet through the DUT radio and checks an independent
receiver's raw frame byte-for-byte against exactly what the DUT submitted.
It retains a PCAP and a JSON result. Both radios are stopped during cleanup;
cleanup failures are reported.
The captured frames are not independently re-validated for FCS: the
receiver's promiscuous filter never sets `WIFI_PROMIS_FILTER_MASK_FCSFAIL`,
so the ESP32-C5 WiFi hardware has already discarded any frame that failed
FCS before this callback ever runs. `rx_ctrl.sig_len` on this chip is
documented as "the length of the reception MPDU" (`esp_wifi_he_types.h`),
not "MPDU + FCS" as older-chip ESP-IDF documentation states; measured
directly, it is 4 bytes longer than the actual frame, and those 4 trailing
bytes are a fixed value independent of frame content (confirmed across
captures with different random payloads) — not a real, software-recoverable
FCS. They are still written into the pcap for inspection
(`result.json`'s `trailing_bytes`), just not checked against anything.
The diagnostic radio commands are application test hooks, not ETSI primitives:
3 configures channel and transmit enable; 4 submits an AL_DATA request; 5 drains
independent receive observations; 6 stops the radio. These hooks can support an
external access test adapter. They do not themselves produce ETSI verdicts.
A matched frame establishes that particular reception. It cannot establish
receiver sensitivity, EIRP, congestion control behavior or complete ITS-G5
compliance. Spectral mask and carrier frequency accuracy are covered
separately by the SYS-RF-001 characterization campaign
(`experiments/evidence/c5-radio-characterization` in the thesis workspace,
HackRF One/PortaPack against ETSI EN 302 571 V2.1.1 clause 4.2.1 and Table 6):
carrier frequency offset is generally within the +-20 ppm limit, but the
transmitted spectral mask fails Table 6 in every tested bench configuration
and MCS rate (0/22 captures per run across three configurations) — this is
disclosed as a measured non-conformance, not a passing result.