# 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 # .oer, .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 `/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 `.oer`, `.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-.oer vidf_issue crl --issuer chain/ROOT.oer --issuer-key root.pem [--revoke ]... --out lists/crl-.oer python3 ports/esp_idf/tools/local_dc.py --dir lists --port 8080 # GET /getctl/, /getcrl/ 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-.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-.oer` and a `root-.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 .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.