From d2709883eda87335b1135c7a079c25c9887d8b11 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Sat, 18 Jul 2026 22:04:46 -0400 Subject: [PATCH 01/21] =?UTF-8?q?docs(rekey.9a):=20design=20spec=20?= =?UTF-8?q?=E2=80=94=20classical=20session=20rekey=20(~120s)=20+=20epoch?= =?UTF-8?q?=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-epoch (current/previous, +next on responder) overlap, no wire change (conn_tag rotates per epoch = linkability fix + epoch discriminator); WireGuard confirmed-switch (initiator on completion, responder on first new-epoch inbound); rekey-mechanics-only scope + one-rekey-in-flight DoS guard; loser-fallback for winner-silent. PQ-hybrid=9b, #34 separate. Issue #9. --- ...-07-19-rekey-9a-session-rotation-design.md | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-19-rekey-9a-session-rotation-design.md 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. From f760f6586caa7defbabc3b6337ae53b602c78a2b Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Sat, 18 Jul 2026 22:36:46 -0400 Subject: [PATCH 02/21] docs(rekey.9a): implementation plan (5 tasks, subagent-driven) EpochSet state machine (owned EpochInbound sidesteps the multi-epoch borrow- return; steady state = one try) / PeerState::Established carries EpochSet / tick rekey scheduling (winner-initiates, one-in-flight, loser-fallback) / completion (initiator promote on Resp, responder install_next on Init) / netns loss-free-rotation + conn_tag-rotation money test. No wire change; yip-crypto + handshake.rs unchanged. --- .../2026-07-19-rekey-9a-session-rotation.md | 514 ++++++++++++++++++ 1 file changed, 514 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-19-rekey-9a-session-rotation.md 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). From ebaba6a032b20564edb900ebc8ecee3af1b22bd8 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Sat, 18 Jul 2026 23:21:29 -0400 Subject: [PATCH 03/21] feat(rekey.9a): EpochSet state machine (current/next/previous, WireGuard confirmed-switch) --- bin/yipd/src/epoch.rs | 474 ++++++++++++++++++++++++++++++++++++++++++ bin/yipd/src/main.rs | 1 + 2 files changed, 475 insertions(+) create mode 100644 bin/yipd/src/epoch.rs diff --git a/bin/yipd/src/epoch.rs b/bin/yipd/src/epoch.rs new file mode 100644 index 0000000..d91f6f2 --- /dev/null +++ b/bin/yipd/src/epoch.rs @@ -0,0 +1,474 @@ +//! 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. +#[expect( + dead_code, + reason = "consumed by PeerManager's rekey scheduler once Task 2 wires EpochSet in" +)] +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. +#[cfg_attr( + not(test), + expect( + dead_code, + reason = "read by PeerManager once Task 2 wires EpochSet in" + ) +)] +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. +#[expect( + dead_code, + reason = "constructed and its fields read by PeerManager's rekey driver in Task 2" +)] +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). +#[expect( + dead_code, + reason = "Send/TunThenSend payloads are read by PeerManager's demux once Task 3 wires \ + EpochSet into the data path; today only Tun/None are exercised by unit tests" +)] +pub enum EpochInbound { + None, + Tun(Vec), + Send(Vec>), + TunThenSend(Vec, Vec>), +} + +#[cfg_attr( + not(test), + expect( + dead_code, + reason = "constructed by PeerManager once Task 2/3 wires EpochSet in" + ) +)] +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, +} + +#[cfg_attr( + not(test), + expect( + dead_code, + reason = "driven by PeerManager once Task 2/3 wires EpochSet in" + ) +)] +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 + } + #[expect( + dead_code, + reason = "used by PeerManager's outbound path once Task 3 wires EpochSet in" + )] + 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 + } +} + +#[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); + 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); + + // 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 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); + 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/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; From 701f31b14e157d9f5c19b8fb2aa081f4a5e80863 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Sat, 18 Jul 2026 23:27:57 -0400 Subject: [PATCH 04/21] test(rekey.9a): assert promotion refreshes current_created_ms (Task 1 review) --- bin/yipd/src/epoch.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bin/yipd/src/epoch.rs b/bin/yipd/src/epoch.rs index d91f6f2..c115527 100644 --- a/bin/yipd/src/epoch.rs +++ b/bin/yipd/src/epoch.rs @@ -377,6 +377,9 @@ mod tests { 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]; From 1086c54f1030c4df58accd22eb80ba55a0bbd3bf Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Sat, 18 Jul 2026 23:38:45 -0400 Subject: [PATCH 05/21] refactor(rekey.9a): PeerState::Established carries an EpochSet; route in/out through current epoch --- bin/yipd/src/epoch.rs | 16 ---- bin/yipd/src/peer_manager.rs | 180 ++++++++++++++++++++++++----------- 2 files changed, 126 insertions(+), 70 deletions(-) diff --git a/bin/yipd/src/epoch.rs b/bin/yipd/src/epoch.rs index c115527..a48373f 100644 --- a/bin/yipd/src/epoch.rs +++ b/bin/yipd/src/epoch.rs @@ -19,13 +19,6 @@ use crate::handshake::HandshakeState; 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. -#[cfg_attr( - not(test), - expect( - dead_code, - reason = "read by PeerManager once Task 2 wires EpochSet in" - ) -)] pub const PREVIOUS_EPOCH_GRACE_MS: u64 = 15_000; /// An in-flight initiator rekey handshake, held alongside the live `current` @@ -46,11 +39,6 @@ pub struct RekeyInFlight { /// 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). -#[expect( - dead_code, - reason = "Send/TunThenSend payloads are read by PeerManager's demux once Task 3 wires \ - EpochSet into the data path; today only Tun/None are exercised by unit tests" -)] pub enum EpochInbound { None, Tun(Vec), @@ -96,10 +84,6 @@ impl EpochSet { pub fn current(&self) -> &DataPlane { &self.current } - #[expect( - dead_code, - reason = "used by PeerManager's outbound path once Task 3 wires EpochSet in" - )] pub fn current_mut(&mut self) -> &mut DataPlane { &mut self.current } diff --git a/bin/yipd/src/peer_manager.rs b/bin/yipd/src/peer_manager.rs index f48739b..120e7e5 100644 --- a/bin/yipd/src/peer_manager.rs +++ b/bin/yipd/src/peer_manager.rs @@ -79,7 +79,7 @@ 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::dataplane::{conn_tag_from_keys, DataPlane}; use crate::handshake::{HandshakeState, PacketType}; use crate::membership::Membership; use crate::mode::TunnelMode; @@ -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. @@ -872,7 +872,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 +927,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); @@ -950,17 +952,14 @@ impl PeerManager { /// egress it produces (TUN writes still go to the local device). 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(pkts) => (None, pkts), + crate::epoch::EpochInbound::TunThenSend(buf, pkts) => (Some(buf), pkts), } }; self.egress.clear(); @@ -1076,14 +1075,52 @@ impl PeerManager { /// re-map its `Outcome` 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), + let out = epochs.inbound_open(dg, now_ms); + // `EpochInbound::Send`/`TunThenSend` carry raw bytes only (see + // `EpochSet::own`): the per-datagram `dst` every `DataPlane` egress + // datagram used to carry is dropped there, but it was always the + // *same* value — this peer's endpoint (`DataPlane::peer_addr` is + // fixed at construction to exactly the address stamped into + // `self.peers[idx].endpoint` at that same moment, and neither changes + // again while the peer stays `Established`) — so it is losslessly + // reconstructed here. `fate` is NOT reconstructed (no longer + // recoverable): these datagrams simply forgo GSO fate-coalescing + // with each other, which is safe (never miscoalesces) even though + // less batched than before. + let to_egress = |pkts: Vec>, dst: SocketAddr| -> Vec { + pkts.into_iter() + .map(|bytes| EgressDatagram { + fate: 0, + dst, + bytes, + }) + .collect() + }; + match out { + crate::epoch::EpochInbound::None => DispatchOut::None, + crate::epoch::EpochInbound::Tun(buf) => { + self.tun_scratch = buf; + DispatchOut::Tun(&self.tun_scratch) + } + crate::epoch::EpochInbound::Send(pkts) => { + let Some(dst) = self.peers[idx].endpoint else { + return DispatchOut::None; + }; + self.egress = to_egress(pkts, dst); + DispatchOut::Udp(&self.egress) + } + crate::epoch::EpochInbound::TunThenSend(buf, pkts) => { + let Some(dst) = self.peers[idx].endpoint else { + self.tun_scratch = buf; + return DispatchOut::Tun(&self.tun_scratch); + }; + self.tun_scratch = buf; + self.egress = to_egress(pkts, dst); + DispatchOut::Both(&self.tun_scratch, &self.egress) + } } } @@ -1125,21 +1162,33 @@ 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(pkts) => Some((None, pkts)), + crate::epoch::EpochInbound::TunThenSend(buf, pkts) => Some((Some(buf), pkts)), } }; let Some((tun, udp)) = hit else { continue; }; + // Same `dst` reconstruction as `dispatch_established`: `udp`'s raw + // bytes lost their per-datagram `dst`, which was always this + // peer's endpoint. + let udp: Vec = match self.peers[idx].endpoint { + Some(dst) => udp + .into_iter() + .map(|bytes| EgressDatagram { + fate: 0, + dst, + bytes, + }) + .collect(), + None => Vec::new(), + }; return match (tun, udp.is_empty()) { (Some(t), true) => { self.tun_scratch = t; @@ -1308,7 +1357,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))); DispatchOut::Udp(&self.egress) } @@ -1378,7 +1428,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 @@ -1849,16 +1900,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 +2088,8 @@ 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) => { + 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 +2104,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 +2497,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 +2602,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, } } @@ -3000,8 +3056,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 +3196,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 +3827,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 +3899,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 +3976,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 +4170,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 +4343,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 +4886,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); From e8bf21092ab3244bb597dd170228dc52184de832 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Sat, 18 Jul 2026 23:54:05 -0400 Subject: [PATCH 06/21] fix(rekey.9a): EpochInbound::Send carries EgressDatagram (preserve dst+fate for relay peers) EpochInbound::Send/TunThenSend previously carried Vec> (bytes only), discarding each EgressDatagram's dst and fate. dispatch_established and the roamed-peer fallback reconstructed dst from self.peers[idx].endpoint, which is wrong for relay-established peers (DataPlane::peer_addr is a server_addr() placeholder there; endpoint holds an unconfirmed candidate or None) -- silently dropping or misdirecting Send outcomes for relay peers. Carry the full EgressDatagram instead, so dst/fate are always correct and GSO fate-coalescing works again for free. --- bin/yipd/src/epoch.rs | 22 +++++---- bin/yipd/src/peer_manager.rs | 88 +++++++++++++----------------------- 2 files changed, 46 insertions(+), 64 deletions(-) diff --git a/bin/yipd/src/epoch.rs b/bin/yipd/src/epoch.rs index a48373f..54c8e44 100644 --- a/bin/yipd/src/epoch.rs +++ b/bin/yipd/src/epoch.rs @@ -9,6 +9,7 @@ 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. @@ -39,11 +40,19 @@ pub struct RekeyInFlight { /// 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>), + Send(Vec), + TunThenSend(Vec, Vec), } #[cfg_attr( @@ -94,13 +103,10 @@ impl EpochSet { 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::Send(pkts) => EpochInbound::Send(pkts.to_vec()), + Outcome::TunWriteThenSend(b, pkts) => { + EpochInbound::TunThenSend(b.to_vec(), pkts.to_vec()) } - Outcome::TunWriteThenSend(b, pkts) => EpochInbound::TunThenSend( - b.to_vec(), - pkts.iter().map(|d| d.bytes.clone()).collect(), - ), } } diff --git a/bin/yipd/src/peer_manager.rs b/bin/yipd/src/peer_manager.rs index 120e7e5..98db91c 100644 --- a/bin/yipd/src/peer_manager.rs +++ b/bin/yipd/src/peer_manager.rs @@ -948,8 +948,12 @@ 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(epochs) = &mut self.peers[idx].state else { @@ -958,8 +962,12 @@ impl PeerManager { 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(pkts) => (None, pkts), - crate::epoch::EpochInbound::TunThenSend(buf, pkts) => (Some(buf), pkts), + 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(); @@ -1071,54 +1079,33 @@ 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(epochs) = &mut self.peers[idx].state else { return DispatchOut::None; }; - let out = epochs.inbound_open(dg, now_ms); - // `EpochInbound::Send`/`TunThenSend` carry raw bytes only (see - // `EpochSet::own`): the per-datagram `dst` every `DataPlane` egress - // datagram used to carry is dropped there, but it was always the - // *same* value — this peer's endpoint (`DataPlane::peer_addr` is - // fixed at construction to exactly the address stamped into - // `self.peers[idx].endpoint` at that same moment, and neither changes - // again while the peer stays `Established`) — so it is losslessly - // reconstructed here. `fate` is NOT reconstructed (no longer - // recoverable): these datagrams simply forgo GSO fate-coalescing - // with each other, which is safe (never miscoalesces) even though - // less batched than before. - let to_egress = |pkts: Vec>, dst: SocketAddr| -> Vec { - pkts.into_iter() - .map(|bytes| EgressDatagram { - fate: 0, - dst, - bytes, - }) - .collect() - }; - match out { + // `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(pkts) => { - let Some(dst) = self.peers[idx].endpoint else { - return DispatchOut::None; - }; - self.egress = to_egress(pkts, dst); + crate::epoch::EpochInbound::Send(dgs) => { + self.egress = dgs; DispatchOut::Udp(&self.egress) } - crate::epoch::EpochInbound::TunThenSend(buf, pkts) => { - let Some(dst) = self.peers[idx].endpoint else { - self.tun_scratch = buf; - return DispatchOut::Tun(&self.tun_scratch); - }; + crate::epoch::EpochInbound::TunThenSend(buf, dgs) => { self.tun_scratch = buf; - self.egress = to_egress(pkts, dst); + self.egress = dgs; DispatchOut::Both(&self.tun_scratch, &self.egress) } } @@ -1168,27 +1155,16 @@ impl PeerManager { 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(pkts) => Some((None, pkts)), - crate::epoch::EpochInbound::TunThenSend(buf, pkts) => Some((Some(buf), pkts)), + 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; }; - // Same `dst` reconstruction as `dispatch_established`: `udp`'s raw - // bytes lost their per-datagram `dst`, which was always this - // peer's endpoint. - let udp: Vec = match self.peers[idx].endpoint { - Some(dst) => udp - .into_iter() - .map(|bytes| EgressDatagram { - fate: 0, - dst, - bytes, - }) - .collect(), - None => Vec::new(), - }; + // `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; From c214b3d0ee3a835aef3736eeec8df4bc55313c41 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Sun, 19 Jul 2026 00:04:24 -0400 Subject: [PATCH 07/21] feat(rekey.9a): schedule mid-session rekey in tick (winner-initiates, one-in-flight, loser-fallback) --- bin/yipd/src/epoch.rs | 23 +-- bin/yipd/src/peer_manager.rs | 337 +++++++++++++++++++++++++++++++++++ 2 files changed, 345 insertions(+), 15 deletions(-) diff --git a/bin/yipd/src/epoch.rs b/bin/yipd/src/epoch.rs index 54c8e44..515cd8d 100644 --- a/bin/yipd/src/epoch.rs +++ b/bin/yipd/src/epoch.rs @@ -13,10 +13,6 @@ use yip_io::poll::EgressDatagram; /// Production rekey cadence (§ Global Constraints). Test-overridden via /// `YIP_REKEY_INTERVAL_MS` at `PeerManager` construction. -#[expect( - dead_code, - reason = "consumed by PeerManager's rekey scheduler once Task 2 wires EpochSet in" -)] 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. @@ -24,11 +20,15 @@ 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. -#[expect( - dead_code, - reason = "constructed and its fields read by PeerManager's rekey driver in Task 2" -)] pub struct RekeyInFlight { + /// Read by rekey *completion* (initiator `read_response`) in Task 4; + /// Task 3 only schedules/retransmits/abandons, so this field is written + /// (constructed, and cleared via `EpochSet::rekey = None` on abandon) but + /// not yet read here. + #[expect( + dead_code, + reason = "read by rekey completion (initiator read_response) in Task 4" + )] pub hs: HandshakeState, pub init_pkt: Vec, pub started_ms: u64, @@ -55,13 +55,6 @@ pub enum EpochInbound { TunThenSend(Vec, Vec), } -#[cfg_attr( - not(test), - expect( - dead_code, - reason = "constructed by PeerManager once Task 2/3 wires EpochSet in" - ) -)] pub struct EpochSet { pub(crate) current: Box, pub(crate) current_created_ms: u64, diff --git a/bin/yipd/src/peer_manager.rs b/bin/yipd/src/peer_manager.rs index 98db91c..aa8bac5 100644 --- a/bin/yipd/src/peer_manager.rs +++ b/bin/yipd/src/peer_manager.rs @@ -350,6 +350,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 @@ -450,6 +457,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 @@ -688,6 +699,149 @@ 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, + ) { + 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; + } + let target = if relay { + self.server_addr() + } else { + 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 = if relay { + self.relay_wrap(idx, init_pkt.clone()) + } else { + Some(EgressDatagram { + fate: 0, + dst: target, + bytes: init_pkt.clone(), + }) + }; + let Some(dg) = dg else { + // No rendezvous configured for a relay peer (should not + // happen for an already-Established relay session): drop + // this attempt, `current` stays untouched, retried next tick. + return; + }; + 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(); + let dg = if relay { + self.relay_wrap(idx, pkt) + } else { + Some(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; + } + if let Some(dg) = dg { + 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 { @@ -2065,6 +2219,8 @@ impl PeerManager { let old_state = std::mem::replace(&mut self.peers[i].state, PeerState::Idle); let new_state = match old_state { 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 @@ -2603,6 +2759,187 @@ 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))) + } + + #[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" + ); + } + + #[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" + ); + } + #[test] fn glare_simultaneous_init_converges_on_one_session() { // Both peers configured with each other; neither initiates until it From 0e3ca533c98e0e63dcd0303c9c4456fde0cd4430 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Sun, 19 Jul 2026 00:26:44 -0400 Subject: [PATCH 08/21] =?UTF-8?q?feat(rekey.9a):=20complete=20rekey=20?= =?UTF-8?q?=E2=80=94=20initiator=20promote=20on=20Resp,=20responder=20inst?= =?UTF-8?q?all=5Fnext=20on=20Init?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bin/yipd/src/epoch.rs | 18 +- bin/yipd/src/peer_manager.rs | 471 ++++++++++++++++++++++++++++++++--- 2 files changed, 443 insertions(+), 46 deletions(-) diff --git a/bin/yipd/src/epoch.rs b/bin/yipd/src/epoch.rs index 515cd8d..409f3b7 100644 --- a/bin/yipd/src/epoch.rs +++ b/bin/yipd/src/epoch.rs @@ -21,14 +21,9 @@ 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* (initiator `read_response`) in Task 4; - /// Task 3 only schedules/retransmits/abandons, so this field is written - /// (constructed, and cleared via `EpochSet::rekey = None` on abandon) but - /// not yet read here. - #[expect( - dead_code, - reason = "read by rekey completion (initiator read_response) in Task 4" - )] + /// 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, @@ -64,13 +59,6 @@ pub struct EpochSet { pub(crate) rekey: Option, } -#[cfg_attr( - not(test), - expect( - dead_code, - reason = "driven by PeerManager once Task 2/3 wires EpochSet in" - ) -)] impl EpochSet { pub fn new(current: Box, now_ms: u64) -> Self { Self { diff --git a/bin/yipd/src/peer_manager.rs b/bin/yipd/src/peer_manager.rs index aa8bac5..ddc1199 100644 --- a/bin/yipd/src/peer_manager.rs +++ b/bin/yipd/src/peer_manager.rs @@ -80,7 +80,7 @@ use yip_rendezvous::{node_id, NodeId}; use crate::addr::node_addr; use crate::config::PeerConfig; use crate::dataplane::{conn_tag_from_keys, DataPlane}; -use crate::handshake::{HandshakeState, PacketType}; +use crate::handshake::{Established, HandshakeState, PacketType}; use crate::membership::Membership; use crate::mode::TunnelMode; use crate::path::{PathAction, PathKind, PathStage, PathState}; @@ -265,11 +265,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). @@ -1412,28 +1427,19 @@ 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. + PeerState::Established(_) => { + self.handle_rekey_init(idx, src, established, resp_pkt, now_ms) + } // 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 @@ -1495,15 +1501,100 @@ impl PeerManager { } } - /// 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. + /// + /// - `!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 — + /// i.e. we were the cold-start responder and this is a duplicate/lost- + /// reply retransmit of the ORIGINAL handshake; 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. + /// - Otherwise: a genuine rekey `Init`. Install `established` as the + /// responder's unconfirmed `next` epoch (`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, + ) -> DispatchOut<'_> { + let PeerState::Established(epochs) = &mut self.peers[idx].state else { + unreachable!("handle_rekey_init is only called for an Established peer") + }; + 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); + + 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() @@ -1577,6 +1668,104 @@ 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. + /// + /// 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 @@ -2940,6 +3129,226 @@ mod tests { ); } + // ── 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`"), + } + } + + #[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 From f6408e4afbd2e4e1443d121d715caf1169f7dddb Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Sun, 19 Jul 2026 00:46:39 -0400 Subject: [PATCH 09/21] fix(rekey.9a): don't schedule rekey for relay peers (completion is direct-only; avoid never-completing churn) --- bin/yipd/src/peer_manager.rs | 101 +++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/bin/yipd/src/peer_manager.rs b/bin/yipd/src/peer_manager.rs index ddc1199..8dd8c1e 100644 --- a/bin/yipd/src/peer_manager.rs +++ b/bin/yipd/src/peer_manager.rs @@ -738,6 +738,21 @@ impl PeerManager { 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 @@ -2994,6 +3009,24 @@ mod tests { .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 @@ -3036,6 +3069,74 @@ mod tests { ); } + /// 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 From e52fc9005dd741cae9f0f6d9e641b890613b50ed Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Sun, 19 Jul 2026 01:18:46 -0400 Subject: [PATCH 10/21] =?UTF-8?q?test(rekey.9a):=20netns=20money=20test=20?= =?UTF-8?q?=E2=80=94=20loss-free=20rotation=20+=20on-wire=20conn=5Ftag=20r?= =?UTF-8?q?otation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run-netns-rekey.sh forks run-netns-tunnel.sh: two yipd peers held to YIP_REKEY_INTERVAL_MS=2000 (~10 rotations over a ~20s ping -i 0.2 -c 100 stream), run under both the poll and YIP_USE_URING=1 drivers, asserting (1) rekey_continuity: <=1% ping loss across every rotation, and (2) conn_tag rotation: >=3 distinct completed Noise-IK rekey rounds observed in a tcpdump capture of the veth (obf off, so the PacketType prefixes are visible). Assertion 2 went through two failed designs before landing on a sound one, documented in both the script and bin/yipd/examples/rekey_epoch_witness.rs: - Diffing the raw masked dg[1..9] header bytes is vacuous: yip-wire's Codec re-seeds the header mask from each frame's own auth tag, so the visible bytes differ on every Data datagram regardless of epoch (verified empirically) -- reported as RAW_DISTINCT_HEADER_PREFIXES, non-gating. - Replaying captured [HandshakeInit] through a fresh responder-role Handshake to re-derive the real (auth_key, hp_key, conn_tag) is cryptographically unsound, not just impractical: Noise_IK's responder draws its own fresh ephemeral while writing message 2, so a local replay computes an unrelated session every time. Failed 100% of the time in testing, exactly as predicted once the mechanism is understood -- this is Noise's forward secrecy working as intended. - rekey_epoch_witness instead counts DISTINCT CLEARTEXT Noise-IK ephemeral public keys (the first message token of both HandshakeInit and HandshakeResp is sent unencrypted, by spec) across the capture. N distinct ephemerals is direct on-wire evidence of N independently completed handshake rounds, and since conn_tag is a deterministic function of the channel binding that mixes in both ephemerals' DH product, N distinct rounds implies N distinct conn_tags with cryptographic-strength probability -- without ever needing to (or being able to) recover what those conn_tag values actually are. Confirmed both poll and uring runs are real: strace on a throwaway netns process shows genuine io_uring_enter syscalls under YIP_USE_URING=1. Confirmed no regression on run-netns-tunnel{,-loss}.sh / run-netns-triangle.sh at the production (unset) rekey interval. CI: wires both driver runs into the existing netns-tunnel-test job alongside the sibling netns steps. Deferred: a rekey_under_loss (netem) variant, per the task brief's "include it if it's a clean addition, else note it deferred" — the continuity + rotation proof above already required significant redesign work; adding combined netem+rekey is left for a follow-up. Pre-commit `cargo test` blocked only by the known yip-io::uring flake (uring_in_flight_send_table_reuses_slots_after_completions / uring_loopback_roundtrip_recycles_recv_buffers, both timing-sensitive and unrelated to this change — this diff touches no files under crates/yip-io). fmt and clippy both passed. --no-verify used per that carve-out. --- .github/workflows/integration.yml | 39 +++ bin/yipd/examples/rekey_epoch_witness.rs | 251 +++++++++++++++++ bin/yipd/tests/run-netns-rekey.sh | 344 +++++++++++++++++++++++ 3 files changed, 634 insertions(+) create mode 100644 bin/yipd/examples/rekey_epoch_witness.rs create mode 100755 bin/yipd/tests/run-netns-rekey.sh 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..a5a9335 --- /dev/null +++ b/bin/yipd/examples/rekey_epoch_witness.rs @@ -0,0 +1,251 @@ +#![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 { + assert!(data.len() >= 24, "pcap file too short for a global header"); + 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 => panic!("unrecognized pcap magic: {magic:02x?}"), + }; + 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| panic!("read {pcap_path}: {e}")); + 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/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" From 92ce0645bb9769bc48790548fd7d587678df90bd Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Sun, 19 Jul 2026 01:30:36 -0400 Subject: [PATCH 11/21] test(rekey.9a): witness tool errors cleanly on malformed pcap instead of panicking (review) --- bin/yipd/examples/rekey_epoch_witness.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/bin/yipd/examples/rekey_epoch_witness.rs b/bin/yipd/examples/rekey_epoch_witness.rs index a5a9335..06a0b16 100644 --- a/bin/yipd/examples/rekey_epoch_witness.rs +++ b/bin/yipd/examples/rekey_epoch_witness.rs @@ -116,11 +116,17 @@ fn read_u32(b: &[u8], le: bool) -> u32 { /// 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 { - assert!(data.len() >= 24, "pcap file too short for a global header"); + 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 => panic!("unrecognized pcap magic: {magic:02x?}"), + magic => { + eprintln!("rekey_epoch_witness: unrecognized pcap magic: {magic:02x?}"); + std::process::exit(1); + } }; let mut out = Vec::new(); let mut off = 24usize; @@ -190,7 +196,10 @@ fn main() { } let pcap_path = &args[1]; - let data = fs::read(pcap_path).unwrap_or_else(|e| panic!("read {pcap_path}: {e}")); + 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(); From 9367363900769145f4423720236b34d1ae6d05b7 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Sun, 19 Jul 2026 02:03:12 -0400 Subject: [PATCH 12/21] fix(rekey.9a): idempotent responder rekey reply (converge on one session under Init retransmit/Resp reorder) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical (9a final review): handle_rekey_init minted a FRESH session (fresh start_responder + install_next) on every rekey Init it saw, including retransmits, discarding the previous `next`. The initiator locks onto the FIRST HandshakeResp it reads, so on high-RTT or reordering links (RTT > retry_ms, or a reordered/duplicated Resp) the two sides diverged onto different epochs and the tunnel black-holed. Fix: EpochSet::next now carries the initiator's Noise ephemeral (init_eph, the unencrypted `e` in msg1) alongside the cached Resp bytes (NextEpoch). A retransmitted rekey Init (same ephemeral) is answered idempotently — resend the cached Resp, never rebuild `next`. A genuinely new round (new ephemeral) still builds a fresh `next`. Important-2: HANDSHAKE_TOTAL_MS (90s) exceeds REKEY_INTERVAL_MS/2 (60s), so a very-late retransmit of the ORIGINAL cold-start Init could land past accept_rekey_init's age gate. Peer::cached_resp_init_eph (set alongside cached_resp) lets handle_rekey_init recognize that case first and resend the cold-start reply instead of misclassifying it as a rekey round. Important-1: handle_rekey_resp still abandons an in-flight rekey on any Resp that fails read_response (rekey-liveness DoS only, current is never touched). snow::HandshakeState is not Clone (owns Box/Box), so the clean clone-and-try fix isn't available; documented as a known limitation instead, riding with #34's authenticated-endpoint work. Minor: dropped the dead `if relay {...}` arms in drive_rekey_schedule that were unreachable after its early `if relay { return; }`. New tests: idempotent-retransmit unit test, an end-to-end Init-retransmit-before-Resp convergence test, a cold-start-dedup regression, and an init_ephemeral helper test. --- bin/yipd/src/epoch.rs | 90 +++++++- bin/yipd/src/handshake.rs | 41 ++++ bin/yipd/src/peer_manager.rs | 396 ++++++++++++++++++++++++++++++----- 3 files changed, 466 insertions(+), 61 deletions(-) diff --git a/bin/yipd/src/epoch.rs b/bin/yipd/src/epoch.rs index 409f3b7..bc17949 100644 --- a/bin/yipd/src/epoch.rs +++ b/bin/yipd/src/epoch.rs @@ -50,10 +50,28 @@ pub enum EpochInbound { 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) next: Option, pub(crate) previous: Option>, pub(crate) previous_retire_ms: u64, pub(crate) rekey: Option, @@ -110,12 +128,13 @@ impl EpochSet { 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); + 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); @@ -142,8 +161,34 @@ impl EpochSet { /// 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); + /// 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) { @@ -329,7 +374,7 @@ mod tests { let (mut peer_new, us_new) = epoch_pair(); let new_conn_tag = us_new.conn_tag(); - set.install_next(us_new); + 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()); @@ -360,6 +405,39 @@ mod tests { } } + #[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(); @@ -367,7 +445,7 @@ mod tests { let mut set = EpochSet::new(us_old, 0); let (_peer_new, us_new) = epoch_pair(); - set.install_next(us_new); + 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 diff --git a/bin/yipd/src/handshake.rs b/bin/yipd/src/handshake.rs index ee5d4c9..d42ef4b 100644 --- a/bin/yipd/src/handshake.rs +++ b/bin/yipd/src/handshake.rs @@ -201,6 +201,24 @@ 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. +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 +394,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/peer_manager.rs b/bin/yipd/src/peer_manager.rs index 8dd8c1e..8054fe7 100644 --- a/bin/yipd/src/peer_manager.rs +++ b/bin/yipd/src/peer_manager.rs @@ -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, @@ -440,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, @@ -525,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, @@ -765,16 +781,14 @@ impl PeerManager { if !epochs.needs_rekey(now_ms, is_glare_winner, self.rekey_interval_ms) { return; } - let target = if relay { - self.server_addr() - } else { - 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, - } + // `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 @@ -790,20 +804,10 @@ impl PeerManager { return; } }; - let dg = if relay { - self.relay_wrap(idx, init_pkt.clone()) - } else { - Some(EgressDatagram { - fate: 0, - dst: target, - bytes: init_pkt.clone(), - }) - }; - let Some(dg) = dg else { - // No rendezvous configured for a relay peer (should not - // happen for an already-Established relay session): drop - // this attempt, `current` stays untouched, retried next tick. - 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) @@ -849,14 +853,12 @@ impl PeerManager { .expect("checked is_some above") .init_pkt .clone(); - let dg = if relay { - self.relay_wrap(idx, pkt) - } else { - Some(EgressDatagram { - fate: 0, - dst: target, - bytes: pkt, - }) + // `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) @@ -867,9 +869,7 @@ impl PeerManager { rk.last_sent_ms = now_ms; rk.retry_ms = new_retry_ms; } - if let Some(dg) = dg { - self.tick_egress.push(dg); - } + self.tick_egress.push(dg); } /// Emit a `lookup(peer_node)` for peer `idx`, debounced to at most one per @@ -1038,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); @@ -1452,8 +1453,17 @@ impl PeerManager { // `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(_) => { - self.handle_rekey_init(idx, src, established, resp_pkt, now_ms) + 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 @@ -1483,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 @@ -1521,25 +1532,44 @@ impl PeerManager { /// 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. + /// 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: /// - /// - `!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 — - /// i.e. we were the cold-start responder and this is a duplicate/lost- - /// reply retransmit of the ORIGINAL handshake; 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. - /// - Otherwise: a genuine rekey `Init`. Install `established` as the - /// responder's unconfirmed `next` epoch (`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`. + /// 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, @@ -1547,10 +1577,37 @@ impl PeerManager { 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) => { @@ -1581,7 +1638,7 @@ impl PeerManager { let PeerState::Established(epochs) = &mut self.peers[idx].state else { unreachable!("state cannot change between the two borrows above") }; - epochs.install_next(dp); + epochs.install_next(dp, init_eph, resp_pkt.clone()); self.egress.clear(); self.egress.push(EgressDatagram { @@ -1694,6 +1751,28 @@ impl PeerManager { /// 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 @@ -3396,6 +3475,155 @@ mod tests { } } + /// `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); @@ -3566,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 From 8f522094cb52f0a6acb0136971764aa62bc00752 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Sun, 19 Jul 2026 02:09:21 -0400 Subject: [PATCH 13/21] docs(rekey.9a): lockstep note on init_ephemeral offset vs HandshakeInit framing (review) --- bin/yipd/src/handshake.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/bin/yipd/src/handshake.rs b/bin/yipd/src/handshake.rs index d42ef4b..2ca6f5c 100644 --- a/bin/yipd/src/handshake.rs +++ b/bin/yipd/src/handshake.rs @@ -215,6 +215,12 @@ pub fn run_responder( /// 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() } From 75e1fe1fadee45ea921cad12f3275e9ff0fae2ff Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Sun, 19 Jul 2026 02:58:32 -0400 Subject: [PATCH 14/21] =?UTF-8?q?docs(rekey.91):=20design=20spec=20?= =?UTF-8?q?=E2=80=94=20relay-path=20session=20rekey=20completion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract rekey_init_core/rekey_resp_core parameterized by RekeyEgress{Direct,Relay} (single-source the 9a idempotent-ephemeral Critical fix); wire relayed_handshake_ init/resp to them; remove drive_rekey_schedule relay gate + restore relay-wrap Init emit. Fail-closed; idempotency covers relay reordering. netns relay-forced money test. Stacks on 9a (PR #90). Issue #91. --- ...026-07-19-relay-rekey-completion-design.md | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-19-relay-rekey-completion-design.md diff --git a/docs/superpowers/specs/2026-07-19-relay-rekey-completion-design.md b/docs/superpowers/specs/2026-07-19-relay-rekey-completion-design.md new file mode 100644 index 0000000..6c3fd5c --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-relay-rekey-completion-design.md @@ -0,0 +1,83 @@ +# Relay-path session rekey completion (#91) — design spec + +**Date:** 2026-07-19 +**Status:** design (pending user review) +**Issue:** #91 (a milestone 9a follow-up). +**Depends on / stacks on:** 9a (PR #90, branch `feat/rekey-9a-session-rotation`, unmerged) — the epoch/rekey machinery + the idempotent-ephemeral Critical fix. +**Scope:** `bin/yipd` (`peer_manager.rs`). No wire-format change; `yip-crypto`/`yip-wire`/`handshake.rs` unchanged (except reusing 9a's `handshake::init_ephemeral`). + +## Goal + +9a rotates each peer's Noise-IK session ~every 120s for forward secrecy, but **relay-reached peers are gated out** of rekey scheduling: `drive_rekey_schedule` returns early on `relay`, because rekey *completion* was only wired into the direct handshake handlers, never the relay ones. #91 wires relay-path rekey completion so **relay-only sessions also rotate**, then removes the gate — reusing the direct path's security-critical idempotent logic rather than duplicating it. + +## Background (current state, post-9a) + +- **Direct rekey completion exists:** `handle_rekey_init` (an Established peer receiving a `[HandshakeInit]`: `cached_resp_init_eph` cold-start dedup → `next_cached_resp_for` rekey-retransmit dedup → `accept_rekey_init` gate → build new-epoch `DataPlane` + `install_next` + emit the Resp) and `handle_rekey_resp` (take the `RekeyInFlight` out → `read_response` → `promote_from_rekey` → update the `conn_tag → peer` map → prime the new epoch). Both emit datagrams **directly** (`EgressDatagram { dst: src, bytes }`) and build the new `DataPlane` with `peer_addr = src`. +- **Relay handlers do NOT rekey:** `relayed_handshake_init`'s `Established` arm just resends `cached_resp` (relay-wrapped) for any relayed Init; `relayed_handshake_resp` drops anything against a non-`Handshaking` peer. +- **The schedule gate:** `drive_rekey_schedule` has `if relay { return; }` at the top, and its Init emit/retransmit is direct-only (`EgressDatagram { dst: endpoint }`) — the 9a Critical fix deleted the (then-dead) relay-wrap branches once the gate made them unreachable. +- Helpers: `relay_wrap(idx, raw) -> Option` (relay egress; `None` if no rendezvous), `server_addr() -> SocketAddr` (the relay placeholder used as a relay `DataPlane`'s `peer_addr`). + +## Design + +### 1. Extract the rekey-completion cores, parameterized by an egress mode + +The rekey-init and rekey-resp logic is **byte-identical** between direct and relay except (a) how a datagram is emitted and (b) the new `DataPlane`'s `peer_addr`. Capture that difference in one small enum and extract two shared helpers: + +```rust +/// How a rekey handler addresses its egress + the new epoch's DataPlane. +enum RekeyEgress { + /// Direct peer: emit straight to `addr`; the new DataPlane's peer_addr = addr. + Direct(SocketAddr), + /// Relay-reached peer: emit via `relay_wrap`; the new DataPlane's peer_addr = + /// `server_addr()` (a placeholder — relay egress is always re-wrapped, so it + /// is never used as a real dst). + Relay, +} +``` + +- `rekey_init_core(&mut self, idx, established, resp_pkt, init_eph, now_ms, egress: RekeyEgress) -> DispatchOut<'_>` — the current `handle_rekey_init` body, with every datagram emit routed through a small `emit(&mut self, idx, bytes, &egress) -> DispatchOut` that produces either `EgressDatagram { dst: addr, bytes }` (Direct) or `relay_wrap(idx, bytes)` (Relay; `None` ⇒ `DispatchOut::None`, a clean no-op), and the new `DataPlane`'s `peer_addr` taken from `egress` (`addr` vs `server_addr()`). +- `rekey_resp_core(&mut self, idx, dg, now_ms, egress: RekeyEgress) -> DispatchOut<'_>` — the current `handle_rekey_resp` body: take the `RekeyInFlight` out, `read_response`, build the new `DataPlane` (`peer_addr` from `egress`), `promote_from_rekey`, update the `conn_tag → peer` map, and prime the new epoch by emitting one frame through `egress`. + +`handle_rekey_init`/`handle_rekey_resp` become thin wrappers passing `RekeyEgress::Direct(src)`. This keeps the **idempotent-ephemeral convergence guarantee (the 9a Critical fix) in exactly one place** — important because relay paths reorder more, making that idempotency load-bearing for relay. + +### 2. Wire the relay handlers to the cores + +- **`relayed_handshake_init`, `Established` arm:** it already runs `start_responder` → `(established, resp_pkt)`. Instead of blindly resending `cached_resp`, compute `init_eph = handshake::init_ephemeral(dg)` and call `rekey_init_core(idx, established, resp_pkt, init_eph, now_ms, RekeyEgress::Relay)`. The core's cold-start-`cached_resp` dedup + rekey-retransmit dedup + `accept_rekey_init` gate subsume the old unconditional-resend behavior (a cold-start Init retransmit still resends `cached_resp`; a genuine rekey Init installs `next`). +- **`relayed_handshake_resp`:** add an `Established`-with-`rekey`-in-flight branch that calls `rekey_resp_core(idx, dg, now_ms, RekeyEgress::Relay)`; the existing non-`Handshaking` drop stays for the no-rekey case. + +### 3. Remove the schedule gate + restore the relay Init emit + +In `drive_rekey_schedule`: delete `if relay { return; }`. For the Init send and each retransmit, emit via `relay_wrap(idx, init_pkt)` when `relay`, else the existing direct `EgressDatagram { dst: endpoint }` — the relay-wrap branches the 9a fix removed, re-added now that they're reachable. A `relay_wrap` `None` just skips that send (retransmits next tick). Same obf/jitter as the direct Init (no new fingerprint). Glare-winner tiebreak (`local_pub < peer.pubkey`), one-in-flight, loser-fallback, and give-up are unchanged — they already apply to all Established peers. + +## Error handling (fail-closed — the 9a invariants hold for relay too) + +- A relay rekey that can't emit (`relay_wrap` `None`, no rendezvous), whose `read_response`/admission fails, or that times out → **no-op on the live session** (keep `current`; retransmit/abandon). Rekey never tears down a working relay session. +- **Idempotency covers relay reordering:** a reordered/duplicate relay rekey Init resends the cached Resp (via `next_cached_resp_for`) rather than minting a second `next`, so the two peers converge on one session — exactly the 9a Critical-fix guarantee, now exercised on the higher-reordering relay path. +- **Residual (not reopened here):** a spoofed/stray relay `[HandshakeResp]` can abandon an in-flight rekey (rekey-liveness only — `current` untouched, session survives, rotation delayed). Same **Important-1** as the direct path; rides with #34 (authenticated endpoint). +- No panic on the connection path; the relay `DataPlane`'s `server_addr()` placeholder is never used as a real dst (relay egress is always `relay_wrap`ped), matching the cold-start relay path. + +## Testing / adversary + +- **Unit:** drive `rekey_init_core`/`rekey_resp_core` through `RekeyEgress::Relay`: (a) a relay rekey Init retransmit (identical ephemeral) resends the cached Resp and does NOT build a second `next` (idempotent); a new ephemeral builds a new `next`. (b) a relay rekey Resp completes → `promote_from_rekey` (new epoch, old→previous, `conn_tag` map updated). (c) a `relay_wrap`-`None` emit is a clean no-op (no state change). (d) the direct-path wrappers still pass `RekeyEgress::Direct` — existing 9a direct rekey tests stay green unchanged (behavior-preserving extraction). +- **netns money test (the gate):** a **relay-forced** topology — block the direct + hole-punch paths so the two peers *must* carry traffic over the rendezvous relay — with `YIP_REKEY_INTERVAL_MS` low and a steady stream: assert **0% (≤1%) packet loss across ~10 rotations over the relay**, and the `rekey_epoch_witness` tool confirms distinct rekey rounds on the **relayed** wire. Both the poll and `YIP_USE_URING=1` drivers; CI-wired. Fork the existing relay netns test (`run-netns-reality-relay.sh`/`run-netns-relay*.sh`) + the 9a `run-netns-rekey.sh`. +- **Regression:** existing 9a direct rekey tests + the 2a/2b/2c relay/triangle/discovery netns tests stay green. + +## Risks + +- **Extraction refactor touches the security-critical direct rekey path.** Mitigation: the extraction is behavior-preserving for `Direct` (the wrappers pass `Direct(src)`); the full existing 9a direct-rekey unit + netns suite is the regression net, and the shared core keeps the idempotent-convergence logic single-sourced (a duplicate would risk the relay copy drifting from the Critical fix). +- **Relay reordering** is worse than direct, so a rekey round may take longer to converge; the ephemeral-keyed idempotency handles it (no divergence), and the loser-fallback/retransmit cover a lost relay Init/Resp. +- **`relay_wrap` `None`** (rendezvous temporarily unavailable) silently skips a rekey send; acceptable — the session keeps running on `current` and rekey retries when the relay returns. + +## Non-goals + +- Important-1 rekey-liveness hardening (spoofed-Resp) — #34. +- No wire-format change; no `yip-crypto`/`yip-wire` change. +- PQ-hybrid handshake — 9b. + +## Success criteria + +1. Relay-reached Established peers schedule + COMPLETE a mid-session rekey (Init via `relay_wrap`, `rekey_init_core`/`rekey_resp_core` through `RekeyEgress::Relay`), rotating their session with the same confirmed-switch + idempotency as direct peers; the `drive_rekey_schedule` relay gate is removed. +2. The rekey-completion logic is single-sourced (shared cores), so the idempotent-convergence guarantee is not duplicated; direct-path behavior is unchanged (existing 9a direct tests green). +3. Fail-closed: a relay rekey that can't emit/complete/times-out is a no-op on the live session; relay reordering does not diverge the peers (idempotency). +4. netns relay-forced money test: 0% loss across ~10 rotations over the relay, distinct rekey rounds on the relayed wire, both drivers. +5. `forbid-unsafe`; no `as` (except the pre-existing `PacketType::X as u8` idiom); no bare `#[allow]`; clippy clean. From cd818238f2a75fb4a1028cec8444bad98f831447 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Sun, 19 Jul 2026 03:06:07 -0400 Subject: [PATCH 15/21] docs(rekey.91): implementation plan (4 tasks, subagent-driven) Extract rekey_init_core/rekey_resp_core (via_relay bool) behavior-preserving / wire relayed_handshake_init/resp to them / remove drive_rekey_schedule gate + relay-wrap Init emit / netns relay-forced money test. Single-sourced idempotent convergence; fail-closed relay_wrap-None no-op. --- .../2026-07-19-relay-rekey-completion.md | 377 ++++++++++++++++++ 1 file changed, 377 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-19-relay-rekey-completion.md diff --git a/docs/superpowers/plans/2026-07-19-relay-rekey-completion.md b/docs/superpowers/plans/2026-07-19-relay-rekey-completion.md new file mode 100644 index 0000000..95081ab --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-relay-rekey-completion.md @@ -0,0 +1,377 @@ +# Relay-path session rekey completion (#91) — 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:** Let relay-reached peers complete a mid-session rekey (so relay-only sessions get forward-secrecy rotation), by sharing the direct path's rekey-completion cores and un-gating relay scheduling. + +**Architecture:** Extract the existing `handle_rekey_init`/`handle_rekey_resp` bodies into shared `rekey_init_core`/`rekey_resp_core` helpers parameterized by a `via_relay: bool` (the only divergence is the datagram emit — `EgressDatagram{dst}` vs `relay_wrap` — and the new `DataPlane`'s `peer_addr`). Wire the relay handlers to those cores, and remove the `drive_rekey_schedule` relay gate (restoring relay-wrapped Init sends). The 9a idempotent-ephemeral convergence logic stays single-sourced. + +**Tech Stack:** Rust, `bin/yipd` (`peer_manager.rs`), Noise-IK (unchanged), netns integration tests. + +## Global Constraints + +- `#![forbid(unsafe_code)]` — NO `unsafe`, NO `as` casts (except the pre-existing pervasive `PacketType::X as u8` enum-discriminant idiom), NO bare `#[allow]` (use `#[expect(reason = "...")]`). +- Reuse `yip-crypto` / `yip-wire` / `bin/yipd/src/handshake.rs` UNCHANGED (only `handshake::init_ephemeral` is reused). NO wire-format change. +- **Fail-closed:** a relay rekey that can't emit (`relay_wrap` `None`), fails `read_response`/admission, or times out is a **NO-OP on the live session** (keep `current`). The 9a ephemeral-keyed idempotency (cold-start `cached_resp_init_eph` dedup, `next_cached_resp_for` rekey-retransmit dedup, `accept_rekey_init` gate) MUST stay intact and single-sourced inside `rekey_init_core` — it is what keeps relay reordering from diverging the peers. +- Out of scope (do NOT touch): Important-1 spoofed-Resp hardening (rides #34); PQ-hybrid (9b). +- Every task: `cargo build -p yipd`, `cargo clippy -p yipd --all-targets -- -D warnings`, `cargo fmt` (**run fmt — do not `--no-verify` unformatted code**). Netns verifies BOTH the poll and `YIP_USE_URING=1` drivers; netns uses the **RELEASE** yipd (rebuild release after yipd changes). +- Branch `feat/rekey-9a-relay-completion` stacks on 9a (PR #90). Leave the PR for the user; do NOT merge; no "not merging" line. +- **Known pre-existing flake:** 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: Extract the shared rekey-completion cores (behavior-preserving) + +Pull the `handle_rekey_init`/`handle_rekey_resp` bodies into `rekey_init_core`/`rekey_resp_core` parameterized by `via_relay: bool`, with a single egress helper. The Direct path stays byte-identical. + +**Files:** +- Modify: `bin/yipd/src/peer_manager.rs` (`handle_rekey_init` ~1573, `handle_rekey_resp` ~1797; add the two cores + one egress helper) + +**Interfaces:** +- Produces: + - `fn push_rekey_egress(&mut self, idx: usize, bytes: Vec, via_relay: bool, direct_dst: SocketAddr)` — pushes ONE egress datagram into `self.egress`: `via_relay` ⇒ `if let Some(d) = self.relay_wrap(idx, bytes) { self.egress.push(d) }` (a `None` is a clean skip); else ⇒ `self.egress.push(EgressDatagram { fate: 0, dst: direct_dst, bytes })`. + - `fn rekey_init_core(&mut self, idx: usize, established: Established, resp_pkt: Vec, init_eph: [u8; 32], now_ms: u64, direct_src: SocketAddr, via_relay: bool) -> DispatchOut<'_>` + - `fn rekey_resp_core(&mut self, idx: usize, dg: &[u8], now_ms: u64, via_relay: bool) -> DispatchOut<'_>` + +- [ ] **Step 1: Add the egress helper** + +Add to `impl PeerManager` (near `relay_wrap`): + +```rust +/// Emit one rekey-related datagram into `self.egress`: relay-wrapped when +/// `via_relay` (a `relay_wrap` `None` is a clean skip — the rekey just +/// retries), else addressed directly to `direct_dst`. Used by the rekey +/// cores so the Direct/Relay split lives in exactly one place. +fn push_rekey_egress(&mut self, idx: usize, bytes: Vec, via_relay: bool, direct_dst: SocketAddr) { + if via_relay { + if let Some(d) = self.relay_wrap(idx, bytes) { + self.egress.push(d); + } + } else { + self.egress.push(EgressDatagram { + fate: 0, + dst: direct_dst, + bytes, + }); + } +} +``` + +- [ ] **Step 2: Extract `rekey_init_core`** + +Rename `handle_rekey_init`'s body into `rekey_init_core` with the new signature, replacing (a) the new `DataPlane`'s `peer_addr` and (b) every direct `self.egress.push(EgressDatagram{ dst: src, .. })` with the helper. `peer_addr = if via_relay { self.server_addr() } else { direct_src }`. The three dedup/gate resend paths and the install-and-emit path each become `self.egress.clear(); self.push_rekey_egress(idx, , via_relay, direct_src); DispatchOut::Udp(&self.egress)` (guard `Udp` with a non-empty check so a relay `None` skip returns `DispatchOut::None`). The idempotent logic (`cached_resp_init_eph`, `next_cached_resp_for`, `accept_rekey_init`) is UNCHANGED. Concretely the shape becomes: + +```rust +fn rekey_init_core( + &mut self, + idx: usize, + established: Established, + resp_pkt: Vec, + init_eph: [u8; 32], + now_ms: u64, + direct_src: SocketAddr, + via_relay: bool, +) -> DispatchOut<'_> { + let peer_addr = if via_relay { self.server_addr() } else { direct_src }; + + // Cold-start retransmit dedup (unchanged logic; emit via helper). + if self.peers[idx].cached_resp_init_eph == Some(init_eph) { + self.egress.clear(); + if let Some(resp) = self.peers[idx].cached_resp.clone() { + self.push_rekey_egress(idx, resp, via_relay, peer_addr); + } + return if self.egress.is_empty() { DispatchOut::None } else { DispatchOut::Udp(&self.egress) }; + } + + let PeerState::Established(epochs) = &mut self.peers[idx].state else { + unreachable!("rekey_init_core is only called for an Established peer") + }; + + // Rekey-retransmit dedup (same round → resend cached resp, no second `next`). + if let Some(cached) = epochs.next_cached_resp_for(&init_eph).map(<[u8]>::to_vec) { + self.egress.clear(); + self.push_rekey_egress(idx, cached, via_relay, peer_addr); + return if self.egress.is_empty() { DispatchOut::None } else { DispatchOut::Udp(&self.egress) }; + } + + // Too-fresh: fall back to resending the cold-start cached resp. + let PeerState::Established(epochs) = &self.peers[idx].state else { unreachable!() }; + if !epochs.accept_rekey_init(now_ms, self.rekey_interval_ms) { + self.egress.clear(); + if let Some(resp) = self.peers[idx].cached_resp.clone() { + self.push_rekey_egress(idx, resp, via_relay, peer_addr); + } + return if self.egress.is_empty() { DispatchOut::None } else { DispatchOut::Udp(&self.egress) }; + } + + // Genuine new rekey round: build + install `next`, emit the 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, peer_addr, self.obf_key.is_some(), self.data_symbol_size, + )); + let PeerState::Established(epochs) = &mut self.peers[idx].state else { unreachable!() }; + epochs.install_next(dp, init_eph, resp_pkt.clone()); + + self.egress.clear(); + self.push_rekey_egress(idx, resp_pkt, via_relay, peer_addr); + if self.egress.is_empty() { DispatchOut::None } else { DispatchOut::Udp(&self.egress) } +} +``` + +(Match the EXACT current borrow-juggling of `handle_rekey_init` — the multiple `let PeerState::Established(epochs)` re-borrows are already how it threads `&mut self` around `self.egress`/`self.peers`. Keep them; only the emit + `peer_addr` change.) + +- [ ] **Step 3: Extract `rekey_resp_core`** + +Rename `handle_rekey_resp`'s body into `rekey_resp_core(idx, dg, now_ms, via_relay)`. Two changes only: `peer_addr = if via_relay { self.server_addr() } else { rk.target }`; and the prime-emit loop pushes each `dp.on_tun_packet(..)` datagram via the helper (`via_relay` ⇒ `relay_wrap` its `.bytes`; else push as-is since its `dst` is already `peer_addr = rk.target`). Everything else (`epochs.rekey.take()`, `read_response`, `responder_cert_ok`, `promote_from_rekey`, `by_tag.remove(old)/insert(new)`) is UNCHANGED: + +```rust +fn rekey_resp_core(&mut self, idx: usize, dg: &[u8], now_ms: u64, via_relay: bool) -> DispatchOut<'_> { + let rk = { + let PeerState::Established(epochs) = &mut self.peers[idx].state else { + unreachable!("rekey_resp_core is only called for an Established peer") + }; + match epochs.rekey.take() { Some(rk) => rk, None => return DispatchOut::None } + }; + let peer_addr = if via_relay { self.server_addr() } else { 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 (BEFORE dp moves into promote_from_rekey), emitting via the helper. + let pending = std::mem::take(&mut self.peers[idx].pending_tun); + self.egress.clear(); + let primed: Vec> = if pending.is_empty() { + dp.on_tun_packet(&[], now_ms).iter().map(|d| d.bytes.clone()).collect() + } else { + pending.iter().flat_map(|inner| dp.on_tun_packet(inner, now_ms).iter().map(|d| d.bytes.clone()).collect::>()).collect() + }; + for bytes in primed { + self.push_rekey_egress(idx, bytes, via_relay, peer_addr); + } + + let old_tag = { + let PeerState::Established(epochs) = &mut self.peers[idx].state else { unreachable!() }; + 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) } +} +``` + +- [ ] **Step 4: Make the direct handlers thin wrappers** + +Replace `handle_rekey_init`'s body with `self.rekey_init_core(idx, established, resp_pkt, init_eph, now_ms, src, false)` (keep its existing signature/params). Replace `handle_rekey_resp`'s body with `self.rekey_resp_core(idx, dg, now_ms, false)`. + +- [ ] **Step 5: Build + run the full suite (behavior-preserving gate)** + +Run: `cargo build -p yipd` then `cargo test -p yipd` +Expected: PASS — every existing 9a direct-rekey unit test + all 2a/2b/2c tests stay green. The Direct path (`via_relay = false`) is byte-identical: `peer_addr = direct_src` and `push_rekey_egress(false)` = the old `EgressDatagram{ dst: src }`. If a test broke, the extraction changed Direct behavior — fix the extraction, not the test. + +- [ ] **Step 6: 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.91): extract rekey_init_core/rekey_resp_core (via_relay) — Direct path unchanged" +``` + +--- + +### Task 2: Wire the relay handlers to the cores + +**Files:** +- Modify: `bin/yipd/src/peer_manager.rs` (`relayed_handshake_init` Established arm ~1011, `relayed_handshake_resp` ~1074; add tests) + +**Interfaces:** +- Consumes: `rekey_init_core`/`rekey_resp_core` (Task 1); `handshake::init_ephemeral`. + +- [ ] **Step 1: Write the failing tests** + +Add to `peer_manager.rs` tests (drive real relayed handshake packets, mirroring the existing `handle_rekey_init`/`resp` tests but through the relay handlers — use a relay-Established peer, `relay = true`): + +```rust +#[test] +fn relay_rekey_init_retransmit_is_idempotent_new_ephemeral_builds_new_next() { + // A relay-Established responder receives a rekey Init → builds `next`, sends a + // relay-wrapped Resp. A RETRANSMIT (identical bytes → identical ephemeral) + // resends the SAME cached Resp and does NOT replace `next` (conn_tag of `next` + // unchanged). A NEW ephemeral (a genuinely new round) DOES build a new `next`. +} + +#[test] +fn relay_rekey_resp_completes_and_promotes() { + // A relay-Established peer with a rekey in flight receives the matching + // relayed [HandshakeResp] → current becomes the new epoch (new conn_tag), + // previous == old, rekey == None, by_tag updated. +} + +#[test] +fn relay_rekey_emit_is_noop_when_relay_wrap_returns_none() { + // With no rendezvous configured (relay_wrap → None), a relay rekey Init at an + // Established peer produces no egress and does not corrupt state (current intact). +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cargo test -p yipd --lib peer_manager::tests::relay_rekey` +Expected: FAIL — the relay handlers don't route to the cores yet. + +- [ ] **Step 3: Wire `relayed_handshake_init`** + +In its `Established` arm, replace the unconditional `cached_resp` resend with the core. After it computes `(established, resp_pkt)` from `start_responder`, add `let init_eph = crate::handshake::init_ephemeral(dg);` and call the core: + +```rust +PeerState::Established(_) => { + let Some(init_eph) = crate::handshake::init_ephemeral(dg) else { + return DispatchOut::None; // malformed Init + }; + self.rekey_init_core(idx, established, resp_pkt, init_eph, now_ms, self.server_addr(), true) +} +``` + +The core's `cached_resp_init_eph` dedup subsumes the old behavior: a cold-start Init retransmit (its ephemeral matches `cached_resp_init_eph`, set when the relay cold-start cached its resp) still resends `cached_resp`; a genuine rekey Init installs `next`. (`established`/`resp_pkt` are the ones this arm already built via `start_responder`; keep that construction.) + +- [ ] **Step 4: Wire `relayed_handshake_resp`** + +Add an Established-with-rekey-in-flight branch BEFORE the existing non-`Handshaking` drop: + +```rust +if matches!(&self.peers[idx].state, PeerState::Established(epochs) if epochs.rekey.is_some()) { + return self.rekey_resp_core(idx, dg, now_ms, true); +} +``` + +Keep the rest unchanged (a Resp for an Established peer with no rekey in flight still drops). + +- [ ] **Step 5: Run tests** + +Run: `cargo test -p yipd --lib peer_manager::` then `cargo test -p yipd` +Expected: PASS (the 3 new relay tests + all existing green). + +- [ ] **Step 6: 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.91): complete relay-path rekey (relayed_handshake_init/resp → cores)" +``` + +--- + +### Task 3: Remove the schedule gate + restore the relay Init emit + +**Files:** +- Modify: `bin/yipd/src/peer_manager.rs` (`drive_rekey_schedule` ~734; remove/replace the 9a `tick_does_not_rekey_relay_peer` test) + +**Interfaces:** +- Consumes: `relay_wrap`, `server_addr`. + +- [ ] **Step 1: Update the tests** + +The 9a test `tick_does_not_rekey_relay_peer` asserted a relay peer does NOT rekey — now it DOES. Replace it with the inverse: + +```rust +#[test] +fn tick_schedules_rekey_for_relay_winner_via_relay_wrap() { + // An Established RELAY (relay = true) glare-winner peer, rekey_interval_ms small, + // advanced past the interval → tick emits a RELAY-WRAPPED [HandshakeInit] + // (a RelaySend to the rendezvous, per has_relayed_handshake_init), EpochSet.rekey + // is Some, and `current` is intact. (Requires rendezvous configured so relay_wrap + // returns Some — mirror the relay-peer test setup.) +} +``` + +Reuse the `has_relayed_handshake_init` helper (added in 9a) to detect the relay-wrapped Init. + +- [ ] **Step 2: Run to verify failure** + +Run: `cargo test -p yipd --lib peer_manager::tests::tick_schedules_rekey_for_relay_winner_via_relay_wrap` +Expected: FAIL — the relay gate still returns early, no Init emitted. + +- [ ] **Step 3: Remove the gate + restore relay-wrap Init emit** + +In `drive_rekey_schedule`: delete the `if relay { return; }` block. Then, where it builds and emits the Init (and in the retransmit arm), branch on `relay`: +- `relay == false` (direct): unchanged — `target = self.peers[idx].endpoint` (return if `None`), emit `EgressDatagram { fate: 0, dst: target, bytes: init_pkt.clone() }`, set `RekeyInFlight.target = target`. +- `relay == true`: emit via `self.relay_wrap(idx, init_pkt.clone())` (a `None` skips this send — retransmit next tick; do NOT abort the round). Set `RekeyInFlight.target = self.server_addr()` (nominal — `rekey_resp_core` uses `server_addr()` for a relay peer's `peer_addr` anyway). +Apply the same `relay` branch in the retransmit arm (resend `rekey.init_pkt` verbatim, relay-wrapped when `relay`). Obf/jitter/glare-winner/one-in-flight/loser-fallback are unchanged. + +- [ ] **Step 4: Run tests** + +Run: `cargo test -p yipd` +Expected: PASS (the new relay-schedules test + all existing green; the old `tick_does_not_rekey_relay_peer` is gone). + +- [ ] **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.91): schedule + relay-wrap rekey Init for relay peers (remove the gate)" +``` + +--- + +### Task 4: netns relay-forced money test + +Prove relay-only sessions rotate loss-free over the rendezvous relay. + +**Files:** +- Create: `bin/yipd/tests/run-netns-rekey-relay.sh` (fork the relay + 9a rekey tests) +- Modify: `.github/workflows/integration.yml` + +- [ ] **Step 1: Read the fork sources** + +Read `bin/yipd/tests/run-netns-reality-relay.sh` (or `run-netns-relay*.sh`) — a two-peer topology where traffic goes over the rendezvous relay because the direct/punch paths can't connect — and the 9a `run-netns-rekey.sh` (fast `YIP_REKEY_INTERVAL_MS`, the `rekey_epoch_witness` on-wire proof, both-driver parameterization, ≤1%-loss + distinct-rounds assertions). + +- [ ] **Step 2: Write `run-netns-rekey-relay.sh`** + +`set -euo pipefail`, `trap` cleanup. Combine the two: a relay-forced topology (block the direct + hole-punch paths so the two peers MUST relay) launched with `YIP_REKEY_INTERVAL_MS=2000`, for BOTH drivers. Assertions (non-zero exit on failure): +1. **relay rekey continuity:** `ping -i 0.2 -c 100` A→B over the relay → ≤1% loss across ~10 rotations (relay carries the traffic AND the rekey handshakes; a rotation that black-holed the relay session would drop many). +2. **distinct rekey rounds actually happened:** note that on a relay path the `[HandshakeInit]`/`[HandshakeResp]` are wrapped INSIDE a `RelaySend`/`RelayDeliver` envelope, so the 9a `rekey_epoch_witness` (which parses a bare Init at `pkt[1..33]`) will NOT see the ephemeral directly on the peer↔relay veth. Prove rotation by one of (implementer's choice, document it): (a) extend `rekey_epoch_witness` (or a small sibling) to unwrap the `RelaySend`/`RelayDeliver` envelope and then read the inner Init/Resp ephemeral → `COMPLETED_ROUNDS >= 3`; OR (b) a log-based proof — grep both yipd peers' stderr for the rekey-completion path (e.g. a `promote_from_rekey`/rekey-completed marker; add a one-line `eprintln!` behind the existing rekey logging if none exists) and assert ≥3 completed rotations across the run, corroborated by the continuous ≤1% loss (a black-holed rotation would spike loss). Prefer (a) if the envelope is easy to strip; (b) is an acceptable, robust fallback. + +- [ ] **Step 3: Run under sudo (both drivers)** + +Run: `cargo build --release -p yipd` then `sudo bash bin/yipd/tests/run-netns-rekey-relay.sh` and `sudo YIP_USE_URING=1 bash bin/yipd/tests/run-netns-rekey-relay.sh` +Expected: `PASS`, exit 0 on both. If the environment can't run netns, capture the exact blocker and report DONE_WITH_CONCERNS (controller decides). + +- [ ] **Step 4: Wire CI + commit** + +Add the script to `.github/workflows/integration.yml` alongside the sibling netns steps (both drivers, SKIP/`[FAIL]` guards like the 9a rekey step). + +```bash +chmod +x bin/yipd/tests/run-netns-rekey-relay.sh +git add bin/yipd/tests/run-netns-rekey-relay.sh .github/workflows/integration.yml +git commit -m "test(rekey.91): netns relay-forced money test — loss-free rotation over the relay" +``` + +--- + +## After all tasks + +- Final whole-branch review (opus) over the branch delta, focused on: the extraction being behavior-preserving for Direct (the security-critical idempotent path is single-sourced, not duplicated); relay rekey being fail-closed (a `relay_wrap` `None`/failed/timed-out rekey is a no-op on the live session); relay reordering not diverging the peers (idempotency exercised on the relay path); the gate removal not perturbing direct scheduling. +- Push; open a PR **stacked on #90** (base = `feat/rekey-9a-session-rotation`). Leave it for the user; do NOT merge; no "not merging" line. +- Update `yip-control-plane-status` memory (#91 done: relay peers now rotate) and note the 9a follow-up #91 is resolved. + +## Self-Review notes + +- **Behavior-preservation hinges on `via_relay = false` being byte-identical to the old direct handlers** — the extraction changes only `peer_addr` (`direct_src`/`rk.target`, unchanged for Direct) and the emit (`push_rekey_egress(false)` = the old `EgressDatagram{dst}`). The full 9a direct-rekey suite is the net. +- **`relay_wrap` returns `Option`** — every relay emit path treats `None` as a clean skip (retry next tick), never an abort or a partial-state commit. This is the fail-closed guarantee for relay. +- **Idempotency is single-sourced** — `rekey_init_core` holds the `cached_resp_init_eph`/`next_cached_resp_for`/`accept_rekey_init` logic once; both direct and relay get the exact convergence guarantee, so the relay copy can't drift from the 9a Critical fix. From c388cb4d24f8aeab870b25f2f920b9ccc7972595 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Sun, 19 Jul 2026 03:10:26 -0400 Subject: [PATCH 16/21] =?UTF-8?q?refactor(rekey.91):=20extract=20rekey=5Fi?= =?UTF-8?q?nit=5Fcore/rekey=5Fresp=5Fcore=20(via=5Frelay)=20=E2=80=94=20Di?= =?UTF-8?q?rect=20path=20unchanged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bin/yipd/src/peer_manager.rs | 177 +++++++++++++++++++++++++---------- 1 file changed, 130 insertions(+), 47 deletions(-) diff --git a/bin/yipd/src/peer_manager.rs b/bin/yipd/src/peer_manager.rs index 8054fe7..8919e7e 100644 --- a/bin/yipd/src/peer_manager.rs +++ b/bin/yipd/src/peer_manager.rs @@ -639,6 +639,30 @@ impl PeerManager { self.rendezvous.as_mut().map(|r| r.relay(local, node, &raw)) } + /// Emit one rekey-related datagram into `self.egress`: relay-wrapped when + /// `via_relay` (a `relay_wrap` `None` is a clean skip — the rekey just + /// retries), else addressed directly to `direct_dst`. Used by the rekey + /// cores so the Direct/Relay split lives in exactly one place. + fn push_rekey_egress( + &mut self, + idx: usize, + bytes: Vec, + via_relay: bool, + direct_dst: SocketAddr, + ) { + if via_relay { + if let Some(d) = self.relay_wrap(idx, bytes) { + self.egress.push(d); + } + } else { + self.egress.push(EgressDatagram { + fate: 0, + dst: direct_dst, + bytes, + }); + } + } + /// Start a fresh initiator handshake toward `target` for peer `idx`, /// returning the framed egress datagram(s) to send (relay-wrapped when /// `via_relay`), the real `HandshakeInit` always last. Transitions the @@ -1579,47 +1603,77 @@ impl PeerManager { now_ms: u64, init_eph: [u8; 32], ) -> DispatchOut<'_> { + self.rekey_init_core(idx, established, resp_pkt, init_eph, now_ms, src, false) + } + + /// Shared core for [`handle_rekey_init`] (`via_relay = false`) and its + /// relay-path counterpart (#91 Task 2, `via_relay = true`). See + /// `handle_rekey_init`'s doc comment above for the four-way dedup/gate + /// logic (UNCHANGED here — only `peer_addr` and the emit are + /// parameterized by `via_relay`, via [`push_rekey_egress`]). + /// + /// `direct_src` is the direct-path peer address (`Direct`/`Punched` + /// `src`); for the relay path it is unused for addressing (the emit goes + /// through `relay_wrap` instead) but is still threaded through so the + /// two paths share one signature. + #[expect( + clippy::too_many_arguments, + reason = "mirrors the pre-existing handle_rekey_init parameter set plus via_relay; the params are all distinct handshake-derived values" + )] + fn rekey_init_core( + &mut self, + idx: usize, + established: Established, + resp_pkt: Vec, + init_eph: [u8; 32], + now_ms: u64, + direct_src: SocketAddr, + via_relay: bool, + ) -> DispatchOut<'_> { + let peer_addr = if via_relay { + self.server_addr() + } else { + direct_src + }; + 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, + self.egress.clear(); + if let Some(resp) = self.peers[idx].cached_resp.clone() { + self.push_rekey_egress(idx, resp, via_relay, peer_addr); + } + return if self.egress.is_empty() { + DispatchOut::None + } else { + DispatchOut::Udp(&self.egress) }; } let PeerState::Established(epochs) = &mut self.peers[idx].state else { - unreachable!("handle_rekey_init is only called for an Established peer") + unreachable!("rekey_init_core 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); + self.push_rekey_egress(idx, cached, via_relay, peer_addr); + return if self.egress.is_empty() { + DispatchOut::None + } else { + DispatchOut::Udp(&self.egress) + }; } + let PeerState::Established(epochs) = &self.peers[idx].state else { + unreachable!("state cannot change between the two borrows above") + }; 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, + self.egress.clear(); + if let Some(resp) = self.peers[idx].cached_resp.clone() { + self.push_rekey_egress(idx, resp, via_relay, peer_addr); + } + return if self.egress.is_empty() { + DispatchOut::None + } else { + DispatchOut::Udp(&self.egress) }; } @@ -1631,7 +1685,7 @@ impl PeerManager { established, conn_tag, self.mode, - src, + peer_addr, self.obf_key.is_some(), self.data_symbol_size, )); @@ -1641,12 +1695,12 @@ impl PeerManager { 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) + self.push_rekey_egress(idx, resp_pkt, via_relay, peer_addr); + if self.egress.is_empty() { + DispatchOut::None + } else { + DispatchOut::Udp(&self.egress) + } } /// Handle an incoming `[HandshakeResp]`: either complete an in-flight @@ -1795,16 +1849,35 @@ impl PeerManager { /// 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<'_> { + self.rekey_resp_core(idx, dg, now_ms, false) + } + + /// Shared core for [`handle_rekey_resp`] (`via_relay = false`) and its + /// relay-path counterpart (#91 Task 2, `via_relay = true`). See + /// `handle_rekey_resp`'s doc comment above for the full behavior + /// (UNCHANGED here — only `peer_addr` and the prime-emit are + /// parameterized by `via_relay`, via [`push_rekey_egress`]). + fn rekey_resp_core( + &mut self, + idx: usize, + dg: &[u8], + now_ms: u64, + via_relay: bool, + ) -> DispatchOut<'_> { let rk = { let PeerState::Established(epochs) = &mut self.peers[idx].state else { - unreachable!("handle_rekey_resp is only called for an Established peer") + unreachable!("rekey_resp_core is only called for an Established peer") }; match epochs.rekey.take() { Some(rk) => rk, None => return DispatchOut::None, } }; - let peer_addr = rk.target; + let peer_addr = if via_relay { + self.server_addr() + } else { + rk.target + }; let (established, responder_payload) = match rk.hs.read_response(dg) { Ok(t) => t, @@ -1828,18 +1901,28 @@ impl PeerManager { self.data_symbol_size, )); - // Prime the new epoch's outbound (see doc comment) BEFORE `dp` is - // moved into `promote_from_rekey` below. + // Prime the new epoch (BEFORE `dp` moves into `promote_from_rekey` + // below), emitting via the helper. 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()); + let primed: Vec> = if pending.is_empty() { + dp.on_tun_packet(&[], now_ms) + .iter() + .map(|d| d.bytes.clone()) + .collect() } else { - for inner in &pending { - self.egress - .extend(dp.on_tun_packet(inner, now_ms).iter().cloned()); - } + pending + .iter() + .flat_map(|inner| { + dp.on_tun_packet(inner, now_ms) + .iter() + .map(|d| d.bytes.clone()) + .collect::>() + }) + .collect() + }; + for bytes in primed { + self.push_rekey_egress(idx, bytes, via_relay, peer_addr); } let old_tag = { From b459ddeb77a9651b604457f6ccbfd1692aeb0b28 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Sun, 19 Jul 2026 03:26:58 -0400 Subject: [PATCH 17/21] fix(rekey.91): preserve FEC fate on the rekey prime-emit; None path leaves egress untouched --- bin/yipd/src/peer_manager.rs | 203 +++++++++++++++++++++++++++-------- 1 file changed, 156 insertions(+), 47 deletions(-) diff --git a/bin/yipd/src/peer_manager.rs b/bin/yipd/src/peer_manager.rs index 8919e7e..77261fe 100644 --- a/bin/yipd/src/peer_manager.rs +++ b/bin/yipd/src/peer_manager.rs @@ -641,25 +641,23 @@ impl PeerManager { /// Emit one rekey-related datagram into `self.egress`: relay-wrapped when /// `via_relay` (a `relay_wrap` `None` is a clean skip — the rekey just - /// retries), else addressed directly to `direct_dst`. Used by the rekey - /// cores so the Direct/Relay split lives in exactly one place. - fn push_rekey_egress( - &mut self, - idx: usize, - bytes: Vec, - via_relay: bool, - direct_dst: SocketAddr, - ) { + /// retries), else pushed AS-IS. Used by the rekey cores so the + /// Direct/Relay split lives in exactly one place. + /// + /// Takes the full `EgressDatagram` (not bare bytes) so the Direct path + /// preserves whatever `fate`/`dst` the caller built — `fate: 0` for + /// handshake emits (a Resp/cached-resp has no FEC object), but the REAL + /// per-FEC-object `fate` for `rekey_resp_core`'s prime-emit (#91 Task 1 + /// review, Important). The relay path only ever needs `.bytes` (the + /// inner `fate` rides inside the `RelaySend` payload; the outer + /// `RelaySend`'s own fate is `relay_wrap`'s, moot for the inner one). + fn push_rekey_egress(&mut self, idx: usize, dg: EgressDatagram, via_relay: bool) { if via_relay { - if let Some(d) = self.relay_wrap(idx, bytes) { + if let Some(d) = self.relay_wrap(idx, dg.bytes) { self.egress.push(d); } } else { - self.egress.push(EgressDatagram { - fate: 0, - dst: direct_dst, - bytes, - }); + self.egress.push(dg); } } @@ -1637,14 +1635,25 @@ impl PeerManager { }; if self.peers[idx].cached_resp_init_eph == Some(init_eph) { - self.egress.clear(); - if let Some(resp) = self.peers[idx].cached_resp.clone() { - self.push_rekey_egress(idx, resp, via_relay, peer_addr); - } - return if self.egress.is_empty() { - DispatchOut::None - } else { - DispatchOut::Udp(&self.egress) + return match self.peers[idx].cached_resp.clone() { + Some(resp) => { + self.egress.clear(); + self.push_rekey_egress( + idx, + EgressDatagram { + fate: 0, + dst: peer_addr, + bytes: resp, + }, + via_relay, + ); + if self.egress.is_empty() { + DispatchOut::None + } else { + DispatchOut::Udp(&self.egress) + } + } + None => DispatchOut::None, }; } @@ -1654,7 +1663,15 @@ impl PeerManager { if let Some(cached) = epochs.next_cached_resp_for(&init_eph).map(<[u8]>::to_vec) { self.egress.clear(); - self.push_rekey_egress(idx, cached, via_relay, peer_addr); + self.push_rekey_egress( + idx, + EgressDatagram { + fate: 0, + dst: peer_addr, + bytes: cached, + }, + via_relay, + ); return if self.egress.is_empty() { DispatchOut::None } else { @@ -1666,14 +1683,25 @@ impl PeerManager { unreachable!("state cannot change between the two borrows above") }; if !epochs.accept_rekey_init(now_ms, self.rekey_interval_ms) { - self.egress.clear(); - if let Some(resp) = self.peers[idx].cached_resp.clone() { - self.push_rekey_egress(idx, resp, via_relay, peer_addr); - } - return if self.egress.is_empty() { - DispatchOut::None - } else { - DispatchOut::Udp(&self.egress) + return match self.peers[idx].cached_resp.clone() { + Some(resp) => { + self.egress.clear(); + self.push_rekey_egress( + idx, + EgressDatagram { + fate: 0, + dst: peer_addr, + bytes: resp, + }, + via_relay, + ); + if self.egress.is_empty() { + DispatchOut::None + } else { + DispatchOut::Udp(&self.egress) + } + } + None => DispatchOut::None, }; } @@ -1695,7 +1723,15 @@ impl PeerManager { epochs.install_next(dp, init_eph, resp_pkt.clone()); self.egress.clear(); - self.push_rekey_egress(idx, resp_pkt, via_relay, peer_addr); + self.push_rekey_egress( + idx, + EgressDatagram { + fate: 0, + dst: peer_addr, + bytes: resp_pkt, + }, + via_relay, + ); if self.egress.is_empty() { DispatchOut::None } else { @@ -1902,27 +1938,24 @@ impl PeerManager { )); // Prime the new epoch (BEFORE `dp` moves into `promote_from_rekey` - // below), emitting via the helper. + // below), emitting via the helper. Clone the FULL `EgressDatagram`s + // (not just `.bytes`) so the Direct path preserves the real + // per-FEC-object `fate` `dp.on_tun_packet` assigns (byte-identical to + // the pre-refactor `.cloned()`) — `push_rekey_egress` relay-wraps + // `.bytes` alone for the relay path, so cloning the full datagram + // here costs nothing there. let pending = std::mem::take(&mut self.peers[idx].pending_tun); self.egress.clear(); - let primed: Vec> = if pending.is_empty() { - dp.on_tun_packet(&[], now_ms) - .iter() - .map(|d| d.bytes.clone()) - .collect() + let primed: Vec = if pending.is_empty() { + dp.on_tun_packet(&[], now_ms).to_vec() } else { pending .iter() - .flat_map(|inner| { - dp.on_tun_packet(inner, now_ms) - .iter() - .map(|d| d.bytes.clone()) - .collect::>() - }) + .flat_map(|inner| dp.on_tun_packet(inner, now_ms).to_vec()) .collect() }; - for bytes in primed { - self.push_rekey_egress(idx, bytes, via_relay, peer_addr); + for dg in primed { + self.push_rekey_egress(idx, dg, via_relay); } let old_tag = { @@ -3558,6 +3591,82 @@ mod tests { } } + /// Regression for the #91 Task 1 review Important finding: the + /// prime-emit in `rekey_resp_core` (draining `pending_tun` through the + /// NEW epoch's `dp.on_tun_packet(..)`) must preserve each datagram's real + /// per-FEC-object `fate` (`sym.object_id`, distinct per queued + /// `pending_tun` packet) on the Direct path — NOT hardcode `fate: 0` as + /// `push_rekey_egress` does for handshake emits (correct there: a + /// Resp/cached-resp has no FEC object). Dropping `fate` to a shared 0 + /// silently defeats GSO coalescing (`yip_io::gso::partition_fate_safe` + /// treats equal `fate` as non-coalescable) for every 2nd+ primed + /// datagram — a real GSO-perf divergence from the pre-refactor + /// `.cloned()` of the full `EgressDatagram`s, even though it is never an + /// FEC-safety violation. + #[test] + fn rekey_resp_prime_emit_preserves_distinct_fec_fate() { + let (mut pm_a, mut pm_b, ep_a, ep_b, kp_a, kp_b) = established_pm_pair(100); + + // Queue 2 distinct TUN packets directly into `pending_tun` — the + // field the prime-emit drains — so `rekey_resp_core` primes the new + // epoch with 2 separate `dp.on_tun_packet(..)` calls, each of which + // allocates a fresh FEC object id (see + // `yip_transport::fec::Encoder::encode`, `next_object_id`). A real + // in-flight-rekey run only ever gets pending_tun populated while + // `Handshaking`/`Idle`, not `Established` — direct field access is + // the pragmatic way to drive this Established-peer scenario in a + // unit test (an Established peer's own `on_tun` sends straight + // through `current`, never touching `pending_tun`). + pm_a.peers[0].pending_tun.push(vec![0x11u8; 40]); + pm_a.peers[0].pending_tun.push(vec![0x22u8; 40]); + + // Splice a `RekeyInFlight` into A's `EpochSet`, exactly as the + // sibling `rekey_resp_promotes_initiator_and_keeps_previous_for_grace` + // test does, to drive rekey completion without depending on + // `tick`'s glare-winner scheduling. + 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, + }); + } + + 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"); + + // Complete the rekey on A: this drives `rekey_resp_core`'s + // prime-emit over the 2 queued `pending_tun` packets. + let out = pm_a.on_udp(ep_b, &resp[0], 100); + let primed: Vec = match out { + DispatchOut::Udp(e) => e.to_vec(), + _ => panic!("expected Udp egress (the primed new-epoch frames)"), + }; + assert!( + primed.len() >= 2, + "2 distinct pending_tun packets must prime at least 2 egress datagrams, got {}", + primed.len() + ); + + let fates: std::collections::HashSet = primed.iter().map(|d| d.fate).collect(); + assert!( + fates.len() >= 2, + "primed datagrams from 2 distinct pending_tun packets must carry DISTINCT FEC \ + fates (one per FEC object), not all collapsed to a shared value \ + (fate: 0) — got fates {:?} across {} datagrams", + primed.iter().map(|d| d.fate).collect::>(), + primed.len() + ); + } + /// `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. From e60b7ba6a4cced906c9144ab04e7f0ce3c6ee6e8 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Sun, 19 Jul 2026 03:34:54 -0400 Subject: [PATCH 18/21] =?UTF-8?q?feat(rekey.91):=20complete=20relay-path?= =?UTF-8?q?=20rekey=20(relayed=5Fhandshake=5Finit/resp=20=E2=86=92=20cores?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bin/yipd/src/peer_manager.rs | 324 +++++++++++++++++++++++++++++++++-- 1 file changed, 314 insertions(+), 10 deletions(-) diff --git a/bin/yipd/src/peer_manager.rs b/bin/yipd/src/peer_manager.rs index 77261fe..e771bc6 100644 --- a/bin/yipd/src/peer_manager.rs +++ b/bin/yipd/src/peer_manager.rs @@ -1030,16 +1030,29 @@ impl PeerManager { } match &self.peers[idx].state { - PeerState::Established(_) => match self.peers[idx].cached_resp.clone() { - Some(resp) => { - self.egress.clear(); - if let Some(d) = self.relay_wrap(idx, resp) { - self.egress.push(d); - } - DispatchOut::Udp(&self.egress) - } - None => DispatchOut::None, - }, + // Established (9a/relay-path completion, #91 Task 2): route + // through the shared core exactly like the direct path's + // `handle_handshake_init` does. `rekey_init_core`'s + // `cached_resp_init_eph` dedup subsumes the old unconditional + // `cached_resp` resend below: a cold-start Init retransmit + // (ephemeral matches `cached_resp_init_eph`, set when the Idle + // branch below first cached its resp) still resends + // `cached_resp` verbatim; a genuine mid-session rekey Init + // installs `next` instead. + PeerState::Established(_) => { + let Some(init_eph) = crate::handshake::init_ephemeral(dg) else { + return DispatchOut::None; // malformed Init + }; + self.rekey_init_core( + idx, + established, + resp_pkt, + init_eph, + now_ms, + self.server_addr(), + true, + ) + } PeerState::Handshaking(_) if self.local_pub < self.peers[idx].pubkey => { DispatchOut::None } @@ -1094,6 +1107,15 @@ impl PeerManager { /// Relay-path counterpart of [`handle_handshake_resp`]: complete a relayed /// `[HandshakeResp]` from peer `idx` and commit `PathKind::Relayed`. fn relayed_handshake_resp(&mut self, idx: usize, dg: &[u8], now_ms: u64) -> DispatchOut<'_> { + // Established with a rekey in flight (#91 Task 2): this is the + // relay-path completion of a mid-session rekey — route through the + // shared core exactly like the direct path's `handle_rekey_resp`. + // An Established peer with NO rekey in flight still falls through + // to the check below and drops (unchanged). + if matches!(&self.peers[idx].state, PeerState::Established(epochs) if epochs.rekey.is_some()) + { + return self.rekey_resp_core(idx, dg, now_ms, true); + } if !matches!(self.peers[idx].state, PeerState::Handshaking(_)) { return DispatchOut::None; } @@ -4651,6 +4673,288 @@ mod tests { ); } + // ── #91 Task 2: relay-path rekey completion ───────────────────────────── + + /// Build a `PeerManager` (with a `MockRdv` rendezvous, so `relay_wrap` + /// succeeds) whose sole peer is already `Established` AND relay-reached + /// (`relay = true`, `path_kind = Relayed`) — mirroring the state + /// `relayed_handshake_init`'s own Idle branch commits at cold start, + /// without needing a second full `PeerManager` on the "peer" side. The + /// `current` epoch is a `fake_established_dataplane` (crypto-agnostic + /// stand-in, exactly like `anti_hijack_established_peer_ignores_*`'s + /// splice): rekey completion never reads `current`'s own key material, + /// only its `conn_tag`/existence, so the fake is sufficient here — the + /// GENUINE crypto in each test below is the fresh rekey Init/Resp itself. + fn established_relay_pm( + rekey_interval_ms: u64, + ) -> ( + PeerManager, + yip_crypto::Keypair, + yip_crypto::Keypair, + u64, // old (current) conn_tag + ) { + let local = generate_keypair(); + let peer_kp = generate_keypair(); + let peer = PeerConfig { + public_key: peer_kp.public, + endpoint: None, + }; + let (mut pm, _sent) = pm_with_mock_rdv(&local, &[peer]); + pm.rekey_interval_ms = rekey_interval_ms; + + const OLD_TAG: u64 = 0xAAAA_BBBB_CCCC_DDDD; + pm.peers[0].state = PeerState::Established(Box::new(crate::epoch::EpochSet::new( + Box::new(fake_established_dataplane(OLD_TAG, mock_server())), + 0, + ))); + pm.by_tag.insert(OLD_TAG, 0); + pm.peers[0].relay = true; + pm.peers[0].path_kind = Some(PathKind::Relayed); + + (pm, local, peer_kp, OLD_TAG) + } + + /// Copy out every `[HandshakeResp]` payload carried inside a + /// relay-wrapped (`RelaySend`) egress datagram — the relay-path + /// counterpart of `resp_bytes`, which expects the Resp unwrapped (as on + /// the direct path). + fn relayed_resp_bytes(out: &DispatchOut<'_>) -> Vec> { + let egress: &[EgressDatagram] = match out { + DispatchOut::Udp(e) | DispatchOut::Both(_, e) => e, + _ => &[], + }; + egress + .iter() + .filter_map(|d| match yip_rendezvous::decode(&d.bytes) { + Some(yip_rendezvous::Message::RelaySend { payload, .. }) + if payload.first() == Some(&(PacketType::HandshakeResp as u8)) => + { + Some(payload) + } + _ => None, + }) + .collect() + } + + /// Wrap `payload` (raw handshake bytes) as a `RelayDeliver` datagram from + /// `src_kp`, as the server would forward it — the input side of the + /// relay tests below, mirroring `anti_hijack_established_peer_ignores_relayed_handshake_init`. + fn relay_deliver(src_kp: &yip_crypto::Keypair, payload: Vec) -> Vec { + let mut buf = Vec::new(); + yip_rendezvous::encode( + &yip_rendezvous::Message::RelayDeliver { + src: node_id(&src_kp.public), + payload, + }, + &mut buf, + ); + buf + } + + #[test] + fn relay_rekey_init_retransmit_is_idempotent_new_ephemeral_builds_new_next() { + // Relay-path counterpart of + // `retransmitted_rekey_init_is_idempotent_new_ephemeral_builds_new_next`: + // a relay-Established responder receiving a rekey `Init` through the + // relay must dedupe identically — same ephemeral resends the SAME + // cached (relay-wrapped) Resp and does NOT mint a second `next`; a + // genuinely new ephemeral DOES build a new `next`. + let (mut pm, local, peer_kp, _old_tag) = established_relay_pm(100); + + let (_hs1, init_pkt_1) = + HandshakeState::start_initiator(&peer_kp.private, &local.public, &[]).unwrap(); + let buf1 = relay_deliver(&peer_kp, init_pkt_1.clone()); + + // First delivery at t=100 (current age 100 >= interval/2 = 50): + // installs `next`, replies via the relay. + let out1 = pm.on_udp(mock_server(), &buf1, 100); + let resp1 = relayed_resp_bytes(&out1); + assert_eq!( + resp1.len(), + 1, + "a genuine relay rekey Init must produce a relay-wrapped Resp" + ); + let next_tag_1 = next_conn_tag(&pm); + + // RETRANSMIT: identical Init bytes -> identical ephemeral. Must + // resend the SAME cached Resp and must NOT rebuild `next`. + let out2 = pm.on_udp(mock_server(), &buf1, 150); + let resp2 = relayed_resp_bytes(&out2); + assert_eq!( + resp2, resp1, + "a retransmitted relay rekey Init must resend the cached Resp verbatim" + ); + assert_eq!( + next_conn_tag(&pm), + next_tag_1, + "a retransmitted relay rekey Init must NOT mint a second `next` session" + ); + + // A second retransmit, again: still idempotent. + let out3 = pm.on_udp(mock_server(), &buf1, 200); + assert_eq!(relayed_resp_bytes(&out3), resp1); + assert_eq!(next_conn_tag(&pm), next_tag_1); + + // A GENUINELY NEW Init (fresh ephemeral) DOES build a new `next`, + // replacing the old one. + let (_hs2, init_pkt_2) = + HandshakeState::start_initiator(&peer_kp.private, &local.public, &[]).unwrap(); + assert_ne!( + init_pkt_1, init_pkt_2, + "sanity: the two Inits must actually differ" + ); + let buf2 = relay_deliver(&peer_kp, init_pkt_2); + let out4 = pm.on_udp(mock_server(), &buf2, 250); + let resp4 = relayed_resp_bytes(&out4); + assert_eq!(resp4.len(), 1); + assert_ne!( + resp4, resp1, + "a genuinely new relay rekey round must produce a NEW Resp" + ); + assert_ne!( + next_conn_tag(&pm), + next_tag_1, + "a genuinely new relay rekey round must replace `next`" + ); + } + + #[test] + fn relay_rekey_resp_completes_and_promotes() { + // Relay-path counterpart of + // `rekey_resp_promotes_initiator_and_keeps_previous_for_grace`: a + // relay-Established peer with a rekey in flight, on receiving the + // matching relayed `[HandshakeResp]`, must promote exactly like the + // direct path — `current` becomes the new epoch, the old epoch + // moves to `previous`, `rekey` clears, and `by_tag` is updated. + let (mut pm, local, peer_kp, old_tag) = established_relay_pm(100); + + // Splice a `RekeyInFlight` in as the INITIATOR side (pm's own rekey + // attempt), exactly as the direct-path sibling test does. + let (hs, init_pkt) = + HandshakeState::start_initiator(&local.private, &peer_kp.public, &[]).unwrap(); + { + let PeerState::Established(epochs) = &mut pm.peers[0].state else { + panic!("pm 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: mock_server(), // unused: the relay path overrides addressing + }); + } + + // The peer's real responder completes the handshake and builds the + // matching Resp — mirrors what a genuine peer PeerManager would send. + let (_established, resp_pkt, remote_static, _payload) = + HandshakeState::start_responder(&peer_kp.private, &init_pkt, &[]).unwrap(); + assert_eq!( + remote_static, local.public, + "sanity: Resp matches pm's own Init" + ); + + let buf = relay_deliver(&peer_kp, resp_pkt); + let out = pm.on_udp(mock_server(), &buf, 100); + + // The prime-emit (empty pending_tun -> one bare new-epoch frame) must + // go out relay-wrapped, not bare. + let egress: &[EgressDatagram] = match &out { + DispatchOut::Udp(e) => e, + _ => panic!("expected relay-wrapped prime-emit egress"), + }; + assert!(!egress.is_empty(), "the prime-emit must produce egress"); + assert!( + egress.iter().all(|d| matches!( + yip_rendezvous::decode(&d.bytes), + Some(yip_rendezvous::Message::RelaySend { .. }) + )), + "the prime-emit must be relay-wrapped (RelaySend), not sent bare" + ); + + let new_tag = established_tag(&pm, 0).unwrap(); + assert_ne!(new_tag, old_tag, "current must become the NEW epoch"); + match &pm.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 must still be Established"), + } + assert_eq!( + pm.by_tag.get(&new_tag), + Some(&0), + "by_tag updated for the new conn_tag" + ); + assert_eq!( + pm.by_tag.get(&old_tag), + None, + "by_tag no longer maps the retired old conn_tag" + ); + } + + #[test] + fn relay_rekey_emit_is_noop_when_relay_wrap_returns_none() { + // Fail-closed regression: if `relay_wrap` cannot emit (no rendezvous + // configured), a relay rekey Init at an Established peer must be a + // clean no-op — no egress, and `current` is left completely intact. + // (No rendezvous means `on_udp` could never route a `RelayDeliver` + // here in production; the relay handler is called directly to + // exercise the wiring's fail-closed behavior in isolation.) + let local = generate_keypair(); + let peer_kp = generate_keypair(); + let peer = PeerConfig { + public_key: peer_kp.public, + endpoint: None, + }; + let mut pm = PeerManager::new( + local.private, + local.public, + &[peer], + TunnelMode::L3Tun, + None, // no rendezvous configured + None, + false, + ); + pm.rekey_interval_ms = 100; + + const OLD_TAG: u64 = 0x1234_5678_9ABC_DEF0; + let placeholder: SocketAddr = "203.0.113.1:51821".parse().unwrap(); + pm.peers[0].state = PeerState::Established(Box::new(crate::epoch::EpochSet::new( + Box::new(fake_established_dataplane(OLD_TAG, placeholder)), + 0, + ))); + pm.by_tag.insert(OLD_TAG, 0); + pm.peers[0].relay = true; + pm.peers[0].path_kind = Some(PathKind::Relayed); + + let (_hs, init_pkt) = + HandshakeState::start_initiator(&peer_kp.private, &local.public, &[]).unwrap(); + + assert!( + matches!( + pm.relayed_handshake_init(0, &init_pkt, 100), + DispatchOut::None + ), + "no rendezvous -> relay_wrap fails -> no egress" + ); + + assert_eq!( + established_tag(&pm, 0), + Some(OLD_TAG), + "current must remain intact when the relay emit fails" + ); + } + /// (h) F2 fix: a peer configured with BOTH a direct endpoint AND a /// rendezvous must still hole-punch. It starts `Handshaking` on the direct /// endpoint via `on_tun`'s `Idle` branch (not via `drive_path_idle`, which From 2fbe21ea10a620fbe45f9cfd806d6431cad18a28 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Sun, 19 Jul 2026 03:48:29 -0400 Subject: [PATCH 19/21] feat(rekey.91): schedule + relay-wrap rekey Init for relay peers (remove the gate) --- bin/yipd/src/peer_manager.rs | 173 ++++++++++++++++++----------------- 1 file changed, 91 insertions(+), 82 deletions(-) diff --git a/bin/yipd/src/peer_manager.rs b/bin/yipd/src/peer_manager.rs index e771bc6..9aac03a 100644 --- a/bin/yipd/src/peer_manager.rs +++ b/bin/yipd/src/peer_manager.rs @@ -752,14 +752,14 @@ 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`. + /// Mid-session rekey scheduler (9a Task 3, relay-completed in #91 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 @@ -769,6 +769,18 @@ impl PeerManager { /// 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). + /// + /// Relay-reached peers (`relay == true`) DO get scheduled here (#91 Task + /// 3 removed the earlier gate, now that `rekey_resp_core`/the relay + /// handshake handlers complete a relay rekey): the Init is emitted via + /// `relay_wrap` (a `RelaySend` to the rendezvous server) instead of a raw + /// datagram to a direct endpoint, and `RekeyInFlight.target` is set to + /// `self.server_addr()` (nominal — `rekey_resp_core` uses `server_addr()` + /// as a relay peer's `peer_addr` too). A `relay_wrap` `None` (no + /// rendezvous configured — should not happen for a peer marked `relay`) + /// skips *this send only*: `RekeyInFlight` is still installed/retried, so + /// the round is not aborted (fail-closed, same spirit as the rest of this + /// function). fn drive_rekey_schedule( &mut self, idx: usize, @@ -776,21 +788,6 @@ impl PeerManager { 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 @@ -803,14 +800,19 @@ impl PeerManager { 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, + // Direct: target this peer's known endpoint (no known endpoint + // this tick — shouldn't normally happen for a non-relay + // Established peer — skips: no egress, no state change, retried + // next tick). Relay: nominal target is the rendezvous server + // (`rekey_resp_core` uses `server_addr()` as a relay peer's + // `peer_addr` too), and there is no endpoint to be missing. + let target = if relay { + self.server_addr() + } else { + match self.peers[idx].endpoint { + Some(ep) => ep, + None => return, + } }; let pubkey = self.peers[idx].pubkey; let payload = self @@ -826,11 +828,6 @@ impl PeerManager { 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 { @@ -838,13 +835,27 @@ impl PeerManager { }; epochs.rekey = Some(crate::epoch::RekeyInFlight { hs, - init_pkt, + init_pkt: init_pkt.clone(), started_ms: now_ms, last_sent_ms: now_ms, retry_ms, target, }); - self.tick_egress.push(dg); + if relay { + // A `None` (no rendezvous configured — should not happen for + // a peer marked `relay`) skips this send only: the round + // stays installed above and retries on the next tick's + // retransmit arm. + if let Some(d) = self.relay_wrap(idx, init_pkt) { + self.tick_egress.push(d); + } + } else { + self.tick_egress.push(EgressDatagram { + fate: 0, + dst: target, + bytes: init_pkt, + }); + } return; } @@ -875,13 +886,20 @@ impl PeerManager { .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, - }; + // Resend the SAME `init_pkt` verbatim, relay-wrapped when `relay` + // (a `relay_wrap` `None` skips this send only — the round stays in + // flight and retries again next tick), else direct to `target`. + if relay { + if let Some(d) = self.relay_wrap(idx, pkt) { + self.tick_egress.push(d); + } + } else { + self.tick_egress.push(EgressDatagram { + fate: 0, + dst: target, + bytes: pkt, + }); + } let new_retry_ms = if self.obf_key.is_some() { jitter_ms(HANDSHAKE_RETRY_MS) } else { @@ -891,7 +909,6 @@ impl PeerManager { 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 @@ -3287,27 +3304,23 @@ mod tests { } /// 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. + /// `relayed_handshake_init`/`relayed_handshake_resp` leave a peer) DOES + /// have a mid-session rekey scheduled by `tick`/`drive_rekey_schedule` + /// when it is the glare-winner and past `rekey_interval_ms` (#91 Task 3: + /// rekey completion is now wired for the relay handshake handlers, so + /// the gate that used to suppress relay scheduling is gone). The Init is + /// relay-wrapped (`relay_wrap` → a `RelaySend` to the rendezvous server), + /// not sent as a raw `[HandshakeInit]` to a direct endpoint. Contrast + /// with `tick_initiates_rekey_for_established_winner_once`, whose + /// otherwise-identical direct peer (`relay == false`) emits the Init + /// un-wrapped. #[test] - fn tick_does_not_rekey_relay_peer() { + fn tick_schedules_rekey_for_relay_winner_via_relay_wrap() { 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. + // needs a configured `Rendezvous` to succeed. Wire up the same + // `MockRdv` the rendezvous-wiring tests use. let sent = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); pm.rendezvous = Some(Box::new(MockRdv { server: mock_server(), @@ -3317,40 +3330,36 @@ mod tests { // 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. + // confounds the `has_handshake_init`/`has_relayed_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. + // Past the interval (age 150 >= 100): a relay peer now rekeys here, + // just like a direct peer (see + // `tick_initiates_rekey_for_established_winner_once`), but the Init + // rides inside a relay-wrapped `RelaySend`, not a raw datagram. 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" + has_relayed_handshake_init(out.as_deref()), + "a relay peer past the interval, as glare-winner, must emit a relay-wrapped rekey HandshakeInit" + ); + assert!( + !has_handshake_init(out.as_deref()), + "the relay peer's rekey Init must never be sent as a raw (unwrapped) HandshakeInit" ); 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" + epochs.rekey.is_some(), + "EpochSet.rekey must be populated once a relay rekey is in flight" ), _ => 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" + "current epoch must be untouched by scheduling a relay rekey" ); } From b1634304109f97b400efa898819a42f2b9bc8cd1 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Sun, 19 Jul 2026 22:36:16 -0400 Subject: [PATCH 20/21] =?UTF-8?q?test(rekey.91):=20netns=20relay-forced=20?= =?UTF-8?q?money=20test=20=E2=80=94=20loss-free=20rotation=20over=20the=20?= =?UTF-8?q?relay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 4: prove relay-only sessions rotate loss-free over the rendezvous relay. run-netns-rekey-relay.sh forks run-netns-relay.sh's RELAY-FORCED topology (A/B mutually unreachable except via R's blind relay) with run-netns-rekey.sh's YIP_REKEY_INTERVAL_MS=2000 cadence, asserting (1) relay-forwarded= N>0, (2) <=1% loss across ~10 rotations, (3) rekey_epoch_witness reports >=3 distinct completed rekey rounds. rekey_epoch_witness.rs grows an opt-in YIP_WITNESS_UNWRAP_RELAY=1 mode that strips the RelaySend (offset 33) / RelayDeliver (offset 17) rendezvous envelope before applying the existing distinct-ephemeral logic, so the direct-path 9a run-netns-rekey.sh is byte-for-byte unaffected (verified by re-running it). Wired into netns-tunnel-test in .github/workflows/integration.yml, both drivers, alongside the sibling 9a rekey steps. --- .github/workflows/integration.yml | 39 +++ bin/yipd/examples/rekey_epoch_witness.rs | 71 +++- bin/yipd/tests/run-netns-rekey-relay.sh | 411 +++++++++++++++++++++++ 3 files changed, 517 insertions(+), 4 deletions(-) create mode 100755 bin/yipd/tests/run-netns-rekey-relay.sh diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 9957c14..22b87e8 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -215,6 +215,45 @@ jobs: exit 1 fi + - name: Run the rekey.91 relay-forced money test under sudo (poll driver) + # run-netns-rekey-relay.sh: the run-netns-relay.sh RELAY-FORCED + # topology (A/B mutually unreachable except via R's blind relay) + # held to the same YIP_REKEY_INTERVAL_MS=2000 cadence as the 9a + # direct-path test above. Proves relay-only sessions also rotate + # loss-free: (1) relay-forwarded= N>0 (the relay, not a + # direct/punched path, carried the traffic), (2) <=1% loss across + # ~10 rotations, (3) rekey_epoch_witness (with the opt-in + # YIP_WITNESS_UNWRAP_RELAY=1 RelaySend/RelayDeliver envelope + # unwrap) reports >=3 distinct completed rekey rounds. Uses the + # release yipd and debug yip-rendezvous already built above in + # this job. + run: | + sudo bash bin/yipd/tests/run-netns-rekey-relay.sh \ + "$(pwd)/target/release/yipd" \ + "$(pwd)/target/debug/yip-rendezvous" | tee /tmp/rekey-relay-poll.log + if grep -q "^SKIP run-netns-rekey-relay" /tmp/rekey-relay-poll.log; then + echo "::error::rekey.91 relay money test (poll) skipped — expected root + tcpdump + the built witness tool in this job" + exit 1 + fi + if grep -q "\[FAIL\]" /tmp/rekey-relay-poll.log; then + echo "::error::rekey.91 relay money test (poll) failed — relay did not carry traffic, loss-free continuity, or on-wire rekey rotation regressed" + exit 1 + fi + + - name: Run the rekey.91 relay-forced money test under sudo (uring driver) + run: | + sudo -E env YIP_USE_URING=1 bash bin/yipd/tests/run-netns-rekey-relay.sh \ + "$(pwd)/target/release/yipd" \ + "$(pwd)/target/debug/yip-rendezvous" | tee /tmp/rekey-relay-uring.log + if grep -q "^SKIP run-netns-rekey-relay" /tmp/rekey-relay-uring.log; then + echo "::error::rekey.91 relay money test (uring) skipped — expected root + tcpdump + the built witness tool in this job" + exit 1 + fi + if grep -q "\[FAIL\]" /tmp/rekey-relay-uring.log; then + echo "::error::rekey.91 relay money test (uring) failed — relay did not carry traffic, loss-free continuity, or on-wire rekey 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 index 06a0b16..77da3ca 100644 --- a/bin/yipd/examples/rekey_epoch_witness.rs +++ b/bin/yipd/examples/rekey_epoch_witness.rs @@ -78,10 +78,39 @@ //! this tool (or any passive observer) can never learn what those N values //! actually are. //! +//! # Relay-envelope unwrap (opt-in, `run-netns-rekey-relay.sh`) +//! +//! On the relay-forced path (milestone 9a/#91 Task 4), the peer<->relay +//! veth never carries a bare `[HandshakeInit]`/`[HandshakeResp]` datagram — +//! `PeerManager::relay_wrap` wraps every relay-routed rekey emission in a +//! `yip_rendezvous::Message::RelaySend` (client -> server) or +//! `RelayDeliver` (server -> client) envelope (`crates/yip-rendezvous/src/ +//! proto.rs`). Both envelope kinds prefix the inner yipd datagram with a +//! fixed-width header: `RelaySend` = `[tag=5][src:16][dst:16][payload..]` +//! (inner datagram starts at offset 33), `RelayDeliver` = +//! `[tag=6][src:16][payload..]` (inner datagram starts at offset 17) — see +//! `proto.rs`'s `encode`/`decode` for `Tag::RelaySend`/`Tag::RelayDeliver`. +//! +//! Set `YIP_WITNESS_UNWRAP_RELAY=1` to have this tool strip that envelope +//! before applying the PacketType+ephemeral logic below: any UDP payload +//! whose first byte is 5 has bytes `[33..]` treated as the inner datagram; +//! first byte 6 has bytes `[17..]`; anything else passes through +//! unchanged. This is OPT-IN and OFF by default — the 9a direct-path +//! `run-netns-rekey.sh` never sets the env var, so its behavior (parsing +//! the bare `[PacketType][ephemeral]` prefix directly) is byte-for-byte +//! unchanged. It is deliberately not auto-detected from the first byte +//! alone: a bare yipd `PacketType::HandshakeInit`/`HandshakeResp` (0/1) is +//! disjoint from the relay tags (5/6) today, but a bare `PacketType::Data` +//! or a future PacketType variant could coincide with 5/6, so auto-sniffing +//! would be a silent footgun — the caller states which topology it is +//! running via the env var instead. +//! //! Usage: //! rekey_epoch_witness +//! YIP_WITNESS_UNWRAP_RELAY=1 rekey_epoch_witness //! -//! Output (stdout), consumed by `run-netns-rekey.sh`: +//! Output (stdout), consumed by `run-netns-rekey.sh` / +//! `run-netns-rekey-relay.sh`: //! HANDSHAKE_INIT_PKTS= total captured [HandshakeInit] datagrams //! HANDSHAKE_RESP_PKTS= total captured [HandshakeResp] datagrams //! DISTINCT_INIT_EPHEMERALS= distinct cleartext initiator ephemerals @@ -188,6 +217,30 @@ const TYPE_DATA: u8 = 2; /// 1-byte PacketType prefix + 32-byte cleartext Noise ephemeral pubkey. const MIN_HANDSHAKE_LEN: usize = 33; +/// `yip_rendezvous::proto::Tag::RelaySend` / `Tag::RelayDeliver` (see +/// `crates/yip-rendezvous/src/proto.rs`). Only meaningful when the +/// `YIP_WITNESS_UNWRAP_RELAY` opt-in is set — see the module doc. +const RELAY_SEND_TAG: u8 = 5; +const RELAY_DELIVER_TAG: u8 = 6; +/// `RelaySend` = `[tag:1][src:16][dst:16][payload..]` — inner datagram at 33. +const RELAY_SEND_INNER_OFFSET: usize = 33; +/// `RelayDeliver` = `[tag:1][src:16][payload..]` — inner datagram at 17. +const RELAY_DELIVER_INNER_OFFSET: usize = 17; + +/// Strip a `RelaySend`/`RelayDeliver` rendezvous envelope from `payload`, +/// returning the inner yipd datagram. Only called when the +/// `YIP_WITNESS_UNWRAP_RELAY` opt-in is enabled. Anything that isn't +/// recognizably one of those two envelope tags (including a too-short +/// buffer) passes through unchanged — panic-safe via `.get(..)`, never +/// indexes past the end. +fn unwrap_relay_envelope(payload: &[u8]) -> &[u8] { + match payload.first() { + Some(&RELAY_SEND_TAG) => payload.get(RELAY_SEND_INNER_OFFSET..).unwrap_or(&[]), + Some(&RELAY_DELIVER_TAG) => payload.get(RELAY_DELIVER_INNER_OFFSET..).unwrap_or(&[]), + _ => payload, + } +} + fn main() { let args: Vec = env::args().collect(); if args.len() != 2 { @@ -195,6 +248,11 @@ fn main() { std::process::exit(2); } let pcap_path = &args[1]; + // Opt-in only (see module doc): the 9a direct-path run-netns-rekey.sh + // never sets this, so its behavior is byte-for-byte unchanged. + let unwrap_relay = env::var("YIP_WITNESS_UNWRAP_RELAY") + .map(|v| v == "1") + .unwrap_or(false); let data = fs::read(pcap_path).unwrap_or_else(|e| { eprintln!("rekey_epoch_witness: read {pcap_path}: {e}"); @@ -208,11 +266,16 @@ fn main() { let mut resp_pkt_count = 0u32; for p in &pkts { - if p.payload.len() < MIN_HANDSHAKE_LEN { + let inner: &[u8] = if unwrap_relay { + unwrap_relay_envelope(&p.payload) + } else { + &p.payload + }; + if inner.len() < MIN_HANDSHAKE_LEN { continue; } - let ephemeral: [u8; 32] = p.payload[1..33].try_into().expect("32 bytes"); - match p.payload[0] { + let ephemeral: [u8; 32] = inner[1..33].try_into().expect("32 bytes"); + match inner[0] { TYPE_HANDSHAKE_INIT => { init_pkt_count += 1; if !init_ephemerals.contains(&ephemeral) { diff --git a/bin/yipd/tests/run-netns-rekey-relay.sh b/bin/yipd/tests/run-netns-rekey-relay.sh new file mode 100755 index 0000000..2f9cad1 --- /dev/null +++ b/bin/yipd/tests/run-netns-rekey-relay.sh @@ -0,0 +1,411 @@ +#!/usr/bin/env bash +# The rekey.91 Task 4 money test: relay-FORCED peers (no direct/punch path +# possible, per run-netns-relay.sh's topology) held to a FAST +# YIP_REKEY_INTERVAL_MS=2000 rekey cadence (~10 rotations over a ~20s ping +# stream) must (1) actually carry their traffic over the blind relay (not a +# direct/punched path) and (2) never black-hole the relayed session across a +# rotation (loss-free continuity) and (3) really rotate the wire session +# per epoch, proven on the wire — not just asserted from source. +# +# Usage: run-netns-rekey-relay.sh +# +# Forked from two siblings: +# - run-netns-relay.sh: the RELAY-FORCED topology (three netns A/B/R; two +# point-to-point veth pairs A<->R, B<->R; no shared bridge; R does NOT +# forward IPv4, so A and B are mutually unreachable and can only ever +# reach each other via R's blind relay). Peers list each other by +# public_key only (no endpoint) + rendezvous=. +# - run-netns-rekey.sh: the fast YIP_REKEY_INTERVAL_MS=2000 cadence, the +# ping -i 0.2 -c 100 loss-continuity assertion, and the +# rekey_epoch_witness on-wire distinct-rounds proof. +# +# ── why a warm-up ping precedes the measured/captured one ── +# run-netns-relay.sh's own money test documents ~PUNCH_MS (5s) of +# unavoidable warm-up loss while each peer's path state machine escalates +# from a silently-dropped direct punch to the blind relay. The measured +# ping below has only a 1% loss budget (matching the 9a direct-path +# script), which that 5s of warm-up would blow on its own — so this script +# runs a generous, ungated warm-up ping first (tolerating exactly the same +# escalation loss run-netns-relay.sh does) to force the relay session up, +# and only starts the tcpdump capture + tight-budget measured ping once a +# relayed session is already flowing. +# +# ── proving on-wire rekey rounds through the relay envelope ── +# On this topology, `PeerManager::relay_wrap` wraps every relay-routed +# [HandshakeInit]/[HandshakeResp] in a `yip_rendezvous::Message::RelaySend` +# (client -> server, tag=5) or `RelayDeliver` (server -> client, tag=6) +# envelope (crates/yip-rendezvous/src/proto.rs) -- the bare +# [PacketType][ephemeral] prefix `rekey_epoch_witness` looks for is not at +# offset 0 anymore. This script uses option (a) from the task-4 brief: the +# witness tool itself grew an opt-in `YIP_WITNESS_UNWRAP_RELAY=1` mode +# (bin/yipd/examples/rekey_epoch_witness.rs) that strips the envelope +# (RelaySend -> inner at offset 33, RelayDeliver -> inner at offset 17) +# before applying its existing distinct-cleartext-ephemeral logic. The 9a +# direct-path run-netns-rekey.sh never sets that env var, so its behavior +# is unchanged. +# +# The pcap is captured on A's own veth into R (VETH_A_N, inside NS_A) -- +# since A and B are the only two mesh peers, every relayed datagram either +# originates at A (RelaySend) or is addressed to A (RelayDeliver), so this +# single capture point sees the full relayed Init/Resp traffic in both +# directions, exactly as run-netns-rekey.sh's single veth capture does for +# the direct path. +# +# Assertions (any failure is non-zero exit, [PASS]/[FAIL] markers): +# 1. relay_forwarded: R's stderr shows `relay-forwarded=`, N>0 -- +# the blind relay, not a direct/punched path, carried the traffic. +# 2. rekey_continuity: ping -i 0.2 -c 100 A->B over the relay, <=1% loss +# across ~10 rotations -- a rotation that black-holed the relayed +# session would drop many. +# 3. rekey rotation: rekey_epoch_witness (YIP_WITNESS_UNWRAP_RELAY=1) +# reports COMPLETED_ROUNDS >= 3 distinct completed Noise-IK rounds +# unwrapped from the relay envelope. +set -euo pipefail + +YIPD="${1:?Usage: $0 }" +RDV="${2:?Usage: $0 }" +WITNESS_BIN="$(dirname "$YIPD")/examples/rekey_epoch_witness" + +# ── 0. root + tool preflight (invoked directly by CI, not through the +# tunnel_netns.rs Rust harness, so it does its own SKIP-gating per the +# run-netns-rekey.sh / run-netns-relay-tls.sh convention) ── +if [ "$(id -u)" -ne 0 ]; then + echo "SKIP run-netns-rekey-relay: 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-relay: required tool '$tool' not found" + exit 0 + fi +done +if [ ! -x "$WITNESS_BIN" ]; then + echo "SKIP run-netns-rekey-relay: 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-relay-test.XXXXXX)" + +NS_A="yipRkRelA" +NS_B="yipRkRelB" +NS_R="yipRkRelR" + +VETH_A_N="vRkRelA1"; VETH_A_R="vRkRelA0" # A<->R pair: A-side, R-side +VETH_B_N="vRkRelB1"; VETH_B_R="vRkRelB0" # B<->R pair: B-side, R-side + +IP_A="10.72.0.2" +IP_R_A="10.72.0.1" # R's address on A's subnet +IP_B="10.73.0.2" +IP_R_B="10.73.0.1" # R's address on B's subnet +PREFIX="24" + +PORT_A="51820" +PORT_B="51820" +RDV_PORT="51821" +TUN_DEV="yip0" + +PID_A="" +PID_B="" +PID_RDV="" +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 "$PID_RDV" ] && kill "$PID_RDV" 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 "$PID_RDV" ] && kill -9 "$PID_RDV" 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 + ip netns del "$NS_R" 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)" + +ADDR_A="$("$YIPD" --addr "$PUB_A")" +ADDR_B="$("$YIPD" --addr "$PUB_B")" +echo "[setup] node_addr A=$ADDR_A B=$ADDR_B" + +# ── 2. write config files (rendezvous-only peers: public_key, no endpoint) ──── +CFG_A="$TMPDIR_TEST/yipA.conf" +CFG_B="$TMPDIR_TEST/yipB.conf" + +cat > "$CFG_A" < "$CFG_B" <R" +ip link add "$VETH_A_R" type veth peer name "$VETH_A_N" +ip link set "$VETH_A_N" netns "$NS_A" +ip link set "$VETH_A_R" netns "$NS_R" +ip netns exec "$NS_A" ip addr add "${IP_A}/${PREFIX}" dev "$VETH_A_N" +ip netns exec "$NS_A" ip link set "$VETH_A_N" up +ip netns exec "$NS_A" ip link set lo up +ip netns exec "$NS_R" ip addr add "${IP_R_A}/${PREFIX}" dev "$VETH_A_R" +ip netns exec "$NS_R" ip link set "$VETH_A_R" up + +echo "[setup] wiring B<->R" +ip link add "$VETH_B_R" type veth peer name "$VETH_B_N" +ip link set "$VETH_B_N" netns "$NS_B" +ip link set "$VETH_B_R" netns "$NS_R" +ip netns exec "$NS_B" ip addr add "${IP_B}/${PREFIX}" dev "$VETH_B_N" +ip netns exec "$NS_B" ip link set "$VETH_B_N" up +ip netns exec "$NS_B" ip link set lo up +ip netns exec "$NS_R" ip addr add "${IP_R_B}/${PREFIX}" dev "$VETH_B_R" +ip netns exec "$NS_R" ip link set "$VETH_B_R" up +ip netns exec "$NS_R" ip link set lo up + +# A's and B's only route beyond their own /24 is via R -- and R does NOT +# forward, so a cross-subnet packet dies silently at R (see +# run-netns-relay.sh's header comment for the full reasoning: this keeps +# the punch attempt a normal, silently-dropped packet rather than a +# synchronous socket error that would kill yipd's event loop). +ip netns exec "$NS_A" ip route add default via "$IP_R_A" dev "$VETH_A_N" +ip netns exec "$NS_B" ip route add default via "$IP_R_B" dev "$VETH_B_N" + +# Explicitly disable IPv4 forwarding in R (belt-and-suspenders: a fresh +# netns already defaults to this, but the isolation invariant this whole +# test rests on deserves to be asserted, not assumed). +ip netns exec "$NS_R" sysctl -q -w net.ipv4.ip_forward=0 +ip netns exec "$NS_R" sysctl -q -w net.ipv4.conf.all.forwarding=0 + +# ── 4. start yip-rendezvous in R, bound on both subnets ─────────────────────── +LOG_RDV="$TMPDIR_TEST/rdv.log" +echo "[start] starting yip-rendezvous in R on 0.0.0.0:${RDV_PORT}" +ip netns exec "$NS_R" "$RDV" "0.0.0.0:${RDV_PORT}" >"$LOG_RDV" 2>&1 & +PID_RDV=$! +sleep 0.3 + +# ── 5. start yipd in A and B with a fast rekey cadence ──────────────────────── +# YIP_REKEY_INTERVAL_MS=2000 (vs. the 120_000 production default) so ~10 +# rotations happen over the ~20s measured ping stream below. `ip netns +# exec` (unlike `sudo`) does not clear the environment, so this and any +# caller-set YIP_USE_URING both flow through to the daemons unmodified -- +# run this script itself as `sudo YIP_USE_URING=1 bash +# run-netns-rekey-relay.sh ` to exercise the uring +# driver. +export YIP_REKEY_INTERVAL_MS=2000 + +LOG_A="$TMPDIR_TEST/yipA.log" +LOG_B="$TMPDIR_TEST/yipB.log" + +dump_logs() { + echo "=== rendezvous log ===" + cat "$LOG_RDV" || true + echo "=== yipRkRelA log ===" + cat "$LOG_A" || true + echo "=== yipRkRelB log ===" + cat "$LOG_B" || true +} + +echo "[start] starting yipRkRelA 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 yipRkRelB with YIP_REKEY_INTERVAL_MS=$YIP_REKEY_INTERVAL_MS" +ip netns exec "$NS_B" "$YIPD" "$CFG_B" >"$LOG_B" 2>&1 & +PID_B=$! + +# ── 6. wait for TUN devices to appear in A and B ────────────────────────────── +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] yipRkRelA daemon died unexpectedly"; dump_logs; exit 1 + fi + if ! kill -0 "$PID_B" 2>/dev/null; then + echo "[error] yipRkRelB daemon died unexpectedly"; dump_logs; exit 1 + fi + if ! kill -0 "$PID_RDV" 2>/dev/null; then + echo "[error] yip-rendezvous died unexpectedly"; dump_logs; 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"; dump_logs; exit 1 + fi + sleep "$INTERVAL" +done + +# ── 7. assign each TUN its node_addr/128 + the mesh-prefix route ───────────── +echo "[setup] assigning node_addr/128 + fd00::/8 route on each TUN" +assign_mesh() { + local ns="$1" addr="$2" + ip netns exec "$ns" ip -6 addr add "${addr}/128" dev "$TUN_DEV" 2>/dev/null || true + ip netns exec "$ns" ip -6 route add fd00::/8 dev "$TUN_DEV" 2>/dev/null || true + ip netns exec "$ns" ip link show "$TUN_DEV" | grep -q "UP" || \ + ip netns exec "$ns" ip link set "$TUN_DEV" up +} +assign_mesh "$NS_A" "$ADDR_A" +assign_mesh "$NS_B" "$ADDR_B" + +# ── 8. warm-up ping: absorb the punch->escalate->relay-handshake loss ─────── +# Same tolerance as run-netns-relay.sh's own money test (ungated, generous +# count/timeout) -- this just gets a relayed session flowing before the +# tight-budget measured ping + capture below start. +echo "[warmup] pinging ${ADDR_B} from yipRkRelA (expect escalate-to-relay warm-up loss, then success)" +set +e +ip netns exec "$NS_A" ping -6 -c 20 -W 2 "$ADDR_B" +WARMUP_STATUS=$? +set -e +if [ "$WARMUP_STATUS" -ne 0 ]; then + echo "[FAIL] warm-up ping A->B did not succeed (exit $WARMUP_STATUS) -- relay path never came up" + dump_logs + exit 1 +fi +echo "[PASS] warm-up ping A->B succeeded -- relayed session is up" + +# ── 9. capture A's veth into R while a steady ping stream crosses ~10 rotations +# Every relayed datagram either originates at A (RelaySend) or is addressed +# to A (RelayDeliver) -- A and B are the only two mesh peers -- so this +# single capture point sees the full relayed Init/Resp traffic. +PCAP="$TMPDIR_TEST/rekey-relay.pcap" +PING_LOG="$TMPDIR_TEST/ping.log" + +echo "[capture] starting tcpdump on $VETH_A_N (udp) -> $PCAP" +ip netns exec "$NS_A" tcpdump -i "$VETH_A_N" -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) yipRkRelA -> ${ADDR_B} over the relay" +set +e +ip netns exec "$NS_A" ping -6 -i 0.2 -c 100 -W 1 "$ADDR_B" >"$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] yipRkRelA daemon died during the ping stream" + echo "=== yipRkRelA log ==="; cat "$LOG_A" || true + exit 1 +fi +if ! kill -0 "$PID_B" 2>/dev/null; then + echo "[error] yipRkRelB daemon died during the ping stream" + echo "=== yipRkRelB log ==="; cat "$LOG_B" || true + exit 1 +fi + +# ── assertion 1: relay carried the traffic -- relay-forwarded=, N>0 ────── +# One more sweep interval so R's final line reflects the traffic that just +# flowed (same convention as run-netns-relay.sh's money test). +sleep 5.5 +FINAL_COUNT="$(grep -oE 'relay-forwarded=[0-9]+' "$LOG_RDV" | tail -1 | cut -d= -f2)" +echo "[check] server's final relay-forwarded count: ${FINAL_COUNT:-}" +if [ -z "${FINAL_COUNT:-}" ] || [ "$FINAL_COUNT" -eq 0 ]; then + echo "[FAIL] relay-forwarded count is 0 (or missing) — traffic did not go through the relay" + dump_logs + exit 1 +fi +echo "[PASS] relay-forwarded=${FINAL_COUNT} (>0): the blind relay carried the traffic" + +# ── assertion 2: rekey_continuity — ≤1% loss across ~10 relayed 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 relayed rekey stream" +else + echo "[FAIL] rekey_continuity: ${LOSS_PCT}% loss (>1%) — a rotation likely black-holed the relayed session" + dump_logs + 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] rekey rotation: capture is empty or missing at $PCAP" + exit 1 +fi + +# ── assertion 3: rekey rotation — distinct completed Noise-IK rounds, +# unwrapped from the RelaySend/RelayDeliver envelope (YIP_WITNESS_UNWRAP_RELAY=1; +# see rekey_epoch_witness.rs's module doc for the offsets and why this is +# opt-in) ── +WITNESS_LOG="$TMPDIR_TEST/witness.log" +YIP_WITNESS_UNWRAP_RELAY=1 "$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] rekey 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) -- same threshold as the 9a +# direct-path script. +if [ "$COMPLETED_ROUNDS" -ge 3 ]; then + echo "[PASS] rekey rotation: $COMPLETED_ROUNDS distinct completed rekey rounds observed over the relay" +else + echo "[FAIL] rekey rotation: only $COMPLETED_ROUNDS distinct completed rounds (need >=3) — relay-path rekey is not rotating on the wire as expected" + dump_logs + exit 1 +fi + +echo "[PASS] run-netns-rekey-relay: relay-forwarded traffic + loss-free rotation + on-wire rekey rotation, all over the blind relay" From 004c1389dcdbf1e1bcbb0b6f70dd5fdff7a5c090 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Sun, 19 Jul 2026 23:06:20 -0400 Subject: [PATCH 21/21] =?UTF-8?q?fix(rekey.91):=20gate=20rekey=20completio?= =?UTF-8?q?n=20on=20peers[idx].relay=20=E2=80=94=20path-consistent=20(fina?= =?UTF-8?q?l=20review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bin/yipd/src/peer_manager.rs | 125 ++++++++++++++++++++++++++++++++++- 1 file changed, 122 insertions(+), 3 deletions(-) diff --git a/bin/yipd/src/peer_manager.rs b/bin/yipd/src/peer_manager.rs index 9aac03a..854812a 100644 --- a/bin/yipd/src/peer_manager.rs +++ b/bin/yipd/src/peer_manager.rs @@ -1056,7 +1056,21 @@ impl PeerManager { // branch below first cached its resp) still resends // `cached_resp` verbatim; a genuine mid-session rekey Init // installs `next` instead. - PeerState::Established(_) => { + // Only complete a relayed rekey Init when this peer's live path + // IS relay (final review, Important): a direct peer receiving a + // relayed Init — reachable via a source-spoofed server address + // or a malicious/compromised blind relay (`on_relayed` only + // requires `src == server`), or with NO attacker under + // asymmetric reachability (the peer relays to us while we reach + // it directly) — must NOT complete via this relay-addressed + // core. Completing it would stamp the new epoch's + // `DataPlane.peer_addr` to `server_addr()` while + // `peers[idx].relay` stays `false`, so `on_tun`'s relay-wrap + // decision (keyed off `peers[idx].relay`) would then emit BARE + // datagrams to the server — black-holing `current`. Fail-closed + // drop instead; `current` (and the in-flight rekey, if any) + // stays untouched. + PeerState::Established(_) if self.peers[idx].relay => { let Some(init_eph) = crate::handshake::init_ephemeral(dg) else { return DispatchOut::None; // malformed Init }; @@ -1070,6 +1084,7 @@ impl PeerManager { true, ) } + PeerState::Established(_) => DispatchOut::None, PeerState::Handshaking(_) if self.local_pub < self.peers[idx].pubkey => { DispatchOut::None } @@ -1128,8 +1143,14 @@ impl PeerManager { // relay-path completion of a mid-session rekey — route through the // shared core exactly like the direct path's `handle_rekey_resp`. // An Established peer with NO rekey in flight still falls through - // to the check below and drops (unchanged). + // to the check below and drops (unchanged). Gated on `peers[idx].relay` + // (final review, Important): a DIRECT peer's rekey must never + // complete via the relay-addressed core — see the matching guard in + // `relayed_handshake_init` for the full black-hole rationale. A + // direct peer with a relayed Resp falls through to the + // non-`Handshaking` drop below (fail-closed; `current` untouched). if matches!(&self.peers[idx].state, PeerState::Established(epochs) if epochs.rekey.is_some()) + && self.peers[idx].relay { return self.rekey_resp_core(idx, dg, now_ms, true); } @@ -1520,12 +1541,20 @@ impl PeerManager { // 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(_) => { + // Only complete a DIRECT rekey Init when this peer's live path is + // NOT relay (final review, Important): a relay peer receiving a + // direct Init (e.g. `peers[idx].relay == true` but the peer + // somehow reached us directly) must NOT complete via this + // direct-addressed core — its Inits are meant to arrive relayed. + // Fail-closed drop instead, mirroring the guard in + // `relayed_handshake_init`; `current` stays untouched. + PeerState::Established(_) if !self.peers[idx].relay => { 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) } + PeerState::Established(_) => DispatchOut::None, // 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 @@ -1792,6 +1821,7 @@ impl PeerManager { if let Some(idx) = self.peers.iter().position(|p| { p.endpoint == Some(src) && matches!(&p.state, PeerState::Established(epochs) if epochs.rekey.is_some()) + && !p.relay }) { return self.handle_rekey_resp(idx, dg, now_ms); } @@ -4911,6 +4941,95 @@ mod tests { ); } + #[test] + fn direct_peer_ignores_relayed_rekey_resp() { + // Regression (final review, Important): a DIRECT (`relay = false`) + // Established peer with a rekey in flight must NOT complete that + // rekey via a RELAYED `[HandshakeResp]`. Completing it would stamp + // the new epoch's `DataPlane.peer_addr` to `server_addr()` (via + // `rekey_resp_core(.., via_relay = true)`) while `peers[idx].relay` + // stays `false`, so `on_tun`'s relay-wrap decision (keyed off + // `peers[idx].relay`) would then send BARE datagrams to the + // rendezvous server — a black hole. `current` must stay untouched + // and the completion must not happen at all: fail-closed drop. + let (mut pm, local, peer_kp, old_tag) = established_relay_pm(100); + // Override to a DIRECT peer — the only difference from + // `relay_rekey_resp_completes_and_promotes`'s setup. + pm.peers[0].relay = false; + pm.peers[0].path_kind = Some(PathKind::Direct); + + // Splice a `RekeyInFlight` in as the INITIATOR side (pm's own rekey + // attempt), exactly as the relay-path sibling test does. + let (hs, init_pkt) = + HandshakeState::start_initiator(&local.private, &peer_kp.public, &[]).unwrap(); + { + let PeerState::Established(epochs) = &mut pm.peers[0].state else { + panic!("pm 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: mock_server(), // unused: this test never reaches addressing + }); + } + + // The peer's real responder completes the handshake and builds the + // matching Resp — mirrors what a genuine peer PeerManager would + // send. Delivered here via a RELAY (`RelayDeliver`/`on_udp(server, + // ..)`), reachable either via a source-spoofed server address or a + // malicious/compromised blind relay (`on_relayed` only requires + // `src == server`). + let (_established, resp_pkt, remote_static, _payload) = + HandshakeState::start_responder(&peer_kp.private, &init_pkt, &[]).unwrap(); + assert_eq!( + remote_static, local.public, + "sanity: Resp matches pm's own Init" + ); + + let buf = relay_deliver(&peer_kp, resp_pkt); + { + // Scoped so `out`'s borrow of `pm` ends before the state checks + // below. + let out = pm.on_udp(mock_server(), &buf, 100); + + // No emitted datagram may be a bare (un-wrapped) send to + // `server_addr()` (which would black-hole against the + // rendezvous server). + let egress: &[EgressDatagram] = match &out { + DispatchOut::Udp(e) | DispatchOut::Both(_, e) => e, + _ => &[], + }; + assert!( + egress.iter().all( + |d| !(d.dst == mock_server() && yip_rendezvous::decode(&d.bytes).is_none()) + ), + "no bare (un-wrapped) datagram may be sent to server_addr()" + ); + } + + // The rekey must NOT complete: `current` stays on the OLD epoch, + // and `epochs.rekey` stays populated (still awaiting a legitimately + // DIRECT Resp). + assert_eq!( + established_tag(&pm, 0), + Some(old_tag), + "current must remain the OLD epoch — a relayed Resp must not \ + complete a direct peer's rekey" + ); + match &pm.peers[0].state { + PeerState::Established(epochs) => { + assert!( + epochs.rekey.is_some(), + "rekey must stay in flight, awaiting a genuinely direct Resp" + ); + } + _ => panic!("pm must still be Established"), + } + } + #[test] fn relay_rekey_emit_is_noop_when_relay_wrap_returns_none() { // Fail-closed regression: if `relay_wrap` cannot emit (no rendezvous