Files
MicrOBU/microbu-esp32c5/external/vanetza-idf/docs/idf/validation.md
T
Ashin Walpola 0e9525162d Keep the colleague's microbu-esp32c5 tree in this repository
obu-firmware builds against vanetza-idf from microbu-esp32c5/external, but
that tree was gitignored, so a clone of this repository could not build the
firmware it ships. It is now committed here as ordinary files in its own
folder, microbu-esp32c5/: the colleague's commit cf4b99f plus the V2X2MAP
bridge's signature verification (--trust) used on the bench. Nothing is
fetched from or pushed to the colleague's repository; this repository and
its remotes carry everything. The folder's own .gitignore keeps build output,
downloaded components and private key material out, as it did there; the
committed file set is identical to that repository's tracked files.

The ESP32-C5 is still flashed from obu-firmware/, which only takes
vanetza-idf from microbu-esp32c5/, so the two stay separate folders.
FLASHING.md says how to take a newer version of the colleague's tree (copy
it over the folder, rebuild, test, commit).
2026-09-23 17:46:40 +02:00

41 KiB

Validation

No full-stack conformity is claimed. Results below were collected from this port, not inferred from the upstream project or other firmware.

Check Result Scope
Host build, GCC 13.2 PASS Network, CAM/DENM/VAM codecs and HIL enabled
Host component regression PASS, 92 checks Includes named NF request length/security/wire mapping, codecs, SHA, GN/BTP and radio frame handling
Official ETSI BTP control, host PASS, 5/5 expected cases Actual stack via named NF request/indication binding; unsecured PICS
ESP32-C5 build PASS ESP-IDF 6.0.2; 1,753,088-byte application, 3 MiB application partition
ESP32-C5 component execution PASS 92 on-device checks; HIL server now survives a JTAG/USB reset (see fix note below)
Official ETSI BTP control, device (COM20) PASS, 5/5 expected cases Real hardware SUT via serial_sut.py; same testcase objects and PICS as the host run
Raw GN-DATA N-SAP (IF-GN-002), host PASS, manual round trip SHB send (GN_DATA_request) and receive (on_receive_gn) verified byte-for-byte via vidf_sut.exe; see below
Official ETSI GeoNetworking control, host PASS, 1/3 executed cases; 1 fail, 1 inconc (both legitimate scope gaps, not bugs) TC_GEONW_FDV_SHB_BV_01; see below
Access/DCC campaign Not executed Required observations and behavior remain incomplete
Independent C5 radio pair, real RF (COM20→COM11) PASS, 3/3 identical reruns Real over-the-air transmit/receive between two boards; see below for the FCS-check fix
Host component regression, security on (Windows Debug, OpenSSL) PASS, 935 checks Security entity incl. receive-side verification, chain and region consistency, revocation, credential bundle and stores, identifier change, SN/SF/MN/MF/MI bindings, TS 102 941 core incl. RCA CTL/CRL, ITS time base; PSA cross-check build (mbedTLS 4.1 from the IDF tree) PASS, 1112 checks; security off 150; access-only 88; Linux Release 935
ESP32-C5 component execution, security on (COM11) PASS, 810 checks PSA Crypto backend of mbedTLS 4.1.0 with the ECDSA peripheral for verification; CONFIG_VANETZA_IDF_SECURITY_VERIFY=y, CONFIG_VANETZA_IDF_PKI=y, CONFIG_VANETZA_IDF_NVS_CREDENTIALS=y; security-device-09 incl. the chain/region consistency, revocation, NVS credential store and RCA CTL/CRL tests (earlier: -08 765, -07 747, -06 668, -05 537, -03/-04 503); heap and cost notes below
ESP32-C5 signing with run-time provisioned credentials, verified by c-its PASS: signatures and chain (3/3 CAMs per capture), CAM authorisation with the SSP-carrying ticket Bundle over the USB diagnostic channel (command 9), twin chain of a real EU root; independent-verifier-03
RCA CTL/CRL from a distribution centre on localhost PASS: library reader and c-its consumer both validate and extract the AA; revocation honoured by the chain validator TS 102 941 clause 6.3 / Annex D; vidf_issue ctl/crl, local_dc.py, fetch_trust_lists.py; trust-lists-01
Enrolment and authorization from a lab EA/AA on localhost PASS: both HTTP round trips complete, both resulting certificates (EC, AT) verify their chain to the root; the AA's entitlement check of the EC succeeds against a genuinely separate request TS 102 941 clause 6.2.3; vidf_issue enrol-*/authorize-*/ea-respond/aa-respond, local_pki.py, pki_client.py; enrolment-authorization-01
ESP32-C5 component execution, security on (COM20, second board) PASS, 503 checks Same image, board recovered over JTAG; security-device-04
Official ETSI Security, GN-MGMT profile, host PASS 7/8; 1 fail (testcase defect, IUT-independent) TC_SEC_ITSS_SND_GENMSG_01..08_BV, framework-side signature verification enforced; see below; rerun with the CPOC-shaped pool and the consistency checks: security-host-09, same verdicts
Official ETSI Security, CAM/DENM profiles, host PASS 7/7 TC_SEC_ITSS_SND_CAM_01..04_BV, TC_SEC_ITSS_SND_DENM_01..03_BV; see below; rerun security-host-10, same verdicts
Independent verifier (c-its) on SUT-signed CAM/DENM, host PASS: signatures, chain, permissions, CAM/DENM authorisation Lab chain from vidf_issue, frames from capture_pcap.py; independent-verifier-01; rehearsal on a twin of a real EU CCMS L0 root (its permission profile and EU region, throwaway key): independent-verifier-02; the same from the board: -03
Official ETSI BTP control, host, secured-capable SUT PASS, 5/5 Regression of the new vidf_sut build: btp-host-08 (earlier -05, -06, -07)
Official ETSI GeoNetworking control, host, secured-capable SUT pass/inconc/fail, identical to geonetworking-host-01 geonetworking-host-05 (earlier -02, -03, -04): no regression
Official ETSI Security, receiving side, host PASS 24/26; 2 errors (testcase defect, IUT-independent) TC_SEC_ITSS_RCV_MSG/CAM/DENM_* with the SUT verifying (VIDF_SECURITY_VERIFY); see below; rerun with the consistency checks security-host-08, same verdicts
PKI ATS Not executed The official AtsPki suite needs a TTCN upper-tester adapter against the test system's compiled binary, not built here; a real HTTP transport for ad hoc testing exists (row above) (GAP-PKI-001)
Complete facilities ATS Not executed Full services remain incomplete

