Skip to content

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

Merged
vxfemboy merged 21 commits into
mainfrom
feat/rekey-9a-relay-completion
Jul 22, 2026
Merged

fix(rekey): complete relay-path session rekey (#91)#92
vxfemboy merged 21 commits into
mainfrom
feat/rekey-9a-relay-completion

Conversation

@vxfemboy

Copy link
Copy Markdown
Member

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 Handshake anti-replay (timestamp) + authenticated endpoint learning #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.

vxfemboy added 21 commits July 18, 2026 22:04
… handling

Two-epoch (current/previous, +next on responder) overlap, no wire change
(conn_tag rotates per epoch = linkability fix + epoch discriminator);
WireGuard confirmed-switch (initiator on completion, responder on first
new-epoch inbound); rekey-mechanics-only scope + one-rekey-in-flight DoS
guard; loser-fallback for winner-silent. PQ-hybrid=9b, #34 separate. Issue #9.
EpochSet state machine (owned EpochInbound sidesteps the multi-epoch borrow-
return; steady state = one try) / PeerState::Established carries EpochSet /
tick rekey scheduling (winner-initiates, one-in-flight, loser-fallback) /
completion (initiator promote on Resp, responder install_next on Init) / netns
loss-free-rotation + conn_tag-rotation money test. No wire change; yip-crypto
+ handshake.rs unchanged.
…t+fate for relay peers)

EpochInbound::Send/TunThenSend previously carried Vec<Vec<u8>> (bytes only),
discarding each EgressDatagram's dst and fate. dispatch_established and the
roamed-peer fallback reconstructed dst from self.peers[idx].endpoint, which
is wrong for relay-established peers (DataPlane::peer_addr is a
server_addr() placeholder there; endpoint holds an unconfirmed candidate or
None) -- silently dropping or misdirecting Send outcomes for relay peers.
Carry the full EgressDatagram instead, so dst/fate are always correct and
GSO fate-coalescing works again for free.
…tag rotation

run-netns-rekey.sh forks run-netns-tunnel.sh: two yipd peers held to
YIP_REKEY_INTERVAL_MS=2000 (~10 rotations over a ~20s ping -i 0.2 -c 100
stream), run under both the poll and YIP_USE_URING=1 drivers, asserting
(1) rekey_continuity: <=1% ping loss across every rotation, and
(2) conn_tag rotation: >=3 distinct completed Noise-IK rekey rounds
observed in a tcpdump capture of the veth (obf off, so the PacketType
prefixes are visible).

Assertion 2 went through two failed designs before landing on a sound one,
documented in both the script and bin/yipd/examples/rekey_epoch_witness.rs:
 - Diffing the raw masked dg[1..9] header bytes is vacuous: yip-wire's
   Codec re-seeds the header mask from each frame's own auth tag, so the
   visible bytes differ on every Data datagram regardless of epoch (verified
   empirically) -- reported as RAW_DISTINCT_HEADER_PREFIXES, non-gating.
 - Replaying captured [HandshakeInit] through a fresh responder-role
   Handshake to re-derive the real (auth_key, hp_key, conn_tag) is
   cryptographically unsound, not just impractical: Noise_IK's responder
   draws its own fresh ephemeral while writing message 2, so a local replay
   computes an unrelated session every time. Failed 100% of the time in
   testing, exactly as predicted once the mechanism is understood -- this is
   Noise's forward secrecy working as intended.
 - rekey_epoch_witness instead counts DISTINCT CLEARTEXT Noise-IK ephemeral
   public keys (the first message token of both HandshakeInit and
   HandshakeResp is sent unencrypted, by spec) across the capture. N
   distinct ephemerals is direct on-wire evidence of N independently
   completed handshake rounds, and since conn_tag is a deterministic
   function of the channel binding that mixes in both ephemerals' DH
   product, N distinct rounds implies N distinct conn_tags with
   cryptographic-strength probability -- without ever needing to (or being
   able to) recover what those conn_tag values actually are.

Confirmed both poll and uring runs are real: strace on a throwaway netns
process shows genuine io_uring_enter syscalls under YIP_USE_URING=1.
Confirmed no regression on run-netns-tunnel{,-loss}.sh / run-netns-triangle.sh
at the production (unset) rekey interval.

CI: wires both driver runs into the existing netns-tunnel-test job
alongside the sibling netns steps.

Deferred: a rekey_under_loss (netem) variant, per the task brief's
"include it if it's a clean addition, else note it deferred" — the
continuity + rotation proof above already required significant redesign
work; adding combined netem+rekey is left for a follow-up.

