diff --git a/.github/workflows/esp-idf.yml b/.github/workflows/esp-idf.yml new file mode 100644 index 0000000..0d08fe6 --- /dev/null +++ b/.github/workflows/esp-idf.yml @@ -0,0 +1,48 @@ +name: ESP-IDF Build + +# Build nanortc ESP-IDF examples to catch component / Kconfig / lwIP regressions +# without requiring real hardware. Compile-only — no flashing, no on-device tests. +# +# The Python E2E test (tests/esp32/test_esp32_dc.py) requires hardware or QEMU and +# is intentionally not part of this workflow; it runs as workflow_dispatch / manual. + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + esp-idf-build: + name: ESP-IDF ${{ matrix.example.target }} / ${{ matrix.example.path }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + example: + # esp32_datachannel: minimal config — exercise nanortc on every supported target + - { path: esp32_datachannel, target: esp32 } + - { path: esp32_datachannel, target: esp32s3 } + - { path: esp32_datachannel, target: esp32p4 } + # esp32_audio / esp32_video: representative target only + # (the codec stacks are target-agnostic; if datachannel builds on all + # three, the additional codec components only need one verification) + - { path: esp32_audio, target: esp32s3 } + - { path: esp32_video, target: esp32s3 } + # esp32_camera (P4 only) is intentionally excluded — its + # board_manager.defaults autogen + camera-sensor selection is + # board-specific and will be added in a follow-up workflow. + steps: + - uses: actions/checkout@v4 + with: + # esp32_video bakes sample_data submodule frames into video.blob / + # audio.blob at build time; without this, those targets fail with + # "sample_data/h264SampleFrames is not a directory". + submodules: recursive + + - name: ESP-IDF build (${{ matrix.example.target }}) + uses: espressif/esp-idf-ci-action@v1 + with: + esp_idf_version: v5.5 + target: ${{ matrix.example.target }} + path: examples/${{ matrix.example.path }} diff --git a/docs/exec-plans/active/phase8-continued-optimization.md b/docs/exec-plans/active/phase8-continued-optimization.md index 1b8bd7f..c0edce2 100644 --- a/docs/exec-plans/active/phase8-continued-optimization.md +++ b/docs/exec-plans/active/phase8-continued-optimization.md @@ -212,6 +212,21 @@ This bug has existed since the initial ICE implementation; Phase 4's interop sui **Dependency.** None — independent of PR-1..PR-4. Can land in parallel with any of them. Recommend landing early in Phase 8 because it unblocks real browser-as-answerer deployments. +### Post-merge hardening + +Landed 2026-04-26 → 2026-04-27. A Copilot review on PR #59 surfaced three latent correctness gaps in the restart path that the original PR-5 work didn't cover. All three landed on `develop` as small follow-up commits, then were pinned with regression tests: + +| Commit | Fix | Reverting it now fails | +|---|---|---| +| `d59a528` | Defer `state = NANORTC_STATE_DTLS_HANDSHAKING` until `dtls_start()` returns OK so a failure leaves the FSM at `ICE_CONNECTED` and is retryable. | `tests/test_trickle_ice.c::test_api_ice_restart_clears_dtls` (FSM-level). | +| `8fea1e8` | Call `dtls_destroy(&rtc->dtls)` first thing in `nanortc_ice_restart()` so the next handshake re-runs `dtls_init()` instead of skipping it via the `if (!crypto_ctx)` guard. | `tests/test_e2e.c::test_e2e_ice_restart_dtls_rehandshake` — without the destroy, the post-restart fingerprint matches the pre-restart fingerprint (cert reused). | +| `9a75412` | `memset` both `sdp.local_fingerprint` and `dtls.local_fingerprint` after the destroy so `rtc_cache_fingerprint()` (a write-once cache) re-populates from the freshly generated cert. | `tests/test_e2e.c::test_e2e_ice_restart_sdp_fingerprint_refresh` — without the clear, the SDP cache holds the stale hash while DTLS holds the new one, so any peer that enforces `a=fingerprint` rejects the new handshake. | +| `af8f566` | Doc-only: corrected the rationale comment so the FSM-vs-receive-path distinction is explicit. | n/a (doc). | + +The two new E2E tests bracket the unit-level T20/T21 coverage with a full ICE → DTLS round trip on both sides, restart, re-sync, reconnect, and assert new keying material — closing the gap that the unit tests probe state in isolation but cannot show that two peers actually re-handshake. Validation: each new e2e test was confirmed to fail when its target commit is surgically reverted in the working tree (and to pass on baseline `develop`). + +See [tech-debt-tracker.md TD-022](../tech-debt-tracker.md#resolved-debt) for the consolidated entry. + --- ## P2 Series (on-demand) diff --git a/docs/exec-plans/tech-debt-tracker.md b/docs/exec-plans/tech-debt-tracker.md index af6e827..8bca401 100644 --- a/docs/exec-plans/tech-debt-tracker.md +++ b/docs/exec-plans/tech-debt-tracker.md @@ -52,6 +52,7 @@ Track known debt, prioritize by impact, pay down continuously. | TD-018 | 2026-04-13 | Phase 8 / PR-5: Replaced the single `last_txid` / `last_{local,remote}_idx` scratch in `nano_ice_t` with a fixed per-pair pending-transaction table (`nano_ice_pending_t pending[NANORTC_ICE_MAX_PENDING_CHECKS=4]`). `ice_generate_check` allocates a slot (free → stale → reap-oldest, RFC 8445 §6.1.4.2); `ice_handle_stun` Binding Response branch scans the table for a txid match, uses the captured pair indices to populate `selected_*`, and frees the slot after MESSAGE-INTEGRITY verifies. Added `selected_local_idx` for `ice_generate_consent` (previously read a stale `last_local_idx`). Secondary fix: ICE FAILED path in `rtc_process_timers` now emits `NANORTC_EV_DISCONNECTED` symmetric to the consent-expiry path so applications see a consistent signal across both failure modes. New config macros `NANORTC_ICE_MAX_PENDING_CHECKS` / `NANORTC_ICE_CHECK_TIMEOUT_MS` with compile-time validation. New regression tests: `test_ice_controlling_multi_pair_response_out_of_order` (feeds the 2nd of 3 responses — pre-fix rejected, post-fix selects the correct pair), `test_ice_controlling_pending_table_full` (asserts reap-oldest makes forward progress), and augmented `test_e2e_ice_connection_timeout` to assert both `EV_ICE_STATE_CHANGE(FAILED)` and `EV_DISCONNECTED`. `sizeof(nano_ice_t)` stays under the 600 B ceiling. All 6 feature combos × 2 crypto backends + ASan + interop green. | | TD-021 | 2026-04-17 | ICE hardening pass per RFC 8445 / 8489 / 7675: (a) `ice_handle_stun()` now rejects incoming Binding Request / Response without MESSAGE-INTEGRITY or FINGERPRINT (§7.1.2.1, §7.1.3, §8.1). (b) `nanortc_init()` fills `ice.tie_breaker` via `cfg->crypto->random_bytes()` (§5.2). (c) `src/nano_sdp.c` emits srflx/relay priorities via `ICE_SRFLX_PRIORITY(idx)` / `ICE_RELAY_PRIORITY(idx)` with the runtime-matching slot index so SDP-advertised priority equals STUN PRIORITY (§5.1.2.1). (d) New `STUN_BINDING_ERROR` (0x0111) handler matches txid, frees the pending slot, and surfaces the error to the caller (§7.3.1.1 / RFC 8489 §6.3.4); full 487 auto-swap is a follow-up. (e) `ice_generate_check()` short-circuits in DISCONNECTED (consent lost — recovery requires `ice_restart`, RFC 7675 §5.2). (f) `ice_consent_expired()` now surfaces an unarmed `consent_expiry_ms` as expired instead of silently returning false, so a forgotten-arm bug fails loud rather than disabling the liveness timeout. Six new unit tests (`test_ice_request_without_fingerprint_rejected`, `test_ice_response_without_integrity_rejected`, `test_ice_binding_error_frees_pending_slot`, `test_ice_generate_check_noop_in_disconnected`, `test_e2e_tie_breaker_is_randomised`, updated `test_consent_expired_when_unarmed`) pin each behavior. Full module audit at [docs/engineering/ice-rfc-compliance.md](../engineering/ice-rfc-compliance.md). 20/20 ctest + 15/15 fast CI green across feature combos. | | TD-020 | 2026-04-17 | ICE pair formation enforces RFC 8445 §6.1.2.2: `ice_advance_to_same_family_pair()` in `src/nano_ice.c` advances past cross-family (local, remote) combinations before allocating a pending slot, so check budget is only spent on pairs a UDP socket can actually carry. Linux run-loop (`examples/common/run_loop_linux.c`) and `browser_interop` (`examples/browser_interop/main.c`) now enumerate `AF_INET6` interfaces alongside `AF_INET`, skipping link-local / multicast / unspecified; global IPv6 addresses are registered as additional local candidates via `nanortc_add_local_candidate()`. End-to-end covered by `test_e2e_ipv6_loopback_connects` (two `nanortc_t` reach DTLS_CONNECTED over `[::1]` with `selected_family == 6`) and three unit tests `test_ice_pair_family_filter_*` (skip cross-family, pick both same-family pairs, emit no packet when no same-family pair exists). All 20 ctest suites + fast CI (clang-format, ASan, `ICE_SRFLX=OFF`, `IPV6=OFF`) green. | +| TD-022 | 2026-04-27 | Post-PR-5 ICE-restart hardening (Copilot review on PR #59). Three correctness fixes plus a doc rewording, all in `nanortc_ice_restart()` / `rtc_begin_dtls_handshake()` (`src/nano_rtc.c:2320–2393`, `:949–:980`): (a) `d59a528` defers `state = NANORTC_STATE_DTLS_HANDSHAKING` until `dtls_start()` succeeds — on failure the FSM stays at `ICE_CONNECTED` so the caller can retry; (b) `8fea1e8` calls `dtls_destroy(&rtc->dtls)` first thing in restart so the next handshake re-runs `dtls_init()` instead of skipping it via the `if (!crypto_ctx)` guard, preventing key-material reuse and orphan handshake timers across the new ICE 5-tuple; (c) `9a75412` `memset`s both `sdp.local_fingerprint` and `dtls.local_fingerprint` after the destroy — `rtc_cache_fingerprint()` is a write-once cache that bails on non-empty input, so without the clear the next `create_offer` / `accept_offer` would advertise the *previous* cert's hash while `dtls_init()` generates a new cert (DTLS verify then fails on any peer that enforces `a=fingerprint`); (d) `af8f566` corrects the now-misleading rationale comment to make the FSM-vs-receive-path distinction explicit. Unit coverage: `test_api_ice_restart_clears_dtls` + `test_api_ice_restart_no_dtls_safe` in `tests/test_trickle_ice.c`. E2E regression coverage added 2026-04-27: `test_e2e_ice_restart_dtls_rehandshake` (full ICE → DTLS → restart → reconnect roundtrip; asserts new cert hash differs from pre-restart) and `test_e2e_ice_restart_sdp_fingerprint_refresh` (asserts `sdp.local_fingerprint` re-syncs to the freshly generated `dtls.local_fingerprint` across the restart) in `tests/test_e2e.c`. Validation matrix in [docs/exec-plans/active/phase8-continued-optimization.md](active/phase8-continued-optimization.md#post-merge-hardening) confirms each new e2e test fails when its target commit is surgically reverted. `scripts/ci-check.sh --fast` 15/15 green; full host suite 22/22. | | TD-019 | 2026-04-15 | Phase 5.2: nanortc-as-TURN-client outbound data path. **F6** added a `bool via_turn` parameter to `ice_handle_stun()` and `rtc_process_receive()`; recursive calls from `turn_unwrap_data` / `turn_unwrap_channel_data` set it true so USE-CANDIDATE checks select `selected_type=NANORTC_ICE_CAND_RELAY` on relay paths. **F7** deferred the TURN wrap from `rtc_enqueue_transmit()` to `nanortc_poll_output()` via a per-slot `out_wrap_meta[]` side-table + dedicated `turn_buf` (sized via `NANORTC_TURN_BUF_SIZE`), preventing eager-wrap collisions when bursts of media share `stun_buf` scratch. **F8** routed RFC 7675 consent freshness checks through `rtc_enqueue_transmit()` so they get wrapped on RELAY pairs (previously bypassed via `rtc_enqueue_output` direct, dropping ~30 s after handshake). **F9** moved `CreatePermission` fan-out into `rtc_process_timers()`: each tick walks `remote_candidates[]` and emits one `CreatePermission` for the first peer without an active permission (previously only `remote_candidates[0]` got one, missing trickle-late relay/srflx candidates). **F10** added `stats_enqueue_via_turn` / `stats_enqueue_direct` / `stats_wrap_dropped` / `stats_tx_queue_full` counters on `nanortc_t` for runtime observability of the wrap path (16 B overhead, reserved for a future public stats API). Hand-verified end-to-end on a downstream macOS camera SDK example with a real cellular phone viewer (`type=relay tx_via_turn=8 tx_direct=0 wrap_drop=0`). All 22 nanortc unit tests + 5 SDK unit tests pass. **Coverage gap:** `tests/interop/test_interop_turn_relay.c` only exercises the libdc-as-relay direction; nanortc-as-relay direction has no automated test yet. See [docs/engineering/turn-rfc-compliance.md](../engineering/turn-rfc-compliance.md) Phase 5.2 section for the full F6–F10 finding table. | ## Principles diff --git a/examples/esp32_video/sdkconfig.defaults b/examples/esp32_video/sdkconfig.defaults index 12b9d76..63fab6b 100644 --- a/examples/esp32_video/sdkconfig.defaults +++ b/examples/esp32_video/sdkconfig.defaults @@ -1,6 +1,11 @@ # nanortc ESP32 A/V example — default SDK configuration # -# Custom partition table (factory = ~12 MB for firmware + embedded media blobs) +# Custom partition table (factory ~3.6 MB) — needs >= 4 MB flash. +# Default IDF flash size is 2 MB, so partition-table generation fails +# without an explicit FLASHSIZE here. Boards with larger flash can +# override via menuconfig. +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y +CONFIG_ESPTOOLPY_FLASHSIZE="4MB" CONFIG_PARTITION_TABLE_CUSTOM=y CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" diff --git a/src/nano_rtc.c b/src/nano_rtc.c index d3fbde7..9778b67 100644 --- a/src/nano_rtc.c +++ b/src/nano_rtc.c @@ -947,9 +947,11 @@ int nanortc_poll_output(nanortc_t *rtc, nanortc_output_t *out) } /* Init DTLS (if needed) and begin handshake after ICE connects. - * State is only advanced to DTLS_HANDSHAKING after dtls_start() succeeds, - * so a failure leaves state at ICE_CONNECTED and the caller can retry - * without a half-initialised DTLS context being driven by inbound bytes. */ + * State is only advanced to DTLS_HANDSHAKING after dtls_start() succeeds. + * On failure, state remains at ICE_CONNECTED so the caller can retry + * DTLS startup from the ICE-connected state. Note: this only governs + * the FSM; the inbound DTLS gate is handle_input's `state >= ICE_CONNECTED` + * check, so DTLS records remain accepted on the failure-retry path. */ static int rtc_begin_dtls_handshake(nanortc_t *rtc, const nanortc_addr_t *src) { int is_server = (rtc->sdp.local_setup == NANORTC_SDP_SETUP_PASSIVE); @@ -2331,6 +2333,15 @@ int nanortc_ice_restart(nanortc_t *rtc) * timers. dtls_destroy is a no-op when crypto_ctx is NULL. */ dtls_destroy(&rtc->dtls); + /* Invalidate cached fingerprints tied to the destroyed DTLS identity. + * rtc_cache_fingerprint() is a write-once cache that bails when + * sdp.local_fingerprint is non-empty; without this clear, the next + * create_offer/accept_offer would advertise the *previous* cert's + * fingerprint while dtls_init() generates a new one — DTLS verify + * would then fail on the peer side. */ + memset(rtc->sdp.local_fingerprint, 0, sizeof(rtc->sdp.local_fingerprint)); + memset(rtc->dtls.local_fingerprint, 0, sizeof(rtc->dtls.local_fingerprint)); + /* Reset ICE state (preserves role + tie_breaker, bumps generation) */ int rc = ice_restart(&rtc->ice); if (rc != NANORTC_OK) { diff --git a/tests/test_e2e.c b/tests/test_e2e.c index 77b6763..2df3dfc 100644 --- a/tests/test_e2e.c +++ b/tests/test_e2e.c @@ -544,6 +544,182 @@ TEST(test_e2e_ice_dtls_loopback) nanortc_destroy(&answerer); } +/* + * Helper: re-sync ICE credentials between offerer/answerer after both have + * called nanortc_ice_restart(). The restart preserves local_candidates but + * blanks the peer-side credentials and remote_candidates; cross-write the + * freshly generated ufrag/pwd and re-arm offerer's remote_candidates entry + * so the second handshake can drive checks on the same loopback 5-tuple. + * Mirrors the final block of e2e_setup_ice_creds(). + */ +static void e2e_resync_ice_creds(nanortc_t *offerer, nanortc_t *answerer) +{ + memcpy(offerer->ice.remote_ufrag, answerer->ice.local_ufrag, + sizeof(offerer->ice.remote_ufrag)); + offerer->ice.remote_ufrag_len = answerer->ice.local_ufrag_len; + memcpy(offerer->ice.remote_pwd, answerer->ice.local_pwd, sizeof(offerer->ice.remote_pwd)); + offerer->ice.remote_pwd_len = answerer->ice.local_pwd_len; + + memcpy(answerer->ice.remote_ufrag, offerer->ice.local_ufrag, + sizeof(answerer->ice.remote_ufrag)); + answerer->ice.remote_ufrag_len = offerer->ice.local_ufrag_len; + memcpy(answerer->ice.remote_pwd, offerer->ice.local_pwd, sizeof(answerer->ice.remote_pwd)); + answerer->ice.remote_pwd_len = offerer->ice.local_pwd_len; + + offerer->ice.remote_candidates[0].family = 4; + offerer->ice.remote_candidates[0].addr[0] = 192; + offerer->ice.remote_candidates[0].addr[1] = 168; + offerer->ice.remote_candidates[0].addr[2] = 1; + offerer->ice.remote_candidates[0].addr[3] = 2; + offerer->ice.remote_candidates[0].port = 5000; + offerer->ice.remote_candidate_count = 1; +} + +/* + * E2E: ICE restart drives a full DTLS re-handshake with a fresh certificate. + * + * Regression for commit 8fea1e8 ("tear down DTLS context on ice_restart"). + * Without dtls_destroy in nanortc_ice_restart, the next handshake would skip + * dtls_init (guarded by `!crypto_ctx`), reuse the original cert, and the + * fingerprint would be byte-identical pre/post restart. + */ +TEST(test_e2e_ice_restart_dtls_rehandshake) +{ + nanortc_t offerer, answerer; + nanortc_config_t off_cfg = e2e_default_config(); + off_cfg.role = NANORTC_ROLE_CONTROLLING; + ASSERT_OK(nanortc_init(&offerer, &off_cfg)); + nanortc_config_t ans_cfg = e2e_default_config(); + ans_cfg.role = NANORTC_ROLE_CONTROLLED; + ASSERT_OK(nanortc_init(&answerer, &ans_cfg)); + + e2e_setup_ice_creds(&offerer, &answerer); + + uint32_t now_ms = 100; + ASSERT_OK(nanortc_handle_input(&offerer, &(nanortc_input_t){.now_ms = now_ms})); + + int connected = 0; + for (int round = 0; round < 30; round++) { + e2e_pump(&offerer, &answerer, now_ms, 5); + if (offerer.state >= NANORTC_STATE_DTLS_CONNECTED && + answerer.state >= NANORTC_STATE_DTLS_CONNECTED) { + connected = 1; + break; + } + } + ASSERT_TRUE(connected); + + /* Snapshot fingerprints by value — dtls_destroy will zero the underlying buffer. */ + char pre_off_fp[sizeof(offerer.dtls.local_fingerprint)]; + char pre_ans_fp[sizeof(answerer.dtls.local_fingerprint)]; + ASSERT_TRUE(dtls_get_fingerprint(&offerer.dtls) != NULL); + ASSERT_TRUE(dtls_get_fingerprint(&answerer.dtls) != NULL); + memcpy(pre_off_fp, offerer.dtls.local_fingerprint, sizeof(pre_off_fp)); + memcpy(pre_ans_fp, answerer.dtls.local_fingerprint, sizeof(pre_ans_fp)); + + ASSERT_OK(nanortc_ice_restart(&offerer)); + ASSERT_OK(nanortc_ice_restart(&answerer)); + + /* Mid-restart invariants — the unit-level T19/T20/T21 properties must hold + * on a fully connected nanortc_t, not just on a hand-built one. */ + ASSERT_TRUE(offerer.dtls.crypto_ctx == NULL); + ASSERT_TRUE(answerer.dtls.crypto_ctx == NULL); + ASSERT_EQ(offerer.dtls.local_fingerprint[0], '\0'); + ASSERT_EQ(answerer.dtls.local_fingerprint[0], '\0'); + ASSERT_EQ(offerer.sdp.local_fingerprint[0], '\0'); + ASSERT_EQ(answerer.sdp.local_fingerprint[0], '\0'); + ASSERT_EQ(offerer.state, NANORTC_STATE_NEW); + ASSERT_EQ(answerer.state, NANORTC_STATE_NEW); + + e2e_resync_ice_creds(&offerer, &answerer); + + /* Bootstrap the second ICE check round and pump until DTLS reconnects. */ + ASSERT_OK(nanortc_handle_input(&offerer, &(nanortc_input_t){.now_ms = now_ms})); + int reconnected = 0; + for (int round = 0; round < 30; round++) { + e2e_pump(&offerer, &answerer, now_ms, 5); + if (offerer.state >= NANORTC_STATE_DTLS_CONNECTED && + answerer.state >= NANORTC_STATE_DTLS_CONNECTED) { + reconnected = 1; + break; + } + } + ASSERT_TRUE(reconnected); + + /* Fingerprint must differ — a regenerated cert is the proof that + * dtls_init actually ran on the second handshake. */ + ASSERT_TRUE(dtls_get_fingerprint(&offerer.dtls) != NULL); + ASSERT_TRUE(dtls_get_fingerprint(&answerer.dtls) != NULL); + ASSERT_NEQ(memcmp(pre_off_fp, offerer.dtls.local_fingerprint, sizeof(pre_off_fp)), 0); + ASSERT_NEQ(memcmp(pre_ans_fp, answerer.dtls.local_fingerprint, sizeof(pre_ans_fp)), 0); + + nanortc_destroy(&offerer); + nanortc_destroy(&answerer); +} + +/* + * E2E: ICE restart re-populates the SDP fingerprint cache with the new cert. + * + * Regression for commit 9a75412 ("clear cached fingerprints on ice_restart"). + * rtc_cache_fingerprint() is a write-once cache that bails when + * sdp.local_fingerprint is non-empty. Without the memset in ice_restart, + * the second nanortc_create_offer() would leave sdp.local_fingerprint at + * the *old* cert hash while dtls_init() generates a new cert — causing + * any peer that enforces a=fingerprint to reject the DTLS handshake. + */ +TEST(test_e2e_ice_restart_sdp_fingerprint_refresh) +{ + nanortc_t rtc; + nanortc_config_t cfg = e2e_default_config(); + cfg.role = NANORTC_ROLE_CONTROLLING; + ASSERT_OK(nanortc_init(&rtc, &cfg)); + +#if NANORTC_FEATURE_DATACHANNEL + ASSERT(nanortc_create_datachannel(&rtc, "x", NULL) >= 0); +#elif NANORTC_HAVE_MEDIA_TRANSPORT + ASSERT(nanortc_add_audio_track(&rtc, NANORTC_DIR_SENDRECV, NANORTC_CODEC_OPUS, 48000, 2) >= 0); +#endif + + /* First create_offer triggers dtls_init + rtc_cache_fingerprint. */ + char offer1[4096]; + ASSERT_OK(nanortc_create_offer(&rtc, offer1, sizeof(offer1), NULL)); + + /* Sanity: SDP cache holds "sha-256 " + dtls.local_fingerprint. */ + ASSERT_TRUE(rtc.dtls.local_fingerprint[0] != '\0'); + ASSERT_TRUE(rtc.sdp.local_fingerprint[0] != '\0'); + ASSERT_EQ(memcmp(rtc.sdp.local_fingerprint, "sha-256 ", 8), 0); + ASSERT_EQ(memcmp(rtc.sdp.local_fingerprint + 8, rtc.dtls.local_fingerprint, + sizeof(rtc.dtls.local_fingerprint) - 1), + 0); + + char pre_dtls_fp[sizeof(rtc.dtls.local_fingerprint)]; + memcpy(pre_dtls_fp, rtc.dtls.local_fingerprint, sizeof(pre_dtls_fp)); + + ASSERT_OK(nanortc_ice_restart(&rtc)); + ASSERT_EQ(rtc.dtls.local_fingerprint[0], '\0'); + ASSERT_EQ(rtc.sdp.local_fingerprint[0], '\0'); + + /* Second create_offer must regenerate the cert and re-cache the fingerprint. */ + char offer2[4096]; + ASSERT_OK(nanortc_create_offer(&rtc, offer2, sizeof(offer2), NULL)); + + ASSERT_TRUE(rtc.dtls.local_fingerprint[0] != '\0'); + ASSERT_NEQ(memcmp(pre_dtls_fp, rtc.dtls.local_fingerprint, sizeof(pre_dtls_fp)), 0); + + /* The advertised SDP fingerprint must match the freshly generated cert, + * not the stale pre-restart hash. Reverting 9a75412 fails this assertion: + * rtc_cache_fingerprint's early-return on non-empty sdp.local_fingerprint + * would leave the SDP cache holding the old "sha-256 OLD..." while the + * DTLS layer holds the new cert hash. */ + ASSERT_TRUE(rtc.sdp.local_fingerprint[0] != '\0'); + ASSERT_EQ(memcmp(rtc.sdp.local_fingerprint, "sha-256 ", 8), 0); + ASSERT_EQ(memcmp(rtc.sdp.local_fingerprint + 8, rtc.dtls.local_fingerprint, + sizeof(rtc.dtls.local_fingerprint) - 1), + 0); + + nanortc_destroy(&rtc); +} + /* ---------------------------------------------------------------- * Helper: check if NUL-terminated haystack contains needle * ---------------------------------------------------------------- */ @@ -2603,6 +2779,8 @@ RUN(test_e2e_multiple_instances); RUN(test_e2e_demux_byte_ranges); RUN(test_e2e_ice_loopback); RUN(test_e2e_ice_dtls_loopback); +RUN(test_e2e_ice_restart_dtls_rehandshake); +RUN(test_e2e_ice_restart_sdp_fingerprint_refresh); RUN(test_e2e_create_offer_content); RUN(test_e2e_offer_answer_roundtrip); RUN(test_e2e_full_sdp_to_dtls); diff --git a/tests/test_trickle_ice.c b/tests/test_trickle_ice.c index 807626e..812062d 100644 --- a/tests/test_trickle_ice.c +++ b/tests/test_trickle_ice.c @@ -569,6 +569,12 @@ static void test_api_ice_restart_clears_dtls(void) TEST_ASSERT_EQUAL_INT(NANORTC_OK, rc); TEST_ASSERT_NOT_NULL(rtc.dtls.crypto_ctx); + /* Pre-populate the cached fingerprints (rtc_cache_fingerprint is a + * write-once cache, so this mirrors a session that already ran an + * SDP exchange before the restart). */ + memcpy(rtc.sdp.local_fingerprint, "sha-256 AA:BB:CC", 17); + memcpy(rtc.dtls.local_fingerprint, "AA:BB:CC", 9); + /* Simulate a connected session on top of the live DTLS context. */ rtc.state = NANORTC_STATE_CONNECTED; rtc.ice.state = NANORTC_ICE_STATE_CONNECTED; @@ -582,6 +588,12 @@ static void test_api_ice_restart_clears_dtls(void) TEST_ASSERT_NULL(rtc.dtls.crypto_ctx); TEST_ASSERT_EQUAL_INT(NANORTC_DTLS_STATE_CLOSED, rtc.dtls.state); + /* Cached fingerprints must be invalidated so rtc_cache_fingerprint + * repopulates them from the next dtls_init's cert (otherwise the + * peer sees a stale SDP fingerprint and DTLS verify fails). */ + TEST_ASSERT_EQUAL_INT('\0', rtc.sdp.local_fingerprint[0]); + TEST_ASSERT_EQUAL_INT('\0', rtc.dtls.local_fingerprint[0]); + nanortc_destroy(&rtc); }