The BTP result records executable and configuration hashes, timestamps and every testcase verdict. Its five cases are TC_BTP_PGA_BV_01, TC_BTP_PGB_BV_01, TC_BTP_PGB_BV_02, TC_BTP_PP_BV_01 and TC_BTP_PP_BV_02. There are no missing, unexpected or duplicate verdicts in that run. The same five cases, same adapter and same PICS pass against the real ESP32-C5 SUT over USB; see the device result.

The C5 build record pins the application, bootloader, partition table and configuration hashes.

HIL server reset fix

examples/esp_idf_test's run_hil_server() previously never returned control after printing VIDF_TEST_RESULT=0 on a JTAG- or USB-triggered reset (as opposed to a full power-on reset): the ESP32-C5 ROM's UART0 initialization waits forever for a clock-ready bit that such a reset can leave cleared. The parent project's own firmware hit and fixed the identical issue (implementation/firmware/main/usb_hil.c:75); this port did not carry that fix. hil_server.cpp now sets PCR_UART0_SCLK_EN before installing the USB-Serial/JTAG driver, guarded by CONFIG_IDF_TARGET_ESP32C5. Confirmed on the COM20 board across repeated resets. The COM11 receiver board needed one physical power cycle to recover from the pre-fix hang before it would boot the corrected image; a JTAG halt/resume showed it genuinely spinning in ROM, not merely slow to answer.

The external BTP source revision is 3576386312f9dff1afcccc8f96247876ab9a0865; the external framework revision is e477d327f4df850e487feab5c0f68b1044db3cf1. Official testcase objects are reused; the library supplies the SUT port adapter. These older tests do not by themselves prove every Release 2 requirement.

GN-DATA N-SAP (IF-GN-002) and a real upstream fix

The GeoNetworking ATS's SHB/GBC "generate message" UT triggers send a raw test SDU with the Common Header next_header set to "Any" (no BTP/IPv6 framing). Stack previously had no way to originate that: Stack::request(BtpRequest) always sets a BTP next-header, and the upstream router's CommonHeader(const DataRequest&, const MIB&) constructor only handled UpperProtocol::BTP_A, BTP_B and IPv6 in its switch, throwing "Unhandled upper protocol" for anything else — UpperProtocol::Unknown is a real, documented value (the router itself assigns it on receive for unrecognized next-headers), so this was a genuine gap, not a deliberate exclusion. Two changes close it:

  • vanetza/geonet/common_header.cpp: the switch now maps UpperProtocol::Unknown to NextHeaderCommon::Any, matching what the default constructor already produces.
  • Stack gained GnRequest/GnIndication and request(GnRequest)/ on_receive_gn(...): the same SHB/GBC-only validation as BtpRequest, but with a raw payload and no header construction, registered against the router's UpperProtocol::Unknown transport handler alongside the existing BTP_A/BTP_B ones.

Verified with vidf_sut.exe (opcodes 3=GN_DATA_request SHB, 4=update_position, both new; 2=AlDataIndication, already existing and transport-agnostic): a raw "HELLO" SHB request produces a GN packet with Common Header next_header=0x00 (Any) and the exact payload at its tail; feeding those same wire bytes back through AlDataIndication produces a GN-DATA.indication with upper_protocol=Unknown and the same "HELLO" payload back out. vidf_tests.exe still passes 92/92 after the change.

