fix(rekey): complete relay-path session rekey (#91) #92

Merged
vxfemboy merged 21 commits from feat/rekey-9a-relay-completion into main 2026-07-22 04:53:08 +00:00
vxfemboy commented 2026-07-20 06:35:06 +00:00 (Migrated from github.com)

Closes #91. Stacked on #90 (base = feat/rekey-9a-session-rotation) — review #90 first.

What this does

9a rotates each peer's Noise-IK session ~120s for forward secrecy, but relay-reached peers were gated out of rekey scheduling (drive_rekey_schedule returned early on relay): rekey completion was only wired into the direct handshake handlers, so scheduling a relay rekey would have churned a never-completing ~1 Hz handshake (a DPI fingerprint). #91 wires relay-path completion, removes the gate, and proves it on the wire — so relay-only sessions now get forward-secrecy rotation too.

The security-critical idempotent logic is single-sourced, not duplicated: relay paths reorder more than direct, which makes 9a's ephemeral-keyed convergence fix more load-bearing there, and a copy would be free to drift from it.

Changes

  • Extract the rekey cores (c388cb4, b459dde) — rekey_init_core/rekey_resp_core(.., via_relay: bool) + push_rekey_egress; handle_rekey_init/handle_rekey_resp become thin via_relay=false wrappers. The direct path is byte-identical. push_rekey_egress takes the full EgressDatagram so the prime-emit preserves each packet's FEC fate (review fix — a collapsed fate would defeat GSO coalescing).
  • Wire the relay handlers (e60b7ba) — relayed_handshake_init's Established arm and relayed_handshake_resp route to the cores with via_relay=true. The cold-start cached_resp_init_eph dedup is preserved, so a cold-start Init retransmit still resends the cached Resp instead of being misread as a rekey.
  • Remove the gate (2fbe21e) — relay peers now schedule rekeys, emitting the Init via relay_wrap. A relay_wrap None skips that send only; the round stays in flight and retries.
  • netns relay-forced money test (b163430) — run-netns-rekey-relay.sh: three namespaces with no direct A↔B path, so traffic must traverse the blind relay. rekey_epoch_witness gained an opt-in YIP_WITNESS_UNWRAP_RELAY=1 mode that strips the RelaySend/RelayDeliver envelope before counting distinct on-wire ephemerals (env-gated, so the 9a direct test is unaffected). Wired into CI, both drivers.
  • Path-consistency fix (004c138) — see below.

Defect caught by the final review

Removing 9a's gate silently dropped an invariant it had been providing: relay and direct rekey completion were implicitly mutually exclusive. Without that, a direct peer completing a rekey via a relayed Init/Resp built the new epoch with peer_addr = server_addr() while peers[idx].relay stayed false — and on_tun decides relay-wrapping from peers[idx].relay, not the stamped address. Result: bare datagrams to the relay placeholder, dropped by the server, outbound black-holed for a full rekey interval. Reachable via a source-spoofed server address or a malicious relay, and with no attacker at all under asymmetric reachability (peer relays to us while we reach it directly).

Fixed by gating completion on peers[idx].relay at all four sites (fail-closed DispatchOut::None on mismatch). The relay flag only flips on a Handshaking → Established transition, never mid-session, so this cannot block a legitimate same-path rekey. Regression test direct_peer_ignores_relayed_rekey_resp was confirmed failing before the fix.

Verification

  • 222 unit tests, clippy -D warnings, cargo fmt — clean. Full workspace: 0 failures.
  • netns money test, both drivers (poll + io_uring), run live against the release binary: 0% packet loss (100/100) across ~10 rotations, relay-forwarded = 3147/3151 (the blind relay carried it), COMPLETED_ROUNDS = 10/10 distinct rekey rounds observed on the relayed wire.
  • Regressions green: 9a direct rekey netns (both drivers, 0% loss, 10 rounds); 2b relay cold-start netns (20/20 ping, relay-forwarded=1367).

Known deferred

  • A spoofed/stray relay [HandshakeResp] can abandon an in-flight rekey — rekey-liveness only (current untouched, session survives, rotation delayed). Rides with #34 (authenticated endpoint).
  • The relay rekey Init skips the cold-start junk burst; worth filing if an oracle probes mid-session rekey.
  • PQ-hybrid handshake is 9b.

No wire-format change; yip-crypto/yip-wire/handshake.rs untouched apart from reusing the read-only handshake::init_ephemeral helper.

Closes #91. Stacked on #90 (base = `feat/rekey-9a-session-rotation`) — review #90 first. ## What this does 9a rotates each peer's Noise-IK session ~120s for forward secrecy, but **relay-reached peers were gated out** of rekey scheduling (`drive_rekey_schedule` returned early on `relay`): rekey *completion* was only wired into the direct handshake handlers, so scheduling a relay rekey would have churned a never-completing ~1 Hz handshake (a DPI fingerprint). #91 wires relay-path completion, removes the gate, and proves it on the wire — so **relay-only sessions now get forward-secrecy rotation too**. The security-critical idempotent logic is **single-sourced**, not duplicated: relay paths reorder more than direct, which makes 9a's ephemeral-keyed convergence fix *more* load-bearing there, and a copy would be free to drift from it. ## Changes - **Extract the rekey cores** (`c388cb4`, `b459dde`) — `rekey_init_core`/`rekey_resp_core(.., via_relay: bool)` + `push_rekey_egress`; `handle_rekey_init`/`handle_rekey_resp` become thin `via_relay=false` wrappers. The direct path is byte-identical. `push_rekey_egress` takes the full `EgressDatagram` so the prime-emit preserves each packet's FEC `fate` (review fix — a collapsed `fate` would defeat GSO coalescing). - **Wire the relay handlers** (`e60b7ba`) — `relayed_handshake_init`'s Established arm and `relayed_handshake_resp` route to the cores with `via_relay=true`. The cold-start `cached_resp_init_eph` dedup is preserved, so a cold-start Init retransmit still resends the cached Resp instead of being misread as a rekey. - **Remove the gate** (`2fbe21e`) — relay peers now schedule rekeys, emitting the Init via `relay_wrap`. A `relay_wrap` `None` skips that send only; the round stays in flight and retries. - **netns relay-forced money test** (`b163430`) — `run-netns-rekey-relay.sh`: three namespaces with no direct A↔B path, so traffic *must* traverse the blind relay. `rekey_epoch_witness` gained an opt-in `YIP_WITNESS_UNWRAP_RELAY=1` mode that strips the `RelaySend`/`RelayDeliver` envelope before counting distinct on-wire ephemerals (env-gated, so the 9a direct test is unaffected). Wired into CI, both drivers. - **Path-consistency fix** (`004c138`) — see below. ## Defect caught by the final review Removing 9a's gate silently dropped an invariant it had been providing: relay and direct rekey completion were implicitly mutually exclusive. Without that, a **direct** peer completing a rekey via a *relayed* Init/Resp built the new epoch with `peer_addr = server_addr()` while `peers[idx].relay` stayed `false` — and `on_tun` decides relay-wrapping from `peers[idx].relay`, not the stamped address. Result: bare datagrams to the relay placeholder, dropped by the server, **outbound black-holed for a full rekey interval**. Reachable via a source-spoofed server address or a malicious relay, and with no attacker at all under asymmetric reachability (peer relays to us while we reach it directly). Fixed by gating completion on `peers[idx].relay` at all four sites (fail-closed `DispatchOut::None` on mismatch). The `relay` flag only flips on a `Handshaking → Established` transition, never mid-session, so this cannot block a legitimate same-path rekey. Regression test `direct_peer_ignores_relayed_rekey_resp` was confirmed failing before the fix. ## Verification - 222 unit tests, `clippy -D warnings`, `cargo fmt` — clean. Full workspace: 0 failures. - **netns money test, both drivers (poll + io_uring), run live against the release binary:** 0% packet loss (100/100) across ~10 rotations, `relay-forwarded` = 3147/3151 (the blind relay carried it), `COMPLETED_ROUNDS` = 10/10 distinct rekey rounds observed on the relayed wire. - **Regressions green:** 9a direct rekey netns (both drivers, 0% loss, 10 rounds); 2b relay cold-start netns (20/20 ping, relay-forwarded=1367). ## Known deferred - A spoofed/stray relay `[HandshakeResp]` can abandon an in-flight rekey — rekey-liveness only (`current` untouched, session survives, rotation delayed). Rides with #34 (authenticated endpoint). - The relay rekey Init skips the cold-start junk burst; worth filing if an oracle probes mid-session rekey. - PQ-hybrid handshake is 9b. No wire-format change; `yip-crypto`/`yip-wire`/`handshake.rs` untouched apart from reusing the read-only `handshake::init_ephemeral` helper.
Sign in to join this conversation.
No description provided.