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).
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 mapsUpperProtocol::UnknowntoNextHeaderCommon::Any, matching what the default constructor already produces.StackgainedGnRequest/GnIndicationandrequest(GnRequest)/on_receive_gn(...): the same SHB/GBC-only validation asBtpRequest, but with a raw payload and no header construction, registered against the router'sUpperProtocol::Unknowntransport 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:
- Verification is the framework's. Secured transmissions are unwrapped by
security_services_its::verify_and_extract_gn_payloadwithenable_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 againstCERT_IUT_A_AT, not only the template match. - 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.
- 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 setsWIFI_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_lenon this chip is documented inesp_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 whatc5_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.