GeoNetworking TTCN adapter: one official case passes

ports/esp_idf/tests/etsi_geonetworking_adapter.cpp implements the three GeoNetworking test-port classes (GeoNetworkingPort, UpperTesterPort, AdapterControlPort) against the official AtsGeoNetworking testcase objects, built the same way as the BTP adapter (build_etsi_geonetworking_adapter.py). PICS_GN_LOCAL_GN_ADDR in etsi_geonetworking.cfg is set to the exact address hil_sut.cpp's Sut::reset() gives the stack (typeOfAddress=e_initial, stationType=e_unknown, mid=02:00:00:00:00:01); f_acGetLongPosVector compares against this value exactly, so it is not an arbitrary PICS choice. PICS_GN_BASIC_HEADER/COMMON_HEADER are disabled because those testcases send deliberately malformed headers expecting rejection, which this adapter does not implement; everything else the ATS defaults to "supported" is disabled to match GAP-GN-001 (only SHB is wired into the adapter; GBC/GUC/GAC/TSB triggers are not).

Under that PICS selection, three cases execute (this is the complete population PICS_GN_SHB_SRC reaches):

Testcase Component Verdict Why
TC_GEONW_FDV_SHB_BV_01 single (ItsGeoNetworking) pass See below
TC_GEONW_PON_FPB_BV_11_05 multi (ItsMtc) inconc, immediately Its body additionally requires PICS_GN_GUC_SRC, which is correctly disabled (GAP-GN-001)
TC_GEONW_PON_SHB_BV_01 multi (ItsMtc) fail Needs a second, independently-addressable simulated station (PTC); this adapter models one IUT only

Getting TC_GEONW_FDV_SHB_BV_01 to a genuine pass took fixing two real bugs, neither of which was in the testcase logic:

1. A null-pointer bug in the external ETSI framework's codec. geonetworking_codec::decode_ (ccsrc/Protocols/GeoNetworking/geonetworking_codec.cc) unconditionally dereferences _params while writing the decoded payload, in all three branches of its length-alignment logic. Every other parameter write in that file — a few lines up, in the enclosing decode() — is correctly guarded with if (_params != NULL); this one payload branch just wasn't. fx__dec__GeoNetworkingPdu (ccsrc/EncDec/LibItsGeoNetworking_Encdec.cc) always calls decode() with the default params (nullptr), so decoding any GeoNetworkingPdu through the official codec wrapper segfaults as soon as it reaches that field. This was never triggered by BTP (whose adapter never calls this decoder) or by any earlier GeoNetworking attempt, because no GN packet had ever actually been transmitted-and-observed through TITAN before fix 2 below — every earlier SHB trigger just sat unsent, so the buggy decode path was never reached.

Confirmed with a real core dump: ulimit -c unlimited, a fixed /proc/sys/kernel/core_pattern (WSL redirects it to a pipe by default, which silently swallows core files), and gdb -batch -ex 'bt full' on the result showed the crash precisely, four frames past abort() inside std::map<...>::operator[] called from geonetworking_codec::decode_ line 257, this=0x8 (a null _params plus a small member offset). No debugger was installed in the WSL environment initially; installing one (apt-get install gdb) is what turned "reproducibly crashes for an unknown reason" into an exact line number.