Pre-commit `cargo test` blocked only by the known yip-io::uring flake
(uring_in_flight_send_table_reuses_slots_after_completions /
uring_loopback_roundtrip_recycles_recv_buffers, both timing-sensitive and
unrelated to this change — this diff touches no files under crates/yip-io).
fmt and clippy both passed. --no-verify used per that carve-out.
…ion under Init retransmit/Resp reorder)

Critical (9a final review): handle_rekey_init minted a FRESH session
(fresh start_responder + install_next) on every rekey Init it saw,
including retransmits, discarding the previous `next`. The initiator
locks onto the FIRST HandshakeResp it reads, so on high-RTT or
reordering links (RTT > retry_ms, or a reordered/duplicated Resp) the
two sides diverged onto different epochs and the tunnel black-holed.

Fix: EpochSet::next now carries the initiator's Noise ephemeral
(init_eph, the unencrypted `e` in msg1) alongside the cached Resp
bytes (NextEpoch). A retransmitted rekey Init (same ephemeral) is
answered idempotently — resend the cached Resp, never rebuild `next`.
A genuinely new round (new ephemeral) still builds a fresh `next`.

Important-2: HANDSHAKE_TOTAL_MS (90s) exceeds REKEY_INTERVAL_MS/2
(60s), so a very-late retransmit of the ORIGINAL cold-start Init could
land past accept_rekey_init's age gate. Peer::cached_resp_init_eph
(set alongside cached_resp) lets handle_rekey_init recognize that case
first and resend the cold-start reply instead of misclassifying it as
a rekey round.

Important-1: handle_rekey_resp still abandons an in-flight rekey on
any Resp that fails read_response (rekey-liveness DoS only, current
is never touched). snow::HandshakeState is not Clone (owns Box<dyn
Dh>/Box<dyn Random>), so the clean clone-and-try fix isn't available;
documented as a known limitation instead, riding with #34's
authenticated-endpoint work.

Minor: dropped the dead `if relay {...}` arms in drive_rekey_schedule
that were unreachable after its early `if relay { return; }`.

New tests: idempotent-retransmit unit test, an end-to-end
Init-retransmit-before-Resp convergence test, a cold-start-dedup
regression, and an init_ephemeral helper test.
Extract rekey_init_core/rekey_resp_core parameterized by RekeyEgress{Direct,Relay}
(single-source the 9a idempotent-ephemeral Critical fix); wire relayed_handshake_
init/resp to them; remove drive_rekey_schedule relay gate + restore relay-wrap Init
emit. Fail-closed; idempotency covers relay reordering. netns relay-forced money
test. Stacks on 9a (PR #90). Issue #91.
Extract rekey_init_core/rekey_resp_core (via_relay bool) behavior-preserving /
wire relayed_handshake_init/resp to them / remove drive_rekey_schedule gate +
relay-wrap Init emit / netns relay-forced money test. Single-sourced idempotent
convergence; fail-closed relay_wrap-None no-op.
…er the relay

Task 4: prove relay-only sessions rotate loss-free over the rendezvous
relay. run-netns-rekey-relay.sh forks run-netns-relay.sh's RELAY-FORCED
topology (A/B mutually unreachable except via R's blind relay) with
run-netns-rekey.sh's YIP_REKEY_INTERVAL_MS=2000 cadence, asserting (1)
relay-forwarded=<N> N>0, (2) <=1% loss across ~10 rotations, (3)
rekey_epoch_witness reports >=3 distinct completed rekey rounds.

rekey_epoch_witness.rs grows an opt-in YIP_WITNESS_UNWRAP_RELAY=1 mode
that strips the RelaySend (offset 33) / RelayDeliver (offset 17)
rendezvous envelope before applying the existing distinct-ephemeral
logic, so the direct-path 9a run-netns-rekey.sh is byte-for-byte
unaffected (verified by re-running it).

Wired into netns-tunnel-test in .github/workflows/integration.yml,
both drivers, alongside the sibling 9a rekey steps.
@vxfemboy
vxfemboy changed the base branch from feat/rekey-9a-session-rotation to main July 22, 2026 04:51
@vxfemboy
vxfemboy merged commit add6b40 into main Jul 22, 2026
9 checks passed
@vxfemboy
vxfemboy deleted the feat/rekey-9a-relay-completion branch July 22, 2026 04:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

9a follow-up: relay-path session rekey (relay-only sessions get no forward-secrecy rotation)

1 participant