Milestone 9a: classical session rekey (~120s rotation) + epoch handling (#9)#90
Open
vxfemboy wants to merge 13 commits into
Open
Milestone 9a: classical session rekey (~120s rotation) + epoch handling (#9)#90vxfemboy wants to merge 13 commits into
vxfemboy wants to merge 13 commits into
Conversation
… 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.
…ard confirmed-switch)
… in/out through current epoch
…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.
… one-in-flight, loser-fallback)
… install_next on Init
…rect-only; avoid never-completing churn)
…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.
… of panicking (review)
…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.
…it framing (review)
This was referenced Jul 19, 2026
Open
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-cryptoandyip-wireare untouched.What's here (
bin/yipd)EpochSet(epoch.rs) — a pure state machine holdingcurrent(outbound),next(the responder's derived-but-unconfirmed new epoch, keyed by the initiator's Noise ephemeral), andprevious(receive-only grace).inbound_opentries current→next→previous and promotesnext→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 throughinbound_open, outbound throughcurrent.PeerManager::tick) — the glare-winner (existinglocal_pub < peer.pubkeytiebreak) starts a fresh handshake atREKEY_INTERVAL_MS(120_000; override viaYIP_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.nextand switches only when it receives a frame under it. Survives a lost msg2 with no black-hole.conn_tagrotates per epoch (derived from each session's keys) — the linkability rotation, for free.Scope & fixes worth calling out
EpochInbound::Sendpath carries fullEgressDatagrams (preservingdst+fate) — a fix for a relay-peer misdirection found in review.Testing
EpochSetswitch 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.run-netns-rekey.sh, ran live under sudo on both the poll andYIP_USE_URING=1drivers): 0% packet loss across ~10 fast rotations, and arekey_epoch_witnesstool proves 10/10 distinct rekey rounds on the wire (via distinct cleartext Noise ephemerals — the naiveconn_tag-byte check is vacuous under per-packet header masking). Wired into CI.-D warningsclean.Follow-ups (non-blocking)
relayed_handshake_init/resp→install_next/promote_from_rekeyso relay-only sessions also rotate (deferred to avoid the churn above).[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).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/ noas/ no bare#[allow].