Per the framework's own stated policy (never edit the pinned ETSI source tree or its testcase objects), the fix is a disposable build overlay: build_etsi_geonetworking_adapter.py reads the pinned geonetworking_codec.cc, applies three if (_params != NULL) guards in memory, compiles the patched copy into its own --out directory, and links that instead of the original .o — the checked-out framework source on disk is never written. This mirrors the project's existing AtsIPv6OverGeoNetworking overlay precedent (tools/ttcn/validation-status.json's suite_overlay entries): before/after source hashes are recorded in the adapter's own build.json (geonetworking-host-01/adapter-build.json).

2. Stack never applied its own configured GN address. With the codec crash fixed, the SHB packet decoded fine but failed the match: the test's mw_longPosVectorPosition template requires the observed source position vector's gnAddr to equal exactly what AcGnPrimitive::getLongPosVector reported, and the two disagreed. hil_sut.cpp's Sut::reset() sets config.mib.itsGnLocalGnAddr.mid({2,0,0,0,0,1}), but Router::update_position() (vanetza/geonet/router.cpp) only ever touches m_local_position_vector's timestamp/latitude/longitude/speed/heading — never its address. The address field of a station's own position vector is populated exclusively by a separate method, Router::set_address(const Address&), which Stack never called. So every transmitted packet actually carried the default vanetza::geonet::Address() (all-zero mid, not manually configured), regardless of what the caller put in mib.itsGnLocalGnAddr — BTP's ATS never checks the source GN address, so this was invisible until GeoNetworking's did. Fixed with one call in Stack::Impl's constructor (ports/esp_idf/src/stack.cpp): router.set_address(cfg.mib.itsGnLocalGnAddr);

With both fixed, TC_GEONW_FDV_SHB_BV_01 passes: initialize, AcGnPrimitive::getLongPosVector, the beacon feed through AdapterControlPort (f_startBeingNeighbour's whole purpose — giving the router a location-table neighbour so TrafficClass.scf doesn't buffer the SHB request instead of transmitting it immediately), the UtGnTrigger::shb request, and the resulting SHB packet's observed header fields (including the now-correct source address) all match exactly (see the MTC log). Rerun three times back to back with an identical verdict every time: not a fluke.

TC_GEONW_PON_SHB_BV_01 and TC_GEONW_PON_FPB_BV_11_05 both declare runs on ItsMtc and use a second config (CF02/CF03) that models an independent simulated station (ItsNodeB/ItsNodeA) as a genuine parallel test component (PTC), each mapping its own port set. This adapter, like the BTP one, models exactly one IUT connection; extending it to back a PTC's ports too (so the "other station" is also a real, addressable simulated peer rather than the same single Process/SUT singleton) is unimplemented. PON_FPB_BV_11_05 happens to end INCONC before reaching that requirement (its own PICS gate on PICS_GN_GUC_SRC triggers first), so this gap is only actually exercised, and confirmed as a fail, by PON_SHB_BV_01.

Evidence: geonetworking-host-01 (host SUT, native Linux build — WSL interop could not pipe stdin/stdout to a Windows-built host executable, so the host SUT for this suite is built natively per test-campaigns.md).

Security entity, cross-layer SAPs and the AtsSecurity campaigns

The security work (branch feature/etsi-cross-layer-security, one commit per increment) adds a signing security entity, the identifier change of TS 102 723-8 clause 6.3, the SN/SF/MN/MF/MI bindings and the TS 102 941 request/response core; standards.md lists the clauses. Every result below comes from this port.

Crypto backend. BackendMbedTls uses only the PSA Crypto API because ESP-IDF 6.0.2 ships mbedTLS 4.1.0 (TF-PSA-Crypto), whose classic ecp/bignum headers are private. PSA imports Weierstrass public keys only in uncompressed form, so compressed IEEE 1609.2 points are recovered by vanetza_idf::ecc (y = rhs^((p+1)/4), p = 3 mod 4 for NIST P-256 and both Brainpool curves). The host tests run the same backend against OpenSSL as an oracle (signature cross-verification, decompression against EC_POINT_set_compressed_coordinates, known-answer vectors in test_backend_kat.cpp) in the VIDF_MBEDTLS_ROOT build (1112 checks); the device runs the PSA path natively (810 checks, the difference being the OpenSSL-only oracle tests).

Trust and refusal. CertificatePool::add checks the TS 103 097 clause 7.2.1 ticket profile and probes the key against the certificate (sign then verify) before accepting it; TrustConfiguration anchors roots and AAs; the entity refuses a request without a valid ticket for the ITS-AID/SSP, with an expired ticket, or with a ticket whose issuer is not anchored, and counts each refusal. Nothing is transmitted unsigned when itsGnSecurity is set, and decapsulate_packet reports Configuration_Problem/Unsigned_Message, never success (test_fail_closed).

Identifier change. IdentityManager runs PREPARE/COMMIT/ABORT/DEREG rounds with a response timeout (default 500 ms), lock with expiry, deferred responders (a subscriber may answer from another context) and re-entrancy guarding; the GN core subscribes when itsGnLocalAddrConfMethod is ANONYMOUS, marks identity_change_pending on PREPARE, flushes the forwarding buffers and rewrites the MID from the committed HashedId8 (least significant six octets, locally administered bit set); the SF binding drives a VRU-style subscriber in test_sf_facilities_hook. The corresponding Linux Release run initially segfaulted in that test: the entity was destroyed after the subscriber it notifies on DEREG had gone out of scope (a use-after-scope in the test, not the library), fixed by scoping the entity's lifetime explicitly.

Upstream Vanetza changes. Three additive ones: SignRequest::context_information and DataRequest::security_context carry the SN-ENCAP context information from BTP/GN request to the security entity (Router::encap_packet gained the parameter); Router::flush_forwarding_buffers() is public so the GN core can drop buffered packets carrying the old identifier on PREPARE. One corrective one (decided 2026-09-14): v3::SecuredMessage::get_inline_p2pcd_request() widened each 3-octet HashedId3 through an 8-octet conversion and truncated the wrong end; it now uses create_hashed_id3() and test_signing_profiles checks the accessor against the raw ASN.1 field. A second corrective one (2026-09-14, found through the independent verifier below): the pinned asn1c files were generated from an IEEE 1609.2 module with eeType EndEntityType DEFAULT '00'H, so the decoder installed '00'H (neither app nor enrol) into every decoded PsidGroupPermissions without an explicit eeType, while IEEE Std 1609.2-2022/-2025 clause 6.4.28, the base of TS 103 097 V2.2.1, defaults to {app}; vanetza/asn1/security/PsidGroupPermissions.c now installs '80'H (asn_DFL_5_cmp/_set, hand-edited and commented) and asn1/IEEE1609dot2.asn says DEFAULT {app}. Nothing else in vanetza/ changed; all five are listed in the thesis's adapted-code.yaml.

ITS time base. vanetza_idf/its_time.hpp converts wall-clock (Unix) time to TAI microseconds since the ITS epoch with the five leap seconds inserted since 2004, as TS 102 894-2 V2.4.1 TimestampIts and the IEEE 1609.2 Time64/Time32 definitions require; test_its_time uses the CDD's own example (2007-01-01T00:00:00Z = 94 694 401 000 ms) and the "5 s ahead of UTC" statement as vectors and shows that upstream Clock::at() is 5 s behind (it subtracts the epoch in UTC). The Security adapter and vidf_test_pool use the helper; the framework's base_time is UTC-based, hence the 5 s offset in its logs.

AtsSecurity campaigns. etsi_security_adapter.cpp implements the ItsSecSystem ports (GeoNetworking, GN/CAM/DENM upper testers, adapter control) against the official AtsSecurity testcase objects (build_etsi_security_adapter.py, adapter-build.json). The host SUT is vidf_sut --security-pool ./certificates, the pool an isolated test trust domain written by vidf_test_pool in the framework's own loader layout (hashes in each result.json). Three properties of the run matter for reading the verdicts:

  1. Verification is the framework's. Secured transmissions are unwrapped by security_services_its::verify_and_extract_gn_payload with enable_security_checks=1, so a failed signature/digest/generation-time check discards the packet instead of passing it up with a warning; a PASS therefore includes a signature the framework verified against CERT_IUT_A_AT, not only the template match.
  2. One SUT per campaign, shared across TITAN components. TITAN's parallel runtime forks the MTC and each PTC from the host controller; the DENM cases trigger from a PTC while the MTC observes the GN port. The adapter starts the SUT once in the host controller before any fork and serialises every command/reply exchange with a process-shared robust mutex; the DENM carrier is deferred to the next clock advance so the transmission surfaces on the component that owns the GN port.
  3. Two stimulus configurations, no per-testcase switching. A running CAM carrier restarts the beacon timer with every SHB (TS 103 836-4-1 clause 10.3.5), so the GN-MGMT cases run with the carrier off (etsi_security_gn.cfg) and the CAM/DENM cases with it on (etsi_security_facilities.cfg, PX_GN_UPPER_LAYER := e_btpB). The carriers are syntactically valid Release 2 CAM (SHB) and DENM (GBC into a 500 m circle, TS 103 831 clause 5.4.2) PDUs of the test application; no CA/DEN service is claimed.

Verdicts (security-host-06, security-host-07; the earlier runs -01/-02 and -03/-04 gave the same verdicts):

Case Verdict Note
TC_SEC_ITSS_SND_GENMSG_01..04, 06..08_BV pass Secured beacons, psid 141, digest/certificate alternation, generationTime, signedData payload
TC_SEC_ITSS_SND_GENMSG_05_BV fail The testcase compares validityPeriod.start (Time32, seconds) with a range built from v_curTime in microseconds (pinned ItsSecurity_TestCases.ttcn line 7313; unchanged at the upstream master's line 6672), so no IUT passes it. The test purpose's own condition (start <= generation time < start + duration) holds for the logged values. Retained, not tuned; analysis
TC_SEC_ITSS_SND_CAM_01..04_BV pass psid 36, headerInfo without expiry/location, signer digest or certificate with appPermissions
TC_SEC_ITSS_SND_DENM_01..03_BV pass psid 37, generationLocation present, GBC packet

Receive-side verification (decided 2026-09-14, ACT-025). SecurityEntity:: decapsulate_packet now implements IEEE Std 1609.2 clause 5.2 as TS 103 097 clause 5.2 requires, built with VIDF_SECURITY_VERIFY (default on): the TS 103 097 clause 7.1 structure of the received message (check_profile), the signer from the inline certificate or the bounded certificate cache, the upstream DefaultCertificateValidator (validity time, ITS-AID permission, anchoring, chain consistency, region through DefaultLocationChecker), the certificate signatures up the chain to a provisioned root (verify_certificate_signature, IEEE 1609.2 clause 5.3.1: the upstream validator only checked anchoring by issuer digest, so a forged ticket naming a known AA would have passed), the message signature over calculate_message_hash, then the generationTime window and replay detection of VerificationPolicy. The P2P certificate distribution of clause 7.1.1 is wired both ways: unknown AT/AA digests are requested through the header policy, and an AA carried in requestedCertificate is learned once its signature chains to a root. test_verification (host and device) covers a valid message with certificate and with digest, the GN-core path with the metadata in BTP-DATA.indication, a tampered signature, an unknown station, an unanchored chain, a forged ticket, a ticket without the ITS-AID, an expired ticket, the time window in both directions, five profile violations and the AA-learning sequence. The verification flow is a port of upstream's StraightVerifyService::verify(v3) (that translation unit drags the v2 code in), with the chain check added; VerificationPolicy bounds the certificate cache (upstream's is unbounded), the learned AAs and the replay window for the device.

Verification cost on the ESP32-C5 (security-device-06, PSA Crypto of mbedTLS 4.1.0): a digest-signed message costs 34.6 ms end to end, of which the raw ECDSA P-256 verification is 29.7 ms on the ECDSA peripheral (CONFIG_MBEDTLS_HARDWARE_ECDSA_VERIFY); the same build with the peripheral disabled needs 57.6 ms for the raw verification and 62 ms end to end (control run, not retained as evidence). The peripheral is only taken when the PSA operation names the hash (PSA_ALG_ECDSA(PSA_ALG_SHA_256)); the backend used PSA_ALG_ECDSA_ANY before, which ESP-IDF's driver declines, so this run also removes that software fallback. Roughly 25 to 30 verified messages per second are therefore the C5's budget; the chain signature of a newly seen ticket is verified once and remembered. The host (OpenSSL) verifies in about 0.2 ms.

Receiving-side campaign (security-host-05, analysis): etsi_security_receive.cfg selects the 26 compiled TC_SEC_ITSS_RCV_* cases inside the SUT's PICS (no implicit certificates, no Brainpool). The test system signs in TTCN-3 with CERT_TS_A_AT, or CERT_TS_B_AT (5 km circular region around the SUT) as PX_AT_CERTIFICATE; the adapter injects the secured GN PDU and reports what the SUT passes up as UtGnEventInd. 24 pass (accepting the valid messages, discarding every protocol-version, profile, algorithm, signer and signature violation); TC_SEC_ITSS_RCV_DENM_01_BV and DENM_02_BV_XX end in error because the testcases read NodeB's position from the position table before f_cf01Up() fills it (pinned lines 9293/9401, same in the upstream master): a second, IUT-independent ATS defect, retained as recorded. The 99 CERT/GENMSG receiving cases of TS 103 096-2 are commented out in the pinned suite.

Chain consistency (2026-09-14, from the independent verifier). The upstream DefaultCertificateValidator reduces permission consistency to "the issuer lists the ITS-AID"; ChainValidator now applies IEEE Std 1609.2-2025 clause 5.1.2 as TS 103 097 clause 5.2 requires: for every appPermissions entry of the signing certificate each ancestor must hold a PsidGroupPermissions group whose chain-length window covers the distance to that ancestor (6.4.28: minChainLength/chainLengthRange count the certificates below the ancestor down to the end entity, -1 unbounded, 0 invalid), whose eeType permits an authorization certificate (absent = {app}), and whose subjectPermissions cover the PSID (all unless another group lists the PSID explicitly) with the SSP inside the range (6.4.30: every 1 bit of sspBitmask fixes the subordinate's bit, the SSP may not be longer than the mask nor shorter than its last 1 bit; opaque: an identical entry; an omitted SSP needs all or an empty opaque entry); a subordinate CA's own ranges must nest inside its issuer's. A violation is reported as INCONSISTENT_CHAIN (TS 102 723-8 Table 27) with Inconsistent_With_Signer. test_chain_consistency exercises a ticket whose CAM SSP sets bits the AA's mask fixes to zero, a root permitting a chain of length 1 only, and a root group with eeType enrol only, all with valid signatures (873 host checks, 747 on the device). On the device the same three checks appended to test_verification failed at first: after that test's stations and a fourth trust configuration the heap was fragmented (largest free block 16 kB), an allocation failure inside verify() surfaced through the catch-all as INCOMPATIBLE_PROTOCOL, and the frame bytes were identical to the host's; the checks now run as their own test with a fresh heap (security-device-07). The test trust domain and vidf_test_pool moved to the EU CCMS CPOC Protocol Release 3.0 root profile at the same time (minChainLength 2, eeType app+enrol, psid 623 01C0/FF3F for end entities plus a second group 013E/FFC1 for authorities; GN-MGMT range all; AA appPermissions psid 623 ssp 0130, EA 010E, root CRL 01 and CTL 0138 per TS 102 941 V2.2.1 Tables B.3/B.6). The three security campaigns were rerun on the new pool with identical verdicts (security-host-08 receiving 24/26, -09 GN-MGMT 7/8, -10 CAM/DENM 7/7).

Region consistency (2026-09-14, from the twin of the real root). The upstream is_within() decides circles, rectangles and polygons only; an identifiedRegion issuer, which the EU CCMS CPOC Protocol Release 3.0 root profile prescribes (clause I.3.9: a root that is not globally valid carries identifiedRegion, countryOnly 65535 for the EU as a whole), made every subordinate "outside" and hence every chain under an EU root inconsistent. The upstream region consistency is switched off in the base validator and ChainValidator applies IEEE Std 1609.2 clause 6.4.17 itself (region_within): no issuer region, anything goes; issuer region but none on the subject, inconsistent; geometric issuer regions by the upstream geometry; identified issuer regions by identifier containment (a country covers its regions and subregions, lists must nest); a geometric subject region under an identified issuer region needs a border database the device does not carry and follows VerificationPolicy::permissive_identified_region, as the location check already did. test_chain_consistency adds a root with countryOnly 65535, an AA derived from it (issue_authority, the derivation vidf_issue uses), a ticket with the inherited region (verifies), an AA without region (INCONSISTENT_CHAIN) and a circular ticket region (strict policy: INCONSISTENT_CHAIN, permissive: verifies), as test_region_consistency (its own test: at most two stations alive on the device): 873 host checks, 747 on the ESP32-C5.

Credentials at run time (2026-09-14, ACT-024). credentials.hpp fixes what a provisioning path hands over: Credentials (root and CA certificates as COER, tickets with their private scalars), the VCR1 bundle encoding, apply() into TrustConfiguration/CertificatePool through the entity's own checks (the first refused item stops it and is reported), the CredentialStore interface with FileCredentialStore and, on ESP-IDF, NvsCredentialStore (CONFIG_VANETZA_IDF_NVS_CREDENTIALS, one blob on the nvs_flash component; NVS initialisation and encryption are the application's). The split is deliberate: the octet format and the stores are generic to any ESP32 station and live in the component; the transport (serial, wireless, a TS 102 941 client) and the policy of when to load or replace credentials are the application's, so the library never reads a bundle on its own. The test application's diagnostic command 9 is such a transport: serial_sut.py --bundle and capture_pcap.py --port --bundle provision a board before its reset. test_credentials (890 host checks, 765 on the device with the NVS round trip) covers the codec, the malformed cases, apply() and the stores; independent-verifier-03 is the ESP32-C5 signing with a chain it received this way, verified by c-its.

Trust lists (2026-09-14, TS 102 941 clause 6.3). pki::build_rca_ctl and build_crl produce the RCA's FullCtl (EA/AA/DC entries) and CRL as EtsiTs103097Data-Signed over EtsiTs102941Data with the RCA certificate as the signer, psid 624/622 and the RCA's CTL/CRL appPermissions required; parse_rca_ctl/parse_crl accept a list only when it verifies as signed by the RCA the station trusts and every EA/AA entry is issued by that RCA with a verifying signature; apply turns the entries into trusted issuers and the CRL into revocations by issuer (TrustConfiguration::revoke), and the base validator's chain_is_revoked walk (now fed with the lookup) rejects any chain link so revoked with REVOKED_CERTIFICATE. test_trust_lists covers build/parse/apply, a tampered list, a foreign root, an AA not issued by the root, a CRL replacing an earlier one; test_chain_consistency the receiver refusing every ticket under a revoked AA and accepting again after the withdrawal (935 host checks, 810 on the device; test_revocation is its own test: on the device the sender's first signature after a receiver and three scoped stations existed threw inside the PSA backend with a fragmented heap, the same lesson as test_chain_consistency). trust-lists-01 serves both lists from tools/local_dc.py (Annex D GETs, content types) and lets c-its' download-int-certs fetch, validate and extract the AA of the twin root; the fetch on the library side is tools/fetch_trust_lists.py (the transport stays the application's). The ASN.1 of the lists is compiled from the TS 102 941 V1.3.1 module in the pinned asn1c set; these types are unchanged in V2.2.1.

