diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index e830370..9957c14 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -176,6 +176,45 @@ jobs: fi done + - name: Build the rekey_epoch_witness on-wire proof tool + # Standalone example binary (not a #[test]): see its module doc for + # why it counts distinct cleartext Noise-IK ephemeral keys rather + # than trying to decode session data, which is cryptographically + # impossible from a passive capture (forward secrecy). + run: cargo build --release -p yipd --example rekey_epoch_witness + + - name: Run the 9a rekey money test under sudo (poll driver) + # run-netns-rekey.sh: two yipd peers held to YIP_REKEY_INTERVAL_MS=2000 + # (~10 rotations over a ~20s ping stream) must show (1) loss-free + # continuity across every rotation and (2) >=3 distinct completed + # Noise-IK rekey rounds on the wire (the rigorous, non-vacuous proof + # that conn_tag rotates per epoch -- see the script's header comment + # for the full argument, including why a naive raw-byte diff would be + # vacuous and why re-deriving session keys from a passive capture is + # cryptographically impossible). + run: | + sudo bash bin/yipd/tests/run-netns-rekey.sh "$(pwd)/target/release/yipd" | tee /tmp/rekey-poll.log + if grep -q "^SKIP run-netns-rekey" /tmp/rekey-poll.log; then + echo "::error::9a rekey money test (poll) skipped — expected root + tcpdump + the built witness tool in this job" + exit 1 + fi + if grep -q "\[FAIL\]" /tmp/rekey-poll.log; then + echo "::error::9a rekey money test (poll) failed — loss-free rotation or on-wire conn_tag rotation regressed" + exit 1 + fi + + - name: Run the 9a rekey money test under sudo (uring driver) + run: | + sudo -E env YIP_USE_URING=1 bash bin/yipd/tests/run-netns-rekey.sh "$(pwd)/target/release/yipd" | tee /tmp/rekey-uring.log + if grep -q "^SKIP run-netns-rekey" /tmp/rekey-uring.log; then + echo "::error::9a rekey money test (uring) skipped — expected root + tcpdump + the built witness tool in this job" + exit 1 + fi + if grep -q "\[FAIL\]" /tmp/rekey-uring.log; then + echo "::error::9a rekey money test (uring) failed — loss-free rotation or on-wire conn_tag rotation regressed" + exit 1 + fi + dpi-undetectability: # The anti-DPI undetectability merge gate (3a Task 7): fails the build if # a wire/obfuscation change reintroduces a DPI-recognizable fingerprint. diff --git a/bin/yipd/examples/rekey_epoch_witness.rs b/bin/yipd/examples/rekey_epoch_witness.rs new file mode 100644 index 0000000..06a0b16 --- /dev/null +++ b/bin/yipd/examples/rekey_epoch_witness.rs @@ -0,0 +1,260 @@ +#![forbid(unsafe_code)] + +//! Standalone wire-witness tool for the 9a rekey netns money test +//! (`bin/yipd/tests/run-netns-rekey.sh`): counts genuinely distinct, +//! completed Noise-IK rekey handshake rounds captured on the wire, as the +//! rigorous proof that "the on-wire `conn_tag` actually rotates per +//! epoch" — without needing (and, as explained below, without being ABLE) +//! to recover the actual `conn_tag` value of a live session from a passive +//! capture. +//! +//! # Two dead ends, kept here as the record of why this is the design +//! +//! 1. **Diffing the raw masked header bytes is vacuous.** `crates/yip-wire`'s +//! `Codec::frame` XORs the entire 15-byte logical header — including the +//! 8 `conn_tag` bytes — under a keystream reseeded by that *specific +//! frame's own* auth tag (see also `bin/yipd/src/peer_manager.rs`'s "UDP +//! demux: why routing is by source address, not raw conn_tag bytes" doc +//! comment, which independently notes the same fact). The visible bytes +//! at `dg[1..9]` therefore differ on *every* Data datagram, even two +//! datagrams of the exact same epoch — a "capture dg[1..9], assert more +//! than one distinct value" check would read >1 in a single-epoch, +//! zero-rekey run too. `run-netns-rekey.sh` still reports this count +//! (`RAW_DISTINCT_HEADER_PREFIXES`), labeled non-gating/informational. +//! +//! 2. **Passively re-deriving the real session keys is cryptographically +//! impossible, by design — an earlier version of this tool tried, and +//! failed 100% of the time, which is how this doc comment came to be +//! written.** The idea was: replay each captured `[HandshakeInit]` +//! through a fresh responder-role `yip_crypto::Handshake` (this test +//! generates both peers' static keys, so it has both `local_private`s), +//! then re-derive `(auth_key, hp_key, conn_tag)` from the resulting +//! `channel_binding` the same way `wire_glue::derive_wire_keys` / +//! `dataplane::conn_tag_from_keys` do. This is unsound: Noise_IK's +//! responder generates a FRESH RANDOM EPHEMERAL KEY of its own while +//! producing message 2 (pattern `<- e, ee, se`), and that ephemeral +//! feeds the `ee`/`se` Diffie-Hellman terms mixed into the transcript +//! hash. A locally-replayed responder draws its OWN random ephemeral, +//! not the real peer's — so it derives a syntactically valid but +//! cryptographically DIFFERENT session every time. Recovering the real +//! session from a passive capture would require the real ephemeral +//! PRIVATE key, which is never transmitted and never stored anywhere +//! this test can reach — that is Noise's forward-secrecy property doing +//! exactly its job. Trying to work around it (e.g. by MITM-ing the +//! daemons, or by having yipd leak its ephemeral) is out of scope for a +//! test-only task and is not attempted here. +//! +//! # What this tool checks instead — sound, and still on-wire +//! +//! Noise_IK's very first token of *both* messages is `e`: the sender's +//! ephemeral public key, written to the message IN CLEARTEXT (Noise mixes +//! it into the running hash but never encrypts it — there is no cipher key +//! yet when writing the first token of message 1, and message 2's leading +//! `e` is likewise unencrypted per the Noise spec). So the first 32 bytes +//! after the 1-byte `PacketType` prefix of every `[HandshakeInit]` / +//! `[HandshakeResp]` datagram (`bin/yipd/src/handshake.rs`'s wire format) +//! are a raw, unencrypted, freshly-random 32-byte key — visible to any +//! passive observer, no key material needed at all. Counting *distinct* +//! cleartext ephemeral keys across the run's `[HandshakeInit]`/ +//! `[HandshakeResp]` datagrams is therefore direct, unambiguous, on-wire +//! evidence of N independently-completed Noise-IK handshake rounds (a +//! replay/retransmit of the SAME round reuses the SAME ephemeral, since +//! `handshake.rs`'s retry logic resends the same message rather than +//! restarting the handshake — see `PeerManager`'s +//! `REKEY_ATTEMPT_TIME`/retransmit comments — so retransmits collapse to +//! one distinct value, not falsely inflating the count). +//! +//! From there, the rest is a mathematical, not empirical, argument: +//! `conn_tag = conn_tag_from_keys(auth_key, hp_key)`, and +//! `(auth_key, hp_key) = derive_wire_keys(channel_binding)`, and +//! `channel_binding` is (by Noise's definition of the transcript hash) a +//! function that mixes in both ephemeral public keys and the `ee` shared +//! secret computed from them. Two handshake rounds with distinct ephemeral +//! keys yield, with the DH problem's usual cryptographic-strength +//! probability (not just "usually" — collision would break X25519), a +//! distinct `ee` term, hence a distinct `channel_binding`, hence a distinct +//! `conn_tag`. So N distinct cleartext ephemerals is a rigorous proof of N +//! distinct on-wire `conn_tag`s, even though — per the dead end above — +//! this tool (or any passive observer) can never learn what those N values +//! actually are. +//! +//! Usage: +//! rekey_epoch_witness +//! +//! Output (stdout), consumed by `run-netns-rekey.sh`: +//! HANDSHAKE_INIT_PKTS= total captured [HandshakeInit] datagrams +//! HANDSHAKE_RESP_PKTS= total captured [HandshakeResp] datagrams +//! DISTINCT_INIT_EPHEMERALS= distinct cleartext initiator ephemerals +//! DISTINCT_RESP_EPHEMERALS= distinct cleartext responder ephemerals +//! COMPLETED_ROUNDS= min(the two distinct counts above) -- +//! a round needs both a distinct Init AND +//! a distinct Resp ephemeral to have +//! produced a fresh conn_tag on both ends +//! RAW_DISTINCT_HEADER_PREFIXES= informational only, see dead end #1 +//! +//! Run: `cargo run --release -p yipd --example rekey_epoch_witness -- ` + +use std::env; +use std::fs; + +// ── minimal classic-pcap + Ethernet/IPv4/UDP parsing (no new dependency) ─────── + +struct UdpPkt { + payload: Vec, +} + +fn read_u32(b: &[u8], le: bool) -> u32 { + let a: [u8; 4] = b.try_into().expect("4 bytes"); + if le { + u32::from_le_bytes(a) + } else { + u32::from_be_bytes(a) + } +} + +/// Parse a classic (non-pcapng) pcap file, as written by `tcpdump -w`. +/// Supports both byte orders and both micro/nanosecond-timestamp magic +/// variants (only the byte order matters here -- timestamps are unused). +fn parse_pcap(data: &[u8]) -> Vec { + if data.len() < 24 { + eprintln!("rekey_epoch_witness: pcap file too short for a global header"); + std::process::exit(1); + } + let le = match &data[0..4] { + [0xd4, 0xc3, 0xb2, 0xa1] | [0x4d, 0x3c, 0xb2, 0xa1] => true, + [0xa1, 0xb2, 0xc3, 0xd4] | [0xa1, 0xb2, 0x3c, 0x4d] => false, + magic => { + eprintln!("rekey_epoch_witness: unrecognized pcap magic: {magic:02x?}"); + std::process::exit(1); + } + }; + let mut out = Vec::new(); + let mut off = 24usize; + while off + 16 <= data.len() { + let incl_len = read_u32(&data[off + 8..off + 12], le) as usize; + off += 16; + if off + incl_len > data.len() { + break; + } + let pkt = &data[off..off + incl_len]; + off += incl_len; + if let Some(u) = parse_eth_ipv4_udp(pkt) { + out.push(u); + } + } + out +} + +/// Strip a 14-byte Ethernet header, an IPv4 header (variable IHL), and an +/// 8-byte UDP header, returning the destination port and UDP payload bytes. +/// Returns `None` for anything that is not an Ethernet/IPv4/UDP frame. +fn parse_eth_ipv4_udp(pkt: &[u8]) -> Option { + if pkt.len() < 14 { + return None; + } + let ethertype = u16::from_be_bytes([pkt[12], pkt[13]]); + if ethertype != 0x0800 { + return None; // not IPv4 + } + let ip = &pkt[14..]; + if ip.len() < 20 { + return None; + } + let ihl = (ip[0] & 0x0F) as usize * 4; + if ihl < 20 || ip.len() < ihl + 8 { + return None; + } + let proto = ip[9]; + if proto != 17 { + return None; // not UDP + } + let udp = &ip[ihl..]; + let udp_len = u16::from_be_bytes([udp[4], udp[5]]) as usize; + if udp.len() < 8 { + return None; + } + let payload_end = udp_len.min(udp.len()); + if payload_end < 8 { + return None; + } + Some(UdpPkt { + payload: udp[8..payload_end].to_vec(), + }) +} + +const TYPE_HANDSHAKE_INIT: u8 = 0; +const TYPE_HANDSHAKE_RESP: u8 = 1; +const TYPE_DATA: u8 = 2; +/// 1-byte PacketType prefix + 32-byte cleartext Noise ephemeral pubkey. +const MIN_HANDSHAKE_LEN: usize = 33; + +fn main() { + let args: Vec = env::args().collect(); + if args.len() != 2 { + eprintln!("usage: rekey_epoch_witness "); + std::process::exit(2); + } + let pcap_path = &args[1]; + + let data = fs::read(pcap_path).unwrap_or_else(|e| { + eprintln!("rekey_epoch_witness: read {pcap_path}: {e}"); + std::process::exit(1); + }); + let pkts = parse_pcap(&data); + + let mut init_ephemerals: Vec<[u8; 32]> = Vec::new(); + let mut resp_ephemerals: Vec<[u8; 32]> = Vec::new(); + let mut init_pkt_count = 0u32; + let mut resp_pkt_count = 0u32; + + for p in &pkts { + if p.payload.len() < MIN_HANDSHAKE_LEN { + continue; + } + let ephemeral: [u8; 32] = p.payload[1..33].try_into().expect("32 bytes"); + match p.payload[0] { + TYPE_HANDSHAKE_INIT => { + init_pkt_count += 1; + if !init_ephemerals.contains(&ephemeral) { + init_ephemerals.push(ephemeral); + } + } + TYPE_HANDSHAKE_RESP => { + resp_pkt_count += 1; + if !resp_ephemerals.contains(&ephemeral) { + resp_ephemerals.push(ephemeral); + } + } + _ => {} + } + } + + let completed_rounds = init_ephemerals.len().min(resp_ephemerals.len()); + + println!("HANDSHAKE_INIT_PKTS={init_pkt_count}"); + println!("HANDSHAKE_RESP_PKTS={resp_pkt_count}"); + println!("DISTINCT_INIT_EPHEMERALS={}", init_ephemerals.len()); + println!("DISTINCT_RESP_EPHEMERALS={}", resp_ephemerals.len()); + println!("COMPLETED_ROUNDS={completed_rounds}"); + + // Informational only, NOT itself a rotation proof (see dead end #1 + // above): the raw masked dg[1..9] bytes on Data frames, deduplicated. + // Expect this to be large (close to the Data-datagram count) even in a + // hypothetical single-epoch run, since the header mask is reseeded by + // each frame's own auth tag -- it is not a stable per-epoch identifier. + let mut raw_prefixes: Vec<[u8; 8]> = Vec::new(); + for p in &pkts { + if p.payload.len() < 9 || p.payload[0] != TYPE_DATA { + continue; + } + let mut prefix = [0u8; 8]; + prefix.copy_from_slice(&p.payload[1..9]); + if !raw_prefixes.contains(&prefix) { + raw_prefixes.push(prefix); + } + } + println!( + "RAW_DISTINCT_HEADER_PREFIXES={} (informational only -- NOT proof of rotation; see module doc)", + raw_prefixes.len() + ); +} diff --git a/bin/yipd/src/epoch.rs b/bin/yipd/src/epoch.rs new file mode 100644 index 0000000..bc17949 --- /dev/null +++ b/bin/yipd/src/epoch.rs @@ -0,0 +1,526 @@ +//! The per-peer session-epoch set (milestone 9a). Holds the live `current` +//! DataPlane plus, during a ~120s rekey overlap, an optional responder-side +//! `next` (derived from a rekey Init, not yet used for sending) and an +//! optional receive-only `previous` (the just-superseded epoch, kept for +//! in-flight frames until a grace deadline). Pure/I-O-free: `PeerManager` +//! drives the schedule and feeds it handshake results. + +use std::net::SocketAddr; + +use crate::dataplane::{DataPlane, Outcome}; +use crate::handshake::HandshakeState; +use yip_io::poll::EgressDatagram; + +/// Production rekey cadence (§ Global Constraints). Test-overridden via +/// `YIP_REKEY_INTERVAL_MS` at `PeerManager` construction. +pub const REKEY_INTERVAL_MS: u64 = 120_000; +/// How long the superseded `previous` epoch stays open for inbound after a +/// switch — generous vs. reordering/loss, bounded so keys don't linger. +pub const PREVIOUS_EPOCH_GRACE_MS: u64 = 15_000; + +/// An in-flight initiator rekey handshake, held alongside the live `current` +/// so the session never pauses. Mirrors `HandshakingState`'s retransmit fields. +pub struct RekeyInFlight { + /// Read by rekey *completion* (`PeerManager::handle_rekey_resp`'s + /// `rk.hs.read_response(dg)`, Task 4). Task 3 only schedules/ + /// retransmits/abandons. + pub hs: HandshakeState, + pub init_pkt: Vec, + pub started_ms: u64, + pub last_sent_ms: u64, + pub retry_ms: u64, + pub target: SocketAddr, +} + +/// Owned inbound result (the `PeerManager` demux already copies the borrowed +/// `Outcome` into owned Vecs at its call sites, so returning owned here is +/// free — and it sidesteps the multi-epoch borrow-return limitation). +/// +/// `Send`/`TunThenSend` carry the full [`EgressDatagram`] (not just its +/// bytes) so each datagram's real `dst` and `fate` survive the trip through +/// `EpochSet` — `dst` cannot be losslessly reconstructed from +/// `self.peers[idx].endpoint` for relay-established peers (their +/// `DataPlane::peer_addr` is a `server_addr()` placeholder; the real +/// destination is only known per-datagram), and dropping `fate` would forgo +/// GSO fate-coalescing on ARQ-retransmit bursts. +pub enum EpochInbound { + None, + Tun(Vec), + Send(Vec), + TunThenSend(Vec, Vec), +} + +/// The responder's unconfirmed rekey epoch (§ `EpochSet::install_next`). +/// Carries not just the derived [`DataPlane`] but the initiator ephemeral +/// that produced it and the exact `[HandshakeResp]` bytes sent in reply, so +/// a RETRANSMIT of the same rekey `Init` (identified by that ephemeral) can +/// be answered idempotently — resend `cached_resp` verbatim, do not mint a +/// second session (milestone 9a final review, the Critical fix: without +/// this, a responder that re-ran `start_responder` on every retransmitted +/// Init produced a NEW session each time, discarding the previous `next`, +/// while the initiator locks onto the FIRST `[HandshakeResp]` it reads — +/// the two sides diverge onto different epochs whenever the initiator +/// retransmits before seeing the first reply (RTT > retry_ms) or a +/// `[HandshakeResp]` is reordered/duplicated, black-holing the tunnel). +pub struct NextEpoch { + pub(crate) dp: Box, + pub(crate) init_eph: [u8; 32], + pub(crate) cached_resp: Vec, +} + +pub struct EpochSet { + pub(crate) current: Box, + pub(crate) current_created_ms: u64, + pub(crate) next: Option, + pub(crate) previous: Option>, + pub(crate) previous_retire_ms: u64, + pub(crate) rekey: Option, +} + +impl EpochSet { + pub fn new(current: Box, now_ms: u64) -> Self { + Self { + current, + current_created_ms: now_ms, + next: None, + previous: None, + previous_retire_ms: 0, + rekey: None, + } + } + + pub fn current(&self) -> &DataPlane { + &self.current + } + pub fn current_mut(&mut self) -> &mut DataPlane { + &mut self.current + } + + /// Convert a borrowed `Outcome` into the owned `EpochInbound` (same copies + /// the caller already performed). Returns `None` for `Outcome::None`. + fn own(outcome: Outcome<'_>) -> EpochInbound { + match outcome { + Outcome::None => EpochInbound::None, + Outcome::TunWrite(b) => EpochInbound::Tun(b.to_vec()), + Outcome::Send(pkts) => EpochInbound::Send(pkts.to_vec()), + Outcome::TunWriteThenSend(b, pkts) => { + EpochInbound::TunThenSend(b.to_vec(), pkts.to_vec()) + } + } + } + + /// Open an inbound datagram against the epochs in order (current, then the + /// responder's `next`, then the grace `previous`). Each `DataPlane` fails + /// closed on a wrong key (cheap failed AEAD, no misdecrypt), so a wrong + /// epoch simply yields `Outcome::None` and we try the next. A NON-None + /// result under `next` means the peer has switched to the new epoch → + /// promote it (responder confirmed-switch). Steady state (no next/previous) + /// is a single try, identical to today. + pub fn inbound_open(&mut self, dg: &[u8], now_ms: u64) -> EpochInbound { + // current + let out = Self::own(self.current.on_udp_datagram(dg, now_ms)); + if !matches!(out, EpochInbound::None) { + return out; + } + // next (responder-side unconfirmed new epoch) + if self.next.is_some() { + let out = Self::own( + self.next + .as_mut() + .expect("checked is_some") + .dp + .on_udp_datagram(dg, now_ms), + ); + if !matches!(out, EpochInbound::None) { + // Confirmed: promote next -> current, old current -> previous. + let new = self.next.take().expect("checked is_some"); + let old = std::mem::replace(&mut self.current, new.dp); + self.current_created_ms = now_ms; + self.previous = Some(old); + self.previous_retire_ms = now_ms.saturating_add(PREVIOUS_EPOCH_GRACE_MS); + return out; + } + } + // previous (grace) + if let Some(prev) = self.previous.as_mut() { + return Self::own(prev.on_udp_datagram(dg, now_ms)); + } + EpochInbound::None + } + + /// Initiator switch on rekey completion: it now holds `new` AND knows the + /// responder installed it (the responder sent the Resp), so switch outbound + /// immediately; the old epoch becomes receive-only `previous`. + pub fn promote_from_rekey(&mut self, new_dp: Box, now_ms: u64) { + let old = std::mem::replace(&mut self.current, new_dp); + self.current_created_ms = now_ms; + self.previous = Some(old); + self.previous_retire_ms = now_ms.saturating_add(PREVIOUS_EPOCH_GRACE_MS); + self.rekey = None; + } + + /// Responder: a new epoch derived from a received rekey Init, kept for + /// inbound but NOT used for sending until confirmed (see `inbound_open`). + /// Replaces any previously-held `next` — callers must only invoke this + /// for a GENUINELY new rekey round (a new `init_eph`); a retransmit of + /// the same round must be answered via [`Self::next_cached_resp_for`] + /// instead (see `NextEpoch`'s doc comment). + pub fn install_next( + &mut self, + new_dp: Box, + init_eph: [u8; 32], + cached_resp: Vec, + ) { + self.next = Some(NextEpoch { + dp: new_dp, + init_eph, + cached_resp, + }); + } + + /// If `next` was installed from an Init bearing this exact `init_eph`, + /// return its cached `[HandshakeResp]` bytes — the idempotent reply for + /// a retransmit of that SAME rekey round. `None` if `next` is unset or + /// was installed from a DIFFERENT (or no) round, in which case the + /// caller must treat the Init as a new round (see `NextEpoch`'s doc + /// comment). + pub fn next_cached_resp_for(&self, init_eph: &[u8; 32]) -> Option<&[u8]> { + self.next + .as_ref() + .filter(|n| &n.init_eph == init_eph) + .map(|n| n.cached_resp.as_slice()) + } + + pub fn retire_previous_if_due(&mut self, now_ms: u64) { + if self.previous.is_some() && now_ms >= self.previous_retire_ms { + self.previous = None; + } + } + + /// Initiator schedule: rekey when `current` is old enough, none in flight, + /// and this side is the glare-winner — OR the loser-fallback fires + /// (`current` aged past 2× the interval and the winner never rekeyed). + pub fn needs_rekey(&self, now_ms: u64, is_glare_winner: bool, interval_ms: u64) -> bool { + if self.rekey.is_some() { + return false; + } + let age = now_ms.saturating_sub(self.current_created_ms); + if is_glare_winner { + age >= interval_ms + } else { + age >= interval_ms.saturating_mul(2) + } + } + + /// Responder guard: ignore a rekey Init that arrives against a very fresh + /// `current` (< interval/2 old) — bounds attacker-induced speculative + /// handshakes without full timestamp anti-replay (#34). + pub fn accept_rekey_init(&self, now_ms: u64, interval_ms: u64) -> bool { + now_ms.saturating_sub(self.current_created_ms) >= interval_ms / 2 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dataplane::conn_tag_from_keys; + use crate::handshake::{Established, HandshakeState}; + use crate::mode::TunnelMode; + use crate::wire_glue::derive_wire_keys; + use yip_crypto::{generate_keypair, Handshake}; + + /// Build a fresh pair of talking `DataPlane`s (a "new epoch") via a real + /// in-process Noise-IK handshake — mirrors `dataplane::tests::dataplane_pair`. + /// Returns `(sender, opener)`: `sender.on_tun_packet` produces frames that + /// `opener` (fed into an `EpochSet` under test) must be able to open. + fn epoch_pair() -> (Box, Box) { + let resp_kp = generate_keypair(); + let init_kp = generate_keypair(); + + let mut ini = Handshake::initiator(&init_kp.private, &resp_kp.public).unwrap(); + let mut res = Handshake::responder(&resp_kp.private).unwrap(); + + let m1 = ini.write_message(&[]).unwrap(); + let _ = res.read_message(&m1).unwrap(); + let m2 = res.write_message(&[]).unwrap(); + let _ = ini.read_message(&m2).unwrap(); + assert!(ini.is_finished() && res.is_finished()); + + let cb_i = ini.channel_binding(); + let cb_r = res.channel_binding(); + assert_eq!(cb_i, cb_r); + + let (auth_key, hp_key) = derive_wire_keys(&cb_i); + + let est_i = Established { + session: ini.into_session().unwrap(), + auth_key, + hp_key, + }; + let est_r = Established { + session: res.into_session().unwrap(), + auth_key, + hp_key, + }; + + let conn_tag = conn_tag_from_keys(&auth_key, &hp_key); + let addr_a: SocketAddr = "203.0.113.1:51820".parse().unwrap(); + let addr_b: SocketAddr = "203.0.113.2:51820".parse().unwrap(); + + ( + Box::new(DataPlane::new( + est_i, + conn_tag, + TunnelMode::L3Tun, + addr_b, + false, + 1200, + )), + Box::new(DataPlane::new( + est_r, + conn_tag, + TunnelMode::L3Tun, + addr_a, + false, + 1200, + )), + ) + } + + /// Seal `payload` with `sender`, feed every resulting datagram into + /// `set.inbound_open`, and return the first non-`None` result (or + /// `EpochInbound::None` if nothing opened it). + fn feed( + sender: &mut DataPlane, + set: &mut EpochSet, + payload: &[u8], + now_ms: u64, + ) -> EpochInbound { + let dgrams = sender.on_tun_packet(payload, now_ms).to_vec(); + for dg in &dgrams { + let out = set.inbound_open(&dg.bytes, now_ms); + if !matches!(out, EpochInbound::None) { + return out; + } + } + EpochInbound::None + } + + fn rekey_in_flight(target: SocketAddr) -> RekeyInFlight { + let init_kp = generate_keypair(); + let resp_kp = generate_keypair(); + let (hs, init_pkt) = + HandshakeState::start_initiator(&init_kp.private, &resp_kp.public, &[]).unwrap(); + RekeyInFlight { + hs, + init_pkt, + started_ms: 0, + last_sent_ms: 0, + retry_ms: 0, + target, + } + } + + #[test] + fn steady_state_inbound_uses_current_only() { + let (mut peer, us) = epoch_pair(); + let mut set = EpochSet::new(us, 0); + + let payload = vec![0x11u8; 64]; + match feed(&mut peer, &mut set, &payload, 0) { + EpochInbound::Tun(got) => assert_eq!(got, payload), + _ => panic!("expected Tun outcome from current"), + } + assert!(set.next.is_none()); + assert!(set.previous.is_none()); + } + + #[test] + fn initiator_promote_switches_outbound_and_grace_keeps_previous() { + let (mut peer_old, us_old) = epoch_pair(); + let mut set = EpochSet::new(us_old, 0); + + let (mut peer_new, us_new) = epoch_pair(); + let new_conn_tag = us_new.conn_tag(); + + set.promote_from_rekey(us_new, 1000); + assert_eq!(set.current().conn_tag(), new_conn_tag); + assert_eq!(set.current_created_ms, 1000); + assert_eq!(set.previous_retire_ms, 1000 + PREVIOUS_EPOCH_GRACE_MS); + assert!(set.previous.is_some()); + assert!(set.rekey.is_none()); + + // Old-epoch frame still opens (via `previous`). + let old_payload = vec![0x22u8; 32]; + match feed(&mut peer_old, &mut set, &old_payload, 1001) { + EpochInbound::Tun(got) => assert_eq!(got, old_payload), + _ => panic!("expected old-epoch frame to open via previous"), + } + + // New-epoch frame opens (via `current`). + let new_payload = vec![0x33u8; 32]; + match feed(&mut peer_new, &mut set, &new_payload, 1002) { + EpochInbound::Tun(got) => assert_eq!(got, new_payload), + _ => panic!("expected new-epoch frame to open via current"), + } + } + + #[test] + fn responder_install_next_then_first_inbound_promotes() { + let (mut peer_old, us_old) = epoch_pair(); + let old_conn_tag = us_old.conn_tag(); + let mut set = EpochSet::new(us_old, 0); + + let (mut peer_new, us_new) = epoch_pair(); + let new_conn_tag = us_new.conn_tag(); + + set.install_next(us_new, [0x11u8; 32], vec![0xEEu8; 4]); + assert_eq!(set.current().conn_tag(), old_conn_tag, "current unchanged"); + assert!(set.next.is_some()); + + let payload = vec![0x44u8; 32]; + match feed(&mut peer_new, &mut set, &payload, 500) { + EpochInbound::Tun(got) => assert_eq!(got, payload), + _ => panic!("expected next-epoch frame to open and promote"), + } + + assert_eq!( + set.current().conn_tag(), + new_conn_tag, + "next promoted to current" + ); + assert!(set.next.is_none()); + assert!(set.previous.is_some()); + assert_eq!(set.previous.as_ref().unwrap().conn_tag(), old_conn_tag); + assert_eq!(set.previous_retire_ms, 500 + PREVIOUS_EPOCH_GRACE_MS); + // The promotion must REFRESH current_created_ms (the rekey schedule + // ages from it) — a stale value here would make `needs_rekey` misfire. + assert_eq!(set.current_created_ms, 500, "promotion refreshes epoch age"); + + // Old-epoch frames still open too, via `previous`. + let old_payload = vec![0x55u8; 32]; + match feed(&mut peer_old, &mut set, &old_payload, 501) { + EpochInbound::Tun(got) => assert_eq!(got, old_payload), + _ => panic!("expected old-epoch frame to still open via previous"), + } + } + + #[test] + fn next_cached_resp_for_matches_only_its_own_init_eph() { + // Critical-bug regression (9a final review): `install_next` must + // remember WHICH Init round produced `next` (its initiator + // ephemeral) and expose the cached resp only for that exact + // ephemeral, so a caller can distinguish "retransmit of the SAME + // round" (resend cached, don't rebuild) from "a genuinely new + // round" (rebuild). + let (_peer_old, us_old) = epoch_pair(); + let mut set = EpochSet::new(us_old, 0); + + let (_peer_new, us_new) = epoch_pair(); + let eph = [0x77u8; 32]; + let cached = vec![0xAAu8; 12]; + set.install_next(us_new, eph, cached.clone()); + + assert_eq!( + set.next_cached_resp_for(&eph), + Some(cached.as_slice()), + "the SAME init_eph must hit the cache" + ); + assert_eq!( + set.next_cached_resp_for(&[0x88u8; 32]), + None, + "a DIFFERENT init_eph must miss the cache" + ); + + // No `next` installed at all: always a miss. + let (_peer_bare, us_bare) = epoch_pair(); + let bare = EpochSet::new(us_bare, 0); + assert_eq!(bare.next_cached_resp_for(&eph), None); + } + + #[test] + fn lost_msg2_leaves_both_on_old_no_blackhole() { + let (mut peer_old, us_old) = epoch_pair(); + let old_conn_tag = us_old.conn_tag(); + let mut set = EpochSet::new(us_old, 0); + + let (_peer_new, us_new) = epoch_pair(); + set.install_next(us_new, [0x22u8; 32], vec![0xFFu8; 4]); + assert!(set.next.is_some()); + + // Never feed a `next` frame (models a lost msg2). Old-epoch frames + // must keep opening via `current`, unaffected. + let payload = vec![0x66u8; 32]; + match feed(&mut peer_old, &mut set, &payload, 10) { + EpochInbound::Tun(got) => assert_eq!(got, payload), + _ => panic!("expected old-epoch frame to open via current"), + } + assert_eq!( + set.current().conn_tag(), + old_conn_tag, + "no promotion occurred" + ); + assert!(set.next.is_some(), "next remains installed, unused"); + } + + #[test] + fn previous_retired_at_grace_deadline() { + let (mut peer_old, us_old) = epoch_pair(); + let mut set = EpochSet::new(us_old, 0); + + let (_peer_new, us_new) = epoch_pair(); + set.promote_from_rekey(us_new, 1000); + let retire_at = set.previous_retire_ms; + assert!(set.previous.is_some()); + + // Before the deadline: previous survives, old-epoch frame still opens. + set.retire_previous_if_due(retire_at - 1); + assert!(set.previous.is_some()); + let payload = vec![0x77u8; 32]; + match feed(&mut peer_old, &mut set, &payload, retire_at - 1) { + EpochInbound::Tun(got) => assert_eq!(got, payload), + _ => panic!("expected old-epoch frame to still open before grace deadline"), + } + + // At/after the deadline: previous is dropped. + set.retire_previous_if_due(retire_at); + assert!(set.previous.is_none()); + let payload2 = vec![0x88u8; 32]; + match feed(&mut peer_old, &mut set, &payload2, retire_at) { + EpochInbound::None => {} + _ => panic!("expected old-epoch frame to no longer open after retirement"), + } + } + + #[test] + fn needs_rekey_trigger_and_one_in_flight_guard() { + let (_peer, us) = epoch_pair(); + let mut set = EpochSet::new(us, 0); + let interval = 1000u64; + + // Not old enough yet. + assert!(!set.needs_rekey(500, true, interval)); + // Winner: age >= interval, no rekey in flight => triggers. + assert!(set.needs_rekey(1000, true, interval)); + + // One-in-flight guard: even though the winner condition holds, an + // in-flight rekey suppresses another trigger. + set.rekey = Some(rekey_in_flight("203.0.113.9:1".parse().unwrap())); + assert!(!set.needs_rekey(10_000, true, interval)); + set.rekey = None; + + // Loser: doesn't trigger until 2x the interval (fallback). + assert!(!set.needs_rekey(1500, false, interval)); + assert!(set.needs_rekey(2000, false, interval)); + } + + #[test] + fn accept_rekey_init_ignores_too_fresh_current() { + let (_peer, us) = epoch_pair(); + let set = EpochSet::new(us, 0); + let interval = 1000u64; + + assert!(!set.accept_rekey_init(400, interval)); + assert!(set.accept_rekey_init(500, interval)); + } +} diff --git a/bin/yipd/src/handshake.rs b/bin/yipd/src/handshake.rs index ee5d4c9..2ca6f5c 100644 --- a/bin/yipd/src/handshake.rs +++ b/bin/yipd/src/handshake.rs @@ -201,6 +201,30 @@ pub fn run_responder( )) } +/// The initiator's Noise ephemeral public key: the raw, unencrypted first 32 +/// bytes of msg1 (Noise-IK's leading `e` token) — i.e. `init_pkt[1..33]` +/// after the 1-byte [`PacketType::HandshakeInit`] prefix. `None` if +/// `init_pkt` is too short to contain it. +/// +/// A fresh ephemeral is drawn once per handshake ATTEMPT, in +/// [`HandshakeState::start_initiator`]'s `write_message` call — a +/// retransmit of the same attempt resends the identical `init_pkt` bytes +/// (and therefore the identical `e`), while a genuinely new attempt (a new +/// cold-start handshake, or a new rekey round) draws a new one. This makes +/// it a stable, cheap per-round identifier: `PeerManager::handle_rekey_init` +/// uses it to recognize a retransmitted rekey `Init` (same ephemeral as the +/// round already answered) and reply idempotently instead of minting a +/// second session. +/// LOCKSTEP INVARIANT: `1..33` = skip the 1-byte `PacketType::HandshakeInit` +/// prefix, then the 32-byte Noise-IK `e` token that leads msg1 in the clear. +/// This offset MUST move in lockstep with `start_initiator`'s framing — if a +/// future anti-DPI change reshapes the `HandshakeInit` header, this must be +/// updated too, or it would return wrong-but-well-typed bytes and silently +/// reintroduce the rekey-convergence bug it exists to prevent. +pub fn init_ephemeral(init_pkt: &[u8]) -> Option<[u8; 32]> { + init_pkt.get(1..33)?.try_into().ok() +} + // ── step-functions (in-band handshakes) ──────────────────────────────────────── /// A handshake in progress, driven step-by-step instead of blocking on a @@ -376,6 +400,29 @@ mod tests { assert_eq!(initiator_static, a.public); } + #[test] + fn init_ephemeral_matches_across_identical_retransmits_and_differs_for_new_attempts() { + let a = generate_keypair(); + let b = generate_keypair(); + + let (_ha, init_pkt) = HandshakeState::start_initiator(&a.private, &b.public, &[]).unwrap(); + let eph1a = init_ephemeral(&init_pkt).expect("init_pkt carries a 32-byte ephemeral"); + // A retransmit resends the SAME bytes verbatim: same ephemeral. + let eph1b = init_ephemeral(&init_pkt).expect("init_pkt carries a 32-byte ephemeral"); + assert_eq!(eph1a, eph1b); + + // A NEW handshake attempt draws a fresh ephemeral. + let (_hb, init_pkt2) = HandshakeState::start_initiator(&a.private, &b.public, &[]).unwrap(); + let eph2 = init_ephemeral(&init_pkt2).expect("init_pkt carries a 32-byte ephemeral"); + assert_ne!( + eph1a, eph2, + "a new handshake attempt must draw a new ephemeral" + ); + + // Too-short input: no panic, just `None`. + assert_eq!(init_ephemeral(&[0u8; 10]), None); + } + #[test] fn crypto_err_converts_to_io_error() { // Exercise the crypto_err helper: a CryptoError converts to io::Error. diff --git a/bin/yipd/src/main.rs b/bin/yipd/src/main.rs index fdaf545..9e9b154 100644 --- a/bin/yipd/src/main.rs +++ b/bin/yipd/src/main.rs @@ -6,6 +6,7 @@ mod addr; mod config; mod dataplane; +mod epoch; mod handshake; mod mac_table; mod membership; diff --git a/bin/yipd/src/peer_manager.rs b/bin/yipd/src/peer_manager.rs index f48739b..8054fe7 100644 --- a/bin/yipd/src/peer_manager.rs +++ b/bin/yipd/src/peer_manager.rs @@ -79,8 +79,8 @@ use yip_rendezvous::{node_id, NodeId}; use crate::addr::node_addr; use crate::config::PeerConfig; -use crate::dataplane::{conn_tag_from_keys, DataPlane, Outcome}; -use crate::handshake::{HandshakeState, PacketType}; +use crate::dataplane::{conn_tag_from_keys, DataPlane}; +use crate::handshake::{Established, HandshakeState, PacketType}; use crate::membership::Membership; use crate::mode::TunnelMode; use crate::path::{PathAction, PathKind, PathStage, PathState}; @@ -177,7 +177,7 @@ enum PeerState { /// An initiator handshake is in flight, awaiting `[HandshakeResp]`. Handshaking(Box), /// A completed session; all data-plane traffic routes here. - Established(Box), + Established(Box), } /// One configured remote peer plus its live handshake/session state. @@ -211,6 +211,20 @@ struct Peer { /// the responder step again — see `handle_handshake_init`. `None` when we /// have no session, or hold one we built as the initiator. cached_resp: Option>, + /// The initiator Noise ephemeral (`handshake::init_ephemeral`) of the + /// `[HandshakeInit]` that produced `cached_resp`, i.e. the ORIGINAL + /// cold-start (or relayed) `Init` that established the *current* + /// session. Set alongside `cached_resp`, `None` under the same + /// conditions. + /// + /// `HANDSHAKE_TOTAL_MS` (90s) is bigger than `REKEY_INTERVAL_MS`/2 + /// (60s), so a very-late retransmit of this ORIGINAL Init can land + /// after `EpochSet::accept_rekey_init` would otherwise treat it as a + /// plausible genuine rekey. `handle_rekey_init` checks this field FIRST + /// (milestone 9a final review, Important-2) so that case still resends + /// `cached_resp` — the cold-start dedup path — rather than being + /// misclassified as a rekey round. + cached_resp_init_eph: Option<[u8; 32]>, /// This peer's self-certifying rendezvous node id (`node_id(pubkey)`), /// used to `lookup`/`relay` for it and to demux `RdvEvent`s back to it. node: NodeId, @@ -265,11 +279,26 @@ pub struct PeerManager { peers: Vec, /// `conn_tag -> peers index`, populated whenever a peer reaches /// `Established`. Consulted as a fast-path hint by `route_data` (see the - /// module doc for why it is not the primary demux mechanism). In 2a a peer - /// establishes exactly once (duplicate/retransmitted inits re-send the - /// cached reply rather than rebuilding — see `handle_handshake_init`), so - /// each peer contributes one entry that never goes stale. M7 rekey will - /// rotate `conn_tag`s per epoch and must evict the superseded entry here. + /// module doc for why it is not the primary demux mechanism) — a miss or + /// a stale entry always falls back to source-address matching, which is + /// authoritative, so this map never needs to be perfectly up to date for + /// correctness. Pre-9a a peer established exactly once (duplicate/ + /// retransmitted inits re-send the cached reply rather than rebuilding — + /// see `handle_handshake_init`), so each peer contributed one entry that + /// never went stale. + /// + /// 9a rekey rotates `conn_tag` per epoch. The initiator's explicit + /// promotion (`PeerManager::handle_rekey_resp`) evicts the superseded + /// tag and inserts the new one, since it has direct access to this map. + /// The responder's confirmed-switch promotion, however, happens + /// automatically inside `EpochSet::inbound_open` (Task 1), which is + /// pure/I-O-free and has no access to `PeerManager` fields — so on that + /// side the old tag is simply left behind as a harmless dead entry (it + /// can never match a live datagram's `conn_tag` bytes again) rather than + /// actively evicted. New datagrams under the responder's promoted epoch + /// still route correctly via the source-address fallback in + /// `route_data` until (if ever) a later inbound datagram happens to + /// warm this map's `insert` path for the peer again. by_tag: HashMap, /// `node_addr -> peers index`, populated at construction (addresses are /// derived from each peer's configured public key and never change). @@ -350,6 +379,13 @@ pub struct PeerManager { /// `PeerManager::new` parameter of the same name; `false` reproduces the /// UDP-path Direct→Punch→Relay escalation byte-identically. relay_only: bool, + /// Mid-session rekey cadence (9a Task 3): an `Established` peer's + /// `current` epoch is rekeyed once it is this old (glare-winner side) or + /// `2×` this old (loser-fallback side — see `EpochSet::needs_rekey`). + /// Defaults to [`crate::epoch::REKEY_INTERVAL_MS`]; overridden via + /// `YIP_REKEY_INTERVAL_MS` at construction so netns/unit tests can drive + /// the schedule without a multi-minute real-time wait. + rekey_interval_ms: u64, } /// MTU budget (bytes) used to size obfuscation padding: handshakes are padded @@ -418,6 +454,7 @@ impl PeerManager { state: PeerState::Idle, pending_tun: Vec::new(), cached_resp: None, + cached_resp_init_eph: None, node, path, path_kind: None, @@ -450,6 +487,10 @@ impl PeerManager { cover_traffic_ms: None, data_symbol_size: DEFAULT_DATA_SYMBOL_SIZE, relay_only, + rekey_interval_ms: std::env::var("YIP_REKEY_INTERVAL_MS") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(crate::epoch::REKEY_INTERVAL_MS), }; // Roots are pre-vetted (CA-signed root set) and therefore always-admit, // exactly like configured peers: seed them into the peer table so an @@ -499,6 +540,7 @@ impl PeerManager { state: PeerState::Idle, pending_tun: Vec::new(), cached_resp: None, + cached_resp_init_eph: None, node, path, path_kind: None, @@ -688,6 +730,148 @@ impl PeerManager { Some(vec![dg]) } + /// Mid-session rekey scheduler (9a Task 3), driven once per tick for an + /// `Established` peer `idx` (`relay` mirrors `tick_dispatch`'s + /// same-named local — whether `idx` is relay-reached). Starts a fresh + /// initiator rekey handshake when due, retransmits one already in + /// flight, or abandons it — entirely alongside `epochs.current`, which + /// this function never touches: a failed/abandoned rekey is therefore a + /// no-op on the live session (fail-closed). Any egress produced is + /// pushed onto `self.tick_egress`. + /// + /// Mirrors `begin_handshake`'s initiator construction (same + /// `cert_payload`, same relay/direct wrap, same `jitter_ms`-derived + /// retry cadence as `HandshakingState`'s cold-start retransmit arm in + /// `tick_dispatch`) so the rekey `Init` carries no new fingerprint + /// distinguishing it from a cold-start one. Unlike `begin_handshake`, it + /// does not transition `PeerState` (the peer stays `Established`) and + /// does not emit an obfuscation junk burst (Task 3 scope: scheduling + /// only, not a new decoy shape). + fn drive_rekey_schedule( + &mut self, + idx: usize, + relay: bool, + epochs: &mut crate::epoch::EpochSet, + now_ms: u64, + ) { + // Relay-reached peers: do NOT schedule a mid-session rekey. Rekey + // COMPLETION is only wired for the direct handshake handlers + // (`handle_handshake_resp`/`handle_handshake_init`'s glare-loser + // path) — `relayed_handshake_init`/`relayed_handshake_resp` never + // call `promote_from_rekey`/`install_next`. A rekey started here for + // a relay peer could therefore never complete: it would retransmit + // until `HANDSHAKE_TOTAL_MS`, get abandoned, and — since the epoch + // age is still past the threshold — restart on the very next tick, + // forever. That's wasted relay bandwidth and a constant ~1 Hz + // `[HandshakeInit]` cadence (a DPI fingerprint), for zero benefit + // (fail-closed: `current` is never touched either way). Wiring relay + // rekey completion is left to a follow-up. + if relay { + return; + } + if epochs.rekey.is_none() { + // Glare tiebreak: reuse the EXACT static-key-order comparison + // `handle_handshake_init`/`relayed_handshake_init` use to decide + // who adopts the responder role on a simultaneous cold-start + // handshake (the smaller public key is the designated + // initiator). The same side is the designated rekey initiator; + // the other side only rekeys via `needs_rekey`'s loser-fallback + // (2x the interval) if the winner never does. + let is_glare_winner = self.local_pub < self.peers[idx].pubkey; + if !epochs.needs_rekey(now_ms, is_glare_winner, self.rekey_interval_ms) { + return; + } + // `relay` peers already returned above, so this is always the + // direct path: target this peer's known endpoint. + let target = match self.peers[idx].endpoint { + Some(ep) => ep, + // No known direct endpoint this tick (shouldn't normally + // happen for a non-relay Established peer): skip: no + // egress, no state change, retried next tick. + None => return, + }; + let pubkey = self.peers[idx].pubkey; + let payload = self + .membership + .as_ref() + .map(Membership::own_cert_bytes) + .unwrap_or_default(); + let (hs, init_pkt) = + match HandshakeState::start_initiator(&self.local_priv, &pubkey, &payload) { + Ok(t) => t, + Err(e) => { + eprintln!("peer_manager: failed to start rekey handshake: {e}"); + return; + } + }; + let dg = EgressDatagram { + fate: 0, + dst: target, + bytes: init_pkt.clone(), + }; + let retry_ms = if self.obf_key.is_some() { + jitter_ms(HANDSHAKE_RETRY_MS) + } else { + HANDSHAKE_RETRY_MS + }; + epochs.rekey = Some(crate::epoch::RekeyInFlight { + hs, + init_pkt, + started_ms: now_ms, + last_sent_ms: now_ms, + retry_ms, + target, + }); + self.tick_egress.push(dg); + return; + } + + // A rekey is already in flight: retransmit (same cadence as + // `HandshakingState`'s cold-start arm) or abandon it once + // `HANDSHAKE_TOTAL_MS` elapses. `current` is never touched by either + // path — abandoning just clears `epochs.rekey`, leaving the live + // session exactly as it was; `needs_rekey` tries again next interval. + let (started_ms, last_sent_ms, retry_ms, target) = { + let rekey = epochs.rekey.as_ref().expect("checked is_some above"); + ( + rekey.started_ms, + rekey.last_sent_ms, + rekey.retry_ms, + rekey.target, + ) + }; + if now_ms.saturating_sub(started_ms) >= HANDSHAKE_TOTAL_MS { + epochs.rekey = None; + return; + } + if now_ms.saturating_sub(last_sent_ms) < retry_ms { + return; + } + let pkt = epochs + .rekey + .as_ref() + .expect("checked is_some above") + .init_pkt + .clone(); + // `relay` peers already returned above, so this is always the + // direct path. + let dg = EgressDatagram { + fate: 0, + dst: target, + bytes: pkt, + }; + let new_retry_ms = if self.obf_key.is_some() { + jitter_ms(HANDSHAKE_RETRY_MS) + } else { + HANDSHAKE_RETRY_MS + }; + if let Some(rk) = epochs.rekey.as_mut() { + rk.last_sent_ms = now_ms; + rk.retry_ms = new_retry_ms; + } + self.tick_egress.push(dg); + } + /// Emit a `lookup(peer_node)` for peer `idx`, debounced to at most one per /// [`LOOKUP_INTERVAL_MS`]. Returns `None` if throttled or no rendezvous. fn maybe_lookup(&mut self, idx: usize, now_ms: u64) -> Option { @@ -854,6 +1038,7 @@ impl PeerManager { self.peers[idx].session_obf_key = sess_obf; self.peers[idx].cached_resp = Some(resp_pkt.clone()); + self.peers[idx].cached_resp_init_eph = crate::handshake::init_ephemeral(dg); self.peers[idx].relay = true; self.peers[idx].path.committed(PathKind::Relayed); self.peers[idx].path_kind = Some(PathKind::Relayed); @@ -872,7 +1057,8 @@ impl PeerManager { .map(|d| d.bytes.clone()), ); } - self.peers[idx].state = PeerState::Established(dp); + self.peers[idx].state = + PeerState::Established(Box::new(crate::epoch::EpochSet::new(dp, now_ms))); for b in owned { if let Some(d) = self.relay_wrap(idx, b) { self.egress.push(d); @@ -926,7 +1112,8 @@ impl PeerManager { .map(|d| d.bytes.clone()), ); } - self.peers[idx].state = PeerState::Established(dp); + self.peers[idx].state = + PeerState::Established(Box::new(crate::epoch::EpochSet::new(dp, now_ms))); for b in owned { if let Some(d) = self.relay_wrap(idx, b) { self.egress.push(d); @@ -946,21 +1133,26 @@ impl PeerManager { } /// Relay-path counterpart of the `Data`/`Control` demux: dispatch a relayed - /// data-plane datagram to peer `idx`'s `DataPlane` and relay-wrap any UDP - /// egress it produces (TUN writes still go to the local device). + /// data-plane datagram to peer `idx`'s `EpochSet` (via `inbound_open`) and + /// relay-wrap any UDP egress it produces (TUN writes still go to the local + /// device). Relay egress always goes through `relay_wrap`, so only the + /// `EpochInbound::Send`/`TunThenSend` payload bytes are needed here — the + /// real `dst`/`fate` on each `EgressDatagram` are irrelevant for a + /// relayed peer (the actual wire destination is the relay server). fn relayed_data(&mut self, idx: usize, dg: &[u8], now_ms: u64) -> DispatchOut<'_> { let (tun, udp): (Option>, Vec>) = { - let PeerState::Established(dp) = &mut self.peers[idx].state else { + let PeerState::Established(epochs) = &mut self.peers[idx].state else { return DispatchOut::None; }; - match dp.on_udp_datagram(dg, now_ms) { - Outcome::None => (None, Vec::new()), - Outcome::TunWrite(buf) => (Some(buf.to_vec()), Vec::new()), - Outcome::Send(pkts) => (None, pkts.iter().map(|d| d.bytes.clone()).collect()), - Outcome::TunWriteThenSend(buf, pkts) => ( - Some(buf.to_vec()), - pkts.iter().map(|d| d.bytes.clone()).collect(), - ), + match epochs.inbound_open(dg, now_ms) { + crate::epoch::EpochInbound::None => (None, Vec::new()), + crate::epoch::EpochInbound::Tun(buf) => (Some(buf), Vec::new()), + crate::epoch::EpochInbound::Send(dgs) => { + (None, dgs.iter().map(|d| d.bytes.clone()).collect()) + } + crate::epoch::EpochInbound::TunThenSend(buf, dgs) => { + (Some(buf), dgs.iter().map(|d| d.bytes.clone()).collect()) + } } }; self.egress.clear(); @@ -1072,18 +1264,35 @@ impl PeerManager { .position(|p| p.endpoint == Some(src) && matches!(p.state, PeerState::Established(_))) } - /// Dispatch a `Data`/`Control` datagram to peer `idx`'s `DataPlane` and - /// re-map its `Outcome` into a `DispatchOut`. Returns `DispatchOut::None` - /// if `idx` is not (or no longer) `Established`. + /// Dispatch a `Data`/`Control` datagram to peer `idx`'s `EpochSet` (via + /// `inbound_open`) and re-map its `EpochInbound` into a `DispatchOut`. + /// Returns `DispatchOut::None` if `idx` is not (or no longer) + /// `Established`. fn dispatch_established(&mut self, idx: usize, dg: &[u8], now_ms: u64) -> DispatchOut<'_> { - let PeerState::Established(dp) = &mut self.peers[idx].state else { + let PeerState::Established(epochs) = &mut self.peers[idx].state else { return DispatchOut::None; }; - match dp.on_udp_datagram(dg, now_ms) { - Outcome::None => DispatchOut::None, - Outcome::TunWrite(buf) => DispatchOut::Tun(buf), - Outcome::Send(pkts) => DispatchOut::Udp(pkts), - Outcome::TunWriteThenSend(buf, pkts) => DispatchOut::Both(buf, pkts), + // `EpochInbound::Send`/`TunThenSend` carry the full `EgressDatagram` + // (real `dst` + `fate`), so no reconstruction from + // `self.peers[idx].endpoint` is needed — that placeholder is wrong + // for relay-established peers (their `DataPlane::peer_addr` is a + // `server_addr()` stand-in; `endpoint` may hold an unconfirmed + // candidate or `None`). + match epochs.inbound_open(dg, now_ms) { + crate::epoch::EpochInbound::None => DispatchOut::None, + crate::epoch::EpochInbound::Tun(buf) => { + self.tun_scratch = buf; + DispatchOut::Tun(&self.tun_scratch) + } + crate::epoch::EpochInbound::Send(dgs) => { + self.egress = dgs; + DispatchOut::Udp(&self.egress) + } + crate::epoch::EpochInbound::TunThenSend(buf, dgs) => { + self.tun_scratch = buf; + self.egress = dgs; + DispatchOut::Both(&self.tun_scratch, &self.egress) + } } } @@ -1125,21 +1334,22 @@ impl PeerManager { .collect(); for idx in candidates { let hit = { - let PeerState::Established(dp) = &mut self.peers[idx].state else { + let PeerState::Established(epochs) = &mut self.peers[idx].state else { continue; }; - match dp.on_udp_datagram(dg, now_ms) { - Outcome::None => None, - Outcome::TunWrite(buf) => Some((Some(buf.to_vec()), Vec::new())), - Outcome::Send(pkts) => Some((None, pkts.to_vec())), - Outcome::TunWriteThenSend(buf, pkts) => { - Some((Some(buf.to_vec()), pkts.to_vec())) - } + match epochs.inbound_open(dg, now_ms) { + crate::epoch::EpochInbound::None => None, + crate::epoch::EpochInbound::Tun(buf) => Some((Some(buf), Vec::new())), + crate::epoch::EpochInbound::Send(dgs) => Some((None, dgs)), + crate::epoch::EpochInbound::TunThenSend(buf, dgs) => Some((Some(buf), dgs)), } }; let Some((tun, udp)) = hit else { continue; }; + // `udp` already carries each datagram's real `dst`/`fate` (see + // `EpochInbound`); no reconstruction from `self.peers[idx].endpoint` + // needed (that placeholder is wrong for relay-established peers). return match (tun, udp.is_empty()) { (Some(t), true) => { self.tun_scratch = t; @@ -1233,28 +1443,28 @@ impl PeerManager { // it unconditionally would silently rekey. Branch on our current state // with that in mind. match &self.peers[idx].state { - // Already have a live session: this `Init` is a duplicate, a - // retransmit after our earlier reply was lost, or a peer restart. - // Never tear down the running session (2a has no rekey — a rebuilt - // session would strand a peer that stays on the old keys and drops - // the new reply). Re-send the cached `[HandshakeResp]` verbatim so a - // peer still handshaking (its reply was lost) completes on the SAME - // session; a peer already established harmlessly ignores it. Discard - // the freshly-built `established`/`resp_pkt`. - PeerState::Established(_) => match &self.peers[idx].cached_resp { - Some(resp) => { - self.egress.clear(); - self.egress.push(EgressDatagram { - fate: 0, - dst: src, - bytes: resp.clone(), - }); - DispatchOut::Udp(&self.egress) - } - // We hold this session as the initiator (no cached reply): a new - // `Init` from the peer is a restart/rekey, deferred to M7. - None => DispatchOut::None, - }, + // Already `Established` (9a): this `Init` is either (a) a + // duplicate/retransmit of the ORIGINAL completing handshake + // (peer hasn't seen our reply yet) or a peer restart, or (b) a + // genuine mid-session rekey `Init` from the peer + // (`drive_rekey_schedule`'s counterpart on their side). + // `EpochSet::accept_rekey_init` is the discriminator: a rekey + // can only legitimately arrive once `current` is at least + // `interval/2` old, so anything younger is (a) — handled + // exactly as before rekey existed (9a Task 4 must not regress + // this). `handle_rekey_init` owns the (b) path. + // + // `init_eph`: `start_responder` above already parsed `dg`'s + // msg1 successfully, and Noise-IK's msg1 leads with the + // unencrypted `e` token, so `dg[1..33]` is guaranteed present — + // this is the same per-round identity `handle_rekey_init` uses + // to deduplicate retransmitted Inits (9a final review). + PeerState::Established(_) => { + let init_eph = crate::handshake::init_ephemeral(dg).expect( + "start_responder already parsed dg's msg1; its leading 32 bytes are `e`", + ); + self.handle_rekey_init(idx, src, established, resp_pkt, now_ms, init_eph) + } // Glare: both sides initiated simultaneously (e.g. the TUN's IPv6 // autoconf multicast races the peer's traffic at startup). Break // the tie deterministically by static-key order so both converge on @@ -1283,6 +1493,7 @@ impl PeerManager { self.peers[idx].session_obf_key = sess_obf; self.peers[idx].endpoint = Some(src); // learn the observed endpoint self.peers[idx].cached_resp = Some(resp_pkt.clone()); + self.peers[idx].cached_resp_init_eph = crate::handshake::init_ephemeral(dg); self.by_tag.insert(dp.conn_tag(), idx); // Commit the path we completed over. `src` is a direct address // (this arm is only reached for non-relayed inits — relayed @@ -1308,22 +1519,154 @@ impl PeerManager { let out = dp.on_tun_packet(inner, now_ms); self.egress.extend(out.iter().cloned()); } - self.peers[idx].state = PeerState::Established(dp); + self.peers[idx].state = + PeerState::Established(Box::new(crate::epoch::EpochSet::new(dp, now_ms))); DispatchOut::Udp(&self.egress) } } } - /// Handle an incoming `[HandshakeResp]`: find the `Handshaking` peer - /// whose endpoint matches `src`, resume via `read_response`, transition - /// to `Established`, and drain any buffered `pending_tun`. + /// Rekey (9a Task 4) counterpart of the `Established(_)` arm in + /// `handle_handshake_init`: `established`/`resp_pkt` were already built + /// by the SAME `start_responder` call (and this peer already passed + /// admission — it was found by static-key match, not by cert) at the + /// top of `handle_handshake_init`, so there is no re-verification to do + /// here. `init_eph` is that Init's Noise ephemeral + /// (`handshake::init_ephemeral`) — the per-round identity used below to + /// tell a RETRANSMIT of an already-answered round from a genuinely new + /// one. + /// + /// In order: + /// + /// 1. `init_eph` matches `cached_resp_init_eph` (Important-2, 9a final + /// review): this Init is a retransmit of the ORIGINAL cold-start (or + /// relayed) Init that established the *current* session — possible + /// even past `interval/2` since `HANDSHAKE_TOTAL_MS` (90s) exceeds it + /// (60s). Resend `cached_resp` verbatim; never build a rekey `next` + /// off it. + /// 2. `init_eph` matches the ephemeral behind the currently-held `next` + /// (the Critical fix, 9a final review): this Init is a retransmit of + /// a rekey round already answered — e.g. the initiator retransmitted + /// before seeing our first `[HandshakeResp]` (RTT > retry_ms), or a + /// `[HandshakeResp]` was reordered/duplicated in flight. Resend that + /// round's cached resp verbatim and do NOT mint a second session: + /// minting a fresh one here would discard the `next` the initiator is + /// about to promote to, stranding the two sides on different epochs + /// (initiator locks onto the FIRST `[HandshakeResp]` it reads and + /// never revisits later ones) and black-holing the tunnel. + /// 3. `!EpochSet::accept_rekey_init`: `current` is too young for this to + /// plausibly be a genuine rekey. Falls back to the pre-9a behavior + /// (re-send the cached `[HandshakeResp]` verbatim if we hold one; + /// otherwise ignore, no reply). This is what keeps + /// `duplicate_init_after_established_does_not_tear_down_session` + /// green: that regression fires at `now_ms == current_created_ms`, + /// always younger than `interval/2`. The freshly-built + /// `established`/`resp_pkt` are discarded on this path — installing + /// them would silently rekey off a mere retransmit. + /// 4. Otherwise: a genuinely NEW rekey round. Install `established` as + /// the responder's unconfirmed `next` epoch, keyed by `init_eph` + /// (`EpochSet::install_next`) — `current` is untouched, so this side + /// keeps SENDING on the old epoch. The responder's own switch happens + /// later, automatically, inside `EpochSet::inbound_open` (Task 1) on + /// the first inbound frame that authenticates under `next`. + fn handle_rekey_init( + &mut self, + idx: usize, + src: SocketAddr, + established: Established, + resp_pkt: Vec, + now_ms: u64, + init_eph: [u8; 32], + ) -> DispatchOut<'_> { + if self.peers[idx].cached_resp_init_eph == Some(init_eph) { + return match &self.peers[idx].cached_resp { + Some(resp) => { + self.egress.clear(); + self.egress.push(EgressDatagram { + fate: 0, + dst: src, + bytes: resp.clone(), + }); + DispatchOut::Udp(&self.egress) + } + None => DispatchOut::None, + }; + } + + let PeerState::Established(epochs) = &mut self.peers[idx].state else { + unreachable!("handle_rekey_init is only called for an Established peer") + }; + + if let Some(cached) = epochs.next_cached_resp_for(&init_eph).map(<[u8]>::to_vec) { + self.egress.clear(); + self.egress.push(EgressDatagram { + fate: 0, + dst: src, + bytes: cached, + }); + return DispatchOut::Udp(&self.egress); + } + + if !epochs.accept_rekey_init(now_ms, self.rekey_interval_ms) { + return match &self.peers[idx].cached_resp { + Some(resp) => { + self.egress.clear(); + self.egress.push(EgressDatagram { + fate: 0, + dst: src, + bytes: resp.clone(), + }); + DispatchOut::Udp(&self.egress) + } + None => DispatchOut::None, + }; + } + + // NOTE: `session_obf_key` (the outer anti-DPI wrap key, keyed off + // `hp_key`) is intentionally left untouched here — see the doc + // comment on `handle_rekey_resp`. + let conn_tag = conn_tag_from_keys(&established.auth_key, &established.hp_key); + let dp = Box::new(DataPlane::new( + established, + conn_tag, + self.mode, + src, + self.obf_key.is_some(), + self.data_symbol_size, + )); + let PeerState::Established(epochs) = &mut self.peers[idx].state else { + unreachable!("state cannot change between the two borrows above") + }; + epochs.install_next(dp, init_eph, resp_pkt.clone()); + + self.egress.clear(); + self.egress.push(EgressDatagram { + fate: 0, + dst: src, + bytes: resp_pkt, + }); + DispatchOut::Udp(&self.egress) + } + + /// Handle an incoming `[HandshakeResp]`: either complete an in-flight + /// rekey for an already-`Established` peer (9a Task 4), or — the + /// cold-start path — find the `Handshaking` peer whose endpoint matches + /// `src`, resume via `read_response`, transition to `Established`, and + /// drain any buffered `pending_tun`. fn handle_handshake_resp( &mut self, src: SocketAddr, dg: &[u8], now_ms: u64, ) -> DispatchOut<'_> { + if let Some(idx) = self.peers.iter().position(|p| { + p.endpoint == Some(src) + && matches!(&p.state, PeerState::Established(epochs) if epochs.rekey.is_some()) + }) { + return self.handle_rekey_resp(idx, dg, now_ms); + } + let Some(idx) = self .peers .iter() @@ -1378,7 +1721,8 @@ impl PeerManager { let out = dp.on_tun_packet(inner, now_ms); self.egress.extend(out.iter().cloned()); } - self.peers[idx].state = PeerState::Established(dp); + self.peers[idx].state = + PeerState::Established(Box::new(crate::epoch::EpochSet::new(dp, now_ms))); if self.egress.is_empty() { DispatchOut::None @@ -1396,6 +1740,126 @@ impl PeerManager { } } + /// Complete an in-flight rekey handshake for an `Established` peer `idx` + /// (initiator side of the WireGuard-style confirmed switch, 9a Task 4) + /// on receipt of the matching `[HandshakeResp]`. + /// + /// `HandshakeState::read_response` consumes the handshake BY VALUE, so + /// the `RekeyInFlight` is taken out of `epochs.rekey` first — after + /// that, `epochs.rekey` is `None` regardless of what happens next, so + /// every early return below is already a fail-closed no-op: `current` + /// is untouched and `drive_rekey_schedule`'s `needs_rekey` will try + /// again at the next interval. + /// + /// KNOWN LIMITATION (9a final review, Important-1), left as-is on + /// purpose: `epochs.rekey.take()` runs BEFORE `rk.hs.read_response(dg)` + /// below, so a delayed/duplicate/spoofed-src `[HandshakeResp]` that + /// fails to `read_response` (wrong bytes, replayed old Resp, or a + /// same-endpoint attacker's garbage — UDP has no source authentication) + /// silently ABANDONS the in-flight rekey rather than just ignoring the + /// bad datagram and letting the real Resp complete it later. This is a + /// rekey-*liveness* DoS only: `current` is never touched (fail-closed + /// per the constraint above), so the live session survives untouched — + /// only the rotation is denied, and `drive_rekey_schedule` starts a + /// fresh rekey attempt next interval. The clean fix would be to `clone` + /// `rk.hs`, try `read_response` on the clone, and only `take()`/clear + /// `rekey` on success — but `snow::HandshakeState` (which + /// `yip_crypto::Handshake`/`crate::handshake::HandshakeState` wrap) is + /// NOT `Clone` (it owns `Box`/`Box` trait objects), + /// so that fix is not available without hand-rolling handshake state + /// duplication in `yip-crypto` — out of scope here. An off-path + /// attacker that can spoof this peer's endpoint as `src` can therefore + /// repeatedly deny rekey rotation (never break the tunnel); closing that + /// rides with the #34 authenticated-endpoint work (verifying `src` + /// against the session before acting on it). + /// + /// On success: builds the new epoch's `DataPlane` exactly like + /// cold-start completion, promotes it via `EpochSet::promote_from_rekey` + /// (switching `current` immediately — the initiator already knows the + /// responder installed this epoch, since it just sent the `Resp`), and + /// emits one outbound frame on the NEW epoch (draining `pending_tun`, or + /// a bare empty-payload frame if none is queued) so the responder + /// observes a `next`-epoch datagram and confirms its own switch inside + /// `EpochSet::inbound_open` (Task 1). + /// + /// `session_obf_key` (the outer anti-DPI wrap key, keyed off `hp_key`) + /// is intentionally left untouched across the promotion: it is derived + /// once at cold start and shared by both peers for the connection's + /// lifetime. The responder's own confirmed-switch promotion happens + /// entirely inside `EpochSet::inbound_open`, which has no access to + /// `PeerManager`/`Peer` fields — so it has no way to resync + /// `session_obf_key` on that side. Rotating it here, on the initiator + /// side only, would desync the two peers' outer-wrap keys rather than + /// fix anything; leaving it alone keeps both sides on the one key they + /// already agree on. (The security-relevant per-epoch key material — the + /// inner AEAD/wire `Codec` — DOES rotate correctly: it is rebuilt fresh + /// inside `DataPlane::new` from this epoch's own `auth_key`/`hp_key`.) + fn handle_rekey_resp(&mut self, idx: usize, dg: &[u8], now_ms: u64) -> DispatchOut<'_> { + let rk = { + let PeerState::Established(epochs) = &mut self.peers[idx].state else { + unreachable!("handle_rekey_resp is only called for an Established peer") + }; + match epochs.rekey.take() { + Some(rk) => rk, + None => return DispatchOut::None, + } + }; + let peer_addr = rk.target; + + let (established, responder_payload) = match rk.hs.read_response(dg) { + Ok(t) => t, + Err(e) => { + eprintln!("peer_manager: rekey read_response failed: {e}"); + return DispatchOut::None; + } + }; + if !self.responder_cert_ok(&responder_payload, self.peers[idx].pubkey) { + eprintln!("peer_manager: rekey responder cert rejected"); + return DispatchOut::None; + } + + let conn_tag = conn_tag_from_keys(&established.auth_key, &established.hp_key); + let mut dp = Box::new(DataPlane::new( + established, + conn_tag, + self.mode, + peer_addr, + self.obf_key.is_some(), + self.data_symbol_size, + )); + + // Prime the new epoch's outbound (see doc comment) BEFORE `dp` is + // moved into `promote_from_rekey` below. + let pending = std::mem::take(&mut self.peers[idx].pending_tun); + self.egress.clear(); + if pending.is_empty() { + self.egress + .extend(dp.on_tun_packet(&[], now_ms).iter().cloned()); + } else { + for inner in &pending { + self.egress + .extend(dp.on_tun_packet(inner, now_ms).iter().cloned()); + } + } + + let old_tag = { + let PeerState::Established(epochs) = &mut self.peers[idx].state else { + unreachable!("state cannot change between the borrows above") + }; + let old_tag = epochs.current().conn_tag(); + epochs.promote_from_rekey(dp, now_ms); + old_tag + }; + self.by_tag.remove(&old_tag); + self.by_tag.insert(conn_tag, idx); + + if self.egress.is_empty() { + DispatchOut::None + } else { + DispatchOut::Udp(&self.egress) + } + } + // ── membership gossip ───────────────────────────────────────────────── /// Handle an inbound `[Gossip]` datagram from `src`: decode the @@ -1849,16 +2313,18 @@ impl PeerManager { // peer's datagrams already carry the correct `dst` — return them // borrowed, byte-identical to 2a. if !self.peers[idx].relay { - let PeerState::Established(dp) = &mut self.peers[idx].state else { + let PeerState::Established(epochs) = &mut self.peers[idx].state else { unreachable!("just matched Established above"); }; - return dp.on_tun_packet(inner, now_ms); + return epochs.current_mut().on_tun_packet(inner, now_ms); } let owned: Vec> = { - let PeerState::Established(dp) = &mut self.peers[idx].state else { + let PeerState::Established(epochs) = &mut self.peers[idx].state else { unreachable!("just matched Established above"); }; - dp.on_tun_packet(inner, now_ms) + epochs + .current_mut() + .on_tun_packet(inner, now_ms) .iter() .map(|d| d.bytes.clone()) .collect() @@ -2035,8 +2501,10 @@ impl PeerManager { let relay = self.peers[i].relay; let old_state = std::mem::replace(&mut self.peers[i].state, PeerState::Idle); let new_state = match old_state { - PeerState::Established(mut dp) => { - if let Some(pkts) = dp.tick(now_ms) { + PeerState::Established(mut epochs) => { + epochs.retire_previous_if_due(now_ms); + self.drive_rekey_schedule(i, relay, &mut epochs, now_ms); + if let Some(pkts) = epochs.current_mut().tick(now_ms) { if relay { // Relay-reached peer: re-wrap each datagram through // the server. Copy bytes out (borrow ends) then wrap. @@ -2051,7 +2519,7 @@ impl PeerManager { self.tick_egress.extend(pkts.iter().cloned()); } } - PeerState::Established(dp) + PeerState::Established(epochs) } PeerState::Handshaking(mut handshaking) if now_ms.saturating_sub(handshaking.last_sent_ms) >= handshaking.retry_ms => @@ -2444,9 +2912,12 @@ mod tests { // (the "test seam": direct access to private fields from the child // `tests` module). const FAKE_TAG: u64 = 0xAAAA_BBBB_CCCC_DDDD; - pm.peers[1].state = PeerState::Established(Box::new(fake_established_dataplane( - FAKE_TAG, - peer_b.endpoint.unwrap(), + pm.peers[1].state = PeerState::Established(Box::new(crate::epoch::EpochSet::new( + Box::new(fake_established_dataplane( + FAKE_TAG, + peer_b.endpoint.unwrap(), + )), + 0, ))); pm.by_tag.insert(FAKE_TAG, 1); @@ -2546,7 +3017,7 @@ mod tests { /// (yet) Established. Used by the handshake state-machine tests below. fn established_tag(pm: &PeerManager, idx: usize) -> Option { match &pm.peers[idx].state { - PeerState::Established(dp) => Some(dp.conn_tag()), + PeerState::Established(epochs) => Some(epochs.current().conn_tag()), _ => None, } } @@ -2571,6 +3042,642 @@ mod tests { vec![0x45u8; 40] } + // ── 9a Task 3: mid-session rekey scheduling ───────────────────────────── + + /// Build a `PeerManager` with a single already-`Established` peer (via + /// `fake_established_dataplane`, spliced in like + /// `routes_inner_dst_to_owning_peer_and_demuxes_by_tag` does), its + /// `EpochSet.current_created_ms` pinned to `0`, and `rekey_interval_ms` + /// overridden to `interval_ms` (bypassing the real + /// `YIP_REKEY_INTERVAL_MS`/120s cadence so tests don't need a multi-minute + /// wait). `local_pub`/peer `public_key` are chosen by the caller so the + /// glare-winner tiebreak (`local_pub < peer.pubkey`) lands as intended. + fn pm_with_established_peer( + local_pub: [u8; 32], + peer_pubkey: [u8; 32], + interval_ms: u64, + ) -> (PeerManager, u64, SocketAddr) { + let ep: SocketAddr = "10.0.0.1:1000".parse().unwrap(); + let cfg = PeerConfig { + public_key: peer_pubkey, + endpoint: Some(ep), + }; + let mut pm = PeerManager::new( + [7u8; 32], + local_pub, + &[cfg], + TunnelMode::L3Tun, + None, + None, + false, + ); + pm.rekey_interval_ms = interval_ms; + const FAKE_TAG: u64 = 0x1234_5678_9abc_def0; + pm.peers[0].state = PeerState::Established(Box::new(crate::epoch::EpochSet::new( + Box::new(fake_established_dataplane(FAKE_TAG, ep)), + 0, + ))); + pm.by_tag.insert(FAKE_TAG, 0); + (pm, FAKE_TAG, ep) + } + + /// `true` iff `out` (a `tick` return) carries a `[HandshakeInit]` datagram. + fn has_handshake_init(out: Option<&[EgressDatagram]>) -> bool { + out.into_iter() + .flatten() + .any(|d| d.bytes.first() == Some(&(PacketType::HandshakeInit as u8))) + } + + /// `true` iff `out` carries a rekey `[HandshakeInit]` relay-wrapped in a + /// `yip_rendezvous::Message::RelaySend` — i.e. what `relay_wrap` produces + /// for a relay-reached peer. Decoding the rendezvous envelope (rather + /// than checking the outer byte, as `has_handshake_init` does) matters + /// here: `RelaySend`'s own wire tag is 5, but a coincidental Register + /// refresh (tag 0, sent once per `tick_dispatch` when a `Rendezvous` is + /// freshly configured) would otherwise be misread as a raw + /// `[HandshakeInit]` (also discriminant 0) by `has_handshake_init`. + fn has_relayed_handshake_init(out: Option<&[EgressDatagram]>) -> bool { + out.into_iter().flatten().any(|d| { + matches!( + yip_rendezvous::decode(&d.bytes), + Some(yip_rendezvous::Message::RelaySend { payload, .. }) + if payload.first() == Some(&(PacketType::HandshakeInit as u8)) + ) + }) + } + + #[test] + fn tick_initiates_rekey_for_established_winner_once() { + // local_pub = [1;32] < peer pubkey = [2;32]: local is the + // glare-winner (the smaller static key), exactly the comparison + // `handle_handshake_init`/`relayed_handshake_init` use to decide who + // adopts the initiator role on a cold-start glare. + let (mut pm, tag, _ep) = pm_with_established_peer([1u8; 32], [2u8; 32], 100); + + // Past the interval (age 150 >= 100): tick emits a HandshakeInit and + // schedules a rekey, WITHOUT touching the live `current` epoch. + let out = pm.tick(150).map(<[EgressDatagram]>::to_vec); + assert!( + has_handshake_init(out.as_deref()), + "winner past the interval must emit a rekey HandshakeInit" + ); + assert_eq!( + established_tag(&pm, 0), + Some(tag), + "current epoch must be untouched by scheduling a rekey" + ); + match &pm.peers[0].state { + PeerState::Established(epochs) => assert!( + epochs.rekey.is_some(), + "EpochSet.rekey must be populated once a rekey is in flight" + ), + _ => panic!("peer must still be Established"), + } + + // A second tick shortly after, before the rekey completes: one rekey + // in flight already, so `needs_rekey` must suppress a second Init. + let out2 = pm.tick(160).map(<[EgressDatagram]>::to_vec); + assert!( + !has_handshake_init(out2.as_deref()), + "a rekey already in flight must not emit a second HandshakeInit" + ); + assert_eq!( + established_tag(&pm, 0), + Some(tag), + "current epoch must remain untouched on the second tick too" + ); + } + + /// A relay-reached `Established` peer (`relay == true`, mirroring how + /// `relayed_handshake_init`/`relayed_handshake_resp` leave a peer) must + /// NOT have a mid-session rekey scheduled by `tick`/`drive_rekey_schedule`, + /// even when it is the glare-winner and well past `rekey_interval_ms`. + /// Rekey COMPLETION is only wired for the direct handshake handlers + /// (`handle_handshake_resp` et al.) — `relayed_handshake_init`/ + /// `relayed_handshake_resp` never call `promote_from_rekey`/`install_next` + /// for a relay session — so a relay rekey scheduled here can never + /// complete: it would retransmit for `HANDSHAKE_TOTAL_MS`, get abandoned, + /// and (since the epoch age is still past the threshold) immediately + /// restart, forever. Contrast with `tick_initiates_rekey_for_established_winner_once`, + /// whose otherwise-identical direct peer (`relay == false`) does rekey. + #[test] + fn tick_does_not_rekey_relay_peer() { + let (mut pm, tag, _ep) = pm_with_established_peer([1u8; 32], [2u8; 32], 100); + pm.peers[0].relay = true; + // A relay-reached peer routes rekey Inits through `relay_wrap`, which + // needs a configured `Rendezvous` to succeed (with none, the pre-fix + // code already silently drops the attempt — that would make this + // test pass for the wrong reason). Wire up the same `MockRdv` the + // rendezvous-wiring tests use so a pre-fix build genuinely emits the + // rekey `HandshakeInit` via the relay. + let sent = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + pm.rendezvous = Some(Box::new(MockRdv { + server: mock_server(), + sent, + })); + // Pre-mark registration done, and push its refresh interval out + // past this test's horizon, so `tick_dispatch`'s periodic + // registration refresh (a `Register` datagram, wire tag 0 — the same + // numeric value as `PacketType::HandshakeInit`) never fires and + // confounds the `has_handshake_init` assertions below. + pm.registered_once = true; + pm.last_register_ms = 150; + pm.reg_refresh_ms = u64::MAX; + + // Past the interval (age 150 >= 100): a direct peer would rekey here + // (see `tick_initiates_rekey_for_established_winner_once`), but a + // relay peer must not. + let out = pm.tick(150).map(<[EgressDatagram]>::to_vec); + assert!( + !has_handshake_init(out.as_deref()) && !has_relayed_handshake_init(out.as_deref()), + "a relay peer must not emit a rekey HandshakeInit, even as glare-winner past the interval" + ); + match &pm.peers[0].state { + PeerState::Established(epochs) => assert!( + epochs.rekey.is_none(), + "EpochSet.rekey must stay None for a relay peer: rekey completion isn't wired for the relay handlers" + ), + _ => panic!("peer must still be Established"), + } + assert_eq!( + established_tag(&pm, 0), + Some(tag), + "current epoch must be untouched" + ); + + // A later tick, well past the point a direct peer would have + // abandoned-and-restarted a rekey, must still emit nothing. + let out2 = pm + .tick(150 + HANDSHAKE_TOTAL_MS) + .map(<[EgressDatagram]>::to_vec); + assert!( + !has_handshake_init(out2.as_deref()) && !has_relayed_handshake_init(out2.as_deref()), + "a relay peer must not ever start the churn cycle a never-completing rekey would cause" + ); + } + + #[test] + fn tick_rekey_loser_waits_for_2x_interval() { + // local_pub = [3;32] > peer pubkey = [2;32]: local is the + // glare-LOSER, so it only rekeys via the fallback at 2x the interval. + let (mut pm, tag, _ep) = pm_with_established_peer([3u8; 32], [2u8; 32], 100); + + // Past 1x the interval only: the loser must NOT rekey yet. + let out = pm.tick(150).map(<[EgressDatagram]>::to_vec); + assert!( + !has_handshake_init(out.as_deref()), + "a glare-loser must not rekey before 2x the interval" + ); + match &pm.peers[0].state { + PeerState::Established(epochs) => { + assert!(epochs.rekey.is_none(), "no rekey should be scheduled yet") + } + _ => panic!("peer must still be Established"), + } + + // Past 2x the interval: the loser-fallback fires. + let out2 = pm.tick(250).map(<[EgressDatagram]>::to_vec); + assert!( + has_handshake_init(out2.as_deref()), + "a glare-loser must rekey once past 2x the interval" + ); + assert_eq!(established_tag(&pm, 0), Some(tag)); + } + + #[test] + fn tick_rekey_retransmits_same_init_after_retry_ms() { + let (mut pm, tag, ep) = pm_with_established_peer([1u8; 32], [2u8; 32], 100); + + let out = pm.tick(100).map(<[EgressDatagram]>::to_vec).unwrap(); + let first_init = out + .iter() + .find(|d| d.bytes.first() == Some(&(PacketType::HandshakeInit as u8))) + .expect("rekey Init on the triggering tick") + .bytes + .clone(); + + // Before HANDSHAKE_RETRY_MS (obf off => exactly 1000ms) elapses: no + // retransmit. + let mid = pm.tick(100 + HANDSHAKE_RETRY_MS - 1).map(|s| s.to_vec()); + assert!( + !has_handshake_init(mid.as_deref()), + "must not retransmit before retry_ms elapses" + ); + + // At/after retry_ms: the SAME init_pkt is retransmitted (same + // ephemeral, so a responder's cached reply — if any — stays valid). + let out2 = pm + .tick(100 + HANDSHAKE_RETRY_MS) + .map(<[EgressDatagram]>::to_vec) + .unwrap(); + let retransmit = out2 + .iter() + .find(|d| d.bytes.first() == Some(&(PacketType::HandshakeInit as u8)) && d.dst == ep) + .expect("retransmitted rekey Init"); + assert_eq!( + retransmit.bytes, first_init, + "retransmit must resend the exact same Init bytes" + ); + assert_eq!(established_tag(&pm, 0), Some(tag)); + } + + #[test] + fn tick_rekey_abandoned_after_handshake_total_ms_keeps_current() { + let (mut pm, tag, _ep) = pm_with_established_peer([1u8; 32], [2u8; 32], 100); + + pm.tick(100); + match &pm.peers[0].state { + PeerState::Established(epochs) => assert!(epochs.rekey.is_some()), + _ => panic!("peer must still be Established"), + } + + // The whole HANDSHAKE_TOTAL_MS window elapses without completing: + // the rekey is abandoned, but `current` (the live session) is a + // no-op survivor — untouched. + pm.tick(100 + HANDSHAKE_TOTAL_MS); + match &pm.peers[0].state { + PeerState::Established(epochs) => assert!( + epochs.rekey.is_none(), + "an abandoned rekey must clear EpochSet.rekey" + ), + _ => panic!("peer must still be Established"), + } + assert_eq!( + established_tag(&pm, 0), + Some(tag), + "abandoning a rekey must be a no-op on the live current epoch" + ); + } + + // ── 9a Task 4: rekey handshake completion wiring ──────────────────────── + + /// Build two real `PeerManager`s and drive them through a cold-start + /// handshake (A initiates) so both land `Established` on the SAME + /// session, with `rekey_interval_ms` set to `interval_ms` on both. + /// Returns `(pm_a, pm_b, ep_a, ep_b, kp_a, kp_b)`. + fn established_pm_pair( + interval_ms: u64, + ) -> ( + PeerManager, + PeerManager, + SocketAddr, + SocketAddr, + yip_crypto::Keypair, + yip_crypto::Keypair, + ) { + let kp_a = generate_keypair(); + let kp_b = generate_keypair(); + let ep_a: SocketAddr = "10.0.0.1:1000".parse().unwrap(); + let ep_b: SocketAddr = "10.0.0.2:2000".parse().unwrap(); + let cfg_b = PeerConfig { + public_key: kp_b.public, + endpoint: Some(ep_b), + }; + let cfg_a = PeerConfig { + public_key: kp_a.public, + endpoint: Some(ep_a), + }; + let mut pm_a = PeerManager::new( + kp_a.private, + kp_a.public, + &[cfg_b], + TunnelMode::L3Tun, + None, + None, + false, + ); + let mut pm_b = PeerManager::new( + kp_b.private, + kp_b.public, + &[cfg_a], + TunnelMode::L3Tun, + None, + None, + false, + ); + pm_a.rekey_interval_ms = interval_ms; + pm_b.rekey_interval_ms = interval_ms; + + let init = pm_a.on_tun(&dummy_tun_pkt(), 0)[0].bytes.clone(); + let resp = resp_bytes(&pm_b.on_udp(ep_a, &init, 0)); + assert_eq!(resp.len(), 1, "cold-start init must produce one resp"); + pm_a.on_udp(ep_b, &resp[0], 0); + assert!(established_tag(&pm_a, 0).is_some()); + assert_eq!(established_tag(&pm_a, 0), established_tag(&pm_b, 0)); + + (pm_a, pm_b, ep_a, ep_b, kp_a, kp_b) + } + + #[test] + fn rekey_resp_promotes_initiator_and_keeps_previous_for_grace() { + let (mut pm_a, mut pm_b, ep_a, ep_b, kp_a, kp_b) = established_pm_pair(100); + let old_tag = established_tag(&pm_a, 0).unwrap(); + + // Capture an OLD-epoch frame (B -> A) sealed on B's still-untouched + // `current`, to prove after the switch that `previous` still opens + // it (the grace window). + let old_payload = vec![0xAAu8; 24]; + let old_frame = pm_b.on_tun(&old_payload, 50)[0].bytes.clone(); + assert_eq!(old_frame[0], PacketType::Data as u8); + + // Drive a rekey `Init` directly (bypassing `tick`'s glare-winner + // scheduling, which depends on the random keypair ordering — Task 4 + // is only exercising the COMPLETION wiring, already covered + // separately by Task 3's scheduling tests) by splicing a + // `RekeyInFlight` into A's `EpochSet`, exactly as + // `drive_rekey_schedule` would have. + let (hs, init_pkt) = + HandshakeState::start_initiator(&kp_a.private, &kp_b.public, &[]).unwrap(); + { + let PeerState::Established(epochs) = &mut pm_a.peers[0].state else { + panic!("pm_a must be Established"); + }; + epochs.rekey = Some(crate::epoch::RekeyInFlight { + hs, + init_pkt: init_pkt.clone(), + started_ms: 100, + last_sent_ms: 100, + retry_ms: 1000, + target: ep_b, + }); + } + + // B (current old enough: age 100 >= interval/2 = 50) accepts the + // rekey Init, installs `next`, and replies — `current` untouched. + let resp = resp_bytes(&pm_b.on_udp(ep_a, &init_pkt, 100)); + assert_eq!(resp.len(), 1, "a genuine rekey Init must produce a Resp"); + assert_eq!( + established_tag(&pm_b, 0), + Some(old_tag), + "B's current must stay on the old epoch until confirmed" + ); + match &pm_b.peers[0].state { + PeerState::Established(epochs) => assert!(epochs.next.is_some()), + _ => panic!("pm_b must still be Established"), + } + + // A completes: read_response promotes `current` to the NEW epoch + // immediately, moves the OLD epoch to `previous`, and clears + // `epochs.rekey`. + let out = pm_a.on_udp(ep_b, &resp[0], 100); + let confirm_frames: Vec> = match &out { + DispatchOut::Udp(e) => e.iter().map(|d| d.bytes.clone()).collect(), + _ => panic!("expected Udp egress (the new-epoch confirm frame)"), + }; + assert!( + !confirm_frames.is_empty(), + "A must emit at least one NEW-epoch frame so B can confirm the switch" + ); + + let new_tag = established_tag(&pm_a, 0).unwrap(); + assert_ne!(new_tag, old_tag, "current must become the NEW epoch"); + match &pm_a.peers[0].state { + PeerState::Established(epochs) => { + assert!( + epochs.rekey.is_none(), + "rekey must be cleared on completion" + ); + assert!(epochs.previous.is_some(), "old epoch must move to previous"); + assert_eq!( + epochs.previous.as_ref().unwrap().conn_tag(), + old_tag, + "previous must hold the OLD epoch" + ); + } + _ => panic!("pm_a must still be Established"), + } + + // OLD-epoch frame (captured before the switch) still opens via + // `previous`. + match pm_a.on_udp(ep_b, &old_frame, 101) { + DispatchOut::Tun(buf) => assert_eq!(buf, old_payload.as_slice()), + _ => panic!("expected the old-epoch frame to open via `previous`"), + } + + // Feed A's confirm frame(s) to B: B's `EpochSet::inbound_open` + // (Task 1) authenticates under `next`, promoting it there too. + for f in &confirm_frames { + pm_b.on_udp(ep_a, f, 101); + } + assert_eq!( + established_tag(&pm_b, 0), + Some(new_tag), + "B must have confirmed the switch to the SAME new epoch" + ); + + // NEW-epoch frame (B -> A, now both on `current`) opens via + // `current`. + let new_payload = vec![0xBBu8; 24]; + let new_frame = pm_b.on_tun(&new_payload, 101)[0].bytes.clone(); + match pm_a.on_udp(ep_b, &new_frame, 102) { + DispatchOut::Tun(buf) => assert_eq!(buf, new_payload.as_slice()), + _ => panic!("expected the new-epoch frame to open via `current`"), + } + } + + /// `pm`'s Established peer 0's `next` epoch's `conn_tag`, panicking if + /// there is no `next` installed. Test helper for the retransmit-dedup + /// regressions below. + fn next_conn_tag(pm: &PeerManager) -> u64 { + match &pm.peers[0].state { + PeerState::Established(epochs) => epochs + .next + .as_ref() + .expect("next must be installed") + .dp + .conn_tag(), + _ => panic!("peer must be Established"), + } + } + + #[test] + fn retransmitted_rekey_init_is_idempotent_new_ephemeral_builds_new_next() { + // Critical-bug regression (9a final review): the responder must + // treat a retransmit of the SAME rekey `Init` (identical bytes, + // hence identical Noise ephemeral) as a no-op — resend the cached + // Resp, do NOT mint a second `next` session. A genuinely NEW Init + // (fresh ephemeral) must still build a new `next`, replacing the + // old one. + let (_pm_a, mut pm_b, ep_a, _ep_b, kp_a, kp_b) = established_pm_pair(100); + + let (_hs1, init_pkt_1) = + HandshakeState::start_initiator(&kp_a.private, &kp_b.public, &[]).unwrap(); + + // First delivery: B installs `next`, replies. + let resp1 = resp_bytes(&pm_b.on_udp(ep_a, &init_pkt_1, 100)); + assert_eq!(resp1.len(), 1, "a genuine rekey Init must produce a Resp"); + let next_tag_1 = next_conn_tag(&pm_b); + + // RETRANSMIT: the exact same Init bytes again, later. Must resend + // the SAME cached Resp and must NOT rebuild `next`. + let resp2 = resp_bytes(&pm_b.on_udp(ep_a, &init_pkt_1, 150)); + assert_eq!( + resp2, resp1, + "a retransmitted rekey Init must resend the cached Resp verbatim" + ); + assert_eq!( + next_conn_tag(&pm_b), + next_tag_1, + "a retransmitted rekey Init must NOT mint a second `next` session" + ); + + // A second retransmit, again: still idempotent. + let resp3 = resp_bytes(&pm_b.on_udp(ep_a, &init_pkt_1, 200)); + assert_eq!(resp3, resp1); + assert_eq!(next_conn_tag(&pm_b), next_tag_1); + + // A GENUINELY NEW Init (fresh ephemeral) — e.g. the initiator gave + // up on round 1 and started a new round — DOES build a new `next`, + // replacing the old one. + let (_hs2, init_pkt_2) = + HandshakeState::start_initiator(&kp_a.private, &kp_b.public, &[]).unwrap(); + assert_ne!( + init_pkt_1, init_pkt_2, + "sanity: the two Inits must actually differ" + ); + let resp4 = resp_bytes(&pm_b.on_udp(ep_a, &init_pkt_2, 250)); + assert_eq!(resp4.len(), 1); + assert_ne!( + resp4, resp1, + "a genuinely new rekey round must produce a NEW Resp" + ); + assert_ne!( + next_conn_tag(&pm_b), + next_tag_1, + "a genuinely new rekey round must replace `next`" + ); + } + + #[test] + fn rekey_init_retransmit_before_resp_converges_on_one_session() { + // Critical-bug end-to-end regression (9a final review): under RTT > + // retry_ms, the initiator retransmits its rekey Init before it has + // seen the responder's first Resp. Pre-fix, the responder minted a + // FRESH session on every Init it saw — including retransmits — so + // the initiator (which locks onto the FIRST Resp it reads) and the + // responder (now holding a DIFFERENT `next`) diverged onto two + // different epochs and the tunnel black-holed. Post-fix, both sides + // converge on ONE session regardless. + let (mut pm_a, mut pm_b, ep_a, ep_b, kp_a, kp_b) = established_pm_pair(100); + + // Splice a `RekeyInFlight` into A directly (as + // `rekey_resp_promotes_initiator_and_keeps_previous_for_grace` + // does), so the SAME `init_pkt` bytes can be "retransmitted" to B + // by simply calling `on_udp` twice. + let (hs, init_pkt) = + HandshakeState::start_initiator(&kp_a.private, &kp_b.public, &[]).unwrap(); + { + let PeerState::Established(epochs) = &mut pm_a.peers[0].state else { + panic!("pm_a must be Established"); + }; + epochs.rekey = Some(crate::epoch::RekeyInFlight { + hs, + init_pkt: init_pkt.clone(), + started_ms: 100, + last_sent_ms: 100, + retry_ms: 1000, + target: ep_b, + }); + } + + // B receives Init #1 (t=100): installs `next`, replies Resp1. + let resp1 = resp_bytes(&pm_b.on_udp(ep_a, &init_pkt, 100)); + assert_eq!(resp1.len(), 1); + let next_tag_after_init1 = next_conn_tag(&pm_b); + + // High RTT: A retransmits the IDENTICAL Init before it has seen + // Resp1. B must reply with the SAME cached Resp and must NOT + // rebuild `next`. + let resp2 = resp_bytes(&pm_b.on_udp(ep_a, &init_pkt, 101)); + assert_eq!( + resp2, resp1, + "a retransmitted rekey Init must be answered with the cached Resp verbatim" + ); + assert_eq!( + next_conn_tag(&pm_b), + next_tag_after_init1, + "a retransmitted rekey Init must NOT mint a second `next` session" + ); + + // Resp1 (produced by the FIRST Init) finally reaches A. A promotes + // to the epoch B actually still holds as `next` (unchanged across + // the retransmit) — the two sides converge on ONE session. + let out = pm_a.on_udp(ep_b, &resp1[0], 102); + let confirm_frames: Vec> = match &out { + DispatchOut::Udp(e) => e.iter().map(|d| d.bytes.clone()).collect(), + _ => panic!("expected Udp egress (the new-epoch confirm frame)"), + }; + let a_new_tag = established_tag(&pm_a, 0).unwrap(); + assert_eq!( + a_new_tag, next_tag_after_init1, + "A must promote to the SAME epoch B is holding as `next`" + ); + + // A's confirm frame lets B's own `inbound_open` promote too. + for f in &confirm_frames { + pm_b.on_udp(ep_a, f, 103); + } + assert_eq!( + established_tag(&pm_b, 0), + Some(a_new_tag), + "both sides must converge on the SAME session despite the Init retransmit" + ); + } + + #[test] + fn rekey_init_on_established_installs_next_without_switching_send() { + let (mut pm_a, mut pm_b, ep_a, ep_b, kp_a, kp_b) = established_pm_pair(100); + let old_tag_a = established_tag(&pm_a, 0).unwrap(); + let old_tag_b = established_tag(&pm_b, 0).unwrap(); + + // Too-fresh: A's `current` was just established at t=0, well under + // interval/2 = 50. B's rekey Init must be IGNORED outright — no + // `next` installed, no Resp. (A was the cold-start INITIATOR, so it + // holds no `cached_resp` — unlike B, testing this on A isolates the + // pure `accept_rekey_init`-false-ignore path from the separate + // cached-resp-retransmit fallback, which + // `duplicate_init_after_established_does_not_tear_down_session` + // already covers.) + let (_hs_early, init_pkt_early) = + HandshakeState::start_initiator(&kp_b.private, &kp_a.public, &[]).unwrap(); + match pm_a.on_udp(ep_b, &init_pkt_early, 10) { + DispatchOut::None => {} + _ => panic!("too-fresh rekey Init must be ignored: no Resp"), + } + match &pm_a.peers[0].state { + PeerState::Established(epochs) => assert!( + epochs.next.is_none(), + "too-fresh current must never install `next`" + ), + _ => panic!("pm_a must still be Established"), + } + assert_eq!(established_tag(&pm_a, 0), Some(old_tag_a)); + + // Old enough (t=100 >= 50): a genuine rekey Init installs `next`, + // replies with a Resp, and leaves `current` untouched (B keeps + // sending on the OLD epoch). + let (_hs, init_pkt) = + HandshakeState::start_initiator(&kp_a.private, &kp_b.public, &[]).unwrap(); + let resp = resp_bytes(&pm_b.on_udp(ep_a, &init_pkt, 100)); + assert_eq!(resp.len(), 1, "an admitted rekey Init must produce a Resp"); + match &pm_b.peers[0].state { + PeerState::Established(epochs) => { + assert!(epochs.next.is_some(), "next must be installed"); + } + _ => panic!("pm_b must still be Established"), + } + assert_eq!( + established_tag(&pm_b, 0), + Some(old_tag_b), + "current must be UNCHANGED — B still sends on the old epoch" + ); + + // B still sends on the OLD epoch (current untouched): a frame it + // emits now still carries the OLD tag. + let still_old = pm_b.on_tun(&dummy_tun_pkt(), 100); + assert_eq!(still_old[0].bytes[0], PacketType::Data as u8); + } + #[test] fn glare_simultaneous_init_converges_on_one_session() { // Both peers configured with each other; neither initiates until it @@ -2687,6 +3794,64 @@ mod tests { ); } + #[test] + fn cold_start_init_retransmit_past_interval_half_resends_original_not_rekey() { + // Important-2 regression (9a final review): `HANDSHAKE_TOTAL_MS` + // (90s) exceeds `REKEY_INTERVAL_MS`/2, so a retransmit of the + // ORIGINAL cold-start Init can legitimately still be in flight once + // `EpochSet::accept_rekey_init` alone would treat any Init as a + // plausible rekey. It must still be recognized (by ephemeral match + // against `cached_resp_init_eph`) as the SAME cold-start round and + // answered with the original cached reply — never misclassified as + // a rekey round, which would install a spurious `next`. + let kp_r = generate_keypair(); + let kp_i = generate_keypair(); + let ep_i: SocketAddr = "10.0.0.20:2000".parse().unwrap(); + let cfg_i = PeerConfig { + public_key: kp_i.public, + endpoint: Some(ep_i), + }; + let mut pm_r = PeerManager::new( + kp_r.private, + kp_r.public, + &[cfg_i], + TunnelMode::L3Tun, + None, + None, + false, + ); + pm_r.rekey_interval_ms = 100; // interval/2 = 50, well under the retransmit's t=70 + + let (_hs, init_pkt) = + HandshakeState::start_initiator(&kp_i.private, &kp_r.public, &[]).unwrap(); + + let resp1 = resp_bytes(&pm_r.on_udp(ep_i, &init_pkt, 0)); + assert_eq!(resp1.len(), 1, "first init must produce one HandshakeResp"); + let tag1 = established_tag(&pm_r, 0).expect("responder must be Established"); + + // Retransmit of the SAME cold-start Init, arriving at t=70 — past + // interval/2 (50), so `accept_rekey_init` alone would consider it a + // plausible rekey. It is NOT: it must resend the cached original + // reply and must NOT install a `next`. + let resp2 = resp_bytes(&pm_r.on_udp(ep_i, &init_pkt, 70)); + assert_eq!( + resp2, resp1, + "a cold-start retransmit past interval/2 must still resend the ORIGINAL cached resp" + ); + assert_eq!( + established_tag(&pm_r, 0), + Some(tag1), + "current must be unchanged" + ); + match &pm_r.peers[0].state { + PeerState::Established(epochs) => assert!( + epochs.next.is_none(), + "a cold-start retransmit must NOT install a rekey `next`" + ), + _ => panic!("responder must stay Established"), + } + } + #[test] fn initiator_retransmits_same_init_within_total_window_then_gives_up() { // Regression for the loss-induced wedge: the initiator must keep @@ -3000,8 +4165,10 @@ mod tests { // Splice in a live Established session reaching `endpoint`. const TAG: u64 = 0x0102_0304_0506_0708; - pm.peers[0].state = - PeerState::Established(Box::new(fake_established_dataplane(TAG, endpoint))); + pm.peers[0].state = PeerState::Established(Box::new(crate::epoch::EpochSet::new( + Box::new(fake_established_dataplane(TAG, endpoint)), + 0, + ))); pm.by_tag.insert(TAG, 0); pm.peers[0].path_kind = Some(PathKind::Direct); @@ -3138,8 +4305,10 @@ mod tests { // Splice in a live direct session reaching `endpoint`. const TAG: u64 = 0x1122_3344_5566_7788; - pm.peers[0].state = - PeerState::Established(Box::new(fake_established_dataplane(TAG, endpoint))); + pm.peers[0].state = PeerState::Established(Box::new(crate::epoch::EpochSet::new( + Box::new(fake_established_dataplane(TAG, endpoint)), + 0, + ))); pm.by_tag.insert(TAG, 0); pm.peers[0].path_kind = Some(PathKind::Direct); assert!(!pm.peers[0].relay); @@ -3767,8 +4936,10 @@ mod tests { // Splice in a live Established session reaching `committed_ep`. const TAG: u64 = 0x0a0b_0c0d_0e0f_1011; - pm.peers[0].state = - PeerState::Established(Box::new(fake_established_dataplane(TAG, committed_ep))); + pm.peers[0].state = PeerState::Established(Box::new(crate::epoch::EpochSet::new( + Box::new(fake_established_dataplane(TAG, committed_ep)), + 0, + ))); pm.by_tag.insert(TAG, 0); pm.peers[0].path_kind = Some(PathKind::Direct); @@ -3837,7 +5008,10 @@ mod tests { false, ); const TAG: u64 = 0x9988_7766_5544_3322; - pm.peers[0].state = PeerState::Established(Box::new(fake_established_dataplane(TAG, src))); + pm.peers[0].state = PeerState::Established(Box::new(crate::epoch::EpochSet::new( + Box::new(fake_established_dataplane(TAG, src)), + 0, + ))); pm.by_tag.insert(TAG, 0); // Valid record → ingested → resolvable. @@ -3911,8 +5085,10 @@ mod tests { false, ); const TAG: u64 = 0x1357_9bdf_2468_ace0; - pm.peers[0].state = - PeerState::Established(Box::new(fake_established_dataplane(TAG, peer_ep))); + pm.peers[0].state = PeerState::Established(Box::new(crate::epoch::EpochSet::new( + Box::new(fake_established_dataplane(TAG, peer_ep)), + 0, + ))); pm.by_tag.insert(TAG, 0); let member = generate_keypair(); @@ -4103,7 +5279,8 @@ mod tests { // sealed frames; give the peer its matching session obf key. let (mut init_dp, resp_dp, hp_key, conn_tag) = established_pair(peer_ep); let sess = yip_obf::derive_key(&hp_key); - pm.peers[0].state = PeerState::Established(Box::new(resp_dp)); + pm.peers[0].state = + PeerState::Established(Box::new(crate::epoch::EpochSet::new(Box::new(resp_dp), 0))); pm.peers[0].session_obf_key = Some(sess); pm.by_tag.insert(conn_tag, 0); @@ -4275,8 +5452,10 @@ mod tests { ); pm.set_obf_psk(Some([0x66u8; 32])); - pm.peers[0].state = - PeerState::Established(Box::new(fake_established_dataplane(TAG, peer_ep))); + pm.peers[0].state = PeerState::Established(Box::new(crate::epoch::EpochSet::new( + Box::new(fake_established_dataplane(TAG, peer_ep)), + 0, + ))); let sess = [0x77u8; 16]; pm.peers[0].session_obf_key = Some(sess); pm.by_tag.insert(TAG, 0); @@ -4816,8 +5995,10 @@ mod tests { } pm.set_cover_traffic_ms(cover_traffic_ms); - pm.peers[0].state = - PeerState::Established(Box::new(fake_established_dataplane(TAG, peer_ep))); + pm.peers[0].state = PeerState::Established(Box::new(crate::epoch::EpochSet::new( + Box::new(fake_established_dataplane(TAG, peer_ep)), + 0, + ))); let sess = [0xBBu8; 16]; pm.peers[0].session_obf_key = Some(sess); pm.by_tag.insert(TAG, 0); diff --git a/bin/yipd/tests/run-netns-rekey.sh b/bin/yipd/tests/run-netns-rekey.sh new file mode 100755 index 0000000..7d196e5 --- /dev/null +++ b/bin/yipd/tests/run-netns-rekey.sh @@ -0,0 +1,344 @@ +#!/usr/bin/env bash +# The milestone 9a money test: two yipd peers held to a FAST +# YIP_REKEY_INTERVAL_MS=2000 rekey cadence (~10 rotations over a ~20s ping +# stream) must (1) never black-hole the session across a rotation (loss-free +# continuity) and (2) actually rotate the wire `conn_tag` per epoch, proven +# for real -- not just asserted from source. +# +# Usage: run-netns-rekey.sh +# +# Forked from run-netns-tunnel.sh (same netns/veth/config plumbing, same +# PASS/FAIL conventions); the two additions are YIP_REKEY_INTERVAL_MS=2000 +# and the conn_tag-rotation proof below. +# +# Obfuscation: deliberately OFF (no obf_psk). With obf on, the masked +# conn_tag rides inside the obf envelope and this script's decode tool +# cannot locate the [HandshakeInit]/[Data] `PacketType` prefixes it needs +# (bin/yipd/src/handshake.rs's "Pre-obfuscation note": those prefixes are +# only fixed/unmasked bytes when obf_psk is unset). obf_psk's own +# obfuscation behavior is covered by the 3a/3c netns suites; this script's +# job is purely the rekey machinery. +# +# ── conn_tag rotation: why this script does NOT just diff raw wire bytes ── +# +# `crates/yip-wire`'s `Codec::frame` XORs the entire 15-byte logical header +# -- including the 8 `conn_tag` bytes -- under a keystream reseeded by each +# individual frame's own auth tag (see also bin/yipd/src/peer_manager.rs's +# "UDP demux: why routing is by source address, not raw conn_tag bytes" doc +# comment, which independently documents the same fact). That means the +# masked bytes at `dg[1..9]` differ on *every* Data datagram, even two +# datagrams of the exact same epoch/conn_tag. A coarse "capture dg[1..9], +# assert more than one distinct value across the run" check is therefore +# true UNCONDITIONALLY: it would read >1 in a single-epoch, zero-rekey run +# too, so it cannot actually distinguish "rotated" from "never rotated". +# This script reports that raw count anyway (RAW_DISTINCT_HEADER_PREFIXES, +# non-gated, informational) to honor the letter of the coarse-check +# request, but the money assertion is the rigorous one below. +# +# A first version of this script instead tried to literally re-derive each +# epoch's real (auth_key, hp_key, conn_tag) by replaying captured +# [HandshakeInit] messages through a fresh responder-role handshake (using +# this test's own generated private keys) and then deframing captured Data +# datagrams under the result. That is cryptographically UNSOUND, not just +# impractical: Noise_IK's responder generates its own fresh random +# ephemeral key while producing message 2, and that ephemeral (never +# transmitted, never recoverable from a passive capture) feeds the +# transcript hash the session keys derive from -- a locally-replayed +# responder computes a different, unrelated session every time. It failed +# 100% of the time in testing, exactly as this predicts. Recovering it is +# Noise's forward-secrecy property working as intended, not a bug. +# +# `bin/yipd/examples/rekey_epoch_witness` (a standalone tool, built +# alongside yipd -- see step 0 below) instead counts DISTINCT CLEARTEXT +# ephemeral public keys: Noise_IK's first message token on both +# [HandshakeInit] and [HandshakeResp] is the sender's ephemeral public key, +# written UNENCRYPTED (there is no cipher key yet when the first token of +# message 1 is written, and message 2's leading token is likewise +# cleartext) -- visible to any passive observer, no key material needed. +# N distinct cleartext ephemerals is a rigorous, on-wire, non-vacuous proof +# of N independently-completed Noise-IK rounds, and since +# `conn_tag = conn_tag_from_keys(derive_wire_keys(channel_binding))` and +# `channel_binding` mixes in both ephemerals' Diffie-Hellman product, N +# distinct ephemeral pairs implies N distinct `conn_tag`s with +# cryptographic-strength probability -- even though (per the paragraph +# above) neither this tool nor a real passive observer can ever learn what +# those N values actually are. See the tool's module doc for the full +# argument. +set -euo pipefail + +YIPD="${1:?Usage: $0 }" +WITNESS_BIN="$(dirname "$YIPD")/examples/rekey_epoch_witness" + +# ── 0. root + tool preflight (this script is invoked directly by CI, not +# through the tunnel_netns.rs Rust harness, so it does its own SKIP-gating +# per the run-netns-reality-probe.sh / run-netns-relay-tls.sh convention) ── +if [ "$(id -u)" -ne 0 ]; then + echo "SKIP run-netns-rekey: needs root (netns + TUN + tcpdump)" + exit 0 +fi +for tool in tcpdump ping; do + if ! command -v "$tool" >/dev/null 2>&1; then + echo "SKIP run-netns-rekey: required tool '$tool' not found" + exit 0 + fi +done +if [ ! -x "$WITNESS_BIN" ]; then + echo "SKIP run-netns-rekey: rekey_epoch_witness not built at $WITNESS_BIN" + echo " build it with: cargo build --release -p yipd --example rekey_epoch_witness" + exit 0 +fi + +TMPDIR_TEST="$(mktemp -d /tmp/yipd-netns-rekey-test.XXXXXX)" + +NS_A="yipRekeyA" +NS_B="yipRekeyB" +VETH_A="vRekeyA" +VETH_B="vRekeyB" +VETH_A_IP="10.0.0.1" +VETH_B_IP="10.0.0.2" +TUN_A_IP="10.9.0.1" +TUN_B_IP="10.9.0.2" +TUN_PREFIX="24" +VETH_PREFIX="24" +PORT_A="51820" +PORT_B="51821" +TUN_DEV="yip0" + +PID_A="" +PID_B="" +TCPDUMP_PID="" + +cleanup() { + echo "[cleanup] killing daemons/tcpdump, removing namespaces" + [ -n "$PID_A" ] && kill "$PID_A" 2>/dev/null || true + [ -n "$PID_B" ] && kill "$PID_B" 2>/dev/null || true + [ -n "$TCPDUMP_PID" ] && kill "$TCPDUMP_PID" 2>/dev/null || true + sleep 0.2 + [ -n "$PID_A" ] && kill -9 "$PID_A" 2>/dev/null || true + [ -n "$PID_B" ] && kill -9 "$PID_B" 2>/dev/null || true + [ -n "$TCPDUMP_PID" ] && kill -9 "$TCPDUMP_PID" 2>/dev/null || true + ip netns del "$NS_A" 2>/dev/null || true + ip netns del "$NS_B" 2>/dev/null || true + rm -rf "$TMPDIR_TEST" +} +trap cleanup EXIT + +# ── 1. generate keypairs ────────────────────────────────────────────────────── +echo "[setup] generating keypairs" +GENKEY_A="$("$YIPD" --genkey)" +GENKEY_B="$("$YIPD" --genkey)" + +PRIV_A="$(echo "$GENKEY_A" | grep '^private=' | cut -d= -f2)" +PUB_A="$(echo "$GENKEY_A" | grep '^public=' | cut -d= -f2)" +PRIV_B="$(echo "$GENKEY_B" | grep '^private=' | cut -d= -f2)" +PUB_B="$(echo "$GENKEY_B" | grep '^public=' | cut -d= -f2)" + +# ── 2. write config files (obf deliberately OFF -- see header comment) ──────── +CFG_A="$TMPDIR_TEST/yipA.conf" +CFG_B="$TMPDIR_TEST/yipB.conf" + +cat > "$CFG_A" < "$CFG_B" <` to +# exercise the uring driver. +export YIP_REKEY_INTERVAL_MS=2000 + +LOG_A="$TMPDIR_TEST/yipA.log" +LOG_B="$TMPDIR_TEST/yipB.log" + +echo "[start] starting yipA (responder) with YIP_REKEY_INTERVAL_MS=$YIP_REKEY_INTERVAL_MS" +ip netns exec "$NS_A" "$YIPD" "$CFG_A" >"$LOG_A" 2>&1 & +PID_A=$! + +echo "[start] starting yipB (initiator) with YIP_REKEY_INTERVAL_MS=$YIP_REKEY_INTERVAL_MS" +ip netns exec "$NS_B" "$YIPD" "$CFG_B" >"$LOG_B" 2>&1 & +PID_B=$! + +# ── 5. wait for handshake and TUN device creation ──────────────────────────── +TUN_WAIT=20 +INTERVAL=0.25 + +echo "[wait] waiting for TUN devices to appear (up to ${TUN_WAIT}s)" +elapsed=0 +while true; do + A_UP=0 + B_UP=0 + ip netns exec "$NS_A" ip link show "$TUN_DEV" >/dev/null 2>&1 && A_UP=1 || true + ip netns exec "$NS_B" ip link show "$TUN_DEV" >/dev/null 2>&1 && B_UP=1 || true + + if [ "$A_UP" -eq 1 ] && [ "$B_UP" -eq 1 ]; then + echo "[wait] both TUN devices are up" + break + fi + + if ! kill -0 "$PID_A" 2>/dev/null; then + echo "[error] yipA daemon died unexpectedly" + echo "=== yipA log ===" + cat "$LOG_A" || true + exit 1 + fi + if ! kill -0 "$PID_B" 2>/dev/null; then + echo "[error] yipB daemon died unexpectedly" + echo "=== yipB log ===" + cat "$LOG_B" || true + exit 1 + fi + + elapsed=$(awk "BEGIN {print $elapsed + $INTERVAL}") + if awk "BEGIN {exit ($elapsed >= $TUN_WAIT) ? 0 : 1}"; then + echo "[error] timed out waiting for TUN devices" + echo "=== yipA log ===" + cat "$LOG_A" || true + echo "=== yipB log ===" + cat "$LOG_B" || true + exit 1 + fi + sleep "$INTERVAL" +done + +# ── 6. assign tunnel IPs ────────────────────────────────────────────────────── +echo "[setup] assigning tunnel IPs" +ip netns exec "$NS_A" ip addr add "${TUN_A_IP}/${TUN_PREFIX}" dev "$TUN_DEV" +ip netns exec "$NS_B" ip addr add "${TUN_B_IP}/${TUN_PREFIX}" dev "$TUN_DEV" + +ip netns exec "$NS_A" ip link show "$TUN_DEV" | grep -q "UP" || \ + ip netns exec "$NS_A" ip link set "$TUN_DEV" up +ip netns exec "$NS_B" ip link show "$TUN_DEV" | grep -q "UP" || \ + ip netns exec "$NS_B" ip link set "$TUN_DEV" up + +sleep 0.5 + +# ── 7. capture the veth while a steady ping stream crosses ~10 rotations ────── +PCAP="$TMPDIR_TEST/rekey.pcap" +PING_LOG="$TMPDIR_TEST/ping.log" + +echo "[capture] starting tcpdump on $VETH_A (udp) -> $PCAP" +ip netns exec "$NS_A" tcpdump -i "$VETH_A" -w "$PCAP" -U udp \ + >"$TMPDIR_TEST/tcpdump.log" 2>&1 & +TCPDUMP_PID=$! +sleep 0.3 + +echo "[test] ping -i 0.2 -c 100 (~20s, ~10 rotations at 2000ms) yipB -> ${TUN_A_IP}" +set +e +ip netns exec "$NS_B" ping -i 0.2 -c 100 -W 1 "$TUN_A_IP" >"$PING_LOG" 2>&1 +PING_STATUS=$? +set -e +cat "$PING_LOG" + +sleep 0.5 +kill "$TCPDUMP_PID" 2>/dev/null || true +wait "$TCPDUMP_PID" 2>/dev/null || true +TCPDUMP_PID="" + +if ! kill -0 "$PID_A" 2>/dev/null; then + echo "[error] yipA daemon died during the ping stream" + echo "=== yipA log ===" + cat "$LOG_A" || true + exit 1 +fi +if ! kill -0 "$PID_B" 2>/dev/null; then + echo "[error] yipB daemon died during the ping stream" + echo "=== yipB log ===" + cat "$LOG_B" || true + exit 1 +fi + +# ── assertion 1: rekey_continuity — ≤1% loss across ~10 rotations ──────────── +LOSS_PCT="$(grep -oE '[0-9]+(\.[0-9]+)?% packet loss' "$PING_LOG" | grep -oE '^[0-9]+(\.[0-9]+)?' || true)" +if [ -z "$LOSS_PCT" ]; then + echo "[FAIL] rekey_continuity: could not parse packet loss from ping output" + exit 1 +fi +echo "[metric] rekey_continuity: packet loss = ${LOSS_PCT}%" +if awk "BEGIN {exit ($LOSS_PCT <= 1.0) ? 0 : 1}"; then + echo "[PASS] rekey_continuity: ${LOSS_PCT}% loss (<=1%) across the rekey stream" +else + echo "[FAIL] rekey_continuity: ${LOSS_PCT}% loss (>1%) — a rotation likely black-holed traffic" + echo "=== yipA log ===" + cat "$LOG_A" || true + echo "=== yipB log ===" + cat "$LOG_B" || true + exit 1 +fi +if [ "$PING_STATUS" -ne 0 ] && [ "$LOSS_PCT" != "100" ]; then + echo "[note] ping exited $PING_STATUS despite <=1% loss (non-fatal; proceeding)" +fi + +if [ ! -s "$PCAP" ]; then + echo "[FAIL] conn_tag rotation: capture is empty or missing at $PCAP" + exit 1 +fi + +# ── assertion 2: conn_tag rotation — distinct completed Noise-IK rounds ────── +WITNESS_LOG="$TMPDIR_TEST/witness.log" +"$WITNESS_BIN" "$PCAP" >"$WITNESS_LOG" +cat "$WITNESS_LOG" + +COMPLETED_ROUNDS="$(grep -oE '^COMPLETED_ROUNDS=[0-9]+' "$WITNESS_LOG" | cut -d= -f2)" + +if [ -z "$COMPLETED_ROUNDS" ]; then + echo "[FAIL] conn_tag rotation: could not parse rekey_epoch_witness output" + exit 1 +fi + +# Threshold: a 20s run at a 2000ms interval predicts ~10 rekey rounds; +# require >=3 completed rounds (well below the expected ~10, so rekey +# backoff/jitter cannot make this flaky). Each completed round is a +# distinct cleartext-ephemeral Noise-IK handshake that mathematically +# implies a distinct conn_tag on both peers (see the header comment above +# and rekey_epoch_witness's module doc for the full argument). +if [ "$COMPLETED_ROUNDS" -ge 3 ]; then + echo "[PASS] conn_tag rotation: $COMPLETED_ROUNDS distinct completed rekey rounds observed on the wire" +else + echo "[FAIL] conn_tag rotation: only $COMPLETED_ROUNDS distinct completed rounds (need >=3) — conn_tag is not rotating on the wire as expected" + echo "=== yipA log ===" + cat "$LOG_A" || true + echo "=== yipB log ===" + cat "$LOG_B" || true + exit 1 +fi + +echo "[PASS] run-netns-rekey: loss-free rotation + on-wire conn_tag rotation both verified" diff --git a/docs/superpowers/plans/2026-07-19-rekey-9a-session-rotation.md b/docs/superpowers/plans/2026-07-19-rekey-9a-session-rotation.md new file mode 100644 index 0000000..9c75ee6 --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-rekey-9a-session-rotation.md @@ -0,0 +1,514 @@ +# Milestone 9a — classical session rekey (~120s) + epoch handling — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rotate each peer's Noise-IK session ~every 120s (forward secrecy + per-epoch `conn_tag` rotation) with an old→new overlap so in-flight packets still decrypt — driven entirely from the daemon, with no wire-format change. + +**Architecture:** A new pure `EpochSet` state machine (`bin/yipd/src/epoch.rs`) holds up to three `DataPlane` epochs (`current` for send, `next` = responder's unconfirmed new epoch, `previous` = receive-only grace). `PeerState::Established` carries a `Box` instead of a `Box`. `PeerManager::tick` schedules a mid-session rekey handshake (reusing the existing handshake machinery) and the completion paths promote epochs per the WireGuard confirmed-switch rule. + +**Tech Stack:** Rust, `bin/yipd`, `yip-crypto` (unchanged), `snow` Noise-IK (unchanged), netns integration tests. + +## Global Constraints + +- `#![forbid(unsafe_code)]` (outside yip-io/yip-device) — NO `unsafe`, NO `as` casts, NO bare `#[allow]` (use `#[expect(reason = "...")]`). +- **Reuse `yip-crypto` and `bin/yipd/src/handshake.rs` UNCHANGED** — 9a only calls them (a rekey is an ordinary handshake whose result installs into an existing `EpochSet`). +- **NO wire-format change** (`yip-wire` untouched) — `conn_tag` is per-epoch already and is the epoch discriminator. +- **Fail-closed:** a rekey that fails/times out is a **no-op on the live session** (keep `current`, retry next interval) — rekey MUST NEVER tear down a working session. Every epoch `DataPlane` fails closed on inbound (wrong key → failed AEAD → try next epoch; no misdecrypt, no panic on attacker bytes). +- Constants: `REKEY_INTERVAL_MS = 120_000`, `PREVIOUS_EPOCH_GRACE_MS = 15_000`. The interval is **test-overridable** via the `YIP_REKEY_INTERVAL_MS` env var read once at `PeerManager` construction (default `REKEY_INTERVAL_MS`), matching the `YIP_USE_URING` netns-override precedent. +- Every task: `cargo build`, `cargo clippy --all-targets -- -D warnings`, `cargo fmt` (**run fmt — do not `--no-verify` unformatted code**). Verify from ground truth: netns under BOTH the poll and `YIP_USE_URING=1` drivers; arq/netns use the **RELEASE** yipd (rebuild release after any yipd change). +- Out of scope (do NOT touch): PQ-hybrid (9b), full #34 handshake-timestamp anti-replay, #36, #41. +- Branch off `main`. Leave the PR for the user; do NOT merge; no "not merging" line. +- **Known pre-existing flake:** the pre-commit hook runs the whole workspace suite; two `yip-io::uring::tests::uring_*` loopback tests flake under load (unrelated crate). Commit `--no-verify` ONLY if that is the sole blocker AND the code is `cargo fmt`-clean. + +--- + +### Task 1: The `EpochSet` pure state machine + +The crux — I/O-free, heavily unit-tested, defines the API Tasks 2–4 consume. + +**Files:** +- Create: `bin/yipd/src/epoch.rs` +- Modify: `bin/yipd/src/main.rs` (or wherever `mod` declarations live — add `mod epoch;`) + +**Interfaces:** +- Consumes: `crate::dataplane::{DataPlane, Outcome}`, `yip_io::poll::EgressDatagram`. +- Produces: + - `pub struct EpochSet { current, current_created_ms, next, previous, previous_retire_ms, rekey }` (fields `pub(crate)` as needed by Tasks 3–4). + - `pub struct RekeyInFlight { pub hs: crate::handshake::HandshakeState, pub init_pkt: Vec, pub started_ms: u64, pub last_sent_ms: u64, pub retry_ms: u64, pub target: std::net::SocketAddr }` + - `pub enum EpochInbound { None, Tun(Vec), Send(Vec>), TunThenSend(Vec, Vec>) }` + - `EpochSet::new(current: Box, now_ms) -> Self` + - `EpochSet::inbound_open(&mut self, dg: &[u8], now_ms) -> EpochInbound` + - `EpochSet::current_mut(&mut self) -> &mut DataPlane` and `current(&self) -> &DataPlane` (outbound/conn_tag) + - `EpochSet::promote_from_rekey(&mut self, new_dp: Box, now_ms)` (initiator switch) + - `EpochSet::install_next(&mut self, new_dp: Box)` (responder unconfirmed) + - `EpochSet::retire_previous_if_due(&mut self, now_ms)` + - `EpochSet::needs_rekey(&self, now_ms, is_glare_winner: bool, interval_ms: u64) -> bool` + - `EpochSet::accept_rekey_init(&self, now_ms, interval_ms: u64) -> bool` (responder: ignore an Init against a too-fresh `current`) + +- [ ] **Step 1: Write the failing tests** + +Create `bin/yipd/src/epoch.rs` with a `#[cfg(test)] mod tests` up front. Use a tiny test-double `DataPlane` is not possible (it's concrete), so the tests use REAL `DataPlane`s built from an in-process handshake — reuse the existing `bin/yipd/src/dataplane.rs` test helper pattern (`established_pair()` around dataplane.rs:660 builds two talking `DataPlane`s via a real Noise-IK handshake). Expose a crate-visible test builder if needed. The behaviors to lock (write these first, watch them fail to compile / fail): + +```rust +#[cfg(test)] +mod tests { + use super::*; + + // Helper: build a fresh pair of talking DataPlanes (a "new epoch") on demand. + // Reuse dataplane::tests::established_pair-style construction (two DataPlanes + // whose sessions seal/open for each other). Return (a_side, b_side). + fn epoch_pair() -> (Box, Box) { /* real handshake, as in dataplane tests */ } + + #[test] + fn steady_state_inbound_uses_current_only() { + // Only `current`; a frame sealed by the peer's current opens via inbound_open, + // and there is no next/previous (one try). + } + + #[test] + fn initiator_promote_switches_outbound_and_grace_keeps_previous() { + // promote_from_rekey(new) => current==new, previous==old, previous_retire_ms==now+GRACE. + // A frame under the OLD epoch still opens (previous), a frame under NEW opens (current). + } + + #[test] + fn responder_install_next_then_first_inbound_promotes() { + // install_next(new): current unchanged (still sends old); inbound under `next` + // that yields a non-None outcome PROMOTES next->current (old->previous). + } + + #[test] + fn lost_msg2_leaves_both_on_old_no_blackhole() { + // install_next(new) but NEVER feed a `next` frame => no promotion; current stays old, + // and old-epoch frames still open. (Models a lost msg2: responder holds an unused next.) + } + + #[test] + fn previous_retired_at_grace_deadline() { + // After promote, retire_previous_if_due(now < retire) keeps previous; + // retire_previous_if_due(now >= retire) drops it (old-epoch frame no longer opens). + } + + #[test] + fn needs_rekey_trigger_and_one_in_flight_guard() { + // age false; age>=interval && winner && rekey.is_none() => true; + // rekey.is_some() => false (one-in-flight); loser && age>=2*interval => true (fallback); + // loser && age in [interval, 2*interval) => false. + } + + #[test] + fn accept_rekey_init_ignores_too_fresh_current() { + // age < interval/2 => false (ignore a rekey Init against a very fresh current); + // age >= interval/2 => true. + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cargo test -p yipd --lib epoch::` +Expected: FAIL — `epoch` module / types not defined. + +- [ ] **Step 3: Implement `EpochSet`** + +```rust +//! The per-peer session-epoch set (milestone 9a). Holds the live `current` +//! DataPlane plus, during a ~120s rekey overlap, an optional responder-side +//! `next` (derived from a rekey Init, not yet used for sending) and an +//! optional receive-only `previous` (the just-superseded epoch, kept for +//! in-flight frames until a grace deadline). Pure/I-O-free: `PeerManager` +//! drives the schedule and feeds it handshake results. + +use std::net::SocketAddr; + +use crate::dataplane::{DataPlane, Outcome}; +use crate::handshake::HandshakeState; + +/// Production rekey cadence (§ Global Constraints). Test-overridden via +/// `YIP_REKEY_INTERVAL_MS` at `PeerManager` construction. +pub const REKEY_INTERVAL_MS: u64 = 120_000; +/// How long the superseded `previous` epoch stays open for inbound after a +/// switch — generous vs. reordering/loss, bounded so keys don't linger. +pub const PREVIOUS_EPOCH_GRACE_MS: u64 = 15_000; + +/// An in-flight initiator rekey handshake, held alongside the live `current` +/// so the session never pauses. Mirrors `HandshakingState`'s retransmit fields. +pub struct RekeyInFlight { + pub hs: HandshakeState, + pub init_pkt: Vec, + pub started_ms: u64, + pub last_sent_ms: u64, + pub retry_ms: u64, + pub target: SocketAddr, +} + +/// Owned inbound result (the `PeerManager` demux already copies the borrowed +/// `Outcome` into owned Vecs at its call sites, so returning owned here is +/// free — and it sidesteps the multi-epoch borrow-return limitation). +pub enum EpochInbound { + None, + Tun(Vec), + Send(Vec>), + TunThenSend(Vec, Vec>), +} + +pub struct EpochSet { + pub(crate) current: Box, + pub(crate) current_created_ms: u64, + pub(crate) next: Option>, + pub(crate) previous: Option>, + pub(crate) previous_retire_ms: u64, + pub(crate) rekey: Option, +} + +impl EpochSet { + pub fn new(current: Box, now_ms: u64) -> Self { + Self { + current, + current_created_ms: now_ms, + next: None, + previous: None, + previous_retire_ms: 0, + rekey: None, + } + } + + pub fn current(&self) -> &DataPlane { + &self.current + } + pub fn current_mut(&mut self) -> &mut DataPlane { + &mut self.current + } + + /// Convert a borrowed `Outcome` into the owned `EpochInbound` (same copies + /// the caller already performed). Returns `None` for `Outcome::None`. + fn own(outcome: Outcome<'_>) -> EpochInbound { + match outcome { + Outcome::None => EpochInbound::None, + Outcome::TunWrite(b) => EpochInbound::Tun(b.to_vec()), + Outcome::Send(pkts) => { + EpochInbound::Send(pkts.iter().map(|d| d.bytes.clone()).collect()) + } + Outcome::TunWriteThenSend(b, pkts) => { + EpochInbound::TunThenSend(b.to_vec(), pkts.iter().map(|d| d.bytes.clone()).collect()) + } + } + } + + /// Open an inbound datagram against the epochs in order (current, then the + /// responder's `next`, then the grace `previous`). Each `DataPlane` fails + /// closed on a wrong key (cheap failed AEAD, no misdecrypt), so a wrong + /// epoch simply yields `Outcome::None` and we try the next. A NON-None + /// result under `next` means the peer has switched to the new epoch → + /// promote it (responder confirmed-switch). Steady state (no next/previous) + /// is a single try, identical to today. + pub fn inbound_open(&mut self, dg: &[u8], now_ms: u64) -> EpochInbound { + // current + let out = Self::own(self.current.on_udp_datagram(dg, now_ms)); + if !matches!(out, EpochInbound::None) { + return out; + } + // next (responder-side unconfirmed new epoch) + if self.next.is_some() { + let out = Self::own( + self.next + .as_mut() + .expect("checked is_some") + .on_udp_datagram(dg, now_ms), + ); + if !matches!(out, EpochInbound::None) { + // Confirmed: promote next -> current, old current -> previous. + let new = self.next.take().expect("checked is_some"); + let old = std::mem::replace(&mut self.current, new); + self.current_created_ms = now_ms; + self.previous = Some(old); + self.previous_retire_ms = now_ms.saturating_add(PREVIOUS_EPOCH_GRACE_MS); + return out; + } + } + // previous (grace) + if let Some(prev) = self.previous.as_mut() { + return Self::own(prev.on_udp_datagram(dg, now_ms)); + } + EpochInbound::None + } + + /// Initiator switch on rekey completion: it now holds `new` AND knows the + /// responder installed it (the responder sent the Resp), so switch outbound + /// immediately; the old epoch becomes receive-only `previous`. + pub fn promote_from_rekey(&mut self, new_dp: Box, now_ms: u64) { + let old = std::mem::replace(&mut self.current, new_dp); + self.current_created_ms = now_ms; + self.previous = Some(old); + self.previous_retire_ms = now_ms.saturating_add(PREVIOUS_EPOCH_GRACE_MS); + self.rekey = None; + } + + /// Responder: a new epoch derived from a received rekey Init, kept for + /// inbound but NOT used for sending until confirmed (see `inbound_open`). + pub fn install_next(&mut self, new_dp: Box) { + self.next = Some(new_dp); + } + + pub fn retire_previous_if_due(&mut self, now_ms: u64) { + if self.previous.is_some() && now_ms >= self.previous_retire_ms { + self.previous = None; + } + } + + /// Initiator schedule: rekey when `current` is old enough, none in flight, + /// and this side is the glare-winner — OR the loser-fallback fires + /// (`current` aged past 2× the interval and the winner never rekeyed). + pub fn needs_rekey(&self, now_ms: u64, is_glare_winner: bool, interval_ms: u64) -> bool { + if self.rekey.is_some() { + return false; + } + let age = now_ms.saturating_sub(self.current_created_ms); + if is_glare_winner { + age >= interval_ms + } else { + age >= interval_ms.saturating_mul(2) + } + } + + /// Responder guard: ignore a rekey Init that arrives against a very fresh + /// `current` (< interval/2 old) — bounds attacker-induced speculative + /// handshakes without full timestamp anti-replay (#34). + pub fn accept_rekey_init(&self, now_ms: u64, interval_ms: u64) -> bool { + now_ms.saturating_sub(self.current_created_ms) >= interval_ms / 2 + } +} +``` + +- [ ] **Step 4: Run tests** + +Run: `cargo test -p yipd --lib epoch::` +Expected: PASS — all epoch state-machine tests green. + +- [ ] **Step 5: Clippy, fmt, commit** + +```bash +cargo clippy -p yipd --all-targets -- -D warnings +cargo fmt +git add bin/yipd/src/epoch.rs bin/yipd/src/main.rs +git commit -m "feat(rekey.9a): EpochSet state machine (current/next/previous, WireGuard confirmed-switch)" +``` + +--- + +### Task 2: Swap `Established(Box)` → `Established(Box)` and route through it + +Type-driven refactor: change the variant, then let the compiler surface every site and apply one transformation rule each. Behavior-preserving for the no-rekey case (steady state = one epoch). + +**Files:** +- Modify: `bin/yipd/src/peer_manager.rs` (the `PeerState` enum ~174; ~30 `Established(...)` sites; the cold-start completion sites ~875/929/1311/1381; the inbound sites 956/1082/1131; test helpers `fake_established_dataplane` at ~2447/3004/3142/3771 and the conn_tag accessor ~2549) + +**Interfaces:** +- Consumes: `crate::epoch::{EpochSet, EpochInbound}` (Task 1). +- Produces: `PeerState::Established(Box)`. + +- [ ] **Step 1: Change the enum variant** + +In `peer_manager.rs`: + +```rust +enum PeerState { + Idle, + Handshaking(Box), + Established(Box), +} +``` + +- [ ] **Step 2: Run the build to enumerate every broken site** + +Run: `cargo build -p yipd 2>&1 | grep -E "error|Established" | head -60` +Expected: a list of ~25–30 sites. Apply these transformation rules (the compiler is the checklist): +- `matches!(state, PeerState::Established(_))` — **unchanged** (still matches). +- A site binding `PeerState::Established(dp)` then calling `dp.on_udp_datagram(dg, now)` (sites 956, 1082, 1131) → bind `epochs` and call `epochs.inbound_open(dg, now)`, matching on `EpochInbound` instead of `Outcome`. The three inbound sites currently produce `(Some(buf.to_vec()), ...)` / `(None, pkts...clone())` — map `EpochInbound::{None,Tun,Send,TunThenSend}` to the same owned tuples (the `.to_vec()`/`.clone()` now happen inside `inbound_open`, so just move the owned Vecs through). +- A site using `dp.on_tun_packet(...)` / `dp.conn_tag()` / any other `&DataPlane` method for OUTBOUND or metadata (e.g. 953→outbound, 2549 conn_tag) → `epochs.current_mut().on_tun_packet(...)` / `epochs.current().conn_tag()`. +- **Construction:** cold-start handshake completion that did `self.peers[idx].state = PeerState::Established(dp)` (sites ~875, 929, 1311, 1381) → `PeerState::Established(Box::new(EpochSet::new(dp, now_ms)))`. `dp` there is the `Box` freshly built from the handshake — wrap it as the sole `current` epoch. +- **Test helpers:** `fake_established_dataplane(...)` returns a `Box`; the sites that build `PeerState::Established(Box::new(fake_established_dataplane(..)))` → `PeerState::Established(Box::new(EpochSet::new(Box::new(fake_established_dataplane(..)), now)))`. Use a fixed `now` (e.g. `0`) in these test builders. The conn_tag test accessor (2549) → `epochs.current().conn_tag()`. + +- [ ] **Step 3: Iterate build until clean** + +Run: `cargo build -p yipd` +Expected: compiles. Every `Established`-binding site now goes through `EpochSet`. + +- [ ] **Step 4: Run the FULL suite (behavior-preserving gate)** + +Run: `cargo test -p yipd` (and the workspace: `cargo test -p yip-crypto -p yip-io`) +Expected: PASS — every existing 2a/2b/2c unit test stays green. Steady state has only `current`, so `inbound_open` is a single try and behavior is identical. If a test that reached into `Established(dp)` broke, it was coupling to the old shape — repoint it at `.current()`. + +- [ ] **Step 5: Clippy, fmt, commit** + +```bash +cargo clippy -p yipd --all-targets -- -D warnings +cargo fmt +git add bin/yipd/src/peer_manager.rs +git commit -m "refactor(rekey.9a): PeerState::Established carries an EpochSet; route in/out through current epoch" +``` + +--- + +### Task 3: Rekey scheduling in `PeerManager::tick` + +Schedule the mid-session rekey handshake without disturbing the live `current` epoch. + +**Files:** +- Modify: `bin/yipd/src/peer_manager.rs` (`tick`/`tick_dispatch` ~1767/1913; add a `rekey_interval_ms` field + `YIP_REKEY_INTERVAL_MS` read at construction; add a rekey-initiation helper; `begin_handshake` ~617 is the reference for building the initiator handshake) + +**Interfaces:** +- Consumes: `EpochSet::{needs_rekey, retire_previous_if_due}`, `RekeyInFlight`, the existing `HandshakeState::start_initiator` + glare tiebreak. +- Produces: rekey Init egress datagrams; `EpochSet.rekey` populated while a rekey is in flight. + +- [ ] **Step 1: Write the failing test** + +Add to `peer_manager.rs` tests: with `rekey_interval_ms` set small (e.g. 100), an `Established` peer that is the glare-winner, advanced past the interval via `tick(now)`, emits a `[HandshakeInit]` and its `EpochSet.rekey` becomes `Some` while `current` is unchanged; a second `tick` before completion does NOT emit another Init (one-in-flight). A non-winner peer does not rekey until `2*interval`. + +```rust +#[test] +fn tick_initiates_rekey_for_established_winner_once() { + // Build a PeerManager with one Established winner peer (fake_established_dataplane), + // rekey_interval_ms = 100, current_created_ms = 0. + // tick(150): expect an egress [HandshakeInit]; EpochSet.rekey.is_some(); current intact. + // tick(160): expect NO new Init (still one in flight). +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cargo test -p yipd --lib peer_manager::tests::tick_initiates_rekey_for_established_winner_once` +Expected: FAIL — no rekey scheduling yet. + +- [ ] **Step 3: Add the interval field + rekey initiation** + +- Add `rekey_interval_ms: u64` to `PeerManager`; in the constructor set it from `std::env::var("YIP_REKEY_INTERVAL_MS").ok().and_then(|s| s.parse().ok()).unwrap_or(crate::epoch::REKEY_INTERVAL_MS)`. +- Determine `is_glare_winner` with the **existing** static-key-order tiebreak used by the cold-start handshake (find how `begin_handshake`/glare compares `self.local_priv`/`self.public` vs `peer.pubkey` — reuse that exact comparison; do not invent a new one). +- In `tick_dispatch` (or `tick`), for each `Established` peer: call `retire_previous_if_due(now)`; if `epochs.needs_rekey(now, is_glare_winner, self.rekey_interval_ms)`, start a rekey: build `(hs, init_pkt) = HandshakeState::start_initiator(&self.local_priv, &peer.pubkey, &cert_payload)` (same `cert_payload` as `begin_handshake`), frame the Init toward the peer's committed endpoint/relay exactly as `begin_handshake` does, set `epochs.rekey = Some(RekeyInFlight { hs, init_pkt, started_ms: now, last_sent_ms: now, retry_ms: , target })`, and emit the Init datagram. +- **Retransmit / give up:** in the same tick pass, for an `Established` peer with `rekey = Some`, if `now - last_sent_ms >= retry_ms` resend `init_pkt` (update `last_sent_ms`, re-roll `retry_ms` like cold-start); if `now - started_ms >= HANDSHAKE_TOTAL_MS`, abandon (`epochs.rekey = None`) — keep `current`, retry at the next interval. +- The rekey Init rides the SAME obf/relay wrapping + `jitter_ms` timing as the cold-start Init (reuse `relay_wrap`/`jitter_ms`) — no new fingerprint. + +- [ ] **Step 4: Run tests** + +Run: `cargo test -p yipd --lib peer_manager::` then `cargo test -p yipd` +Expected: PASS (new rekey-scheduling test + all existing green). + +- [ ] **Step 5: Clippy, fmt, commit** + +```bash +cargo clippy -p yipd --all-targets -- -D warnings +cargo fmt +git add bin/yipd/src/peer_manager.rs +git commit -m "feat(rekey.9a): schedule mid-session rekey in tick (winner-initiates, one-in-flight, loser-fallback)" +``` + +--- + +### Task 4: Rekey handshake completion wiring + +Complete the confirmed-switch: initiator promotes on Resp; responder installs `next` on a rekey Init. + +**Files:** +- Modify: `bin/yipd/src/peer_manager.rs` (`handle_handshake_resp` ~1321, `handle_handshake_init` ~1182 — the arms that currently see an already-`Established` peer) + +**Interfaces:** +- Consumes: `EpochSet::{promote_from_rekey, install_next, accept_rekey_init}`; `HandshakeState::read_response`/`start_responder` (unchanged); `DataPlane::new` + `conn_tag_from_keys` (the cold-start completion pattern). +- Produces: promoted epochs; updated `conn_tag → peer` map. + +- [ ] **Step 1: Write the failing tests** + +Two in-process tests (build two `PeerManager`s or drive the handshake packets directly, mirroring existing handshake tests): +```rust +#[test] +fn rekey_resp_promotes_initiator_and_keeps_previous_for_grace() { + // An Established winner with rekey in flight receives the matching [HandshakeResp]: + // EpochSet.current becomes the NEW epoch (new conn_tag), previous == old epoch, + // rekey == None. A frame under the OLD keys still opens (previous); NEW keys open (current). +} + +#[test] +fn rekey_init_on_established_installs_next_without_switching_send() { + // An Established responder (current old enough per accept_rekey_init) receives a rekey + // [HandshakeInit]: it emits a [HandshakeResp], EpochSet.next.is_some(), current UNCHANGED + // (still sends old). A too-fresh current (< interval/2) ignores the Init (no next, no Resp). +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cargo test -p yipd --lib peer_manager::tests::rekey_resp_promotes_initiator_and_keeps_previous_for_grace` +Expected: FAIL. + +- [ ] **Step 3: Implement completion** + +- **`handle_handshake_resp` for an `Established` peer with `epochs.rekey = Some`:** `read_response` consumes the handshake by value, so **take the `RekeyInFlight` out** first (`let Some(rk) = epochs.rekey.take() else { return … }`) and call `rk.hs.read_response(resp_pkt)` → `Established { session, auth_key, hp_key, .. }`; build the new `Box` exactly as cold-start does (`DataPlane::new(established, conn_tag_from_keys(&auth_key,&hp_key), mode, peer_addr, obf_on, symbol_size)`); call `epochs.promote_from_rekey(new_dp, now)`; update the `conn_tag → peer` map (remove old tag, insert new); emit the peer's next outbound frame or one keepalive on the new epoch (`epochs.current_mut()`), so the responder receives a `next`-epoch frame and switches. If `read_response` errors, `epochs.rekey = None` (no-op, keep `current`). +- **`handle_handshake_init` for an already-`Established` peer:** if `!epochs.accept_rekey_init(now, self.rekey_interval_ms)`, ignore (return no Resp). Otherwise run `HandshakeState::start_responder(&self.local_priv, init_pkt, ...)` → `(Established, resp_pkt)` (same admission/cert-verify as cold-start), build the new `Box`, `epochs.install_next(new_dp)` (do NOT touch `current`), and emit `resp_pkt`. The responder's switch happens later inside `inbound_open` (Task 1) on the first `next`-epoch frame. +- Do NOT change the cold-start (non-`Established`) arms of these handlers. + +- [ ] **Step 4: Run tests** + +Run: `cargo test -p yipd` +Expected: PASS (both completion tests + all existing green). + +- [ ] **Step 5: Clippy, fmt, commit** + +```bash +cargo clippy -p yipd --all-targets -- -D warnings +cargo fmt +git add bin/yipd/src/peer_manager.rs +git commit -m "feat(rekey.9a): complete rekey — initiator promote on Resp, responder install_next on Init" +``` + +--- + +### Task 5: netns money tests (the end-to-end gate) + +Prove rotation is loss-free and the `conn_tag` actually rotates on the wire. + +**Files:** +- Create: `bin/yipd/tests/run-netns-rekey.sh` (fork the closest existing peer-to-peer tunnel test) +- Modify: `.github/workflows/integration.yml` (add the step alongside the sibling netns tests) + +- [ ] **Step 1: Read the existing tunnel netns test to reuse plumbing** + +Run: `ls bin/yipd/tests/run-netns-*.sh` and read `run-netns-tunnel.sh` (two peers, a steady ping/data stream, both drivers). Reuse its namespace/veth setup, the two-peer config, and its packet-count/ping assertions. + +- [ ] **Step 2: Write `run-netns-rekey.sh`** + +`set -euo pipefail`, trap-cleanup namespaces. Launch both yipd peers with **`YIP_REKEY_INTERVAL_MS=2000`** (fast rotation) — for BOTH the poll and `YIP_USE_URING=1` driver (parameterize like the sibling scripts). Assertions (non-zero exit on any failure): +1. **rekey_continuity:** run a steady stream (e.g. `ping -i 0.2 -c 100` A→B, ~20s = ~10 rotations) and assert **0% (or below a strict threshold, e.g. ≤1) packet loss** across the whole run — the session survives many rotations with no application-visible gap. +2. **conn_tag rotation:** `tcpdump`/capture the UDP payloads on the veth during the run; assert the first 8 header bytes (the masked `conn_tag`, as an on-path observer sees them) take **more than one distinct value** over the run for the same peer pair (i.e. it rotated), whereas a single-epoch run would show one. (A coarse check: `sort -u` of the first-8-bytes hex across captured data datagrams has >1 line.) + +Add a second script or a netem branch for **rekey_under_loss** (10% netem loss on the veth during the run) asserting the ping stream still completes (session survives; lost msg2 just retries). + +- [ ] **Step 3: Run under sudo (both drivers)** + +Run: `sudo bash bin/yipd/tests/run-netns-rekey.sh` and `sudo YIP_USE_URING=1 bash bin/yipd/tests/run-netns-rekey.sh` +Expected: `PASS`, exit 0 on both. (Rebuild the RELEASE yipd first — `cargo build --release -p yipd` — the netns scripts use the release binary.) +If the environment cannot run netns (no root/kernel), capture the exact blocker and report DONE_WITH_CONCERNS; the controller decides whether to run it or defer. + +- [ ] **Step 4: Confirm no regression + wire CI** + +Run the existing `run-netns-tunnel.sh` / triangle / relay tests at the PRODUCTION interval (no `YIP_REKEY_INTERVAL_MS`) to confirm rekey-at-120s doesn't perturb them (they run <120s so rekey never fires, but confirm green). Add the new script to `.github/workflows/integration.yml` next to the sibling netns steps (grep the workflow for `run-netns`). + +- [ ] **Step 5: Commit** + +```bash +chmod +x bin/yipd/tests/run-netns-rekey.sh +git add bin/yipd/tests/run-netns-rekey.sh .github/workflows/integration.yml +git commit -m "test(rekey.9a): netns money test — loss-free rotation + on-wire conn_tag rotation" +``` + +--- + +## After all tasks + +- Final whole-branch review (opus) over the branch delta, focused on: the `EpochSet` switch state machine (no black-hole on lost msg2; promotion only on confirmed `next` inbound; `previous` retirement), rekey-never-tears-down-a-working-session, the glare-winner/loser-fallback trigger, and behavior-preservation of the steady-state (single-epoch) path (2a/2b/2c green). +- Push; open a PR off `main`. Leave it for the user; do NOT merge; no "not merging" line. +- Update `yip-control-plane-status` memory (9a rekey shipped; conn_tag rotates per epoch; 9b = PQ-hybrid next; #34/#36/#41 still open but now have the rekey machinery to ride). + +## Self-Review notes + +- **Borrow-checker:** `inbound_open` returns OWNED `EpochInbound` (not a borrowed `Outcome`) precisely so the try-current→next→previous chain compiles on stable Rust — and it is free because the three `on_udp_datagram` caller sites already `.to_vec()`/`.clone()` the outcome. Steady state stays a single `on_udp_datagram` call. +- **Promotion signal:** the responder promotes `next`→`current` on the first `next` frame that yields a **non-None** outcome (a decoded inner packet or an ARQ send) — `Outcome::None` is ambiguous (auth-fail vs partial-FEC), so a bare auth-success is not used as the trigger; promotion is at worst delayed to the first fully-decoded new-epoch packet, which is harmless (the responder keeps sending old until then, the initiator receives it on `previous`). +- **No cross-epoch replay:** each epoch's counter/replay window lives in its own `Session`; a counter value is only meaningful within one `DataPlane`, so trying a frame against the wrong epoch fails AEAD (never a replay-accept). diff --git a/docs/superpowers/specs/2026-07-19-rekey-9a-session-rotation-design.md b/docs/superpowers/specs/2026-07-19-rekey-9a-session-rotation-design.md new file mode 100644 index 0000000..4c72e77 --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-rekey-9a-session-rotation-design.md @@ -0,0 +1,133 @@ +# Milestone 9a — classical session rekey (~120s rotation) + epoch handling — design spec + +**Date:** 2026-07-19 +**Status:** design (pending user review) +**Issue:** #9 (session rekey + PQ-hybrid handshake) — this spec is **9a (classical rekey)**; PQ-hybrid is **9b**, a separate later cycle. +**Scope:** `bin/yipd` (the real work: rekey scheduling + epoch state/routing in `PeerManager`/`DataPlane`) + minimal `yip-crypto`/`handshake` surface. No wire-format change. + +## Goal + +The daemon does one Noise-IK handshake per peer at session start and never rekeys, so a session's keys (and its `conn_tag`) live forever. 9a adds **~120s session rotation** for forward secrecy: each peer periodically runs a fresh mid-session Noise-IK handshake, producing a new **epoch** (fresh keys, fresh `conn_tag`, fresh counter + replay window). An **overlap window** keeps the previous epoch alive for inbound so in-flight packets still decrypt across the switch. `conn_tag` rotates per epoch — the linkability fix (an observer no longer sees one stable 64-bit tag per peer for the connection's lifetime) falls out for free. + +## Non-goals (explicit) + +- **PQ-hybrid handshake (McEliece + ML-KEM → PSK)** — that is **9b**, built on this milestone's rekey machinery. +- **Full handshake anti-replay / authenticated endpoint learning (#34)** — its endpoint-hijack half is already mitigated by 2b's "endpoint committed only by a completed Noise handshake" invariant; the remaining timestamp-anti-replay is a separate hardening piece. 9a includes only a minimal rekey-local DoS guard (below), not #34. +- **#36 (path-switch half-open), #41 (revocation lag)** — these benefit from rekey existing but are not implemented here. +- **No wire-format change.** An explicit epoch/key-id field in the header would be a fresh, near-static byte for a DPI to fingerprint — regressing the anti-DPI work. The `conn_tag` (already per-epoch and header-protected) is the epoch discriminator. + +## Background (current state) + +- `yip-crypto`: a `Handshake` (Noise-IK via `snow`) → `into_session()` → a `Session` with fresh send/recv keys, `send_counter = 0`, and a fresh WireGuard-style `ReplayWindow`. A new handshake is a new `Session` — the mechanism already exists. +- `bin/yipd/src/handshake.rs`: `run_initiator`/`run_responder` (and the `start_initiator`/`read_response`/`start_responder` step-functions) yield an `Established { session, auth_key, hp_key, remote_static }`, from which `DataPlane::from_established` builds a `DataPlane` that owns the `Session`, the `yip_wire::Codec`, and the derived `conn_tag` (`conn_tag_from_keys(auth_key, hp_key)`). +- `bin/yipd/src/peer_manager.rs`: `PeerState` per peer is `Idle` → `Handshaking(Box)` → `Established(Box)`. Inbound is demuxed **by source address → peer**, then that peer's `DataPlane` opens the frame (AEAD-authenticated). Glare (both sides initiating at once) is resolved by static-key order. `HANDSHAKE_RETRY_MS = 1000`, `HANDSHAKE_TOTAL_MS = 90_000`. + +## Design + +### 1. Epoch set — `Established` holds up to three epochs + +Replace `PeerState::Established(Box)` with `PeerState::Established(Box)`: + +```rust +struct EpochSet { + /// The epoch used for OUTBOUND (the newest confirmed epoch). + current: Box, + /// When `current` was installed (monotonic ms) — drives the rekey schedule. + current_created_ms: u64, + /// Responder only: a new epoch derived from a received rekey Init but NOT yet + /// used for sending — promoted to `current` on the first inbound frame that + /// decrypts under it (confirming the peer switched). `None` otherwise. + next: Option>, + /// The just-superseded epoch, kept RECEIVE-ONLY through the grace window so + /// in-flight/reordered old-epoch frames still open. Retired at `previous_retire_ms`. + previous: Option>, + previous_retire_ms: u64, + /// Initiator only: an in-flight rekey handshake (retransmit Init until Resp or + /// give up). `None` when no rekey is in flight. Caps rekey to ONE in flight. + rekey: Option, +} + +struct RekeyInFlight { + handshake: Handshaking, // the same step-function state used for cold-start + init_pkt: Vec, // retransmitted every HANDSHAKE_RETRY_MS + started_ms: u64, + last_sent_ms: u64, +} +``` + +At most three `DataPlane`s coexist briefly: `current`, `next` (responder mid-switch), `previous` (grace). Outside a rekey, only `current` exists. + +### 2. Inbound routing — try current, then next, then previous + +`PeerManager` already routes an inbound datagram to a peer by source address. Within the peer, try the epochs in order and use whichever authenticates (each `DataPlane::on_udp_datagram` fails closed on a wrong key, so a mismatch is a cheap failed AEAD/MAC, not a misdecrypt): + +1. `current` — the common case (steady state: one try). +2. `next` (if present) — a **successful** open here means the peer has switched to the new epoch → **promote** (§4, responder path). +3. `previous` (if present and not yet retired) — in-flight old-epoch frames during overlap. + +Only during the few-second overlap are there 2–3 tries; steady state is one. No wire change; `conn_tag` (per-epoch) is what makes the epochs distinguishable to their own codecs. + +### 3. Rekey scheduling + trigger (initiator side) + +In `PeerManager::tick` (the existing per-peer time-driven pass), for each `Established` peer: + +- **Trigger:** `now - current_created_ms >= REKEY_INTERVAL_MS` (120_000) AND `rekey.is_none()` AND **this side is the glare-winner** (the same static-key-order tiebreak the cold-start handshake uses, so only one side initiates and both don't rekey simultaneously). +- **Loser fallback (asymmetric-liveness safety):** if the glare-*loser* observes `current` aging past a hard ceiling (`2 × REKEY_INTERVAL_MS`) with no rekey — the winner is silent/dead but this side still carries traffic — the loser initiates instead, so rotation can't stall on a one-sided-silent winner. (In the common case the winner rekeys first and the loser only ever responds; a genuinely idle session carries no traffic and needs no rotation.) +- **Start:** build a fresh initiator `Handshaking` for the peer's `remote_static`, send its Init, set `rekey = Some(RekeyInFlight{..})`. The current session keeps carrying traffic throughout — **rekey never interrupts the live session.** +- **Retransmit / give up:** identical to cold-start — resend `init_pkt` every `HANDSHAKE_RETRY_MS`, abandon the rekey attempt after `HANDSHAKE_TOTAL_MS` (set `rekey = None`, keep `current`, retry at the next interval). A failed rekey is never fatal: the session keeps running on the current epoch. + +**One-rekey-in-flight DoS guard (the only #34-adjacent hardening in 9a):** `rekey.is_some()` blocks starting another; a replayed/spoofed rekey Init cannot make a peer thrash into repeated speculative handshakes because (a) the responder already holds at most one `next`, and (b) a fresh `next` is only derived when the current epoch is at least, say, `REKEY_INTERVAL_MS/2` old (ignore rekey Inits against a very fresh `current`). This bounds attacker-induced handshake CPU without full timestamp anti-replay. + +### 4. The outbound-switch policy (WireGuard confirmed-switch) + +In Noise-IK the responder derives the new session when it processes the Init (as it sends msg2); the initiator derives it on reading msg2. The switch is asymmetric so it survives a lost msg2: + +- **Initiator, on rekey completion (read the Resp):** it now holds the new epoch AND knows the responder installed it (the responder sent the Resp). **Switch outbound immediately:** `previous = Some(current)`, `previous_retire_ms = now + PREVIOUS_EPOCH_GRACE_MS`, `current = `, `rekey = None`. Send the next outbound frame (or, if idle, one cover/keepalive frame) on the new epoch to prompt the responder's switch. +- **Responder, on receiving a rekey Init from an already-`Established` peer:** run the responder step → a new `DataPlane`; store it as `next` (do **not** switch sending yet); reply with the Resp. Keep sending on `current`. +- **Responder, on the first inbound frame that decrypts under `next` (§2 step 2):** the initiator has completed and switched → **promote:** `previous = Some(current)`, `previous_retire_ms = now + GRACE`, `current = next.take()`, `next = None`. + +If msg2 is lost: the initiator never completes (stays on the old epoch, retries the Init); the responder holds a `next` it never promotes (both sides still on the old epoch — no black-hole). The stale `next` is dropped when a later rekey supersedes it or on peer teardown. + +### 5. Overlap, retirement, and conn_tag rotation + +- **`previous`** is receive-only and retired when `now >= previous_retire_ms` (`PREVIOUS_EPOCH_GRACE_MS`, e.g. 15_000 — generous vs. reordering/loss, bounded). Retiring = drop the `DataPlane` (and its keys). +- **`conn_tag`** rotates automatically: the new epoch's `DataPlane` derives a new `conn_tag` from its own keys. `PeerManager`'s `conn_tag → peer` map (kept for tests/logging) is updated on promotion; routing itself stays source-address-primary, so no lookup depends on the old tag. +- **`obf`/cover-traffic (3b) and path state (2b)** are per-peer, not per-epoch — unchanged across rekey. Only the `DataPlane`/`Session`/`Codec`/`conn_tag` rotate. + +### 6. `yip-crypto` / `handshake.rs` surface (minimal) + +The rotation is entirely daemon-driven; `yip-crypto` already exposes everything needed (`Handshake` → `into_session` → `Session`). The only additions are in `bin/yipd`: +- `DataPlane` gains no new field (its creation time is tracked by `EpochSet.current_created_ms` in `PeerManager`, where the schedule lives). +- Reuse `handshake.rs`'s existing initiator/responder step-functions verbatim for the rekey handshake — a rekey is a normal handshake whose result happens to install into an existing `EpochSet` rather than a fresh `Idle` peer. + +## Error handling (fail-closed, never drop a working session) + +- A rekey handshake that fails/times out → `rekey = None`, **keep `current`**, retry next interval. Rekey failure must never tear down a working session. +- A rekey Init that arrives for a non-`Established` peer is the ordinary cold-start path (unchanged). +- Glare during rekey (both timers fire): the static-key loser defers — it accepts the winner's rekey Init as responder (deriving `next`) and abandons/does-not-start its own initiator rekey, exactly as cold-start glare resolves. +- All epoch `DataPlane`s fail closed on inbound (wrong-key → failed AEAD → try next epoch; no misdecrypt, no panic on attacker bytes — the existing `DataPlane::on_udp_datagram` guarantees hold per epoch). +- Counter/replay are per-epoch (per `Session`); a counter value is only meaningful within one epoch's `DataPlane`, so the "counter resets on rekey" ambiguity cannot cause a cross-epoch replay accept. + +## Testing / adversary + +- **Unit (pure/fast):** epoch routing (`EpochSet` tries current→next→previous, uses the one that authenticates); the WireGuard switch state machine (initiator promotes on completion; responder derives `next` on Init and promotes on first `next`-decrypt; lost-msg2 leaves both on old epoch, no black-hole); `previous` retirement at the grace deadline; the one-rekey-in-flight guard (a second rekey trigger while one is in flight is a no-op; a rekey Init against a too-fresh `current` is ignored). +- **netns money tests (sudo, both poll + `YIP_USE_URING=1` drivers, CI-gated — the repo's standard bar):** + - **rekey_continuity:** two peers exchange a steady packet stream while the rekey interval is set low (test override, e.g. 2s) for several rotations; assert **zero** application-visible packet loss across each rotation and that the on-wire `conn_tag` actually changes between epochs (linkability rotation observed). + - **rekey_under_loss:** the same with netem loss on the path during the rekey window; assert the session survives (old epoch covers in-flight; a lost msg2 just retries) and traffic continues. + - **rekey_conn_tag_rotates:** capture datagrams across a rotation; assert the header `conn_tag` (as an observer sees the masked header change) differs pre/post rotation for the same peer. +- **No regression:** the existing 2a/2b/2c netns money tests (triangle, relay/punch, discovery) stay green with rekey enabled at the production interval. + +## Risks + +- **State-machine complexity** (three coexisting epochs, asymmetric switch). Mitigation: keep the WireGuard model verbatim (well-understood), model it as a small pure `EpochSet` state machine that's unit-tested independently of I/O, and make rekey-failure a no-op on the live session. +- **Overlap window sizing:** too short → reordered old-epoch frames dropped mid-switch; too long → keys linger. Mitigation: `PREVIOUS_EPOCH_GRACE_MS` generous-but-bounded (≈15s), a named tunable constant. +- **Interaction with cover-traffic/obf timing (3b):** the extra rekey handshake packets must not create a new timing tell. Mitigation: rekey Init/Resp ride the same obf envelope + jittered send timing as cold-start handshakes (reuse the existing path), so they are not a new fingerprint. + +## Success criteria + +1. An `Established` peer runs a fresh Noise-IK handshake ~every `REKEY_INTERVAL_MS` (120s), producing a new epoch, **without interrupting** the live session; rekey failure is a no-op that retries. +2. Inbound decrypts across the switch with zero application-visible loss (old epoch covers in-flight); outbound follows the WireGuard confirmed-switch (initiator on completion, responder on first new-epoch inbound); lost-msg2 does not black-hole. +3. `conn_tag` rotates per epoch (linkability fix), verified on the wire; each epoch has its own counter/replay window with no cross-epoch replay accept. +4. A replayed/spoofed rekey Init cannot force repeated speculative handshakes (one-in-flight + too-fresh-current guard). +5. No wire-format change; no `as`/`unsafe`/bare-`allow`; clippy clean. netns rekey-continuity + rekey-under-loss pass on both drivers; existing 2a/2b/2c tests stay green. +6. PQ-hybrid (9b), full #34, #36, #41 are **out of scope** and untouched.