Skip to content

Milestone 9a: classical session rekey (~120s rotation) + epoch handling (#9)#90

Open
vxfemboy wants to merge 13 commits into
mainfrom
feat/rekey-9a-session-rotation
Open

Milestone 9a: classical session rekey (~120s rotation) + epoch handling (#9)#90
vxfemboy wants to merge 13 commits into
mainfrom
feat/rekey-9a-session-rotation

Conversation

@vxfemboy

Copy link
Copy Markdown
Member

Milestone 9a — classical session rekey (~120s) + epoch handling

Closes the rekey half of #9 (session rekey + PQ-hybrid handshake). This is 9a (classical rekey); 9b (PQ-hybrid, McEliece + ML-KEM → PSK) is the sequel, built on this machinery.

Each peer's Noise-IK session now rotates ~every 120s for forward secrecy, with an old→new epoch overlap so in-flight packets keep decrypting across the switch. No wire-format change; yip-crypto and yip-wire are untouched.

What's here (bin/yipd)

  • EpochSet (epoch.rs) — a pure state machine holding current (outbound), next (the responder's derived-but-unconfirmed new epoch, keyed by the initiator's Noise ephemeral), and previous (receive-only grace). inbound_open tries current→next→previous and promotes next→current on the first frame that decrypts under it (the responder's confirmed-switch). Steady state is a single decrypt attempt — behaviorally identical to before.
  • PeerState::Established(Box<EpochSet>) — inbound routes through inbound_open, outbound through current.
  • Scheduling (PeerManager::tick) — the glare-winner (existing local_pub < peer.pubkey tiebreak) starts a fresh handshake at REKEY_INTERVAL_MS (120_000; override via YIP_REKEY_INTERVAL_MS), one-in-flight, loser-fallback at 2×interval, retransmit/give-up. The live session keeps flowing throughout — rekey failure is a no-op.
  • Completion — initiator switches outbound immediately on the Resp (WireGuard confirmed-switch); responder installs next and switches only when it receives a frame under it. Survives a lost msg2 with no black-hole.
  • conn_tag rotates per epoch (derived from each session's keys) — the linkability rotation, for free.

Scope & fixes worth calling out

  • Relay peers are gated out of rekey scheduling. Relay-path rekey completion is unwired, and scheduling it would churn a never-completing ~1 Hz handshake (a constant-cadence DPI fingerprint). Direct/punched peers rotate; relay-only sessions don't yet — a documented follow-up.
  • Idempotent responder rekey (final-review Critical fix). The responder recognizes a retransmitted Init by its cleartext Noise ephemeral and resends the cached Resp without minting a new session — so a retransmitted Init before the first Resp (RTT > retry, or Resp reordering) can no longer make the two peers commit to different sessions and black-hole the rotation. Two end-to-end tests reproduce and close the scenario.
  • The EpochInbound::Send path carries full EgressDatagrams (preserving dst+fate) — a fix for a relay-peer misdirection found in review.

Testing

  • Unit: the full EpochSet switch state machine (promote/retire/lost-msg2-no-blackhole/guards), rekey scheduling (winner/one-in-flight/loser-fallback/retransmit/abandon), completion (initiator promote / responder install-next / idempotent retransmit / cold-start dedup), all with real Noise-IK crypto + AEAD, not stubs.
  • netns money test (run-netns-rekey.sh, ran live under sudo on both the poll and YIP_USE_URING=1 drivers): 0% packet loss across ~10 fast rotations, and a rekey_epoch_witness tool proves 10/10 distinct rekey rounds on the wire (via distinct cleartext Noise ephemerals — the naive conn_tag-byte check is vacuous under per-packet header masking). Wired into CI.
  • 217 unit + 27 netns tests green; clippy -D warnings clean.

Follow-ups (non-blocking)

  • Relay-path rekey completion — wire relayed_handshake_init/respinstall_next/promote_from_rekey so relay-only sessions also rotate (deferred to avoid the churn above).
  • Rekey-liveness hardening — a spoofed/duplicate [HandshakeResp] can abandon an in-flight rekey (delaying rotation, not breaking the tunnel); rides with Handshake anti-replay (timestamp) + authenticated endpoint learning #34 (authenticated endpoint).
  • Anti-DPI — a mid-session rekey Init skips the cold-start junk-burst; benign today (obf pads uniformly), file if a future oracle probes mid-session rekey.
  • 9b — PQ-hybrid handshake over this rekey path.

Final whole-branch review (opus): the one Critical (rotation black-hole under Init-retransmit/Resp-reorder) was fixed and re-reviewed Approved; fail-closed throughout (a rekey never disturbs a live session), no wire/crypto change, forbid-unsafe / no as / no bare #[allow].

vxfemboy added 13 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.
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.

1 participant