Cryptographic hardware of the ESP32-C5 (2026-09-14). Used by the PSA backend as configured (sdkconfig): the ECDSA peripheral for verification (CONFIG_MBEDTLS_HARDWARE_ECDSA_VERIFY), the ECC point-multiplication accelerator for the software ECDSA sign (CONFIG_MBEDTLS_HARDWARE_ECC, P-256), the SHA accelerator (CONFIG_MBEDTLS_HARDWARE_SHA), the MPI and AES accelerators (CONFIG_MBEDTLS_HARDWARE_MPI, _AES, the latter for the ECIES AES-CCM of the PKI core). Measured on the board (security-device-09): a raw ECDSA P-256 sign 23.2 ms, one SN-ENCAP (hash, sign, encode) 32.4 ms; a raw verify 29.3 ms, one SN-DECAP 35.4 ms (host, OpenSSL: sign 0.11 ms, SN-ENCAP 0.23 ms). Not used: the ECDSA peripheral's signing mode (CONFIG_MBEDTLS_HARDWARE_ECDSA_SIGN) takes its private key from an eFuse key block only (purpose ECDSA_KEY: burnt once, never readable), which fits one long-term device key such as the TS 102 941 canonical key but not authorization tickets provisioned at run time; the Key Manager (SOC_KEY_MANAGER_ECDSA_KEY_DEPLOY) can deploy a device-bound encrypted key into that peripheral and would give tickets hardware-held keys at a deployment per ticket switch, through ESP-IDF's esp_key_mgr/esp_ecdsa rather than the PSA path the backend uses; neither is attempted (thesis ACT-030). Keys at rest: NVS/flash encryption, the application's configuration.

Independent verifier (independent-verifier-01, -02 on a twin of a real EU CCMS L0 root, -03 from the ESP32-C5). c-its (an unrelated Rust implementation with its own ASN.1 modules and crypto) verifies the frames the host SUT signs with a lab chain from vidf_issue, recorded as an 802.11 pcap by tools/capture_pcap.py: message signature, the three certificate signatures, chain lengths, end-entity permissions and the CAM/DENM content against the ticket's SSP all pass (security_authorized: true for three CAMs and one DENM). Its first run, against the previous lab profile, is what exposed the chain-length and eeType issues above. Its one disagreement with the standard ticket is its own limitation: it treats an appPermissions entry without SSP (psid 141 as the ETSI test-suite tickets carry it) as unsupported, whereas IEEE Std 1609.2 makes an omitted SSP consistent with an issuer range all.

Device heap. The first device run with security failed in test_fail_closed with "OER decoding failed" (security-device-02). Section markers now print the free heap on the device; the re-run showed 14 kB free when the asn1c copy failed, with five test stations (router, security entity, trust domain each) alive at once, 25-45 kB apiece. The test scopes each scenario now; the library was not changed (security-device-03, 112 kB free at every section). Sizing note for applications: one secured station on the C5 test firmware leaves roughly 110 kB of the internal heap.

Board recovery. That failing device run also left the board unflashable ("Write timeout" from esptool): the ROM UART0 clock-enable repair described above lived in run_hil_server(), which a failed test run never reaches, so the next USB-triggered warm reset hung in ROM. The repair now lives in the component itself (ports/esp_idf/src/esp32c5_rom_uart_clock.c, an ESP_SYSTEM_INIT_FN kept by an undefined-symbol reference, so every application using the component on an ESP32-C5 gets it before app_main); after that build both boards accepted a second esptool --before default-reset flash (security-device-05, 537 checks). The board that was still running the older image (COM20) had been recovered over JTAG first: OpenOCD halts the core, sets PCR_UART0_SCLK_EN before each program_esp step and programs the three images (security-device-04, openocd-flash.log).

Independent C5 radio pair: a real bug in an "FCS" check that could never pass

radio_pair.py's independent two-board radio test (COM20 transmits, COM11 receives, over real RF) initially failed on every run with "no exact independent frame with valid FCS observed", even though the frame's actual content — every header field and the full application payload — decoded correctly. Decoding the raw captured bytes by hand (initially mis-modeling ShbHeader as a 24-byte LongPositionVector with no trailing field, which made 8 bytes look unaccounted for instead of 4) led to the real structure: vanetza::geonet::ShbHeader is LongPositionVector (24 bytes) plus a 4-byte reserved DccField (vanetza/geonet/shb_header.hpp); once that's accounted for, the frame is exactly 102 bytes of correct content plus a 4-byte trailer.

That trailer is not a real FCS. Three separate captures with completely different random payloads all produced the identical trailing 4 bytes (00 00 99 00) — direct proof the value is not content-derived and can never match a recomputed CRC32, regardless of whether the frame was received correctly. Two independent facts explain why checking it was never necessary in the first place:

  • vanetza_idf::C5Radio's promiscuous filter (ports/esp_idf/src/c5_radio.cpp) never sets WIFI_PROMIS_FILTER_MASK_FCSFAIL ("Filter the FCS failed packets, do not open it in general" — Espressif's own comment on that flag). Any frame reaching the receive callback at all has already passed a real, hardware-validated FCS check; there is nothing left to verify at the software level.
  • rx_ctrl.sig_len on this chip is documented in esp_wifi_he_types.h (this chip's 802.11ax/HE RX descriptor) as "the length of the reception MPDU" — not "MPDU + FCS" as the ESP-IDF documentation for older, non-HE ESP32 chips states, which is what c5_radio.cpp's previous comment was written against.

Fixed by checking content match only — which is what "the independent receiver correctly received what the DUT transmitted" actually means here — and correcting the comments in c5_radio.cpp, its_g5_frame.hpp and radio_pair.py that assumed the trailing bytes were a real FCS. The trailing bytes are still captured and reported (result.json's trailing_bytes) for inspection, just no longer gated on. Verified stable across three consecutive real-RF reruns (evidence), each with a fresh random payload and an identical 00 00 99 00 trailer.

See campaign instructions and interface/capability assessment for remaining work.