diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 3ef16fb..eb4aee3 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -255,18 +255,31 @@ jobs: fi - name: Run the hardening.36 path-switch convergence money test under sudo (poll driver) - # run-netns-pathswitch-rehandshake.sh: proves the #36 fix -- a - # Punch->Relay path re-target must not draw a fresh Noise ephemeral. - # Forks run-netns-relay.sh's RELAY-FORCED topology (A/B mutually + # run-netns-pathswitch-rehandshake.sh: proves the #36 INVERSION + # (anti-replay.34 Task 4) -- a Punch->Relay path re-target now + # legitimately draws a FRESH Noise ephemeral instead of preserving + # the in-flight one, and the session still converges because the + # responder rebuilds on the fresh, freshness-gated Init. Forks + # run-netns-relay.sh's RELAY-FORCED topology (A/B mutually # unreachable except via R's blind relay) so every session is forced # through punch->escalate->relay; asserts (1) A converges over the - # relay, (2) relay-forwarded= N>0, (3) rekey_epoch_witness (with - # the opt-in YIP_WITNESS_UNWRAP_RELAY=1 envelope unwrap, filtered to - # A's own outbound traffic) reports exactly 1 distinct cleartext - # Init ephemeral across >=2 captured Init datagrams -- the rigorous, - # on-wire, non-vacuous proof that the retarget resent the SAME Init - # rather than cold-starting. Uses the release yipd and debug - # yip-rendezvous already built above in this job. + # relay with >=98% steady-state delivery, (2) relay-forwarded= + # N>0, (3) rekey_epoch_witness (with the opt-in + # YIP_WITNESS_UNWRAP_RELAY=1 envelope unwrap, filtered to A's own + # outbound traffic) reports >=2 distinct cleartext Init ephemerals + # across >=2 captured Init datagrams -- the rigorous, on-wire, + # non-vacuous proof that the retarget drew a fresh ephemeral rather + # than preserving the punch attempt's. Uses the release yipd and + # debug yip-rendezvous already built above in this job. + # + # POLL DRIVER ONLY: the punch->relay escalation is driven by discovery + # + path-SM timers, whose convergence within the fixed capture window + # is flaky under the uring driver in CI (the escalation Init can miss + # the window, yielding only 1 captured ephemeral) -- the same + # discovery-timing sensitivity that already makes run-netns-discovery.sh + # and run-netns-cert-revocation.sh poll-only here. The retarget logic + # is driver-independent (verified on both drivers locally), so uring + # adds only flakiness with no extra coverage. run: | sudo bash bin/yipd/tests/run-netns-pathswitch-rehandshake.sh \ "$(pwd)/target/release/yipd" \ @@ -276,21 +289,53 @@ jobs: exit 1 fi if grep -q "\[FAIL\]" /tmp/pathswitch-poll.log; then - echo "::error::hardening.36 path-switch money test (poll) failed — A did not converge over the relay, or drew a fresh ephemeral across the retarget" + echo "::error::hardening.36 path-switch money test (poll) failed — A did not converge over the relay, or the retarget did not draw a fresh ephemeral" + exit 1 + fi + + - name: Install tshark + # run-netns-replay-hijack.sh (below) uses tshark to extract a + # captured [HandshakeInit] datagram's raw UDP payload for replay -- + # unlike tcpdump (preinstalled on the runner image), tshark is not + # guaranteed present, so install it explicitly. + # DEBIAN_FRONTEND=noninteractive: tshark's postinst otherwise prompts + # (debconf) to ask whether non-superusers may capture packets; this + # job always invokes it via sudo anyway, so the prompt is moot. + run: sudo DEBIAN_FRONTEND=noninteractive apt-get install -y tshark + + - name: Run the anti-replay.34 endpoint-hijack/restart money test under sudo (poll driver) + # run-netns-replay-hijack.sh: proves the #34 freshness gate end-to-end + # -- a captured-and-replayed HandshakeInit from an off-path attacker + # (a fourth netns, S) cannot redirect B's learned endpoint for A + # (steady ping A->B stays <=1% loss across the replay), B's stderr + # shows the replay was actually refused by the freshness gate, and a + # genuine restart of A (fresh ephemeral + newer wall-clock ts) still + # recovers within a bounded ping window. Uses the release yipd and + # debug yip-rendezvous already built above in this job. + run: | + sudo bash bin/yipd/tests/run-netns-replay-hijack.sh \ + "$(pwd)/target/release/yipd" \ + "$(pwd)/target/debug/yip-rendezvous" | tee /tmp/replay-hijack-poll.log + if grep -q "^SKIP run-netns-replay-hijack" /tmp/replay-hijack-poll.log; then + echo "::error::anti-replay.34 endpoint-hijack/restart money test (poll) skipped — expected root + tcpdump/tshark in this job" + exit 1 + fi + if grep -q "\[FAIL\]" /tmp/replay-hijack-poll.log; then + echo "::error::anti-replay.34 endpoint-hijack/restart money test (poll) failed — the replay redirected B's endpoint for A, was not refused by the freshness gate, or A's restart did not recover" exit 1 fi - - name: Run the hardening.36 path-switch convergence money test under sudo (uring driver) + - name: Run the anti-replay.34 endpoint-hijack/restart money test under sudo (uring driver) run: | - sudo -E env YIP_USE_URING=1 bash bin/yipd/tests/run-netns-pathswitch-rehandshake.sh \ + sudo -E env YIP_USE_URING=1 bash bin/yipd/tests/run-netns-replay-hijack.sh \ "$(pwd)/target/release/yipd" \ - "$(pwd)/target/debug/yip-rendezvous" | tee /tmp/pathswitch-uring.log - if grep -q "^SKIP run-netns-pathswitch-rehandshake" /tmp/pathswitch-uring.log; then - echo "::error::hardening.36 path-switch money test (uring) skipped — expected root + tcpdump + the built witness tool in this job" + "$(pwd)/target/debug/yip-rendezvous" | tee /tmp/replay-hijack-uring.log + if grep -q "^SKIP run-netns-replay-hijack" /tmp/replay-hijack-uring.log; then + echo "::error::anti-replay.34 endpoint-hijack/restart money test (uring) skipped — expected root + tcpdump/tshark in this job" exit 1 fi - if grep -q "\[FAIL\]" /tmp/pathswitch-uring.log; then - echo "::error::hardening.36 path-switch money test (uring) failed — A did not converge over the relay, or drew a fresh ephemeral across the retarget" + if grep -q "\[FAIL\]" /tmp/replay-hijack-uring.log; then + echo "::error::anti-replay.34 endpoint-hijack/restart money test (uring) failed — the replay redirected B's endpoint for A, was not refused by the freshness gate, or A's restart did not recover" exit 1 fi diff --git a/bin/yipd/src/epoch.rs b/bin/yipd/src/epoch.rs index bc17949..7fbc022 100644 --- a/bin/yipd/src/epoch.rs +++ b/bin/yipd/src/epoch.rs @@ -211,13 +211,6 @@ impl EpochSet { 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)] @@ -513,14 +506,4 @@ mod tests { assert!(!set.needs_rekey(1500, false, interval)); assert!(set.needs_rekey(2000, false, interval)); } - - #[test] - fn accept_rekey_init_ignores_too_fresh_current() { - let (_peer, us) = epoch_pair(); - let set = EpochSet::new(us, 0); - let interval = 1000u64; - - assert!(!set.accept_rekey_init(400, interval)); - assert!(set.accept_rekey_init(500, interval)); - } } diff --git a/bin/yipd/src/handshake.rs b/bin/yipd/src/handshake.rs index 2ca6f5c..40a657f 100644 --- a/bin/yipd/src/handshake.rs +++ b/bin/yipd/src/handshake.rs @@ -225,6 +225,44 @@ pub fn init_ephemeral(init_pkt: &[u8]) -> Option<[u8; 32]> { init_pkt.get(1..33)?.try_into().ok() } +/// TAI64N label length: 8-byte seconds + 4-byte nanoseconds. +pub const TAI64N_LEN: usize = 12; + +/// The current wall clock as a TAI64N label: big-endian `2^62 + unix_secs` +/// (8 bytes) followed by big-endian nanoseconds (4 bytes). Big-endian so that +/// a lexicographic byte comparison of two labels is chronological. Wall-clock +/// based, so it survives a peer restart (a fresh Init is always newer in real +/// time) with no persisted state. A clock that jumps backwards yields a label +/// that a peer with a higher last-accepted label will reject — the WireGuard +/// behavior, accepted. +pub fn now_tai64n() -> [u8; TAI64N_LEN] { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + let secs = now.as_secs().wrapping_add(1u64 << 62); + let nanos = now.subsec_nanos(); + let mut out = [0u8; TAI64N_LEN]; + out[..8].copy_from_slice(&secs.to_be_bytes()); + out[8..].copy_from_slice(&nanos.to_be_bytes()); + out +} + +/// Build the msg1 Noise payload: the anti-replay TAI64N label followed by the +/// (optional) membership cert. Empty `cert` (2a/2b) yields a 12-byte payload. +pub fn frame_init_payload(cert: &[u8]) -> Vec { + let mut out = Vec::with_capacity(TAI64N_LEN + cert.len()); + out.extend_from_slice(&now_tai64n()); + out.extend_from_slice(cert); + out +} + +/// Split a received msg1 payload into `(ts_label, cert_remainder)`. +/// `None` (fail-closed) if it is shorter than the 12-byte label. +pub fn parse_init_payload(payload: &[u8]) -> Option<([u8; TAI64N_LEN], &[u8])> { + let ts: [u8; TAI64N_LEN] = payload.get(..TAI64N_LEN)?.try_into().ok()?; + Some((ts, &payload[TAI64N_LEN..])) +} + // ── step-functions (in-band handshakes) ──────────────────────────────────────── /// A handshake in progress, driven step-by-step instead of blocking on a @@ -491,4 +529,37 @@ mod tests { } let _ = faker.join(); } + + #[test] + fn tai64n_is_big_endian_monotonic_and_roundtrips() { + // frame/parse roundtrip: ts prefix split from the cert remainder. + let cert = b"a-cert-blob"; + let framed = frame_init_payload(cert); + assert_eq!(framed.len(), TAI64N_LEN + cert.len()); + let (ts, rest) = parse_init_payload(&framed).expect("parses"); + assert_eq!(rest, cert); + assert_eq!(&framed[..TAI64N_LEN], &ts); + + // empty cert (2a/2b): payload is exactly the 12-byte ts. + let framed_empty = frame_init_payload(&[]); + assert_eq!(framed_empty.len(), TAI64N_LEN); + let (_ts, rest) = parse_init_payload(&framed_empty).expect("parses"); + assert!(rest.is_empty()); + + // big-endian so lexicographic byte-compare is chronological: a later + // wall-clock ts compares strictly greater than an earlier one. + let earlier = now_tai64n(); + std::thread::sleep(std::time::Duration::from_millis(2)); + let later = now_tai64n(); + assert!( + later > earlier, + "TAI64N must increase with wall-clock and byte-compare" + ); + } + + #[test] + fn parse_init_payload_rejects_short_payload() { + assert!(parse_init_payload(&[0u8; TAI64N_LEN - 1]).is_none()); + assert!(parse_init_payload(&[]).is_none()); + } } diff --git a/bin/yipd/src/peer_manager.rs b/bin/yipd/src/peer_manager.rs index bd902ec..8b2ada0 100644 --- a/bin/yipd/src/peer_manager.rs +++ b/bin/yipd/src/peer_manager.rs @@ -218,13 +218,24 @@ struct Peer { /// 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. + /// (60s), so a very-late retransmit of this ORIGINAL Init can carry the + /// SAME `ts` as when it was first accepted — not strictly newer than + /// `last_accepted_init_ts`, so `PeerManager::accept_fresh_init` (#34) + /// alone would reject it as stale. `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 or dropped as a replay. cached_resp_init_eph: Option<[u8; 32]>, + /// #34 anti-replay: the greatest TAI64N label (`handshake::parse_init_payload`'s + /// `ts`) accepted in a session-building Init from this peer — i.e. one + /// that either admitted a cold-start (`Idle`/`Handshaking` → `Established`) + /// or installed a mid-session rekey `next` epoch. In-memory only (not + /// persisted across process restart); gates both session rebuild/rekey + /// (`PeerManager::accept_fresh_init`) and endpoint learning. `None` until + /// the first such Init is accepted, and — deliberately — NEVER reset by + /// `drop_session`: a peer that is dropped and later re-establishes must + /// still reject a replay of an Init from before the drop. + last_accepted_init_ts: Option<[u8; 12]>, /// 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, @@ -467,6 +478,7 @@ impl PeerManager { pending_tun: Vec::new(), cached_resp: None, cached_resp_init_eph: None, + last_accepted_init_ts: None, node, path, path_kind: None, @@ -554,6 +566,7 @@ impl PeerManager { pending_tun: Vec::new(), cached_resp: None, cached_resp_init_eph: None, + last_accepted_init_ts: None, node, path, path_kind: None, @@ -702,11 +715,15 @@ impl PeerManager { // Present our CA-signed membership cert as msg1's Noise payload so the // responder can admit us by cert (2c). Empty when membership is None — // byte-identical to 2a/2b. - let payload = self + let cert = self .membership .as_ref() .map(Membership::own_cert_bytes) .unwrap_or_default(); + // #34: frame the msg1 payload as [ts || cert] so the responder can + // reject stale replays (Task 3 adds the freshness gate; this task + // only establishes the wire format). + let payload = crate::handshake::frame_init_payload(&cert); let (hs, init_pkt) = match HandshakeState::start_initiator(&self.local_priv, &pubkey, &payload) { Ok(t) => t, @@ -765,53 +782,6 @@ impl PeerManager { Some(vec![dg]) } - /// Re-point an in-flight handshake at `new_target` over the given path, - /// PRESERVING the Noise ephemeral: resend the existing `init_pkt` rather than - /// drawing a fresh one, so a responder that already adopted us on the old path - /// completes us via its `cached_resp` (#36). Falls back to a fresh - /// `begin_handshake` only when no handshake is in flight (Idle/cold). - /// - /// `started_ms` is intentionally NOT reset — the `HANDSHAKE_TOTAL_MS` give-up - /// clock keeps running across re-targets (a re-target does not buy a fresh - /// 90 s). On a `via_relay` re-target `endpoint` is cleared: a late direct - /// `[HandshakeResp]` for this same ephemeral must not complete us onto a now - /// `relay`-flagged peer (that would mismatch egress — data-plane egress is - /// re-wrapped by the `relay` flag, not the stamped dst). - fn retarget_handshake( - &mut self, - idx: usize, - new_target: SocketAddr, - via_relay: bool, - now_ms: u64, - ) -> Option> { - let PeerState::Handshaking(h) = &mut self.peers[idx].state else { - // No handshake in flight: a fresh attempt is correct here. - return self.begin_handshake(idx, new_target, via_relay, now_ms); - }; - h.target = new_target; - // Reset the retransmit clock to `now_ms` so the retransmit arm does not - // fire an immediate redundant copy of the Init we resend here (which - // would also break the anti-DPI timing jitter): the re-targeted Init - // IS this interval's send. - h.last_sent_ms = now_ms; - let init_pkt = h.init_pkt.clone(); - self.peers[idx].relay = via_relay; - if via_relay { - self.peers[idx].endpoint = None; - return self.relay_wrap(idx, init_pkt).map(|d| vec![d]); - } - // Direct/Punch re-target: re-stamp `endpoint` to the new candidate, as - // `begin_handshake`'s direct branch does — `handle_handshake_resp` - // matches an inbound Resp against `peers[idx].endpoint == Some(src)`, - // so without this a Resp from the new candidate would not complete us. - self.peers[idx].endpoint = Some(new_target); - Some(vec![EgressDatagram { - fate: 0, - dst: new_target, - bytes: init_pkt, - }]) - } - /// 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 @@ -875,11 +845,13 @@ impl PeerManager { } }; let pubkey = self.peers[idx].pubkey; - let payload = self + let cert = self .membership .as_ref() .map(Membership::own_cert_bytes) .unwrap_or_default(); + // #34: frame the msg1 payload as [ts || cert] (see begin_handshake). + let payload = crate::handshake::frame_init_payload(&cert); let (hs, init_pkt) = match HandshakeState::start_initiator(&self.local_priv, &pubkey, &payload) { Ok(t) => t, @@ -1106,6 +1078,15 @@ impl PeerManager { return DispatchOut::None; } + // #34: the msg1 payload is [ts || cert]. Split the anti-replay label + // off; the cert remainder is what admission checks. Too short to hold + // the label ⇒ malformed ⇒ fail closed. + let Some((init_ts, initiator_cert)) = + crate::handshake::parse_init_payload(&initiator_payload) + else { + return DispatchOut::None; + }; + match &self.peers[idx].state { // Established (9a/relay-path completion, #91 Task 2): route // through the shared core exactly like the direct path's @@ -1131,15 +1112,11 @@ impl PeerManager { // drop instead; `current` (and the in-flight rekey, if any) // stays untouched. // - // EXCEPTION (#36, responder-side half): a direct peer receiving a - // relayed cold-start RETRANSMIT of the exact Init that built its - // OWN session (`init_eph == cached_resp_init_eph`) is the - // initiator having escalated punch->relay after we already - // established over punch — not a spoof/attack scenario, since it - // reproduces the very Init we ourselves answered. That single - // case adopts the relay below before falling into the same core; - // every other relayed Init against a direct peer stays - // fail-closed, per the paragraph above. + // #34 (retires #36): a direct/punch-established (relay==false) + // peer receiving a relayed Init now adopts the relay only for a + // FRESH new-ephemeral Init — never for a bare retransmit of the + // Init that built its own session. See the freshness-gated + // adoption block below. PeerState::Established(_) => { let Some(init_eph) = crate::handshake::init_ephemeral(dg) else { return DispatchOut::None; // malformed Init @@ -1147,30 +1124,44 @@ impl PeerManager { // #41: a mid-session rekey Init must carry a currently-valid cert // (mesh mode). A revoked/expired member presenting a stale cert // loses its session within a rekey interval instead of at process - // restart. Checked before the #36 adoption so a revoked peer is - // never adopted onto the relay. + // restart. Checked before the #34/#36 adoption so a revoked peer + // is never adopted onto the relay. if !self.is_root(remote_static) - && !self.responder_cert_ok(&initiator_payload, remote_static) + && !self.responder_cert_ok(initiator_cert, remote_static) { self.drop_session(idx); return DispatchOut::None; } - // #36: a relay==false (direct/punch-established) peer receiving a - // relayed cold-start RETRANSMIT of the Init that built our session - // (init_eph == cached_resp_init_eph) means the initiator could not - // complete our direct/punch reply and has moved to relay-only. - // Adopt the relay for OUR egress too, so B->A data also flows over - // the relay (else B keeps sending to A's dead punch address — the - // reverse black hole); rekey_init_core's case-1 dedup then replays - // cached_resp over the relay so A completes with the SAME ephemeral. - // A relayed Init with a NEW ephemeral (a genuine rekey, or an - // attack) against a direct peer is NOT adopted — it stays - // fail-closed, preserving #91's path-consistency guard for the - // session-churning case. Accepted tradeoff: an attacker replaying - // A's captured original Init forces a direct->relay path DOWNGRADE - // (data still reaches the real A via its registered node, no - // hijack); a full fix rides with #34 (authenticated endpoint). - if !self.peers[idx].relay && self.peers[idx].cached_resp_init_eph == Some(init_eph) + // #34/#36: a direct-established (relay==false) peer receiving a + // relayed FRESH new-ephemeral Init means the initiator escalated + // punch->relay (or restarted) with a fresh Init — adopt the relay + // for our egress and rebuild. Freshness-gated (accept_fresh_init), + // so a REPLAY (stale ts) is refused → no downgrade. A seen-ephemeral + // relayed Init is a retransmit of the escalation Init we already + // adopted+answered (this peer is then already relay==true) and is + // replayed by rekey_init_core's cached_resp dedup. The #91 + // path-consistency guard is preserved: a direct peer never + // completes a relayed Init unless it adopts the relay here. + // + // `last_accepted_init_ts.is_some()` is REQUIRED here (not present + // in earlier drafts of this gate): a peer that reached `Established` + // as the INITIATOR of its own direct session (it sent the cold-start + // Init and completed on the responder's `[HandshakeResp]`) never + // accepted an inbound Init from this peer, so `last_accepted_init_ts` + // stays `None` — and `accept_fresh_init` treats "no baseline yet" as + // always-fresh (by design, for genuine cold-start admission). Without + // this guard that same "no baseline" reading would let ANY fresh + // relayed Init hijack such a peer onto the relay, reintroducing + // exactly the #91 anti-hijack hole + // (`anti_hijack_established_peer_ignores_relayed_handshake_init`) — + // the adoption exception only ever applied to a peer that + // *previously responded* to a direct/punch Init (cached_resp_init_eph + // + last_accepted_init_ts set together), never to an initiator-only + // session. + if !self.peers[idx].relay + && self.peers[idx].last_accepted_init_ts.is_some() + && self.peers[idx].cached_resp_init_eph != Some(init_eph) + && self.accept_fresh_init(idx, &init_ts) { self.peers[idx].relay = true; } @@ -1180,7 +1171,7 @@ impl PeerManager { established, resp_pkt, init_eph, - now_ms, + init_ts, self.server_addr(), true, ) @@ -1200,10 +1191,19 @@ impl PeerManager { // sweep). No-op for pure 2a/2b: `responder_cert_ok` returns // `true` when membership is `None`. if !self.is_root(remote_static) - && !self.responder_cert_ok(&initiator_payload, remote_static) + && !self.responder_cert_ok(initiator_cert, remote_static) { return DispatchOut::None; } + // #34: same freshness gate as the direct cold-start arm below. + // A relay peer doesn't learn an `endpoint` (its egress is + // always relay-wrapped, keyed off `relay`/`server_addr`, not a + // direct address) but still records `last_accepted_init_ts` on + // a fresh accept, so a later direct-path replay of an old Init + // against this same peer can't rebuild a session either. + if !self.accept_fresh_init(idx, &init_ts) { + return DispatchOut::None; + } let conn_tag = conn_tag_from_keys(&established.auth_key, &established.hp_key); let sess_obf = self.session_obf_key_for(&established.hp_key); // A relay peer's egress is always re-wrapped, so the DataPlane's @@ -1221,6 +1221,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].last_accepted_init_ts = Some(init_ts); self.peers[idx].relay = true; self.peers[idx].path.committed(PathKind::Relayed); self.peers[idx].path_kind = Some(PathKind::Relayed); @@ -1600,6 +1601,35 @@ impl PeerManager { .is_some_and(|m| m.roots().iter().any(|(rpk, _)| *rpk == pk)) } + /// #34 anti-replay: whether `ts` is strictly newer than the greatest + /// label we have accepted in a session-building Init from peer `idx` (or + /// this is the first such Init). Gates both session rebuild/rekey + /// (`rekey_init_core`) and endpoint learning (the cold-start establish + /// arms of `handle_handshake_init`/`relayed_handshake_init`). + /// Retransmit/`cached_resp` paths do NOT call this — they replay + /// idempotently without a freshness check (see `rekey_init_core`'s + /// dedup cases 1/2). + /// + /// On refusal this logs a single-line stderr marker (same bare + /// `eprintln!` convention as this file's other handshake-rejection + /// notices, e.g. "peer_manager: relayed responder cert rejected") so a + /// black-box observer (the netns money tests) can confirm a replay was + /// actually refused here rather than by some other gate. The marker + /// carries no packet contents and changes no wire format — the ts still + /// rides only inside the encrypted msg1 payload. + fn accept_fresh_init(&self, idx: usize, ts: &[u8; 12]) -> bool { + match self.peers[idx].last_accepted_init_ts { + None => true, + Some(last) => { + let fresh = *ts > last; + if !fresh { + eprintln!("peer_manager: stale/replayed Init refused (freshness gate)"); + } + fresh + } + } + } + /// Tear down a peer's live session: remove every `conn_tag` it holds /// (current + any in-flight `next` + grace `previous`) from `by_tag`, and /// revert the peer to `Idle`. Idempotent for a non-Established peer. @@ -1645,6 +1675,15 @@ impl PeerManager { } }; + // #34: the msg1 payload is [ts || cert]. Split the anti-replay label + // off; the cert remainder is what admission checks. Too short to hold + // the label ⇒ malformed ⇒ fail closed. + let Some((init_ts, initiator_cert)) = + crate::handshake::parse_init_payload(&initiator_payload) + else { + return DispatchOut::None; + }; + // Admission: a configured/root/already-admitted peer (static-key match) // OR — with membership enabled — the initiator presented a valid // CA-signed cert covering its static key (`remote_static`). A cert-admit @@ -1656,7 +1695,7 @@ impl PeerManager { Some(i) => i, None => { let cert_admits = self.membership.as_ref().is_some_and(|m| { - Cert::decode(&initiator_payload) + Cert::decode(initiator_cert) .is_some_and(|cert| m.verify_cert(&cert, &remote_static, now_secs())) }); if !cert_admits { @@ -1683,11 +1722,13 @@ impl PeerManager { // (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. + // `PeerManager::accept_fresh_init` (#34) is the discriminator: a + // rekey Init's `ts` must be strictly newer than the greatest + // label we have ever accepted from this peer, else it is (a) — a + // retransmit/replay/backwards-clock peer — handled by + // `handle_rekey_init`'s cached-resp dedup (case 1/2) when it IS a + // retransmit, or silently dropped otherwise. `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 @@ -1707,7 +1748,7 @@ impl PeerManager { // loses its session within a rekey interval instead of at process // restart. if !self.is_root(remote_static) - && !self.responder_cert_ok(&initiator_payload, remote_static) + && !self.responder_cert_ok(initiator_cert, remote_static) { self.drop_session(idx); return DispatchOut::None; @@ -1715,7 +1756,7 @@ impl PeerManager { 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) + self.handle_rekey_init(idx, src, established, resp_pkt, init_ts, init_eph) } PeerState::Established(_) => DispatchOut::None, // Glare: both sides initiated simultaneously (e.g. the TUN's IPv6 @@ -1740,10 +1781,20 @@ impl PeerManager { // sweep). No-op for pure 2a/2b: `responder_cert_ok` returns // `true` when membership is `None`. if !self.is_root(remote_static) - && !self.responder_cert_ok(&initiator_payload, remote_static) + && !self.responder_cert_ok(initiator_cert, remote_static) { return DispatchOut::None; } + // #34: gate endpoint learning on a fresh accept. A cold-start + // (Idle) peer's first-ever Init is always fresh + // (`last_accepted_init_ts` is `None`); a peer that reached + // `Idle` again after `drop_session` (revocation/liveness/ + // give-up) still remembers the greatest label it ever + // accepted, so a replayed OLD Init cannot resurrect it and + // hijack `endpoint` toward a spoofed source. + if !self.accept_fresh_init(idx, &init_ts) { + return DispatchOut::None; + } let conn_tag = conn_tag_from_keys(&established.auth_key, &established.hp_key); let sess_obf = self.session_obf_key_for(&established.hp_key); let mut dp = Box::new(DataPlane::new( @@ -1757,6 +1808,7 @@ impl PeerManager { self.peers[idx].session_obf_key = sess_obf; self.peers[idx].endpoint = Some(src); // learn the observed endpoint + self.peers[idx].last_accepted_init_ts = Some(init_ts); 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); @@ -1820,31 +1872,38 @@ impl PeerManager { /// 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 + /// 3. `!self.accept_fresh_init(idx, &init_ts)` (#34): `init_ts` is not + /// strictly newer than the greatest label we have accepted from this + /// peer in a session-building Init — a stale/replayed Init, or a + /// backwards-clock peer. SILENT DROP (`DispatchOut::None`): unlike the + /// retired age gate, this does NOT fall back to resending + /// `cached_resp` — a stale-ts Init is either an attacker replay or a + /// clock regression, and either way it gets no reply. Case 1 above + /// (`cached_resp_init_eph` match) already answers the legitimate "same + /// round, arrived again" case — with the SAME ts as when it was first + /// accepted — before this point is ever reached, so this only fires + /// for a genuinely DIFFERENT (new-ephemeral) Init whose ts fails to + /// advance. This 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`. + /// green via case 1's dedup, not this gate. + /// 4. Otherwise: a genuinely NEW, fresh-ts 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. Record + /// `last_accepted_init_ts = init_ts` (#34) so a later replay of this + /// same round's ts can never be re-accepted. The responder's own + /// switch happens later, automatically, inside `EpochSet::inbound_open` + /// (Task 1) on the first inbound frame that authenticates under `next`. fn handle_rekey_init( &mut self, idx: usize, src: SocketAddr, established: Established, resp_pkt: Vec, - now_ms: u64, + init_ts: [u8; 12], init_eph: [u8; 32], ) -> DispatchOut<'_> { - self.rekey_init_core(idx, established, resp_pkt, init_eph, now_ms, src, false) + self.rekey_init_core(idx, established, resp_pkt, init_eph, init_ts, src, false) } /// Shared core for [`handle_rekey_init`] (`via_relay = false`) and its @@ -1867,7 +1926,7 @@ impl PeerManager { established: Established, resp_pkt: Vec, init_eph: [u8; 32], - now_ms: u64, + init_ts: [u8; 12], direct_src: SocketAddr, via_relay: bool, ) -> DispatchOut<'_> { @@ -1922,30 +1981,14 @@ impl PeerManager { }; } - 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.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, - }; + // #34: `init_ts` must be strictly newer than the greatest label we + // have ever accepted from this peer, else this is a stale/replayed + // Init (or a backwards-clock peer) — silently dropped, no reply. See + // `handle_rekey_init`'s doc comment (case 3) for why this is a + // silent drop rather than the retired age gate's cached-resp + // fallback. + if !self.accept_fresh_init(idx, &init_ts) { + return DispatchOut::None; } // NOTE: `session_obf_key` (the outer anti-DPI wrap key, keyed off @@ -1964,6 +2007,9 @@ impl PeerManager { unreachable!("state cannot change between the two borrows above") }; epochs.install_next(dp, init_eph, resp_pkt.clone()); + // #34: record the accepted label so a replay of this same round (or + // anything older) can never be re-accepted. + self.peers[idx].last_accepted_init_ts = Some(init_ts); self.egress.clear(); self.push_rekey_egress( @@ -2807,21 +2853,32 @@ impl PeerManager { }; match self.peers[i].path.advance(now_ms) { PathAction::Relay => { - // Escalate the in-flight direct/punch attempt to the relay, PRESERVING - // the ephemeral (#36): resend the same Init over the relay so a responder - // already Established on the old path completes us via its cached_resp. - // `retarget_handshake` clears `endpoint` (anti-mismatch) as the old - // Idle+begin_handshake path did. + // #34: escalate the in-flight direct/punch attempt to the relay by + // sending a FRESH Init (new ephemeral + fresh ts) instead of + // preserving the old one (#36, inverted). The responder now + // REBUILDS on a fresh new-ephemeral relayed Init — freshness-gated + // in `relayed_handshake_init` — so preserving the ephemeral to hit a + // stale `cached_resp` is no longer necessary, and a captured replay + // of an old Init can no longer force a relay downgrade (the closed + // #36 tradeoff). `endpoint` is cleared (anti-mismatch, Fix-pass-2, + // as the removed `retarget_handshake` did): a late direct + // `[HandshakeResp]` for the abandoned punch candidate must not match + // this now-relay-flagged peer. let server = self.server_addr(); - if let Some(dgs) = self.retarget_handshake(i, server, true, now_ms) { + self.peers[i].state = PeerState::Idle; + self.peers[i].endpoint = None; + if let Some(dgs) = self.begin_handshake(i, server, true, now_ms) { self.tick_egress.extend(dgs); } continue; } PathAction::Probe(addr) if addr != target => { - // The SM chose a *different* candidate: re-target the in-flight attempt, - // PRESERVING the ephemeral (#36) instead of abandoning it to a fresh one. - if let Some(dgs) = self.retarget_handshake(i, addr, false, now_ms) { + // #34: the SM chose a *different* candidate — re-target with a + // FRESH Init (new ephemeral + fresh ts), same inversion as the + // `Relay` arm above. `begin_handshake`'s direct branch re-stamps + // `endpoint` to `addr` itself. + self.peers[i].state = PeerState::Idle; + if let Some(dgs) = self.begin_handshake(i, addr, false, now_ms) { self.tick_egress.extend(dgs); } continue; @@ -3353,8 +3410,12 @@ mod tests { // A valid HandshakeInit from a real, but unconfigured, key. let stranger = generate_keypair(); - let (_hs, init_pkt) = - HandshakeState::start_initiator(&stranger.private, &local_kp.public, &[]).unwrap(); + let (_hs, init_pkt) = HandshakeState::start_initiator( + &stranger.private, + &local_kp.public, + &crate::handshake::frame_init_payload(&[]), + ) + .unwrap(); let src: SocketAddr = "203.0.113.5:5".parse().unwrap(); match pm.on_udp(src, &init_pkt, 0) { @@ -3431,6 +3492,37 @@ mod tests { vec![0x45u8; 40] } + // ── #34: handshake anti-replay test helpers ───────────────────────────── + + /// Build a msg1 app payload `[ts || cert]` with an EXPLICIT, caller-chosen + /// `ts` label — the test-only counterpart of `handshake::frame_init_payload` + /// (which always stamps `now_tai64n()`), needed to hand-craft stale/fresh + /// Inits deterministically. + fn init_payload_with_ts(ts: [u8; crate::handshake::TAI64N_LEN], cert: &[u8]) -> Vec { + let mut out = ts.to_vec(); + out.extend_from_slice(cert); + out + } + + /// One second OLDER than `ts` (TAI64N is big-endian seconds ++ nanos, so + /// decrementing the leading 8-byte seconds field yields a label that + /// compares strictly less, lexicographically, regardless of the nanos + /// suffix). + fn older_ts(ts: [u8; crate::handshake::TAI64N_LEN]) -> [u8; crate::handshake::TAI64N_LEN] { + let secs = u64::from_be_bytes(ts[..8].try_into().unwrap()); + let mut out = ts; + out[..8].copy_from_slice(&(secs - 1).to_be_bytes()); + out + } + + /// One second NEWER than `ts` — see `older_ts`. + fn newer_ts(ts: [u8; crate::handshake::TAI64N_LEN]) -> [u8; crate::handshake::TAI64N_LEN] { + let secs = u64::from_be_bytes(ts[..8].try_into().unwrap()); + let mut out = ts; + out[..8].copy_from_slice(&(secs + 1).to_be_bytes()); + out + } + // ── 9a Task 3: mid-session rekey scheduling ───────────────────────────── /// Build a `PeerManager` with a single already-`Established` peer (via @@ -3767,8 +3859,12 @@ mod tests { // 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 (hs, init_pkt) = HandshakeState::start_initiator( + &kp_a.private, + &kp_b.public, + &crate::handshake::frame_init_payload(&[]), + ) + .unwrap(); { let PeerState::Established(epochs) = &mut pm_a.peers[0].state else { panic!("pm_a must be Established"); @@ -3889,8 +3985,12 @@ mod tests { // 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 (hs, init_pkt) = HandshakeState::start_initiator( + &kp_a.private, + &kp_b.public, + &crate::handshake::frame_init_payload(&[]), + ) + .unwrap(); { let PeerState::Established(epochs) = &mut pm_a.peers[0].state else { panic!("pm_a must be Established"); @@ -3957,8 +4057,12 @@ mod tests { // 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(); + let (_hs1, init_pkt_1) = HandshakeState::start_initiator( + &kp_a.private, + &kp_b.public, + &crate::handshake::frame_init_payload(&[]), + ) + .unwrap(); // First delivery: B installs `next`, replies. let resp1 = resp_bytes(&pm_b.on_udp(ep_a, &init_pkt_1, 100)); @@ -3986,8 +4090,12 @@ mod tests { // 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(); + let (_hs2, init_pkt_2) = HandshakeState::start_initiator( + &kp_a.private, + &kp_b.public, + &crate::handshake::frame_init_payload(&[]), + ) + .unwrap(); assert_ne!( init_pkt_1, init_pkt_2, "sanity: the two Inits must actually differ" @@ -4021,8 +4129,12 @@ mod tests { // `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 (hs, init_pkt) = HandshakeState::start_initiator( + &kp_a.private, + &kp_b.public, + &crate::handshake::frame_init_payload(&[]), + ) + .unwrap(); { let PeerState::Established(epochs) = &mut pm_a.peers[0].state else { panic!("pm_a must be Established"); @@ -4082,39 +4194,61 @@ mod tests { } #[test] - fn rekey_init_on_established_installs_next_without_switching_send() { + fn rekey_init_freshness_gate_replaces_age_gate() { + // #34: `current`'s age is no longer the discriminator for whether a + // NEW-ephemeral Init against an Established peer is a genuine rekey + // — the retired `EpochSet::accept_rekey_init` age gate is replaced by + // `PeerManager::accept_fresh_init`'s ts freshness check. This test + // used to assert the OPPOSITE for its first case (a rekey Init + // arriving well under interval/2 was REJECTED); that assertion is + // now wrong by design (a fresh-ts Init completes regardless of + // `current`'s age), so it is rewritten here per the #34 admission + // change rather than left red. 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"), - } + // A never admitted an inbound Init (it was the cold-start + // INITIATOR), so its `last_accepted_init_ts` is `None`: the very + // first Init it receives is always fresh and completes — even at + // t=10, well under the OLD interval/2 = 50 threshold that used to + // gate this. + let t_b = crate::handshake::now_tai64n(); + let (_hs_early, init_pkt_early) = HandshakeState::start_initiator( + &kp_b.private, + &kp_a.public, + &init_payload_with_ts(t_b, &[]), + ) + .unwrap(); + let resp_early = resp_bytes(&pm_a.on_udp(ep_b, &init_pkt_early, 10)); + assert_eq!( + resp_early.len(), + 1, + "a fresh-ts new-ephemeral Init must complete regardless of current's age" + ); match &pm_a.peers[0].state { - PeerState::Established(epochs) => assert!( - epochs.next.is_none(), - "too-fresh current must never install `next`" - ), + PeerState::Established(epochs) => { + assert!(epochs.next.is_some(), "next must be installed") + } _ => 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(); + assert_eq!(pm_a.peers[0].last_accepted_init_ts, Some(t_b)); + + // B DOES have a baseline (it admitted A's cold-start Init). A + // fresh-ts rekey Init from A installs `next`, replies with a Resp, + // and leaves `current` untouched (B keeps sending on the OLD epoch) + // — exactly like before, just gated on `ts` now instead of age. + let last_b = pm_b.peers[0] + .last_accepted_init_ts + .expect("B admitted A's cold-start Init"); + let t_fresh = newer_ts(last_b); + let (_hs, init_pkt) = HandshakeState::start_initiator( + &kp_a.private, + &kp_b.public, + &init_payload_with_ts(t_fresh, &[]), + ) + .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 { @@ -4128,13 +4262,401 @@ mod tests { Some(old_tag_b), "current must be UNCHANGED — B still sends on the old epoch" ); + assert_eq!(pm_b.peers[0].last_accepted_init_ts, Some(t_fresh)); - // 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); + // A STALE-ts new-ephemeral Init (older than the label B just + // accepted) is dropped outright, no matter how much later in wall + // time it arrives — `current`'s age is irrelevant either way. + let t_stale = older_ts(t_fresh); + let (_hs2, init_pkt_stale) = HandshakeState::start_initiator( + &kp_a.private, + &kp_b.public, + &init_payload_with_ts(t_stale, &[]), + ) + .unwrap(); + match pm_b.on_udp(ep_a, &init_pkt_stale, 100_000) { + DispatchOut::None => {} + _ => panic!("a stale-ts rekey Init must be dropped"), + } + assert_eq!( + pm_b.peers[0].last_accepted_init_ts, + Some(t_fresh), + "a rejected Init must not move last_accepted_init_ts" + ); + + // B still sends on the OLD epoch (current untouched throughout): a + // frame it emits now still carries the OLD tag. + let still_old = pm_b.on_tun(&dummy_tun_pkt(), 100_000); assert_eq!(still_old[0].bytes[0], PacketType::Data as u8); } + #[test] + fn stale_replayed_init_is_rejected_and_endpoint_unchanged() { + // #34: an Established peer that already accepted an Init at ts T1 + // must reject a NEW-ephemeral Init carrying an OLDER ts T0 < T1 — + // even from a SPOOFED source address — as a silent drop: `current` + // untouched, the learned `endpoint` untouched, `last_accepted_init_ts` + // untouched. + let kp_r = generate_keypair(); + let kp_i = generate_keypair(); + let ep_i: SocketAddr = "10.0.0.30:3000".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; + + let t1 = crate::handshake::now_tai64n(); + let (_hs1, init_pkt_1) = HandshakeState::start_initiator( + &kp_i.private, + &kp_r.public, + &init_payload_with_ts(t1, &[]), + ) + .unwrap(); + let resp1 = resp_bytes(&pm_r.on_udp(ep_i, &init_pkt_1, 0)); + assert_eq!(resp1.len(), 1, "first fresh Init must establish"); + let tag1 = established_tag(&pm_r, 0).unwrap(); + assert_eq!(pm_r.peers[0].endpoint, Some(ep_i)); + assert_eq!(pm_r.peers[0].last_accepted_init_ts, Some(t1)); + + // A NEW-ephemeral Init (distinct from init_pkt_1) carrying an OLDER + // ts, arriving from a spoofed source, routes through the Established + // (rekey) arm — `rekey_init_core`'s freshness gate must drop it. + let t0 = older_ts(t1); + let (_hs2, init_pkt_2) = HandshakeState::start_initiator( + &kp_i.private, + &kp_r.public, + &init_payload_with_ts(t0, &[]), + ) + .unwrap(); + let spoofed: SocketAddr = "203.0.113.66:6".parse().unwrap(); + match pm_r.on_udp(spoofed, &init_pkt_2, 100) { + DispatchOut::None => {} + _ => panic!("a stale-ts new-ephemeral Init must be silently dropped"), + } + assert_eq!( + established_tag(&pm_r, 0), + Some(tag1), + "current must be untouched by a rejected Init" + ); + assert_eq!( + pm_r.peers[0].endpoint, + Some(ep_i), + "endpoint must not change on a rejected Init" + ); + assert_eq!( + pm_r.peers[0].last_accepted_init_ts, + Some(t1), + "last_accepted_init_ts must not change on a rejected Init" + ); + } + + #[test] + fn fresh_new_ephemeral_init_rebuilds_and_relearns_endpoint() { + // #34: a peer that was Established, got dropped back to `Idle` (e.g. + // a #41 cert-revocation sweep or the liveness sweep), but still + // remembers the greatest ts it ever accepted, correctly REBUILDS on + // a genuinely fresh-ts cold-start Init: new epoch, endpoint relearned + // from the new source, `last_accepted_init_ts` advances — while the + // old session's `by_tag` entry (evicted at drop time) stays gone. + let kp_r = generate_keypair(); + let kp_i = generate_keypair(); + let ep_i: SocketAddr = "10.0.0.31:3100".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, + ); + + let t1 = crate::handshake::now_tai64n(); + let (_hs1, init_pkt_1) = HandshakeState::start_initiator( + &kp_i.private, + &kp_r.public, + &init_payload_with_ts(t1, &[]), + ) + .unwrap(); + let resp1 = resp_bytes(&pm_r.on_udp(ep_i, &init_pkt_1, 0)); + assert_eq!(resp1.len(), 1); + let old_tag = established_tag(&pm_r, 0).unwrap(); + assert!(pm_r.by_tag.contains_key(&old_tag)); + + // Drop the session: reverts to Idle, evicts by_tag — but + // `last_accepted_init_ts` is in-memory and survives (never reset by + // `drop_session`). + pm_r.drop_session(0); + assert!(matches!(pm_r.peers[0].state, PeerState::Idle)); + assert!( + !pm_r.by_tag.contains_key(&old_tag), + "drop_session must evict the old tag" + ); + assert_eq!( + pm_r.peers[0].last_accepted_init_ts, + Some(t1), + "last_accepted_init_ts survives drop_session" + ); + + // A genuinely fresh-ts NEW-ephemeral cold-start Init from a NEW + // source address rebuilds the session. + let t2 = newer_ts(t1); + let new_src: SocketAddr = "10.0.0.32:3200".parse().unwrap(); + let (_hs2, init_pkt_2) = HandshakeState::start_initiator( + &kp_i.private, + &kp_r.public, + &init_payload_with_ts(t2, &[]), + ) + .unwrap(); + let resp2 = resp_bytes(&pm_r.on_udp(new_src, &init_pkt_2, 200)); + assert_eq!( + resp2.len(), + 1, + "a fresh-ts cold-start Init must rebuild the session" + ); + let new_tag = established_tag(&pm_r, 0).unwrap(); + assert_ne!(new_tag, old_tag); + assert!(pm_r.by_tag.contains_key(&new_tag)); + assert!( + !pm_r.by_tag.contains_key(&old_tag), + "the old tag must stay evicted" + ); + assert_eq!( + pm_r.peers[0].endpoint, + Some(new_src), + "endpoint must be relearned from the new source" + ); + assert_eq!( + pm_r.peers[0].last_accepted_init_ts, + Some(t2), + "last_accepted_init_ts must advance to the new label" + ); + } + + #[test] + fn stale_replayed_cold_start_init_does_not_hijack_endpoint() { + // #34 hijack fix, defensive direction: the sibling of + // `fresh_new_ephemeral_init_rebuilds_and_relearns_endpoint`. A peer + // that was Established (endpoint learned = `ep_i`), dropped back to + // `Idle`, still remembers `last_accepted_init_ts = t1`. A captured + // OLD Init (or any new-ephemeral Init with ts <= t1) replayed from a + // SPOOFED source must be rejected by the `Idle` arm's + // `accept_fresh_init` guard BEFORE it ever reaches the + // `endpoint = Some(src)` line — proving the guard actually gates + // endpoint learning, not just session admission. Without Step 3's + // gate, this scenario would silently redirect the peer's `endpoint` + // to the attacker's spoofed address. + let kp_r = generate_keypair(); + let kp_i = generate_keypair(); + let ep_i: SocketAddr = "10.0.0.34:3400".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, + ); + + let t1 = crate::handshake::now_tai64n(); + let (_hs1, init_pkt_1) = HandshakeState::start_initiator( + &kp_i.private, + &kp_r.public, + &init_payload_with_ts(t1, &[]), + ) + .unwrap(); + let resp1 = resp_bytes(&pm_r.on_udp(ep_i, &init_pkt_1, 0)); + assert_eq!(resp1.len(), 1); + assert_eq!(pm_r.peers[0].endpoint, Some(ep_i)); + + pm_r.drop_session(0); + assert!(matches!(pm_r.peers[0].state, PeerState::Idle)); + assert_eq!(pm_r.peers[0].last_accepted_init_ts, Some(t1)); + + // A NEW-ephemeral Init with ts <= t1, replayed from an attacker's + // SPOOFED source address (not `ep_i`). + let t0 = older_ts(t1); + let (_hs2, init_pkt_2) = HandshakeState::start_initiator( + &kp_i.private, + &kp_r.public, + &init_payload_with_ts(t0, &[]), + ) + .unwrap(); + let spoofed: SocketAddr = "203.0.113.77:7".parse().unwrap(); + match pm_r.on_udp(spoofed, &init_pkt_2, 200) { + DispatchOut::None => {} + _ => panic!("a stale-ts cold-start Init must be silently dropped"), + } + assert!( + matches!(pm_r.peers[0].state, PeerState::Idle), + "a rejected cold-start Init must not admit a session" + ); + assert_eq!( + pm_r.peers[0].endpoint, + Some(ep_i), + "endpoint must NOT be hijacked toward the spoofed source" + ); + assert_eq!( + pm_r.peers[0].last_accepted_init_ts, + Some(t1), + "last_accepted_init_ts must not change on a rejected Init" + ); + assert!( + pm_r.by_tag.is_empty(), + "no session (hence no conn_tag) must have been admitted" + ); + } + + /// Relay-path counterpart of + /// `stale_replayed_cold_start_init_does_not_hijack_endpoint`: the same + /// #34 freshness gate, `if !self.accept_fresh_init(idx, &init_ts) { + /// return DispatchOut::None; }`, guards `relayed_handshake_init`'s own + /// `Idle | Handshaking` arm (~line 1241), not just the direct path's. + /// A relay-reached peer that was `Established`, dropped back to `Idle`, + /// still remembers `last_accepted_init_ts = t1`. A captured OLD Init + /// (new ephemeral, ts <= t1) redelivered over the relay must be rejected + /// BEFORE it re-admits a session, exactly like the direct-path sibling — + /// proving the gate is wired into the relay cold-start arm too, not only + /// `handle_handshake_init`'s. + #[test] + fn stale_replayed_relayed_cold_start_init_is_rejected() { + let local = generate_keypair(); + let peer_kp = generate_keypair(); + let cfg_peer = PeerConfig { + public_key: peer_kp.public, + endpoint: None, + }; + let (mut pm, _sent) = pm_with_mock_rdv(&local, &[cfg_peer]); + + // Cold-start over the relay with a genuine ts `t1`: establishes and + // records `last_accepted_init_ts`. + let t1 = crate::handshake::now_tai64n(); + let (_hs1, init_pkt_1) = HandshakeState::start_initiator( + &peer_kp.private, + &local.public, + &init_payload_with_ts(t1, &[]), + ) + .unwrap(); + let out1 = pm.on_udp(mock_server(), &relay_deliver(&peer_kp, init_pkt_1), 0); + assert!( + has_relayed_handshake_resp(&out1), + "a genuine relayed cold-start Init must establish" + ); + assert!(pm.peers[0].relay, "sanity: the peer adopted the relay path"); + assert_eq!(pm.peers[0].last_accepted_init_ts, Some(t1)); + + // Drop the session: reverts to Idle, evicts by_tag — but + // `last_accepted_init_ts` (and `relay`) are in-memory and survive + // (never reset by `drop_session`), same as the direct-path sibling. + pm.drop_session(0); + assert!(matches!(pm.peers[0].state, PeerState::Idle)); + assert_eq!(pm.peers[0].last_accepted_init_ts, Some(t1)); + + // A NEW-ephemeral Init with ts < t1, redelivered over the relay (as + // a captured/replayed datagram forwarded by the rendezvous server). + let t0 = older_ts(t1); + let (_hs2, init_pkt_2) = HandshakeState::start_initiator( + &peer_kp.private, + &local.public, + &init_payload_with_ts(t0, &[]), + ) + .unwrap(); + let out2 = pm.on_udp(mock_server(), &relay_deliver(&peer_kp, init_pkt_2), 200); + + match out2 { + DispatchOut::None => {} + _ => panic!("a stale-ts relayed cold-start Init must be silently dropped"), + } + assert!( + matches!(pm.peers[0].state, PeerState::Idle), + "a rejected relayed cold-start Init must not admit a session" + ); + assert_eq!( + pm.peers[0].last_accepted_init_ts, + Some(t1), + "last_accepted_init_ts must not change on a rejected relayed Init" + ); + assert!( + pm.by_tag.is_empty(), + "no session (hence no conn_tag) must have been admitted" + ); + } + + #[test] + fn retransmit_still_replays_cached_resp_regardless_of_ts() { + // #34: a retransmit of the SAME Init (identical ephemeral == identical + // ts) is recognized by the pre-existing `cached_resp_init_eph` dedup + // — checked BEFORE the ts freshness gate — so it still replays + // `cached_resp` verbatim and never rejects or mutates `endpoint`/ + // `last_accepted_init_ts`, even though its ts is NOT strictly newer + // than itself (retransmit != rebuild). + let kp_r = generate_keypair(); + let kp_i = generate_keypair(); + let ep_i: SocketAddr = "10.0.0.33:3300".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, + ); + + let t1 = crate::handshake::now_tai64n(); + let (_hs1, init_pkt) = HandshakeState::start_initiator( + &kp_i.private, + &kp_r.public, + &init_payload_with_ts(t1, &[]), + ) + .unwrap(); + let resp1 = resp_bytes(&pm_r.on_udp(ep_i, &init_pkt, 0)); + assert_eq!(resp1.len(), 1); + let tag1 = established_tag(&pm_r, 0).unwrap(); + + // Retransmit: EXACT same bytes (same ephemeral, same ts), delivered + // much later. + let resp2 = resp_bytes(&pm_r.on_udp(ep_i, &init_pkt, 500)); + assert_eq!( + resp2, resp1, + "a retransmit must resend the cached Resp verbatim" + ); + assert_eq!( + established_tag(&pm_r, 0), + Some(tag1), + "current must be untouched by a retransmit" + ); + assert_eq!(pm_r.peers[0].endpoint, Some(ep_i)); + assert_eq!( + pm_r.peers[0].last_accepted_init_ts, + Some(t1), + "last_accepted_init_ts must not change on a retransmit" + ); + } + #[test] fn glare_simultaneous_init_converges_on_one_session() { // Both peers configured with each other; neither initiates until it @@ -4232,8 +4754,12 @@ mod tests { ); // The initiator's HandshakeInit (built out-of-band, as if received). - let (_hs, init_pkt) = - HandshakeState::start_initiator(&kp_i.private, &kp_r.public, &[]).unwrap(); + let (_hs, init_pkt) = HandshakeState::start_initiator( + &kp_i.private, + &kp_r.public, + &crate::handshake::frame_init_payload(&[]), + ) + .unwrap(); // First delivery establishes the responder session; capture its reply. let resp1 = resp_bytes(&pm_r.on_udp(ep_i, &init_pkt, 0)); @@ -4255,12 +4781,14 @@ mod tests { 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 + // ORIGINAL cold-start Init can legitimately still be in flight well + // past interval/2. 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`. + // answered with the original cached reply — never misclassified as a + // rekey round (which would install a spurious `next`) or, post-#34, + // dropped as stale (the retransmit's `ts` equals — not exceeds — + // `last_accepted_init_ts`, so the ephemeral dedup MUST run before the + // freshness gate is ever consulted; see `rekey_init_core`'s case 1). let kp_r = generate_keypair(); let kp_i = generate_keypair(); let ep_i: SocketAddr = "10.0.0.20:2000".parse().unwrap(); @@ -4279,17 +4807,20 @@ mod tests { ); 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 (_hs, init_pkt) = HandshakeState::start_initiator( + &kp_i.private, + &kp_r.public, + &crate::handshake::frame_init_payload(&[]), + ) + .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`. + // interval/2 (50). It is NOT a plausible rekey: 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, @@ -4525,46 +5056,51 @@ mod tests { (pm, 0) } - /// #36 Task 1: `retarget_handshake` must preserve the in-flight Noise - /// ephemeral (resend the SAME `init_pkt`) across a path re-target rather - /// than minting a fresh one — see the module-level #36 discussion at the - /// `PathAction::Relay`/`PathAction::Probe` escalation arms in - /// `tick_dispatch`. + /// #34 Task 4: retires #36's ephemeral-preservation hack. A path + /// re-target of an in-flight `Handshaking` attempt now sends a FRESH + /// Init (new ephemeral, drawn by `begin_handshake` off a fresh + /// `Idle` state) instead of resending the old `init_pkt` — the inverse + /// of the removed `retarget_handshake_preserves_ephemeral_and_flips_relay`. + /// The old #36 concern (a fresh ephemeral orphans the responder's + /// `cached_resp`) is resolved on the responder side instead: it REBUILDS + /// on a fresh new-ephemeral relayed Init (see + /// `direct_established_responder_adopts_relay_on_fresh_relayed_init_and_rebuilds`), + /// so preserving the ephemeral here is no longer necessary. #[test] - fn retarget_handshake_preserves_ephemeral_and_flips_relay() { + fn path_switch_sends_fresh_init_and_responder_rebuilds() { // A peer mid-handshake toward a direct candidate. let (mut pm, idx) = pm_handshaking_direct_peer([7u8; 32], "10.0.0.9:9000", 100); - let (orig_init, orig_started, orig_target) = match &pm.peers[idx].state { - PeerState::Handshaking(h) => (h.init_pkt.clone(), h.started_ms, h.target), + let (orig_init, orig_target) = match &pm.peers[idx].state { + PeerState::Handshaking(h) => (h.init_pkt.clone(), h.target), _ => panic!("peer must be Handshaking"), }; + let orig_eph = + crate::handshake::init_ephemeral(&orig_init).expect("valid Init carries an ephemeral"); let server = pm.server_addr(); - // Re-target to the relay (Punch->Relay escalation). + // Re-target to the relay (Punch->Relay escalation): the #34 arm resets + // to `Idle` (clearing `endpoint`, anti-mismatch) and calls + // `begin_handshake`, exactly like the production `PathAction::Relay` + // arm in `tick_dispatch`. + pm.peers[idx].state = PeerState::Idle; + pm.peers[idx].endpoint = None; let out = pm - .retarget_handshake(idx, server, true, 5_000) + .begin_handshake(idx, server, true, 5_000) .expect("emits an Init"); - // Ephemeral preserved: the resent Init is byte-identical, still Handshaking, - // started_ms unchanged (the 90s give-up clock keeps running). + // A FRESH ephemeral is drawn — NOT the byte-identical resend #36 used to do. match &pm.peers[idx].state { PeerState::Handshaking(h) => { - assert_eq!( - h.init_pkt, orig_init, - "init_pkt (ephemeral) must be preserved" - ); - assert_eq!( - h.started_ms, orig_started, - "started_ms must not reset on re-target" - ); - assert_eq!( - h.last_sent_ms, 5_000, - "last_sent_ms resets to now so no immediate redundant retransmit" + let new_eph = crate::handshake::init_ephemeral(&h.init_pkt) + .expect("valid Init carries an ephemeral"); + assert_ne!( + new_eph, orig_eph, + "path switch must draw a FRESH ephemeral, not resend the old Init (#34 inverts #36)" ); assert_eq!(h.target, server, "target must update to the new path"); assert_ne!(h.target, orig_target); } - _ => panic!("peer must stay Handshaking"), + _ => panic!("peer must be Handshaking again after the fresh begin_handshake"), } assert!( pm.peers[idx].relay, @@ -4574,7 +5110,7 @@ mod tests { pm.peers[idx].endpoint.is_none(), "relay re-target clears endpoint (anti-mismatch)" ); - // The emitted datagram is the relay-wrapped Init (a RelaySend), carrying the SAME ephemeral. + // The emitted datagram is the relay-wrapped Init (a RelaySend), carrying the NEW ephemeral. assert!( has_relayed_handshake_init(Some(&out)), "must emit a relay-wrapped Init" @@ -4857,8 +5393,12 @@ mod tests { // A valid HandshakeInit from the peer, delivered THROUGH the relay // (RelayDeliver from the server, src = peer node). - let (_hs, init_pkt) = - HandshakeState::start_initiator(&peer_kp.private, &local.public, &[]).unwrap(); + let (_hs, init_pkt) = HandshakeState::start_initiator( + &peer_kp.private, + &local.public, + &crate::handshake::frame_init_payload(&[]), + ) + .unwrap(); let mut buf = Vec::new(); yip_rendezvous::encode( &yip_rendezvous::Message::RelayDeliver { @@ -5064,8 +5604,12 @@ mod tests { }; let (mut pm_r, _sent) = pm_with_mock_rdv(&kp_r, &[cfg_a]); - let (_hs, a_init_pkt) = - HandshakeState::start_initiator(&kp_a.private, &kp_r.public, &[]).unwrap(); + let (_hs, a_init_pkt) = HandshakeState::start_initiator( + &kp_a.private, + &kp_r.public, + &crate::handshake::frame_init_payload(&[]), + ) + .unwrap(); let buf = relay_deliver(&kp_a, a_init_pkt.clone()); let out = pm_r.on_udp(mock_server(), &buf, now_ms); assert!( @@ -5134,8 +5678,12 @@ mod tests { }; let (mut pm_r, _sent) = pm_with_mock_rdv(&kp_r, &[cfg_a]); - let (_hs, a_init_pkt) = - HandshakeState::start_initiator(&kp_a.private, &kp_r.public, &[]).unwrap(); + let (_hs, a_init_pkt) = HandshakeState::start_initiator( + &kp_a.private, + &kp_r.public, + &crate::handshake::frame_init_payload(&[]), + ) + .unwrap(); let resp1 = resp_bytes(&pm_r.on_udp(ep_a, &a_init_pkt, now_ms)); assert_eq!( resp1.len(), @@ -5154,48 +5702,90 @@ mod tests { (pm_r, kp_a, a_init_pkt) } - /// #36 Task 1b (the responder-side half of the headline scenario): a - /// peer that adopted the responder role over a DIRECT/punch path - /// (`Established`, `relay == false`, holding `cached_resp` for A's - /// ephemeral E1) receives a RELAYED cold-start RETRANSMIT of that same - /// Init (E1) — A escalated punch->relay and resent its original Init - /// unchanged. B must adopt the relay for its own egress (else B keeps + /// #34 Task 4 (retires #36): a peer that adopted the responder role over + /// a DIRECT/punch path (`Established`, `relay == false`) receives a + /// RELAYED FRESH new-ephemeral Init — the initiator escalated + /// punch->relay and sent a FRESH Init (#34 inverts #36's ephemeral + /// preservation, so this is no longer a byte-identical retransmit of the + /// original). B adopts the relay for its own egress (else B keeps /// sending to A's dead punch address — the reverse black hole) and - /// replay `cached_resp` over the relay, without churning a new session. + /// REBUILDS: `rekey_init_core` installs a fresh `next` epoch (a + /// genuinely new session round, not a `cached_resp` replay) and advances + /// `last_accepted_init_ts`. #[test] - fn direct_established_responder_adopts_relay_on_relayed_cold_start_retransmit() { - let (mut pm_r, _kp_a, a_init_pkt) = responder_established_direct_for_initiator(100); - let tag_before = established_tag(&pm_r, 0); - - // A escalated to relay and resent the SAME init (E1), relay-wrapped. - let relayed = wrap_relay_deliver(&pm_r, &a_init_pkt); - let replayed = has_relayed_handshake_resp(&pm_r.on_udp(mock_server(), &relayed, 5_000)); + fn direct_established_responder_adopts_relay_on_fresh_relayed_init_and_rebuilds() { + let (mut pm_r, kp_a, _a_init_pkt) = responder_established_direct_for_initiator(100); + let local_pub = pm_r.local_pub; + let tag_before = established_tag(&pm_r, 0).expect("responder established"); + let last_ts = pm_r.peers[0] + .last_accepted_init_ts + .expect("cold-start Init recorded a ts"); + + // A escalated punch->relay with a FRESH Init: new ephemeral, fresh ts. + let t_fresh = newer_ts(last_ts); + let (_hs2, fresh_init_pkt) = HandshakeState::start_initiator( + &kp_a.private, + &local_pub, + &init_payload_with_ts(t_fresh, &[]), + ) + .unwrap(); + let relayed = wrap_relay_deliver(&pm_r, &fresh_init_pkt); + let out = pm_r.on_udp(mock_server(), &relayed, 5_000); + assert!( + has_relayed_handshake_resp(&out), + "must reply with a relay-wrapped Resp" + ); assert!( pm_r.peers[0].relay, "must adopt the relay for B's own egress" ); - assert!(replayed, "must replay cached resp over the relay"); assert_eq!( established_tag(&pm_r, 0), - tag_before, - "current session must be untouched (no churn)" + Some(tag_before), + "current epoch stays untouched (rekey semantics: B keeps sending on it \ + until the initiator confirms the new one)" + ); + match &pm_r.peers[0].state { + PeerState::Established(epochs) => assert!( + epochs.next.is_some(), + "a fresh new-ephemeral Init must REBUILD: install a new `next` epoch, \ + not replay a cached_resp" + ), + _ => panic!("must stay Established"), + } + assert_eq!( + pm_r.peers[0].last_accepted_init_ts, + Some(t_fresh), + "last_accepted_init_ts must advance to the fresh label" ); } - /// #36 Task 1b: the same direct-established peer receiving a RELAYED - /// Init with a DIFFERENT ephemeral (a genuine new rekey Init, or an - /// attack) must NOT adopt the relay and must fail-closed drop, preserving - /// #91's path-consistency guard for the session-churning case. + /// #34 Task 4 (retires #36): the same direct-established peer receiving + /// a RELAYED Init with a DIFFERENT ephemeral but a STALE ts (not + /// strictly newer than what we already accepted from this peer) must NOT + /// adopt the relay and must NOT rebuild — `accept_fresh_init` refuses it + /// before `relay` is ever flipped. This is the downgrade #36 used to + /// accept as a tradeoff (an attacker replaying a captured old Init could + /// force a direct->relay downgrade); #34 closes it. #[test] fn direct_established_responder_ignores_relayed_new_ephemeral_init() { let (mut pm_r, kp_a, _a_init_pkt) = responder_established_direct_for_initiator(100); let local_pub = pm_r.local_pub; - - // A fresh Init from the SAME initiator identity draws a NEW ephemeral. - let (_hs2, other_init_pkt) = - HandshakeState::start_initiator(&kp_a.private, &local_pub, &[]).unwrap(); - let relayed = wrap_relay_deliver(&pm_r, &other_init_pkt); + let tag_before = established_tag(&pm_r, 0).expect("responder established"); + let last_ts = pm_r.peers[0] + .last_accepted_init_ts + .expect("cold-start Init recorded a ts"); + + // A relayed Init with a NEW ephemeral but a STALE ts (<= last accepted) — a replay. + let t_stale = older_ts(last_ts); + let (_hs2, stale_init_pkt) = HandshakeState::start_initiator( + &kp_a.private, + &local_pub, + &init_payload_with_ts(t_stale, &[]), + ) + .unwrap(); + let relayed = wrap_relay_deliver(&pm_r, &stale_init_pkt); let dropped = matches!( pm_r.on_udp(mock_server(), &relayed, 5_000), DispatchOut::None @@ -5203,9 +5793,19 @@ mod tests { assert!( !pm_r.peers[0].relay, - "must NOT adopt relay for a new-ephemeral relayed init" + "must NOT adopt relay for a stale-ts relayed init — the downgrade is closed" + ); + assert!(dropped, "must fail-closed drop, no reply"); + assert_eq!( + established_tag(&pm_r, 0), + Some(tag_before), + "must NOT rebuild: current session untouched" + ); + assert_eq!( + pm_r.peers[0].last_accepted_init_ts, + Some(last_ts), + "last_accepted_init_ts must not change on a rejected Init" ); - assert!(dropped, "must fail-closed drop, mirroring #91's guard"); } // ── #91 Task 2: relay-path rekey completion ───────────────────────────── @@ -5296,8 +5896,12 @@ mod tests { // 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 (_hs1, init_pkt_1) = HandshakeState::start_initiator( + &peer_kp.private, + &local.public, + &crate::handshake::frame_init_payload(&[]), + ) + .unwrap(); let buf1 = relay_deliver(&peer_kp, init_pkt_1.clone()); // First delivery at t=100 (current age 100 >= interval/2 = 50): @@ -5332,8 +5936,12 @@ mod tests { // 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(); + let (_hs2, init_pkt_2) = HandshakeState::start_initiator( + &peer_kp.private, + &local.public, + &crate::handshake::frame_init_payload(&[]), + ) + .unwrap(); assert_ne!( init_pkt_1, init_pkt_2, "sanity: the two Inits must actually differ" @@ -5365,8 +5973,12 @@ mod tests { // 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 (hs, init_pkt) = HandshakeState::start_initiator( + &local.private, + &peer_kp.public, + &crate::handshake::frame_init_payload(&[]), + ) + .unwrap(); { let PeerState::Established(epochs) = &mut pm.peers[0].state else { panic!("pm must be Established"); @@ -5456,8 +6068,12 @@ mod tests { // 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 (hs, init_pkt) = HandshakeState::start_initiator( + &local.private, + &peer_kp.public, + &crate::handshake::frame_init_payload(&[]), + ) + .unwrap(); { let PeerState::Established(epochs) = &mut pm.peers[0].state else { panic!("pm must be Established"); @@ -5561,8 +6177,12 @@ mod tests { 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(); + let (_hs, init_pkt) = HandshakeState::start_initiator( + &peer_kp.private, + &local.public, + &crate::handshake::frame_init_payload(&[]), + ) + .unwrap(); assert!( matches!( @@ -5926,9 +6546,12 @@ mod tests { ); let mut cert_bytes = Vec::new(); cert.encode(&mut cert_bytes); - let (_hs, init_pkt) = - HandshakeState::start_initiator(&stranger.private, &local.public, &cert_bytes) - .unwrap(); + let (_hs, init_pkt) = HandshakeState::start_initiator( + &stranger.private, + &local.public, + &crate::handshake::frame_init_payload(&cert_bytes), + ) + .unwrap(); let replies = resp_bytes(&pm.on_udp(src, &init_pkt, 0)); assert_eq!(replies.len(), 1, "a valid cert is admitted and replied to"); @@ -5950,8 +6573,12 @@ mod tests { Some(membership_for(&ca, local.public)), false, ); - let (_hs, init_pkt) = - HandshakeState::start_initiator(&stranger.private, &local.public, &[]).unwrap(); + let (_hs, init_pkt) = HandshakeState::start_initiator( + &stranger.private, + &local.public, + &crate::handshake::frame_init_payload(&[]), + ) + .unwrap(); assert!(matches!(pm.on_udp(src, &init_pkt, 0), DispatchOut::None)); assert!(pm.peers.is_empty(), "no cert ⇒ no admission"); assert!(pm.by_tag.is_empty()); @@ -5978,11 +6605,90 @@ mod tests { ); let mut cert_bytes = Vec::new(); bad_cert.encode(&mut cert_bytes); + let (_hs, init_pkt) = HandshakeState::start_initiator( + &stranger.private, + &local.public, + &crate::handshake::frame_init_payload(&cert_bytes), + ) + .unwrap(); + assert!(matches!(pm.on_udp(src, &init_pkt, 0), DispatchOut::None)); + assert!(pm.peers.is_empty(), "untrusted-CA cert ⇒ no admission"); + assert!(pm.by_tag.is_empty()); + } + } + + /// #34 Task 2: the msg1 payload is now `[ts || cert]`. A properly framed + /// Init (built via `frame_init_payload`, exactly as the real initiator's + /// `begin_handshake`/`drive_rekey_schedule` now do) still recovers the + /// cert remainder after the responder strips the 12-byte ts label, and + /// admits/establishes exactly as before the wire format changed. + /// + /// The negative counterpart proves the framing is actually enforced on + /// the consume side, not merely a no-op: an Init carrying a RAW cert with + /// NO ts prefix (the pre-#34 wire format) no longer admits, because the + /// responder's parse consumes the cert's own leading 12 bytes as the ts + /// label, corrupting the cert remainder handed to `Cert::decode`. + #[test] + fn framed_init_payload_still_establishes_and_admits_by_cert() { + let ca = test_ca(); + let local = generate_keypair(); + let src: SocketAddr = "203.0.113.5:5".parse().unwrap(); + + let stranger = generate_keypair(); + let stranger_sign = SigningKey::from_bytes(&[220u8; 32]); + let cert = mk_cert( + &ca, + stranger.public, + stranger_sign.verifying_key().to_bytes(), + ); + let mut cert_bytes = Vec::new(); + cert.encode(&mut cert_bytes); + + // ── properly framed [ts || cert] → admitted + establishes ── + { + let mut pm = PeerManager::new( + local.private, + local.public, + &[], + TunnelMode::L3Tun, + None, + Some(membership_for(&ca, local.public)), + false, + ); + let framed = crate::handshake::frame_init_payload(&cert_bytes); + let (_hs, init_pkt) = + HandshakeState::start_initiator(&stranger.private, &local.public, &framed).unwrap(); + + let replies = resp_bytes(&pm.on_udp(src, &init_pkt, 0)); + assert_eq!( + replies.len(), + 1, + "a framed [ts || cert] Init is admitted and replied to" + ); + assert_eq!(pm.peers.len(), 1, "the cert-verified member was admitted"); + assert!(matches!(pm.peers[0].state, PeerState::Established(_))); + } + + // ── raw (unframed) cert, no ts prefix → NOT admitted ── + { + let mut pm = PeerManager::new( + local.private, + local.public, + &[], + TunnelMode::L3Tun, + None, + Some(membership_for(&ca, local.public)), + false, + ); let (_hs, init_pkt) = HandshakeState::start_initiator(&stranger.private, &local.public, &cert_bytes) .unwrap(); - assert!(matches!(pm.on_udp(src, &init_pkt, 0), DispatchOut::None)); - assert!(pm.peers.is_empty(), "untrusted-CA cert ⇒ no admission"); + assert!( + matches!(pm.on_udp(src, &init_pkt, 0), DispatchOut::None), + "an unframed raw-cert Init must no longer admit: the responder mis-reads \ + its leading 12 bytes as the ts label, corrupting the cert remainder" + ); + assert!(pm.peers.is_empty(), "no admission from an unframed payload"); assert!(pm.by_tag.is_empty()); } } @@ -6010,10 +6716,12 @@ mod tests { /// `local_sign_seed`/`peer_sign_seed` seed the local/peer Ed25519 /// signing keys embedded in their CA-signed certs (mirrors /// `membership_for`'s `[200u8; 32]` pattern); `interval_ms` becomes - /// `pm.rekey_interval_ms`, so a rekey Init at a `now_ms` far past - /// `interval_ms / 2` is accepted by `EpochSet::accept_rekey_init`. The - /// peer's data-plane keypair is generated fresh and stashed in the - /// registry above (keyed by its pubkey). + /// `pm.rekey_interval_ms` (kept as a parameter for parity with the other + /// #41 rekey fixtures, though #34's freshness gate — not `current`'s age + /// — is what actually admits the rekey Inits these tests craft below, + /// via real `now_tai64n()` timestamps that are always fresher than the + /// cold-start's). The peer's data-plane keypair is generated fresh and + /// stashed in the registry above (keyed by its pubkey). fn pm_mesh_established_peer( local_sign_seed: [u8; 32], peer_sign_seed: [u8; 32], @@ -6065,7 +6773,7 @@ mod tests { let (_hs, init_pkt) = HandshakeState::start_initiator( &peer.private, &local.public, - &valid_cert_bytes(&pm, 0), + &crate::handshake::frame_init_payload(&valid_cert_bytes(&pm, 0)), ) .unwrap(); let out = pm.on_udp(peer_ep, &init_pkt, 0); @@ -6087,8 +6795,9 @@ mod tests { /// A `[HandshakeInit] ++ msg1` datagram "from" `pm.peers[idx]` (using its /// private key stashed by `pm_mesh_established_peer`), carrying `payload` - /// as the msg1 app payload — the slot `responder_cert_ok` reads the cert - /// from on a rekey Init. + /// as the msg1 app CERT payload — the slot `responder_cert_ok` reads the + /// cert from on a rekey Init. `payload` is framed as `[ts || payload]` + /// (#34), mirroring what a real initiator (`drive_rekey_schedule`) sends. fn rekey_init_with_payload(pm: &PeerManager, idx: usize, payload: &[u8]) -> Vec { let peer_pub = pm.peers[idx].pubkey; let (peer_priv, _sign_seed) = *peer_test_key_registry() @@ -6096,8 +6805,9 @@ mod tests { .unwrap() .get(&peer_pub) .expect("peer keypair registered by pm_mesh_established_peer"); + let framed = crate::handshake::frame_init_payload(payload); let (_hs, init_pkt) = - HandshakeState::start_initiator(&peer_priv, &pm.local_pub, payload).unwrap(); + HandshakeState::start_initiator(&peer_priv, &pm.local_pub, &framed).unwrap(); init_pkt } @@ -6155,7 +6865,9 @@ mod tests { /// its live session until process restart (#41). #[test] fn rekey_init_with_invalid_cert_drops_session() { - // Mesh Established peer, current old enough that accept_rekey_init would pass. + // Mesh Established peer; `rekey_init_with_payload` crafts each rekey + // Init with a real, later `now_tai64n()` ts, so #34's freshness gate + // admits it regardless of `interval_ms`. let (mut pm, tag) = pm_mesh_established_peer([5u8; 32], [6u8; 32], /*age past interval/2*/ 100_000); assert_eq!(established_tag(&pm, 0), Some(tag)); @@ -6295,8 +7007,12 @@ mod tests { // Cold-start Init from the root, NO cert payload — would fail // `responder_cert_ok` for a non-root peer. - let (_hs, init_pkt) = - HandshakeState::start_initiator(&root.private, &local.public, &[]).unwrap(); + let (_hs, init_pkt) = HandshakeState::start_initiator( + &root.private, + &local.public, + &crate::handshake::frame_init_payload(&[]), + ) + .unwrap(); let out = pm.on_udp(root_ep, &init_pkt, 0); assert_eq!( @@ -6352,8 +7068,12 @@ mod tests { // Cold-start Init from the root, NO cert payload — admitted by the // Task 2b exemption (mirrors `root_exempt_from_readmission_check`). - let (_hs, init_pkt) = - HandshakeState::start_initiator(&root.private, &local.public, &[]).unwrap(); + let (_hs, init_pkt) = HandshakeState::start_initiator( + &root.private, + &local.public, + &crate::handshake::frame_init_payload(&[]), + ) + .unwrap(); let out = pm.on_udp(root_ep, &init_pkt, 0); assert_eq!(resp_bytes(&out).len(), 1, "root cold-start admitted"); assert!(matches!(pm.peers[0].state, PeerState::Established(_))); @@ -6363,8 +7083,12 @@ mod tests { // ordinary retransmit-of-a-different-round or a genuine rekey, // either reachable in normal operation), again carrying an EMPTY // (no-cert) payload, arriving well past interval/2. - let (_hs2, init_pkt2) = - HandshakeState::start_initiator(&root.private, &local.public, &[]).unwrap(); + let (_hs2, init_pkt2) = HandshakeState::start_initiator( + &root.private, + &local.public, + &crate::handshake::frame_init_payload(&[]), + ) + .unwrap(); let _out2 = pm.on_udp(root_ep, &init_pkt2, 200_000); assert!( @@ -6402,8 +7126,12 @@ mod tests { false, ); - let (_hs, init1) = - HandshakeState::start_initiator(&peer.private, &local.public, &[]).unwrap(); + let (_hs, init1) = HandshakeState::start_initiator( + &peer.private, + &local.public, + &crate::handshake::frame_init_payload(&[]), + ) + .unwrap(); let out1 = pm.on_udp(peer_ep, &init1, 0); assert_eq!(resp_bytes(&out1).len(), 1, "initial cold-start establishes"); assert!(matches!(pm.peers[0].state, PeerState::Established(_))); @@ -6413,8 +7141,12 @@ mod tests { pm.drop_session(0); assert!(matches!(pm.peers[0].state, PeerState::Idle)); - let (_hs2, init2) = - HandshakeState::start_initiator(&peer.private, &local.public, &[]).unwrap(); + let (_hs2, init2) = HandshakeState::start_initiator( + &peer.private, + &local.public, + &crate::handshake::frame_init_payload(&[]), + ) + .unwrap(); let out2 = pm.on_udp(peer_ep, &init2, 1_000); assert_eq!( resp_bytes(&out2).len(), @@ -6475,7 +7207,7 @@ mod tests { let (_hs, init_pkt) = HandshakeState::start_initiator( &peer.private, &local.public, - &valid_cert_bytes(&pm, 0), + &crate::handshake::frame_init_payload(&valid_cert_bytes(&pm, 0)), ) .unwrap(); let out = pm.on_udp(mock_server(), &relay_deliver(&peer, init_pkt), 0); @@ -6491,8 +7223,12 @@ mod tests { // A fresh relayed cold-start Init carrying an EXPIRED cert. let expired = expired_cert_bytes(&pm, 0); - let (_hs2, reinit_pkt) = - HandshakeState::start_initiator(&peer.private, &local.public, &expired).unwrap(); + let (_hs2, reinit_pkt) = HandshakeState::start_initiator( + &peer.private, + &local.public, + &crate::handshake::frame_init_payload(&expired), + ) + .unwrap(); let out2 = pm.on_udp(mock_server(), &relay_deliver(&peer, reinit_pkt), 300_000); assert!( @@ -6536,8 +7272,12 @@ mod tests { ); let mut cert_bytes = Vec::new(); cert.encode(&mut cert_bytes); - let (_hs, init_pkt) = - HandshakeState::start_initiator(&stranger.private, &local.public, &cert_bytes).unwrap(); + let (_hs, init_pkt) = HandshakeState::start_initiator( + &stranger.private, + &local.public, + &crate::handshake::frame_init_payload(&cert_bytes), + ) + .unwrap(); let src: SocketAddr = "203.0.113.5:5".parse().unwrap(); assert!(matches!(pm.on_udp(src, &init_pkt, 0), DispatchOut::None)); assert!(pm.peers.is_empty()); @@ -6997,11 +7737,18 @@ mod tests { assert_eq!(dg.bytes[0], PacketType::Data as u8); let wrapped = yip_obf::obfuscate(&sess, PacketType::Data as u8, &dg.bytes[1..], 0) .expect("small test body fits u16"); - // The wire datagram carries no plaintext PacketType prefix. + // The wire datagram carries no plaintext PacketType prefix and no + // constant signature a DPI box could match: a second obfuscation of + // the SAME (type, body) differs (fresh random nonce → different + // keystream). Asserting a *single* wire byte differs from the + // plaintext type is 1/256-flaky (the leading nonce byte can equal + // it by chance); comparing two full wrappings is deterministic + // (collision ≈ 2^-64) and captures the actual anti-DPI property. + let wrapped2 = yip_obf::obfuscate(&sess, PacketType::Data as u8, &dg.bytes[1..], 0) + .expect("small test body fits u16"); assert_ne!( - wrapped[0], - PacketType::Data as u8, - "type byte must be masked" + wrapped, wrapped2, + "obfuscation must randomize the wire form (no constant signature)" ); if let DispatchOut::Tun(buf) = pm.on_udp(peer_ep, &wrapped, 1) { recovered = Some(buf.to_vec()); @@ -7042,8 +7789,12 @@ mod tests { let obf_key = yip_obf::derive_key(&psk); // A real [HandshakeInit]‖msg1, obfuscated with the network key. - let (_hs, init_pkt) = - HandshakeState::start_initiator(&kp_i.private, &kp_r.public, &[]).unwrap(); + let (_hs, init_pkt) = HandshakeState::start_initiator( + &kp_i.private, + &kp_r.public, + &crate::handshake::frame_init_payload(&[]), + ) + .unwrap(); assert_eq!(init_pkt[0], PacketType::HandshakeInit as u8); let wrapped = yip_obf::obfuscate( &obf_key, @@ -7472,8 +8223,12 @@ mod tests { false, ); // No set_obf_psk ⇒ obfuscation disabled. - let (_hs, init_pkt) = - HandshakeState::start_initiator(&kp_i.private, &kp_r.public, &[]).unwrap(); + let (_hs, init_pkt) = HandshakeState::start_initiator( + &kp_i.private, + &kp_r.public, + &crate::handshake::frame_init_payload(&[]), + ) + .unwrap(); let resp = resp_bytes(&pm.on_udp(ep_i, &init_pkt, 0)); assert_eq!(resp.len(), 1, "one plaintext HandshakeResp is emitted"); assert_eq!( diff --git a/bin/yipd/tests/run-netns-pathswitch-rehandshake.sh b/bin/yipd/tests/run-netns-pathswitch-rehandshake.sh index 87ae12e..1619ded 100755 --- a/bin/yipd/tests/run-netns-pathswitch-rehandshake.sh +++ b/bin/yipd/tests/run-netns-pathswitch-rehandshake.sh @@ -1,8 +1,10 @@ #!/usr/bin/env bash -# The hardening.36 money test: proves the #36 fix end-to-end — a Punch->Relay -# path re-target must NOT draw a fresh Noise ephemeral, or the relayed -# retransmit black-holes against a responder that already adopted the -# session. +# The hardening.36 money test: proves the #36 INVERSION (anti-replay.34 Task +# 4) end-to-end — a Punch->Relay path re-target now legitimately draws a +# FRESH Noise ephemeral (state -> Idle -> begin_handshake) instead of +# preserving the in-flight one, and the session still CONVERGES because the +# responder rebuilds on the fresh Init (freshness-gated, #34 Task 3) rather +# than relying on ephemeral preservation to avoid a black hole. # # Usage: run-netns-pathswitch-rehandshake.sh # @@ -10,9 +12,12 @@ # The brief's headline scenario is: A and B rendezvous-only; B adopts the # responder role and goes Established over a DIRECT punch reply, but that # reply is lost through A's punch window, so A escalates to the relay while -# B is already direct-established — testing BOTH halves of the fix (A's -# ephemeral preservation across the retarget, AND B's responder-side relay -# adoption on the relayed cold-start retransmit). +# B is already direct-established. Pre-#34, this needed ephemeral +# PRESERVATION across the retarget (the original #36 fix) so the responder's +# `cached_resp` dedup would recognize the relayed retry. Post-#34, the +# responder instead REBUILDS on a fresh-ephemeral, fresh-ts Init (Task 3's +# freshness gate + Task 4's fresh-Init inversion), so preservation is no +# longer needed for convergence — and no longer happens. # # Deterministically dropping "only B->A's first resp, through exactly the # punch window" is impractical to construct in netns (it requires @@ -22,66 +27,72 @@ # (three netns A/B/R; R does not forward IPv4, so A and B are mutually # unreachable and can only ever converge via R's blind relay) — this forces # EVERY session, including the very first one, through the punch->escalate -# ->relay path, and asserts A converges over the relay carrying the SAME -# ephemeral it started with (Task 1's fix). It does not exercise Task 2's -# responder-side relay adoption (B never gets far enough into a direct punch -# to adopt anything in this topology — punch delivery is unconditionally -# dropped by R's lack of forwarding), but that half is covered by the -# `direct_established_responder_adopts_relay_on_relayed_cold_start_retransmit` -# unit test in bin/yipd/src/peer_manager.rs. +# ->relay path, and asserts A converges over the relay. It does not exercise +# Task 4's responder-side relay adoption in isolation (B never gets far +# enough into a direct punch to adopt anything in this topology — punch +# delivery is unconditionally dropped by R's lack of forwarding), but that +# half is covered by the freshness-gated-rebuild unit tests in +# bin/yipd/src/peer_manager.rs. # -# ── proving "the SAME ephemeral" on the wire, not just from source ── +# ── proving "a FRESH ephemeral" on the wire, not just from source ── # `bin/yipd/examples/rekey_epoch_witness` (built alongside yipd; see step 0 # below) counts DISTINCT cleartext Noise-IK ephemeral public keys across # captured [HandshakeInit] datagrams — cleartext because Noise_IK's leading # token on message 1 is `e`, unencrypted (see the tool's own module doc and -# run-netns-rekey.sh's header for the full argument). Pre-fix, a Punch->Relay -# retarget drew a FRESH ephemeral for the relayed resend; post-fix it resends -# the SAME `init_pkt` byte-for-byte. So: capture every datagram A itself +# run-netns-rekey.sh's header for the full argument). Post-#34/#36-inversion, +# a Punch->Relay retarget goes `state = Idle; begin_handshake(..)`, drawing a +# NEW ephemeral for the relayed resend instead of resending the punch +# attempt's `init_pkt` byte-for-byte. So: capture every datagram A itself # SENDS (both its raw, silently-dropped punch attempt AND its later # RelaySend-wrapped escalation retry — `YIP_WITNESS_UNWRAP_RELAY=1`, the same # opt-in run-netns-rekey-relay.sh uses, strips the RelaySend/RelayDeliver # envelope before applying the witness's cleartext-ephemeral logic; a # non-relay-tagged datagram, like A's raw punch attempt, passes through -# unchanged and is still counted) and assert exactly ONE distinct INIT -# ephemeral appears. Two would mean a cold restart drew a new one — the #36 -# regression this test exists to catch. +# unchanged and is still counted) and assert AT LEAST TWO distinct INIT +# ephemerals appear. Exactly one would mean the retarget reused the punch +# attempt's ephemeral — the OLD #36 behavior, now itself the regression this +# inverted test exists to catch (a preserved ephemeral means the escalation +# is a bare retransmit the freshness gate would refuse as stale on a replay, +# reopening the downgrade #34 Task 4 closes). # # ── why the capture is restricted to A's own outbound traffic ── # Neither peer has a static `endpoint=`/`initiate=` field (config.rs's -# `initiate` key is now a documented no-op — Task 5 removed it), so on a cold -# start BOTH A and B independently attempt to become the initiator -# ("startup-glare"). `handle_handshake_init`'s tie-break -# (`self.local_pub < self.peers[idx].pubkey`) makes the SMALLER public key -# the persistent initiator; the larger-pubkey side sends at most one -# abortive attempt of its own before deferring and completing as responder -# instead. An UNFILTERED capture on A's veth would therefore also see B's -# relayed loser-attempt arrive as a `RelayDeliver` addressed to A (R forwards -# it), which the witness tool would count as a second, unrelated INIT -# ephemeral — a false positive with nothing to do with #36. Two -# countermeasures close this: (1) keys are generated in a retry loop until -# `PUB_A < PUB_B`, so A is deterministically the persistent initiator (the -# one that actually performs the punch->escalate dance this test is about); -# (2) the tcpdump capture filters `src host $IP_A`, so only datagrams A -# itself transmits (its own raw punch Init, its own relay-wrapped retry) are -# captured — B's inbound `RelayDeliver` traffic to A is excluded. Losing the -# Resp side of the capture this way means `COMPLETED_ROUNDS` isn't -# meaningful here (no [HandshakeResp] ever originates at A); this script -# instead gates on DISTINCT_INIT_EPHEMERALS (the actual #36 proof) plus +# `initiate` key is a documented no-op), so on a cold start BOTH A and B +# independently attempt to become the initiator ("startup-glare"). +# `handle_handshake_init`'s tie-break (`self.local_pub < +# self.peers[idx].pubkey`) makes the SMALLER public key the persistent +# initiator; the larger-pubkey side sends at most one abortive attempt of its +# own before deferring and completing as responder instead. An UNFILTERED +# capture on A's veth would therefore also see B's relayed loser-attempt +# arrive as a `RelayDeliver` addressed to A (R forwards it), which the +# witness tool would count as an extra, unrelated INIT ephemeral — a false +# positive with nothing to do with #36. Two countermeasures close this: (1) +# keys are generated in a retry loop until `PUB_A < PUB_B`, so A is +# deterministically the persistent initiator (the one that actually performs +# the punch->escalate dance this test is about); (2) the tcpdump capture +# filters `src host $IP_A`, so only datagrams A itself transmits (its own raw +# punch Init, its own relay-wrapped retry) are captured — B's inbound +# `RelayDeliver` traffic to A is excluded. Losing the Resp side of the +# capture this way means `COMPLETED_ROUNDS` isn't meaningful here (no +# [HandshakeResp] ever originates at A); this script instead gates on +# DISTINCT_INIT_EPHEMERALS>=2 (the #36-inversion proof) plus # HANDSHAKE_INIT_PKTS>=2 (non-vacuity: proves both a punch attempt AND a # relay retransmit were actually observed, not just one lucky send) and # leans on the ping convergence + relay-forwarded assertions below for "it # actually completed". # # Assertions (any failure is non-zero exit, [PASS]/[FAIL] markers): -# 1. convergence: ping -6 -c 20 -W 2 A->B succeeds (tolerating the same -# ~PUNCH_MS warm-up loss run-netns-relay.sh documents) — the headline -# #36 claim: A converges instead of black-holing. +# 1. convergence: ping -6 -c 50 -W 2 A->B over the relay succeeds with +# >=98% delivery (tolerating the same ~PUNCH_MS warm-up loss +# run-netns-relay.sh documents) — the headline #36-inversion claim: A +# still converges, now via rebuild rather than preservation. # 2. relay_forwarded: R's stderr shows `relay-forwarded=`, N>0 — the # blind relay, not a direct/punched path, carried the traffic. -# 3. ephemeral preservation: rekey_epoch_witness (YIP_WITNESS_UNWRAP_RELAY=1) -# on a src-host-$IP_A-filtered capture reports exactly 1 distinct INIT -# ephemeral across >=2 captured HandshakeInit datagrams. +# 3. fresh-ephemeral escalation: rekey_epoch_witness +# (YIP_WITNESS_UNWRAP_RELAY=1) on a src-host-$IP_A-filtered capture +# reports >=2 distinct INIT ephemerals across >=2 captured +# HandshakeInit datagrams — proving the retarget drew a fresh ephemeral +# instead of preserving the punch attempt's. set -euo pipefail YIPD="${1:?Usage: $0 }" @@ -340,20 +351,49 @@ assign_mesh "$NS_B" "$ADDR_B" # ── 9. ping A->B, tolerating warm-up loss while the path escalates to relay ── # Same tolerance run-netns-relay.sh documents: ~PUNCH_MS (5s) of unavoidable # warm-up while the path state machine escalates from a silently-dropped -# direct punch to the blind relay. This IS the headline #36 assertion: with -# the fix, A converges instead of black-holing. +# direct punch to the blind relay. This proves initial convergence (with the +# #36 inversion, A still converges instead of black-holing, now via rebuild +# rather than ephemeral preservation) without the fixed warm-up window +# skewing a delivery percentage. echo "[test] pinging ${ADDR_B} from yipPswA (expect escalate-to-relay warm-up loss, then success)" set +e ip netns exec "$NS_A" ping -6 -c 20 -W 2 "$ADDR_B" PING_STATUS=$? set -e if [ "$PING_STATUS" -ne 0 ]; then - echo "[FAIL] ping A->B did not converge (exit $PING_STATUS) — #36 regression: A black-holed" + echo "[FAIL] ping A->B did not converge (exit $PING_STATUS) — #36-inversion regression: A black-holed" dump_logs exit 1 fi echo "[PASS] ping A->B converged over the relay" +# ── 9b. steady-state continuity, now that warm-up is over ────────────────── +# A fresh, stricter ping burst AFTER the path has already converged (step 9 +# above): with the escalation's one-time warm-up loss out of the way, this +# is the ">=98% delivery" convergence bar the anti-replay.34 plan calls for +# — proving the rebuilt (fresh-ephemeral) relay session is fully stable, not +# just "received at least one reply". +STEADY_PING_LOG="$TMPDIR_TEST/steady_ping.log" +echo "[test] steady-state ping ${ADDR_B} from yipPswA (post-convergence, >=98% delivery required)" +set +e +ip netns exec "$NS_A" ping -6 -i 0.2 -c 50 -W 1 "$ADDR_B" >"$STEADY_PING_LOG" 2>&1 +set -e +cat "$STEADY_PING_LOG" +LOSS_PCT="$(grep -oE '[0-9]+(\.[0-9]+)?% packet loss' "$STEADY_PING_LOG" | grep -oE '^[0-9]+(\.[0-9]+)?' || true)" +if [ -z "$LOSS_PCT" ]; then + echo "[FAIL] convergence: could not parse packet loss from the steady-state ping output" + dump_logs + exit 1 +fi +echo "[metric] convergence: steady-state packet loss = ${LOSS_PCT}%" +if awk "BEGIN {exit ($LOSS_PCT <= 2.0) ? 0 : 1}"; then + echo "[PASS] convergence: ${LOSS_PCT}% loss (<=2%, i.e. >=98% delivery) in steady state over the relay" +else + echo "[FAIL] convergence: ${LOSS_PCT}% loss (>2%, i.e. <98% delivery) — #36-inversion regression" + dump_logs + exit 1 +fi + # ── 10. stop the capture ────────────────────────────────────────────────────── sleep 0.3 kill "$TCPDUMP_PID" 2>/dev/null || true @@ -380,9 +420,9 @@ if [ -z "${FINAL_COUNT:-}" ] || [ "$FINAL_COUNT" -eq 0 ]; then fi echo "[PASS] relay-forwarded=${FINAL_COUNT} (>0): the blind relay carried the traffic" -# ── assertion: ephemeral preservation — the #36 headline proof ────────────── +# ── assertion: fresh-ephemeral escalation — the #36-inversion headline proof ── if [ ! -s "$PCAP" ]; then - echo "[FAIL] ephemeral preservation: capture is empty or missing at $PCAP" + echo "[FAIL] fresh-ephemeral escalation: capture is empty or missing at $PCAP" dump_logs exit 1 fi @@ -395,29 +435,35 @@ INIT_PKTS="$(grep -oE '^HANDSHAKE_INIT_PKTS=[0-9]+' "$WITNESS_LOG" | cut -d= -f2 DISTINCT_INIT="$(grep -oE '^DISTINCT_INIT_EPHEMERALS=[0-9]+' "$WITNESS_LOG" | cut -d= -f2)" if [ -z "$INIT_PKTS" ] || [ -z "$DISTINCT_INIT" ]; then - echo "[FAIL] ephemeral preservation: could not parse rekey_epoch_witness output" + echo "[FAIL] fresh-ephemeral escalation: could not parse rekey_epoch_witness output" dump_logs exit 1 fi # Non-vacuity: must have actually observed both the raw punch attempt and at -# least one relay-wrapped escalation retry -- else "1 distinct ephemeral" -# would trivially hold because only one Init was ever sent at all. +# least one relay-wrapped escalation retry -- else ">=2 distinct ephemerals" +# couldn't even be structurally possible with fewer than 2 Inits observed. if [ "$INIT_PKTS" -lt 2 ]; then - echo "[FAIL] ephemeral preservation: only $INIT_PKTS Init packet(s) captured from A (need >=2: punch attempt + relay retry) — test is vacuous, not proof" + echo "[FAIL] fresh-ephemeral escalation: only $INIT_PKTS Init packet(s) captured from A (need >=2: punch attempt + relay retry) — test is vacuous, not proof" dump_logs exit 1 fi -# The money assertion: exactly one distinct cleartext ephemeral across every -# Init A itself sent (punch attempt + relay-wrapped retry/retries). Two would -# mean the retarget drew a fresh ephemeral -- the #36 regression. -if [ "$DISTINCT_INIT" -eq 1 ]; then - echo "[PASS] ephemeral preservation: $INIT_PKTS Init packets from A, all sharing the SAME ephemeral (no cold-start churn across the punch->relay retarget)" +# The money assertion (INVERTED from the pre-#34 test): at least two distinct +# cleartext ephemerals across every Init A itself sent (punch attempt + +# relay-wrapped retry/retries) -- proving the punch->relay retarget drew a +# FRESH ephemeral (Task 4's `state = Idle; begin_handshake(..)`) rather than +# reusing the punch attempt's. Exactly one would mean the OLD #36 behavior +# (ephemeral preservation) is still happening, which is now itself the +# regression: a preserved ephemeral is a bare retransmit the freshness gate +# would refuse as stale on a genuine replay, reopening the downgrade #34 +# Task 4 closed. +if [ "$DISTINCT_INIT" -ge 2 ]; then + echo "[PASS] fresh-ephemeral escalation: $INIT_PKTS Init packets from A, $DISTINCT_INIT distinct ephemerals (punch attempt + a freshly-drawn relay retry, no #36-preservation regression)" else - echo "[FAIL] ephemeral preservation: $DISTINCT_INIT distinct Init ephemerals from A (need exactly 1) — the punch->relay retarget drew a fresh ephemeral, reproducing #36" + echo "[FAIL] fresh-ephemeral escalation: only $DISTINCT_INIT distinct Init ephemeral(s) from A (need >=2) — the punch->relay retarget reused the punch attempt's ephemeral, reproducing the OLD #36 behavior" dump_logs exit 1 fi -echo "[PASS] run-netns-pathswitch-rehandshake: A converged over the relay, carrying the SAME ephemeral throughout" +echo "[PASS] run-netns-pathswitch-rehandshake: A converged over the relay (>=98% steady-state delivery) via a freshly-drawn escalation ephemeral" diff --git a/bin/yipd/tests/run-netns-replay-hijack.sh b/bin/yipd/tests/run-netns-replay-hijack.sh new file mode 100755 index 0000000..c1bf0a6 --- /dev/null +++ b/bin/yipd/tests/run-netns-replay-hijack.sh @@ -0,0 +1,563 @@ +#!/usr/bin/env bash +# The anti-replay.34 money test for yipd's freshness-gated Init admission: +# proves (1) a captured-and-replayed HandshakeInit from an off-path attacker +# is REFUSED by the freshness gate (observed black-box via B's stderr marker) +# and does not disrupt the victim's live session with the real peer, and +# (2) a genuine peer restart (fresh ephemeral + newer wall-clock ts) still +# recovers, bounded in time. +# +# Scope note: A and B are ESTABLISHED, so the replayed Init routes through the +# rekey admission path (`rekey_init_core` + `accept_fresh_init`), which is +# where the freshness gate refuses it (assertion 2 below is the discriminating +# check — it greps B's refusal marker). That rekey path never writes +# `peers[idx].endpoint`, so the ping check (assertion 1) proves the replay +# neither wedges nor corrupts B's live session, NOT that an endpoint write was +# suppressed. The cold-start Idle-arm case — where a stale Init WOULD otherwise +# set `endpoint = Some(src)` and the freshness gate suppresses it — is covered +# at unit level by `stale_replayed_cold_start_init_does_not_hijack_endpoint` +# in peer_manager.rs. +# +# Usage: run-netns-replay-hijack.sh +# +# ── topology: four netns, A / B / T / S ── +# A --10.86.0.0/24-- T --10.87.0.0/24-- B +# | +# 10.88.0.0/24 +# | +# S +# Forked from run-netns-punch.sh's topology (T DOES route between subnets, +# IPv4 forwarding enabled — so A and B converge over a direct/punched path, +# never the blind relay), with a fourth point-to-point leg into T for S, the +# attacker namespace. yip-rendezvous runs in T (bound on all three subnets); +# A and B are configured rendezvous-only (public_key, no endpoint), exactly +# like run-netns-punch.sh. +# +# ── why S doesn't need raw-socket IP spoofing ── +# The brief scenario is "a THIRD namespace with a spoofed source" replaying a +# captured Init at B. A literal forged IP source (matching A's real address +# bit-for-bit) would be UNOBSERVABLE in this topology: yipd's admission match +# is keyed off the Noise `remote_static` embedded IN the ciphertext payload, +# not the UDP source address (see `handle_handshake_init`'s +# `self.peers.iter().position(|p| p.pubkey == remote_static)`), and IP +# routing is destination-based, so B's replies would route back to A's real +# interface regardless of which physical netns actually sent the spoofed +# packet — the "hijack" would be invisible either way. So S uses its own +# real, distinct address — no raw sockets, no CAP_NET_RAW, just a concocted +# duplicated ciphertext. The endpoint-write vector the #34 freshness gate +# closes (pre-#34, ANY successfully-parsed Init from an admitted peer updated +# `endpoint` unconditionally) lives on the cold-start Idle arm; against these +# already-established peers the replay instead exercises the rekey admission +# path, and what this test proves black-box is that the gate REFUSES the +# replay (marker) without disrupting the live session (ping). +# +# ── forcing the replay through the freshness gate, not the retransmit dedup ── +# `rekey_init_core`'s cases 1/2 (`cached_resp_init_eph` / `next_cached_resp_for` +# match) deliberately bypass the freshness gate for a genuine retransmit — +# that is by design (see peer_manager.rs's `accept_fresh_init` doc comment). +# A byte-for-byte replay of the captured Init would therefore only prove +# something if it is no longer reachable via EITHER dedup cache by the time +# it's replayed. Two different things must therefore NOT be the round this +# test replays: +# - round 0 (the cold-start Init): `self.peers[idx].cached_resp_init_eph` +# is a STICKY field, set ONLY on the Idle->Established cold-start +# transition (`handle_handshake_init`'s establish arm) and never touched +# again by any later rekey round (`rekey_init_core`'s case 4 install only +# calls `epochs.install_next(..)`, a DIFFERENT, per-`EpochSet` field). A +# replay of round 0 would ALWAYS hit case 1's dedup, no matter how many +# rekey rounds have since completed. +# - the CURRENT (most recently installed, not-yet-superseded) rekey round: +# `install_next` unconditionally REPLACES `epochs.next` on every call, so +# `next_cached_resp_for` only ever matches the latest round. +# So this script captures round 1 (the SECOND Init frame — A's first rekey +# round, after cold-start) and replays it only once at least one FURTHER +# round has completed (superseding round 1's `next` slot). Both peers are +# held to a fast YIP_REKEY_INTERVAL_MS=2000 cadence (same constant +# run-netns-rekey.sh uses) and a burn-in ping stream runs long enough for +# several rounds to complete, so round 1 is safely stale-on-both-counts by +# replay time: neither cached slot matches its ephemeral, so it falls +# through to `accept_fresh_init`, whose `ts` is now stale relative to what B +# has since accepted from A — landing on the actual freshness-gate refusal +# (`peer_manager: stale/replayed Init refused (freshness gate)`, added by +# this task since no such marker existed before). +# +# ── obfuscation deliberately OFF ── +# Same reasoning as run-netns-rekey.sh: this script identifies a captured +# [HandshakeInit] by its first cleartext wire byte (`PacketType::HandshakeInit +# as u8 == 0`, via a tshark `udp.payload[0:1] == 00` filter). With obf on, +# that prefix rides inside the obf envelope and is unrecoverable from a +# passive capture. +# +# Assertions (any failure is non-zero exit, [PASS]/[FAIL] markers): +# 1. no_disruption: a steady `ping A->B` (over the mesh v6 addr) spanning +# the replay send shows <=1% loss — the replay neither wedged nor +# corrupted B's live session with A. (The endpoint-write suppression this +# gate provides is unit-tested; see the scope note above.) +# 2. freshness_gate_refusal: B's stderr contains the freshness-gate marker +# after the replay — the discriminating check that the replay was refused. +# 3. restart_recovery: killing and restarting A (same identity, fresh +# ephemeral + newer wall-clock ts) re-establishes A<->B within a +# generous bounded ping window (bounded, unlike the pre-#34 stuck state +# this replaces). +set -euo pipefail + +YIPD="${1:?Usage: $0 }" +RDV="${2:?Usage: $0 }" + +# ── 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-pathswitch-rehandshake.sh convention) ── +if [ "$(id -u)" -ne 0 ]; then + echo "SKIP run-netns-replay-hijack: needs root (netns + TUN + tcpdump)" + exit 0 +fi +for tool in tcpdump tshark ping python3; do + if ! command -v "$tool" >/dev/null 2>&1; then + echo "SKIP run-netns-replay-hijack: required tool '$tool' not found" + exit 0 + fi +done + +TMPDIR_TEST="$(mktemp -d /tmp/yipd-netns-replay-hijack-test.XXXXXX)" + +NS_A="yipReplA" +NS_B="yipReplB" +NS_T="yipReplT" +NS_S="yipReplS" + +VETH_A_N="vReplA1"; VETH_A_T="vReplA0" # A<->T pair: A-side, T-side +VETH_B_N="vReplB1"; VETH_B_T="vReplB0" # B<->T pair: B-side, T-side +VETH_S_N="vReplS1"; VETH_S_T="vReplS0" # S<->T pair: S-side, T-side + +IP_A="10.86.0.2" +IP_T_A="10.86.0.1" # T's address on A's subnet +IP_B="10.87.0.2" +IP_T_B="10.87.0.1" # T's address on B's subnet +IP_S="10.88.0.2" +IP_T_S="10.88.0.1" # T's address on S'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_T" 2>/dev/null || true + ip netns del "$NS_S" 2>/dev/null || true + rm -rf "$TMPDIR_TEST" +} +trap cleanup EXIT + +# ── 1. generate keypairs, retrying until PUB_A < PUB_B ──────────────────── +# `handle_handshake_init`'s glare tie-break (`self.local_pub < +# self.peers[idx].pubkey`) makes the SMALLER public key the deterministic, +# persistent initiator — for BOTH the cold-start handshake and every +# subsequent rekey round (`drive_rekey_schedule` reuses the exact same +# comparison). This test needs to capture-and-replay an Init that A itself +# SENT and B ACCEPTED (that's what populates B's `cached_resp_init_eph`/ +# `last_accepted_init_ts` for A) — without this, a ~50% coin flip would make +# B the initiator instead, and A's own captured "src host $IP_A" traffic +# would carry [HandshakeResp]s, not [HandshakeInit]s, making the test +# non-deterministically vacuous rather than reliably proving anything. Same +# fix run-netns-pathswitch-rehandshake.sh uses. hex_encode is +# byte-order-preserving, so a plain bash string `<` comparison of the two hex +# pubkeys is equivalent to Rust's `[u8; 32]` lexicographic `<`. ~50% chance +# per attempt; capped well above what randomness could plausibly need. +echo "[setup] generating keypairs (retrying until PUB_A < PUB_B, for a deterministic initiator)" +PUB_A="" +PUB_B="" +for _attempt in $(seq 1 50); do + GENKEY_A="$("$YIPD" --genkey)" + GENKEY_B="$("$YIPD" --genkey)" + CAND_PUB_A="$(echo "$GENKEY_A" | grep '^public=' | cut -d= -f2)" + CAND_PUB_B="$(echo "$GENKEY_B" | grep '^public=' | cut -d= -f2)" + if [[ "$CAND_PUB_A" < "$CAND_PUB_B" ]]; then + PRIV_A="$(echo "$GENKEY_A" | grep '^private=' | cut -d= -f2)" + PUB_A="$CAND_PUB_A" + PRIV_B="$(echo "$GENKEY_B" | grep '^private=' | cut -d= -f2)" + PUB_B="$CAND_PUB_B" + break + fi +done +if [ -z "$PUB_A" ]; then + echo "[error] could not generate PUB_A < PUB_B after 50 attempts (something is very wrong)" + exit 1 +fi + +ADDR_A="$("$YIPD" --addr "$PUB_A")" +ADDR_B="$("$YIPD" --addr "$PUB_B")" +echo "[setup] node_addr A=$ADDR_A B=$ADDR_B (A is the deterministic glare-winner/initiator)" + +# ── 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" <T" +ip link add "$VETH_A_T" type veth peer name "$VETH_A_N" +ip link set "$VETH_A_N" netns "$NS_A" +ip link set "$VETH_A_T" netns "$NS_T" +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_T" ip addr add "${IP_T_A}/${PREFIX}" dev "$VETH_A_T" +ip netns exec "$NS_T" ip link set "$VETH_A_T" up + +echo "[setup] wiring B<->T" +ip link add "$VETH_B_T" type veth peer name "$VETH_B_N" +ip link set "$VETH_B_N" netns "$NS_B" +ip link set "$VETH_B_T" netns "$NS_T" +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_T" ip addr add "${IP_T_B}/${PREFIX}" dev "$VETH_B_T" +ip netns exec "$NS_T" ip link set "$VETH_B_T" up + +echo "[setup] wiring S<->T (S is the off-path attacker namespace)" +ip link add "$VETH_S_T" type veth peer name "$VETH_S_N" +ip link set "$VETH_S_N" netns "$NS_S" +ip link set "$VETH_S_T" netns "$NS_T" +ip netns exec "$NS_S" ip addr add "${IP_S}/${PREFIX}" dev "$VETH_S_N" +ip netns exec "$NS_S" ip link set "$VETH_S_N" up +ip netns exec "$NS_S" ip link set lo up +ip netns exec "$NS_T" ip addr add "${IP_T_S}/${PREFIX}" dev "$VETH_S_T" +ip netns exec "$NS_T" ip link set "$VETH_S_T" up +ip netns exec "$NS_T" ip link set lo up + +# A/B/S each default-route via T (their only path off-subnet). +ip netns exec "$NS_A" ip route add default via "$IP_T_A" dev "$VETH_A_N" +ip netns exec "$NS_B" ip route add default via "$IP_T_B" dev "$VETH_B_N" +ip netns exec "$NS_S" ip route add default via "$IP_T_S" dev "$VETH_S_N" + +# T routes between all three subnets: this is what makes A and B directly +# reachable (no relay needed) and lets S's replay actually arrive at B. +ip netns exec "$NS_T" sysctl -q -w net.ipv4.ip_forward=1 +ip netns exec "$NS_T" iptables -P FORWARD ACCEPT + +# ── 4. start yip-rendezvous in T, bound on all three subnets ────────────────── +LOG_RDV="$TMPDIR_TEST/rdv.log" +echo "[start] starting yip-rendezvous in T on 0.0.0.0:${RDV_PORT}" +ip netns exec "$NS_T" "$RDV" "0.0.0.0:${RDV_PORT}" >"$LOG_RDV" 2>&1 & +PID_RDV=$! +sleep 0.3 + +# ── 5. start the capture on A's link BEFORE A starts ────────────────────────── +# Must be attached before A's very first cold-start Init (t=0) so that Init +# (the one this test replays) is actually captured. `src host $IP_A and dst +# host $IP_B` restricts the capture to A's own P2P datagrams TO B specifically +# — excluding A's rendezvous Register/Lookup traffic to T. That exclusion is +# not just tidiness: `yip-rendezvous`'s own wire format +# (crates/yip-rendezvous/src/proto.rs) tags `Register` messages with a +# leading byte of `0`, colliding with `PacketType::HandshakeInit as u8 == 0` +# (bin/yipd/src/handshake.rs) — an unfiltered capture's EARLIEST 0x00-prefixed +# datagram would be A's Register call to T, not its cold-start Init to B. +PCAP="$TMPDIR_TEST/replay.pcap" +echo "[capture] starting tcpdump on $VETH_A_N (udp, src host $IP_A, dst host $IP_B) -> $PCAP" +ip netns exec "$NS_A" tcpdump -i "$VETH_A_N" -w "$PCAP" -U udp and src host "$IP_A" and dst host "$IP_B" \ + >"$TMPDIR_TEST/tcpdump.log" 2>&1 & +TCPDUMP_PID=$! +sleep 0.3 + +# ── 6. start yipd in A and B with a fast rekey cadence ───────────────────────── +# YIP_REKEY_INTERVAL_MS=2000 (same constant run-netns-rekey.sh uses) so +# several rekey rounds complete during the burn-in ping below, making the +# captured round-1 Init genuinely stale (both in ephemeral and in ts) by the +# time it's replayed -- see the header comment for why that's required to +# actually exercise the freshness gate rather than the retransmit dedup. +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 "=== yipReplA log ===" + cat "$LOG_A" || true + if [ -n "${LOG_A_RESTART:-}" ] && [ -f "$LOG_A_RESTART" ]; then + echo "=== yipReplA log (post-restart) ===" + cat "$LOG_A_RESTART" || true + fi + echo "=== yipReplB log ===" + cat "$LOG_B" || true +} + +echo "[start] starting yipReplA (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 yipReplB (YIP_REKEY_INTERVAL_MS=$YIP_REKEY_INTERVAL_MS)" +ip netns exec "$NS_B" "$YIPD" "$CFG_B" >"$LOG_B" 2>&1 & +PID_B=$! + +# ── 7. wait for TUN devices to appear ───────────────────────────────────────── +TUN_WAIT=20 +INTERVAL=0.25 + +wait_for_tun() { + local ns="$1" label="$2" pid="$3" + local elapsed=0 + echo "[wait] waiting for $label's TUN device to appear (up to ${TUN_WAIT}s)" + while true; do + if ip netns exec "$ns" ip link show "$TUN_DEV" >/dev/null 2>&1; then + echo "[wait] $label's TUN device is up" + return 0 + fi + if ! kill -0 "$pid" 2>/dev/null; then + echo "[error] $label 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 $label's TUN device"; dump_logs; exit 1 + fi + sleep "$INTERVAL" + done +} + +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 +} + +wait_for_tun "$NS_A" "yipReplA" "$PID_A" +wait_for_tun "$NS_B" "yipReplB" "$PID_B" + +echo "[setup] assigning node_addr/128 + fd00::/8 route on each TUN" +assign_mesh "$NS_A" "$ADDR_A" +assign_mesh "$NS_B" "$ADDR_B" + +# ── 8. establish + burn in several rekey rounds ─────────────────────────────── +# Long enough (>= ~7 rounds at 2000ms) that by the time the capture below is +# extracted, round 1 (the round this test replays) has long since been +# superseded in `epochs.next`, and B's `last_accepted_init_ts` has moved well +# past round 1's ts. +BURNIN_LOG="$TMPDIR_TEST/burnin_ping.log" +echo "[test] establishing + burning in rekey rounds: ping ${ADDR_B} from yipReplA (~14s)" +set +e +ip netns exec "$NS_A" ping -6 -i 0.2 -c 70 -W 1 "$ADDR_B" >"$BURNIN_LOG" 2>&1 +BURNIN_STATUS=$? +set -e +cat "$BURNIN_LOG" +if [ "$BURNIN_STATUS" -ne 0 ]; then + echo "[FAIL] initial establishment / burn-in ping A->B did not succeed (exit $BURNIN_STATUS)" + dump_logs + exit 1 +fi +echo "[PASS] A<->B established and burned in several rekey rounds" + +# ── 9. stop the capture, extract round 1 (now-stale) Init ───────────────── +sleep 0.3 +kill "$TCPDUMP_PID" 2>/dev/null || true +wait "$TCPDUMP_PID" 2>/dev/null || true +TCPDUMP_PID="" + +if [ ! -s "$PCAP" ]; then + echo "[FAIL] capture is empty or missing at $PCAP" + dump_logs + exit 1 +fi + +# PacketType::HandshakeInit as u8 == 0 (bin/yipd/src/handshake.rs), so the +# first byte of a [HandshakeInit] datagram's UDP payload is always 0x00 (no +# obf on this run — see header comment). `udp.length >= 41` (8-byte UDP +# header + a 33-byte minimum [PacketType][32-byte ephemeral] payload, the +# same MIN_HANDSHAKE_LEN bin/yipd/examples/rekey_epoch_witness.rs uses) is a +# defense-in-depth belt to the capture filter's dst-host suspenders above, +# in case anything else ever emits a short 0x00-prefixed datagram on this +# link. +# +# Take the SECOND such frame (round 1: A's first rekey round), not the +# first (round 0: the cold-start Init) -- see the header comment's "forcing +# the replay through the freshness gate" section for why round 0 would +# always hit the sticky `cached_resp_init_eph` dedup instead, no matter how +# stale it is. Non-vacuity: need >=3 captured Inits (round 0 + round 1 + +# something that supersedes round 1's `next` slot), else round 1 could +# still be the CURRENT round at replay time. +INIT_FRAMES="$TMPDIR_TEST/init_frames.txt" +tshark -r "$PCAP" -Y "udp && udp.payload[0:1] == 00 && udp.length >= 41" \ + -T fields -e udp.payload 2>/dev/null | tr -d ':' > "$INIT_FRAMES" +INIT_FRAME_COUNT="$(wc -l < "$INIT_FRAMES" | tr -d ' ')" +echo "[check] captured $INIT_FRAME_COUNT HandshakeInit frame(s) from A during burn-in" +if [ "$INIT_FRAME_COUNT" -lt 3 ]; then + echo "[FAIL] only $INIT_FRAME_COUNT Init frame(s) captured (need >=3: cold-start + a round to replay + a round that supersedes it) — burn-in did not produce enough rekey rounds" + dump_logs + exit 1 +fi +INIT_HEX="$(sed -n '2p' "$INIT_FRAMES")" +if [ -z "$INIT_HEX" ]; then + echo "[FAIL] could not extract round 1's captured [HandshakeInit] payload from $PCAP" + dump_logs + exit 1 +fi +echo "[check] captured round-1 Init to replay: ${#INIT_HEX} hex chars" + +# ── 10. replay the captured Init from S (a different, real address) at B, +# concurrently with a steady ping A->B, and assert no session disruption ──── +REPLAY_PY="$TMPDIR_TEST/replay.py" +cat > "$REPLAY_PY" <<'PYEOF' +import socket +import sys + +hex_payload, dst_ip, dst_port = sys.argv[1], sys.argv[2], int(sys.argv[3]) +payload = bytes.fromhex(hex_payload) +s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) +s.sendto(payload, (dst_ip, dst_port)) +s.close() +PYEOF + +HIJACK_PING_LOG="$TMPDIR_TEST/hijack_ping.log" +echo "[test] pinging ${ADDR_B} from yipReplA while S replays the captured Init at B" +set +e +ip netns exec "$NS_A" ping -6 -i 0.2 -c 40 -W 1 "$ADDR_B" >"$HIJACK_PING_LOG" 2>&1 & +PING_PID=$! +sleep 1.5 +echo "[attack] S (${IP_S}) replaying captured round-1 Init at B (${IP_B}:${PORT_B})" +ip netns exec "$NS_S" python3 "$REPLAY_PY" "$INIT_HEX" "$IP_B" "$PORT_B" +REPLAY_STATUS=$? +wait "$PING_PID" +PING_STATUS=$? +set -e +cat "$HIJACK_PING_LOG" + +if [ "$REPLAY_STATUS" -ne 0 ]; then + echo "[FAIL] the replay send from S failed (exit $REPLAY_STATUS)" + dump_logs + exit 1 +fi + +if ! kill -0 "$PID_A" 2>/dev/null; then + echo "[error] yipReplA daemon died during the replay"; dump_logs; exit 1 +fi +if ! kill -0 "$PID_B" 2>/dev/null; then + echo "[error] yipReplB daemon died during the replay"; dump_logs; exit 1 +fi + +LOSS_PCT="$(grep -oE '[0-9]+(\.[0-9]+)?% packet loss' "$HIJACK_PING_LOG" | grep -oE '^[0-9]+(\.[0-9]+)?' || true)" +if [ -z "$LOSS_PCT" ]; then + echo "[FAIL] no_disruption: could not parse packet loss from ping output" + dump_logs + exit 1 +fi +echo "[metric] no_disruption: packet loss during replay = ${LOSS_PCT}%" +if awk "BEGIN {exit ($LOSS_PCT <= 1.0) ? 0 : 1}"; then + echo "[PASS] no_disruption: ${LOSS_PCT}% loss (<=1%) across the replay -- B's live session with A was not disrupted" +else + echo "[FAIL] no_disruption: ${LOSS_PCT}% loss (>1%) -- the replay may have disrupted B's session with A" + 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 + +# ── assertion: the replay was actually refused by the freshness gate ──────── +if grep -q "stale/replayed Init refused (freshness gate)" "$LOG_B"; then + echo "[PASS] freshness_gate_refusal: B's stderr shows the replay was refused" +else + echo "[FAIL] freshness_gate_refusal: no freshness-gate marker in B's stderr -- the replay was not observed to be refused there" + dump_logs + exit 1 +fi + +# ── 11. restart leg: kill A, restart it, assert bounded recovery ──────────── +echo "[test] restart leg: killing yipReplA" +kill "$PID_A" 2>/dev/null || true +sleep 0.3 +kill -9 "$PID_A" 2>/dev/null || true +wait "$PID_A" 2>/dev/null || true +PID_A="" + +# Killing the process tears down its TUN device (non-persistent); wait for it +# to actually disappear before restarting, so the "wait for TUN" loop below +# isn't fooled by the old interface still lingering. +TUN_GONE_WAIT=10 +elapsed=0 +while ip netns exec "$NS_A" ip link show "$TUN_DEV" >/dev/null 2>&1; do + elapsed=$(awk "BEGIN {print $elapsed + 0.25}") + if awk "BEGIN {exit ($elapsed >= $TUN_GONE_WAIT) ? 0 : 1}"; then + echo "[error] yipReplA's old TUN device did not disappear after kill"; dump_logs; exit 1 + fi + sleep 0.25 +done + +LOG_A_RESTART="$TMPDIR_TEST/yipA-restart.log" +echo "[start] restarting yipReplA (same identity, fresh ephemeral + newer ts on its next Init)" +ip netns exec "$NS_A" "$YIPD" "$CFG_A" >"$LOG_A_RESTART" 2>&1 & +PID_A=$! + +wait_for_tun "$NS_A" "yipReplA (restarted)" "$PID_A" +assign_mesh "$NS_A" "$ADDR_A" + +RESTART_PING_LOG="$TMPDIR_TEST/restart_ping.log" +# Generous bound (worst case ~60s): still far under the pre-#34 stuck state +# (bounded only by the responder's own attempt timeout), and ping's own exit +# code already only requires >=1 reply, so no `|| true` is needed to keep the +# measured result load-bearing. +echo "[test] restart recovery: pinging ${ADDR_B} from restarted yipReplA (bounded window)" +set +e +ip netns exec "$NS_A" ping -6 -c 30 -W 2 "$ADDR_B" >"$RESTART_PING_LOG" 2>&1 +RESTART_PING_STATUS=$? +set -e +cat "$RESTART_PING_LOG" + +if [ "$RESTART_PING_STATUS" -ne 0 ]; then + echo "[FAIL] restart_recovery: ping A->B did not resume after A's restart (exit $RESTART_PING_STATUS)" + dump_logs + exit 1 +fi +echo "[PASS] restart_recovery: A<->B re-established within the bounded ping window" + +echo "[PASS] run-netns-replay-hijack: replay refused with no session disruption, restart recovered" diff --git a/docs/superpowers/plans/2026-07-23-handshake-anti-replay-34.md b/docs/superpowers/plans/2026-07-23-handshake-anti-replay-34.md new file mode 100644 index 0000000..44c84fb --- /dev/null +++ b/docs/superpowers/plans/2026-07-23-handshake-anti-replay-34.md @@ -0,0 +1,427 @@ +# Handshake anti-replay + authenticated endpoint (#34) 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:** Add a per-peer anti-replay timestamp to the handshake initiation, gate endpoint-learning and session-rebuild on freshness, and invert #36 to fresh-Init + safe-rebuild. + +**Architecture:** A 12-byte TAI64N wall-clock timestamp rides inside the encrypted Noise msg1 payload (prefixed to the existing optional cert). The responder stores the greatest ts accepted per peer and uses a `ts > last` freshness check as the single "build a new session" discriminator — replacing the 9a age gate — while retransmit/`cached_resp` paths (seen ephemeral) bypass it. Endpoint learning and session rebuild happen only on a fresh accepted Init; #36's ephemeral-preservation + relay-adoption become fresh-Init + freshness-gated rebuild. + +**Tech Stack:** Rust, `#![forbid(unsafe_code)]`; Noise-IK via `yip-crypto` (unchanged); netns integration tests under poll + `YIP_USE_URING=1`. + +## Global Constraints + +- `#![forbid(unsafe_code)]` — NO `unsafe`. +- NO `as` casts, except the pre-existing `PacketType::X as u8` idiom. +- NO bare `#[allow]` — use `#[expect(reason = "...")]`. +- Run `cargo fmt` before every commit; never `--no-verify` to skip fmt. +- `cargo clippy -p yipd --all-targets -- -D warnings` clean. +- NO change to `yip-crypto` (Noise) or `yip-wire`. The timestamp rides INSIDE the encrypted Noise msg1 payload — no new cleartext field, no `PacketType`/framing change. +- `yipd` is a binary crate: test with `cargo test -p yipd --bin yipd` (NOT `--lib`). +- The ts applies to ALL modes (2a/2b/2c) — there is no membership-off exemption (unlike #41's cert checks). A 2a/2b peer's payload becomes `[ts]` with an empty cert remainder. +- Known env flake: the `yip-io` uring loopback test may fail unrelated to yipd; acceptable only if it is the sole failure and the code is fmt-clean. +- Regression net: the full 9a/#91/#36/#41 unit + netns suites must stay green — this milestone modifies their admission/rekey/adoption paths. + +--- + +### Task 1: TAI64N + msg1 payload framing helpers (pure, in `handshake.rs`) + +**Files:** +- Modify: `bin/yipd/src/handshake.rs` (add `now_tai64n`, `frame_init_payload`, `parse_init_payload` + unit tests) + +**Interfaces:** +- Produces: `pub fn now_tai64n() -> [u8; 12]`; `pub fn frame_init_payload(cert: &[u8]) -> Vec`; `pub fn parse_init_payload(payload: &[u8]) -> Option<([u8; 12], &[u8])>`; `pub const TAI64N_LEN: usize = 12`. + +- [ ] **Step 1: Write the failing tests** + +Add to `handshake.rs`'s `#[cfg(test)] mod tests`: + +```rust +#[test] +fn tai64n_is_big_endian_monotonic_and_roundtrips() { + // frame/parse roundtrip: ts prefix split from the cert remainder. + let cert = b"a-cert-blob"; + let framed = frame_init_payload(cert); + assert_eq!(framed.len(), TAI64N_LEN + cert.len()); + let (ts, rest) = parse_init_payload(&framed).expect("parses"); + assert_eq!(rest, cert); + assert_eq!(&framed[..TAI64N_LEN], &ts); + + // empty cert (2a/2b): payload is exactly the 12-byte ts. + let framed_empty = frame_init_payload(&[]); + assert_eq!(framed_empty.len(), TAI64N_LEN); + let (_ts, rest) = parse_init_payload(&framed_empty).expect("parses"); + assert!(rest.is_empty()); + + // big-endian so lexicographic byte-compare is chronological: a later + // wall-clock ts compares strictly greater than an earlier one. + let earlier = now_tai64n(); + std::thread::sleep(std::time::Duration::from_millis(2)); + let later = now_tai64n(); + assert!(later > earlier, "TAI64N must increase with wall-clock and byte-compare"); +} + +#[test] +fn parse_init_payload_rejects_short_payload() { + assert!(parse_init_payload(&[0u8; TAI64N_LEN - 1]).is_none()); + assert!(parse_init_payload(&[]).is_none()); +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cargo test -p yipd --bin yipd handshake::tests::tai64n` +Expected: FAIL — helpers not defined. + +- [ ] **Step 3: Implement the helpers** + +```rust +/// TAI64N label length: 8-byte seconds + 4-byte nanoseconds. +pub const TAI64N_LEN: usize = 12; + +/// The current wall clock as a TAI64N label: big-endian `2^62 + unix_secs` +/// (8 bytes) followed by big-endian nanoseconds (4 bytes). Big-endian so that +/// a lexicographic byte comparison of two labels is chronological. Wall-clock +/// based, so it survives a peer restart (a fresh Init is always newer in real +/// time) with no persisted state. A clock that jumps backwards yields a label +/// that a peer with a higher last-accepted label will reject — the WireGuard +/// behavior, accepted. +pub fn now_tai64n() -> [u8; TAI64N_LEN] { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + let secs = now.as_secs().wrapping_add(1u64 << 62); + let nanos = now.subsec_nanos(); + let mut out = [0u8; TAI64N_LEN]; + out[..8].copy_from_slice(&secs.to_be_bytes()); + out[8..].copy_from_slice(&nanos.to_be_bytes()); + out +} + +/// Build the msg1 Noise payload: the anti-replay TAI64N label followed by the +/// (optional) membership cert. Empty `cert` (2a/2b) yields a 12-byte payload. +pub fn frame_init_payload(cert: &[u8]) -> Vec { + let mut out = Vec::with_capacity(TAI64N_LEN + cert.len()); + out.extend_from_slice(&now_tai64n()); + out.extend_from_slice(cert); + out +} + +/// Split a received msg1 payload into `(ts_label, cert_remainder)`. +/// `None` (fail-closed) if it is shorter than the 12-byte label. +pub fn parse_init_payload(payload: &[u8]) -> Option<([u8; TAI64N_LEN], &[u8])> { + let ts: [u8; TAI64N_LEN] = payload.get(..TAI64N_LEN)?.try_into().ok()?; + Some((ts, &payload[TAI64N_LEN..])) +} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `cargo test -p yipd --bin yipd handshake::tests` +Expected: PASS. + +- [ ] **Step 5: fmt, clippy, commit** + +```bash +cargo fmt +cargo clippy -p yipd --all-targets -- -D warnings +git add bin/yipd/src/handshake.rs +git commit -m "feat(anti-replay.34): TAI64N + msg1 payload framing helpers" +``` + +--- + +### Task 2: wire the payload framing into build + consume (no freshness gate yet) + +**Files:** +- Modify: `bin/yipd/src/peer_manager.rs` — the two payload-BUILD sites (`begin_handshake` ~705, `drive_rekey_schedule` ~878) and the responder CONSUME path (parse the ts off `initiator_payload`/`responder_payload` before `responder_cert_ok`). + +**Interfaces:** +- Consumes: `handshake::frame_init_payload`, `handshake::parse_init_payload`, `handshake::TAI64N_LEN` (Task 1). +- Produces: msg1 payloads are now `[ts ‖ cert]`; the cert remainder is what `responder_cert_ok` / `Cert::decode` see. This task establishes the wire format end-to-end (all 4 handshake variants still establish); the ts is parsed but NOT yet enforced (Task 3 adds the freshness gate). A parse failure (< 12 B) drops the Init. + +- [ ] **Step 1: Write the failing test** + +The end-to-end invariant: a normal handshake still completes with the new framing, and a cert-bearing (mesh) handshake still admits by cert (the cert remainder is intact after ts stripping). Mirror an existing establish test (e.g. the 2c admission test or `initiator_rejects_responder_with_bad_cert`'s positive counterpart) but assert it still establishes with the framed payload. + +```rust +#[test] +fn framed_init_payload_still_establishes_and_admits_by_cert() { + // A mesh initiator's Init now carries [ts || cert]; the responder strips + // the ts and admits by the cert remainder exactly as before. (Build a + // mesh PeerManager pair as the 2c admission tests do; drive an Init->Resp + // exchange; assert both reach Established.) + // ... reuse the 2c admission scaffolding ... +} +``` + +If building a full pair is heavy, at minimum assert the responder-side parse: an Init whose payload is `frame_init_payload(&valid_cert_bytes)` admits (cert recovered from `parse_init_payload(...).1`), and an Init whose payload is a raw cert with NO ts prefix now FAILS to admit (the first 12 cert bytes are mis-read as a ts, so the cert remainder no longer decodes) — proving the framing is enforced on the consume side. + +- [ ] **Step 2: Run to verify failure** + +Run: `cargo test -p yipd --bin yipd framed_init_payload_still_establishes` +Expected: FAIL — build sites still send a raw cert; the responder still `Cert::decode`s the whole payload. + +- [ ] **Step 3: Implement — build side** + +At BOTH payload-build sites, wrap the cert with `frame_init_payload`. `begin_handshake` (~705): + +```rust + let cert = self + .membership + .as_ref() + .map(Membership::own_cert_bytes) + .unwrap_or_default(); + let payload = crate::handshake::frame_init_payload(&cert); + let (hs, init_pkt) = + match HandshakeState::start_initiator(&self.local_priv, &pubkey, &payload) { +``` + +`drive_rekey_schedule` (~878): identical wrap of its `own_cert_bytes`-or-default into `frame_init_payload(&cert)` before `start_initiator`. + +Leave the msg2 (responder `resp_payload`) build UNCHANGED — anti-replay protects only the initiation. + +- [ ] **Step 4: Implement — consume side** + +Every responder path that reads the initiator's msg1 payload must parse the ts off first. The initiator payload is destructured from `start_responder` as `initiator_payload` in `handle_handshake_init` (~1571) and `relayed_handshake_init` (~1090). Immediately after `start_responder` succeeds, split it: + +```rust + // #34: the msg1 payload is [ts || cert]. Split the anti-replay label + // off; the cert remainder is what admission checks. A payload too + // short to hold the label is malformed — fail closed. + let Some((init_ts, initiator_cert)) = crate::handshake::parse_init_payload(&initiator_payload) else { + return DispatchOut::None; + }; +``` + +Then replace every downstream use of `initiator_payload` as the cert with `initiator_cert` (the `responder_cert_ok(&initiator_cert, remote_static)` calls, and the `Cert::decode(&initiator_cert)` in the cold-start admission `None => { ... }` arm). Bind `init_ts` for Task 3 (unused this task — prefix `_init_ts` or `#[expect(unused)]` is NOT needed if Task 2 and 3 are one commit; if separate, name it `_init_ts` and Task 3 renames). + +**Note:** msg2's responder cert is checked via `responder_cert_ok(&responder_payload, ...)` on the initiator side — the responder payload is NOT ts-framed (msg2 has no anti-replay), so those sites are UNCHANGED. + +- [ ] **Step 5: Run tests + full suite** + +Run: `cargo test -p yipd --bin yipd` +Expected: PASS — all establish tests green with the new framing; any test that hand-builds a raw-cert msg1 payload must be updated to `frame_init_payload(&cert)` (these are legitimate test-scaffolding updates — the wire format changed). + +- [ ] **Step 6: fmt, clippy, commit** + +```bash +cargo fmt +cargo clippy -p yipd --all-targets -- -D warnings +git add bin/yipd/src/peer_manager.rs +git commit -m "feat(anti-replay.34): frame [ts||cert] into msg1; responder strips the ts" +``` + +--- + +### Task 3: freshness gate (replace the age gate) + endpoint gating + by_tag eviction + +**Files:** +- Modify: `bin/yipd/src/peer_manager.rs` (new `Peer.last_accepted_init_ts` field + init; `accept_fresh_init` helper; freshness gate in `rekey_init_core` replacing `accept_rekey_init`; endpoint-learning moved into the fresh-accept branch; by_tag eviction on rebuild) +- Modify: `bin/yipd/src/epoch.rs` (remove/deprecate `accept_rekey_init`'s age gate — see Step 4) + +**Interfaces:** +- Consumes: `handshake::parse_init_payload` output `init_ts: [u8; 12]` (Task 2). +- Produces: `Peer.last_accepted_init_ts: Option<[u8; 12]>`; `fn accept_fresh_init(&self, idx: usize, ts: &[u8; 12]) -> bool`. After this task, a new-ephemeral Init builds a session only when `ts > last_accepted_init_ts`; retransmit/`cached_resp` paths bypass; endpoint is learned only on a fresh accept. + +- [ ] **Step 1: Write the failing tests** + +```rust +#[test] +fn stale_replayed_init_is_rejected_and_endpoint_unchanged() { + // An Established peer (last_accepted_init_ts = T1). A NEW-ephemeral Init + // carrying an OLDER ts T0 < T1 from a SPOOFED src is rejected: session + // intact (established_tag unchanged), endpoint unchanged, last ts unchanged. + // (Build an Established peer via the existing helper; splice + // last_accepted_init_ts = a known label; craft a new-ephemeral Init whose + // frame_init_payload uses an older label; deliver from a spoofed src.) +} + +#[test] +fn fresh_new_ephemeral_init_rebuilds_and_relearns_endpoint() { + // An Established peer receiving a NEW-ephemeral Init with ts > last + // rebuilds: new epoch, old by_tag entry evicted, endpoint = the new src, + // last_accepted_init_ts = the new ts. +} + +#[test] +fn retransmit_still_replays_cached_resp_regardless_of_ts() { + // A retransmit (init_eph == cached_resp_init_eph, same ts) still replays + // cached_resp and does NOT reject / does NOT change endpoint or last ts. +} +``` + +Reuse the 9a/#91 rekey test scaffolding (`pm_with_established_peer`, `established_tag`, the cached-resp helpers). For the ts labels, use `handshake::now_tai64n()` and hand-decremented/incremented arrays. + +- [ ] **Step 2: Run to verify failure** + +Run: `cargo test -p yipd --bin yipd stale_replayed_init_is_rejected` +Expected: FAIL — no freshness gate; a new-ephemeral Init against an Established peer currently goes through the age gate, not a ts check. + +- [ ] **Step 3: Add the field + helper** + +`Peer` struct: add `last_accepted_init_ts: Option<[u8; 12]>` (doc: "#34: greatest TAI64N label accepted in a session-building Init from this peer; in-memory, gates rebuild/rekey and endpoint learning"). Initialize `None` at every `Peer` construction site (grep `Peer {` / the peer-builder — there are a few: config peers, `admit_member`, test helpers). + +Add to `impl PeerManager`: + +```rust +/// #34 anti-replay: whether `ts` is strictly newer than the greatest label +/// we have accepted in a session-building Init from peer `idx` (or the first +/// such Init). Retransmit/`cached_resp` paths do NOT call this — they replay +/// without a freshness check. +fn accept_fresh_init(&self, idx: usize, ts: &[u8; 12]) -> bool { + match self.peers[idx].last_accepted_init_ts { + None => true, + Some(last) => *ts > last, + } +} +``` + +- [ ] **Step 4: Wire the freshness gate into `rekey_init_core` + thread `init_ts`** + +Thread `init_ts: [u8; 12]` into `rekey_init_core` (add a param) from both callers (`handle_rekey_init` wrapper / the `relayed_handshake_init` Established arm), which get it from Task 2's `parse_init_payload`. In `rekey_init_core`, the decision tree becomes: + +1. `cached_resp_init_eph == Some(init_eph)` → replay `cached_resp` (unchanged; **no ts check, no ts update**). +2. `next_cached_resp_for(init_eph)` → replay cached rekey resp (unchanged; no ts check). +3. **Replace** `if !epochs.accept_rekey_init(now_ms, self.rekey_interval_ms) { ...replay cached_resp... }` **with** `if !self.accept_fresh_init(idx, &init_ts) { return DispatchOut::None; }` — a stale/replayed new-ephemeral Init is now a **silent drop** (not a cached_resp replay), because it is either an attacker replay or a backwards-clock peer. +4. Build the new session (rebuild) — and here **set `self.peers[idx].last_accepted_init_ts = Some(init_ts)`** and evict the old `by_tag` entries before inserting the new (reuse the `drop_session` tag-collection or the existing `promote_from_rekey` `by_tag.remove(old_tag)`), so no stale tag routes to the superseded session. + +Remove `accept_rekey_init` from `epoch.rs` (and its test) once no caller remains — grep to confirm `rekey_init_core` was its only caller. Update the doc comment on `EpochSet` that references the age gate. + +- [ ] **Step 5: Gate endpoint learning on a fresh accept** + +The direct cold-start establish arm of `handle_handshake_init` currently sets `self.peers[idx].endpoint = Some(src)` unconditionally (~1759). Move/guard it so `endpoint` is set only when this Init was a fresh accept: for a cold-start (Idle/Handshaking peer), the first Init is always fresh (`last_accepted_init_ts` is `None`) → accept + set `last_accepted_init_ts = Some(init_ts)` + `endpoint = Some(src)`. A cold-start Init that is NOT fresh (a replay against a peer that already has a `last_accepted_init_ts` but is somehow Idle — e.g. after a give-up) must NOT set `endpoint`. Concretely: add `if !self.accept_fresh_init(idx, &init_ts) { return DispatchOut::None; }` at the top of the cold-start establish arm, and set `last_accepted_init_ts = Some(init_ts)` alongside the existing `endpoint = Some(src)`. + +- [ ] **Step 6: Run tests + full suite** + +Run: `cargo test -p yipd --bin yipd` +Expected: PASS. The 9a rekey tests that relied on the age gate (`accept_rekey_init_ignores_too_fresh_current`, `tick_*rekey*` timing) must be updated: a rekey now completes on a fresh-ts Init regardless of `current` age — rewrite those to assert freshness-gated completion (a fresh-ts new-ephemeral rekey Init completes; a stale-ts one is dropped). These are legitimate updates (the age gate was replaced by design). + +- [ ] **Step 7: fmt, clippy, commit** + +```bash +cargo fmt +cargo clippy -p yipd --all-targets -- -D warnings +git add bin/yipd/src/peer_manager.rs bin/yipd/src/epoch.rs +git commit -m "feat(anti-replay.34): ts freshness gate replaces the age gate; endpoint learned only on a fresh Init" +``` + +--- + +### Task 4: #36 retirement — fresh-Init inversion + freshness-gated relay adoption + +**Files:** +- Modify: `bin/yipd/src/peer_manager.rs` (`retarget_handshake`, the two tick escalation arms, `relayed_handshake_init`'s Established arm; the #36 unit tests) + +**Interfaces:** +- Consumes: the freshness gate + `accept_fresh_init` (Task 3), `begin_handshake`. +- Produces: path re-targets send a fresh Init (new ephemeral + newer ts); a direct-established responder rebuilds + adopts the relay only on a fresh-ts new-ephemeral relayed Init; the `cached_resp_init_eph` relay-adoption and the ephemeral-preservation are removed; the downgrade tradeoff is closed. + +- [ ] **Step 1: Write the failing tests** + +```rust +#[test] +fn path_switch_sends_fresh_init_and_responder_rebuilds() { + // A Handshaking peer re-targeted to a new path now emits a Init with a + // DIFFERENT ephemeral (fresh) than before — NOT the byte-identical resend + // Task-1(#36) asserted. (Inverse of the removed + // `retarget_handshake_preserves_ephemeral_and_flips_relay`.) +} + +#[test] +fn relayed_fresh_init_adopts_relay_and_rebuilds_but_replay_does_not() { + // A direct-established (relay=false) responder receiving a relayed + // NEW-ephemeral FRESH-ts Init rebuilds + adopts relay (relay=true, new + // epoch). A relayed replay (old ts, OR the old cached_resp_init_eph + // retransmit) does NOT adopt relay and does NOT rebuild (freshness gate) — + // the downgrade is closed. +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cargo test -p yipd --bin yipd path_switch_sends_fresh_init` +Expected: FAIL — `retarget_handshake` still preserves the ephemeral; the relayed arm still adopts on the `cached_resp_init_eph` retransmit. + +- [ ] **Step 3: Invert `retarget_handshake`** + +On a path re-target of an in-flight `Handshaking` attempt, send a **fresh** Init instead of resending `init_pkt`. Simplest: the escalation arms (`PathAction::Relay`, `PathAction::Probe(addr) if addr != target`) revert to `state = Idle; begin_handshake(idx, new_target, via_relay, now_ms)` (a fresh ephemeral + fresh ts via Task-2's framed payload), and `retarget_handshake` is removed (or reduced to a thin `begin_handshake` wrapper that also updates `relay`/`endpoint`). The old #36 concern (a fresh ephemeral orphaned the responder's `cached_resp`) is now resolved by the responder REBUILDING on the fresh Init (Task 3 + Step 4), so preservation is unnecessary. Keep the relay branch's `endpoint = None` clear (a fresh direct Resp must not complete onto a relay-flagged peer — still valid). + +- [ ] **Step 4: Re-gate the relayed relay-adoption on freshness** + +In `relayed_handshake_init`'s Established arm (the #95/#36 adoption), REPLACE the `!self.peers[idx].relay && self.peers[idx].cached_resp_init_eph == Some(init_eph)` adoption condition with a freshness-gated rebuild: a relayed **new-ephemeral** Init (not a seen-ephemeral retransmit) that `accept_fresh_init(idx, &init_ts)` → set `relay = true`, `endpoint = None`, and route to `rekey_init_core(.., via_relay = true)` which (with Task 3) rebuilds the session (fresh ts) and sets `last_accepted_init_ts`. A retransmit (seen ephemeral) → replay `cached_resp` over the relay as today (no adoption change). A stale-ts new-ephemeral relayed Init → dropped by the freshness gate (no adoption, no downgrade). Remove the `cached_resp_init_eph`-retransmit-triggered relay adoption entirely. + +- [ ] **Step 5: Rewrite the #36 unit tests** + +The #36 tests from PR #95 assert ephemeral-preservation + retransmit-adoption, which are now removed. Replace: +- `retarget_handshake_preserves_ephemeral_and_flips_relay` → `path_switch_sends_fresh_init_and_responder_rebuilds` (Step 1). +- `established_responder_completes_retargeted_initiator_via_cached_resp` → retained only if it still models a retransmit; otherwise fold into the rebuild test. +- `direct_established_responder_adopts_relay_on_relayed_cold_start_retransmit` → `relayed_fresh_init_adopts_relay_and_rebuilds_but_replay_does_not` (Step 1) — adoption is now on a fresh Init, and a replay is refused. +- Keep `direct_established_responder_ignores_relayed_new_ephemeral_init` semantics as the stale-ts case (a new-ephemeral relayed Init with a non-fresh ts is refused). + +- [ ] **Step 6: Run tests + full suite** + +Run: `cargo test -p yipd --bin yipd` +Expected: PASS. + +- [ ] **Step 7: fmt, clippy, commit** + +```bash +cargo fmt +cargo clippy -p yipd --all-targets -- -D warnings +git add bin/yipd/src/peer_manager.rs +git commit -m "feat(anti-replay.34): retire #36 — fresh-Init + freshness-gated rebuild; downgrade closed" +``` + +--- + +### Task 5: netns money tests (endpoint-hijack refused, restart recovery, #36 convergence) + CI + +**Files:** +- Create: `bin/yipd/tests/run-netns-replay-hijack.sh` (endpoint-hijack + restart) +- Modify: `bin/yipd/tests/run-netns-pathswitch-rehandshake.sh` (#36 now converges via rebuild — update assertions), `.github/workflows/integration.yml` + +- [ ] **Step 1: Read fork sources** + +Read `bin/yipd/tests/run-netns-relay.sh` / `run-netns-punch.sh` (two-peer + a third namespace for the spoofed replay source), `run-netns-rekey.sh` (both-driver parameterization, `set -euo pipefail`/`trap`, `[PASS]`/`[FAIL]`), and the current `run-netns-pathswitch-rehandshake.sh` (its ephemeral-preservation assertion — `DISTINCT_INIT_EPHEMERALS=1` — is now WRONG; the #36 fix now uses a FRESH ephemeral on escalation). + +- [ ] **Step 2: Write `run-netns-replay-hijack.sh`** + +Two peers A/B establish. Capture one of A's `HandshakeInit` datagrams (tcpdump on the A↔B link). From a THIRD namespace with a spoofed source, replay the captured Init at B. Assert: (1) B does NOT redirect A's traffic to the spoof source — a steady `ping A→B` keeps flowing (≤1% loss) throughout the replay, proving B's `endpoint` for A was not hijacked; (2) B's stderr shows the replay was refused (add a one-line marker on the freshness-gate drop if none exists, behind existing handshake logging). Then, a **restart** leg: kill A, restart it (fresh ephemeral + newer wall-clock ts), assert A↔B re-establishes and ping resumes within a bounded window (previously stuck until B's attempt timed out). `set -euo pipefail`, `trap` cleanup. BOTH drivers. + +- [ ] **Step 3: Update `run-netns-pathswitch-rehandshake.sh`** + +The #36 path-switch still must CONVERGE (ping A→B succeeds over the relay), but now via a FRESH-ephemeral rebuild, not ephemeral preservation. Remove/invert the `DISTINCT_INIT_EPHEMERALS=1` assertion (A now legitimately draws a fresh ephemeral on escalation); assert convergence (ping ≥98%) + relay-forwarded>0 as before. Update the header note. + +- [ ] **Step 4: Run under sudo (both drivers)** + +```bash +cargo build --release -p yipd +sudo bash bin/yipd/tests/run-netns-replay-hijack.sh "$(pwd)/target/release/yipd" "$(pwd)/target/release/yip-rendezvous" +sudo YIP_USE_URING=1 bash bin/yipd/tests/run-netns-replay-hijack.sh "$(pwd)/target/release/yipd" "$(pwd)/target/release/yip-rendezvous" +sudo bash bin/yipd/tests/run-netns-pathswitch-rehandshake.sh "$(pwd)/target/release/yipd" "$(pwd)/target/release/yip-rendezvous" +sudo YIP_USE_URING=1 bash bin/yipd/tests/run-netns-pathswitch-rehandshake.sh "$(pwd)/target/release/yipd" "$(pwd)/target/release/yip-rendezvous" +``` +Expected: PASS, exit 0 all four. Rebuild release after any yipd change. If the environment can't run netns, capture the exact blocker and report DONE_WITH_CONCERNS. + +- [ ] **Step 5: Wire CI + commit** + +Add `run-netns-replay-hijack.sh` to `.github/workflows/integration.yml` next to the sibling netns steps (both drivers, same SKIP/`[FAIL]` guards). `chmod +x`. + +```bash +chmod +x bin/yipd/tests/run-netns-replay-hijack.sh +git add bin/yipd/tests/run-netns-replay-hijack.sh bin/yipd/tests/run-netns-pathswitch-rehandshake.sh .github/workflows/integration.yml +git commit -m "test(anti-replay.34): netns — endpoint-hijack refused, restart recovery, #36 rebuild" +``` + +--- + +## After all tasks + +- Final whole-branch review (opus) over the branch delta (base = `main` at the branch point). Focus: the admission decision tree (freshness gate placement — retransmit/`cached_resp` paths bypass, new-ephemeral paths gated; the age gate is fully removed and its churn-bound is covered by the un-forgeable fresh-ts requirement); endpoint learning strictly on a fresh accept; `by_tag` eviction leaves no stale tag; the #36 inversion closes the downgrade (a replayed escalation Init is refused) without regressing convergence; the ts rides only inside the encrypted payload (no cleartext/framing change); no `unsafe`/`as`/bare-`allow`. +- Push; open a PR based on `main`. Leave it for the user; do NOT merge; no "not merging" line. +- Update `yip-control-plane-status` memory (#34 done; #36 retired/inverted; age gate replaced). + +## Global test/verify discipline + +- Run build/clippy/fmt and the netns money tests under BOTH poll and `YIP_USE_URING=1`; netns uses the RELEASE `yipd` — rebuild release after every yipd change. +- The full 9a/#91/#36/#41 netns + unit suite must stay green; this milestone modifies their admission/rekey/adoption paths, so treat any red there as a real regression, not a test to relax. diff --git a/docs/superpowers/specs/2026-07-23-handshake-anti-replay-34-design.md b/docs/superpowers/specs/2026-07-23-handshake-anti-replay-34-design.md new file mode 100644 index 0000000..a170e5b --- /dev/null +++ b/docs/superpowers/specs/2026-07-23-handshake-anti-replay-34-design.md @@ -0,0 +1,112 @@ +# Handshake anti-replay + authenticated endpoint learning (#34) — design spec + +**Date:** 2026-07-23 +**Status:** design (pending user review) +**Issue:** #34 (handshake hardening; anchor for #36's downgrade tradeoff, #37, #64). +**Depends on:** 9a (PR #90) + #91 (PR #92) rekey machinery, and #36 (PR #95) — all merged to `main`. This milestone modifies their handshake-admission / rekey-completion / relay-adoption code. +**Scope:** `bin/yipd/src/peer_manager.rs` (admission, rekey, escalation), `bin/yipd/src/handshake.rs` (msg1 payload framing helpers). No change to `yip-crypto` (Noise) or `yip-wire`. The timestamp rides **inside** the encrypted Noise msg1 payload — no new cleartext field, no `PacketType`/framing change. + +## Goal + +The Noise-IK handshake carries no anti-replay token. Two consequences: + +1. **Endpoint hijack via replay.** `handle_handshake_init` learns a peer's endpoint from the datagram source (`endpoint = src`) after admission. An on-path adversary who captures one genuine `HandshakeInit` from peer P can replay it from a spoofed source S: `start_responder` succeeds, admission passes (recovers P's real static key), and our outbound for P is redirected to S. +2. **No safe session rebuild on genuine peer restart.** A replayed old `Init` is indistinguishable from a genuine re-initiation, so an `Established` responder cannot rebuild on a peer restart (rebuilding on any differing `Init` would let a replay tear down a live session — a DoS). 9a/#36 work around specific loss/escalation cases with ephemeral-preservation hacks, but a genuine restart mid-session still can't be recovered until timeout, and #36 carries an accepted attacker-induced downgrade tradeoff. + +Add a monotonic wall-clock timestamp (WireGuard TAI64N-style) to the initiation; reject any `Init` not strictly newer than the last accepted from that static key. Then: gate endpoint learning on a fresh `Init`, enable safe session rebuild on a newer `Init`, and **retire #36's ephemeral-preservation hack and its downgrade tradeoff** by inverting it to fresh-Init-then-rebuild. + +## Background (current state) + +- **msg1 payload:** built as `Membership::own_cert_bytes()` (a raw encoded `Cert`) in mesh mode, or **empty** in 2a/2b; consumed by `responder_cert_ok` via `Cert::decode(payload)`. It already rides inside Noise's encrypted msg1. +- **Admission (`handle_handshake_init` / `relayed_handshake_init`):** `start_responder` → `(established, resp_pkt, remote_static, initiator_payload)`; static-key match or cert admits; on cold-start establish, `endpoint = Some(src)` (peer_manager.rs ~1759, ~2045). Established peers route through the rekey path. +- **Rekey completion (`rekey_init_core`):** (1) `cached_resp_init_eph` cold-start dedup → replay `cached_resp`; (2) `next_cached_resp_for` rekey-retransmit dedup; (3) `accept_rekey_init` **age gate** (`current ≥ interval/2`) — reject-as-too-fresh else build `next`. +- **#36 (PR #95):** `retarget_handshake` preserves the in-flight ephemeral across a path re-target (resend the same `init_pkt`); `relayed_handshake_init`'s Established arm adopts the relay + replays `cached_resp` on a relayed cold-start **retransmit** (`init_eph == cached_resp_init_eph`). Accepted tradeoff: an attacker replaying a captured Init forces a persistent direct→relay downgrade. +- **Per-peer state:** `Peer { pubkey, endpoint, state, cached_resp_init_eph, relay, … }`. No anti-replay state. + +## Design + +### 1. Wire: msg1 payload = `[TAI64N ts (12 B)] ‖ [optional cert]` + +Add helpers to `handshake.rs`: +- `now_tai64n() -> [u8; 12]` — the current wall clock as TAI64N: 8-byte big-endian seconds (`2^62 + unix_secs`) ‖ 4-byte big-endian nanoseconds. Big-endian so lexicographic byte comparison is chronological. +- `frame_init_payload(cert: &[u8]) -> Vec` — `now_tai64n() ‖ cert` (cert empty in 2a/2b). +- `parse_init_payload(payload: &[u8]) -> Option<([u8; 12], &[u8])>` — split the 12-byte ts prefix from the cert remainder; `None` if `payload.len() < 12` (fail-closed). + +`begin_handshake` and the rekey scheduler (`drive_rekey_schedule`) build the payload via `frame_init_payload(own_cert_bytes-or-empty)` instead of the raw cert. The responder calls `parse_init_payload` on `initiator_payload` before `responder_cert_ok` (which now receives the cert remainder, unchanged). msg2 (response) is unchanged — anti-replay protects only the initiation (the responder answers a fresh init; WireGuard model). + +### 2. State: `Peer.last_accepted_init_ts: Option<[u8; 12]>` + +The greatest TAI64N ts accepted from this peer, in-memory (no persistence — WireGuard's model; after a responder restart there is no live session to protect, so `None` safely re-accepts). Compared by `<=`/`>` on the 12-byte big-endian arrays. + +### 3. The unified freshness gate (replaces the 9a age gate) + +A single helper `accept_fresh_init(peer_idx, ts) -> bool`: `true` iff `last_accepted_init_ts` is `None` (first Init) or `ts > last_accepted_init_ts`. It is the discriminator for **building a new session** (cold-start with a new ephemeral, rekey, or restart-rebuild): + +- **Seen ephemeral** (retransmit: `init_eph == cached_resp_init_eph`, or matches an in-flight `next`) → replay `cached_resp` as today. **No ts check, no ts update** — a retransmit is byte-identical (same ts) and must not be rejected. +- **New ephemeral + `accept_fresh_init`** → build/rebuild the session; **set `last_accepted_init_ts = ts`** on acceptance. +- **New ephemeral + not fresh** (`ts <= last`) → **reject** (silent drop): a stale replay, or a peer with a backwards clock (same failure mode as WireGuard). `current` untouched, no endpoint update. + +`accept_rekey_init`'s age gate (`current ≥ interval/2`) is **removed**: it was a proxy for "not a replay" absent anti-replay; the ts check is the real thing, and an attacker cannot forge a fresh-ts new-ephemeral Init (it requires the peer's static key), so the anti-churn purpose is also covered. + +### 4. Endpoint gating + +`endpoint = Some(src)` is set **only on a fresh accepted Init** (inside the accept branch, after `accept_fresh_init` passes). A replayed old Init is rejected before this point → no redirect. (Relay-reached peers are unaffected — they set `relay`/`server_addr()`, not `endpoint`.) + +### 5. by_tag eviction on rebuild + +When a fresh-ts new-ephemeral Init rebuilds an `Established` peer's session, the old session's `conn_tag` (and any `next`/`previous`) is removed from `by_tag` before inserting the new one — reuse the `drop_session`/`promote_from_rekey` tag-bookkeeping so no stale tag routes to the superseded session. (Resolves the issue's `by_tag`-eviction note.) + +### 6. #36 retirement (inverted to fresh-Init + safe rebuild) + +- **`retarget_handshake` drops ephemeral-preservation.** On a path re-target, the initiator sends a **fresh** Init (new ephemeral + newer ts) — via `begin_handshake` — instead of resending the same `init_pkt`. This was the original #36 *bug*, now *safe* because the responder rebuilds on the fresh-ts new-ephemeral Init rather than replaying a stale `cached_resp`. `retarget_handshake` is either removed (callers revert to `begin_handshake`) or reduced to just updating the path/relay target with a fresh Init; the escalation arms (`PathAction::Relay`, `PathAction::Probe(addr) if addr != target`) call the fresh path. +- **Responder-side relay adoption re-gated on freshness.** `relayed_handshake_init`'s Established arm: a relayed **fresh-ts new-ephemeral** Init against a `relay == false` peer → **rebuild** the session (new `DataPlane`) **and** adopt the relay (`relay = true`, egress via `relay_wrap`). A **replay** (old ts, or the `cached_resp_init_eph` retransmit) → **not** adopted (rejected by the freshness gate) → **no downgrade**. The #95 `cached_resp_init_eph`-retransmit adoption path is removed (superseded). +- **The accepted downgrade tradeoff is deleted** — an *off-path* attacker replaying an old captured Init can no longer force a path change; only a fresh-ts Init rebuilds/adopts. Residual: an *on-path* attacker who observes a genuine fresh-ts direct Init and re-injects a copy via the relay can still race a one-round relay downgrade — but that requires on-path capture, which already subsumes forcing relay by blackholing the direct path, so it grants no new capability. + +The `is_root` / cert-exemption logic (#41, #96) is untouched — it operates on the cert remainder after ts parsing. + +## Error handling + +- **Fail-closed parsing:** a payload shorter than 12 bytes, or otherwise unparseable, → reject the Init (silent drop), `current` and `endpoint` untouched. +- **Stale replay:** rejected before any state mutation — no endpoint update, no session build/teardown, no `by_tag` change. +- **Retransmit safety:** the seen-ephemeral (`cached_resp_init_eph`/`next`) replay paths are unchanged and bypass the ts check, so the 2a/9a loss-wedge + idempotent-convergence guarantees hold. +- **No panic on the wire path.** TAI64N comparison is a byte-array compare; parsing uses `.get(..)`. +- **Clock behavior:** a peer whose clock jumps backwards (bad NTP / restart with a bad RTC) cannot handshake until its clock passes the last-accepted ts — the WireGuard behavior, accepted. No skew-tolerance window (unlike cert validity): the check is strict monotonicity per peer, not a validity window. + +## Testing / adversary + +- **Unit:** + 1. First Init (no `last_accepted_init_ts`) is accepted; `last_accepted_init_ts` recorded; `endpoint` learned from `src`. + 2. A **replayed** Init (same bytes, same ts) from a **spoofed src** against an Established peer is **rejected**: `endpoint` unchanged, session intact, `last_accepted_init_ts` unchanged (retransmit path if the ephemeral matches → replays cached_resp to `src` but still does NOT change `endpoint`; a captured *older* Init with a since-superseded ephemeral → rejected outright). + 3. A **genuine restart** (new ephemeral + newer ts) rebuilds the Established peer's session: new epoch, old `by_tag` evicted, `endpoint` re-learned. + 4. A retransmit (seen ephemeral, same ts) still replays `cached_resp` (loss-wedge unregressed). + 5. `parse_init_payload` rejects a < 12-byte payload; `accept_fresh_init` boundary (`ts == last` rejected, `ts == last+1ns` accepted). + 6. #36 path-switch: an escalating peer sending a fresh-ts Init over the relay converges via **rebuild**; a **replayed** escalation Init (old ts) is rejected → no relay adoption (downgrade closed). + 7. The ts applies to **all** modes (no membership-off exemption, unlike #41): a 2a/2b peer still establishes with a `[ts]`-only payload (empty cert), and its replays are rejected the same way. +- **netns money tests (both drivers, release yipd):** + - **endpoint-hijack:** a captured `Init` replayed from a third namespace with a spoofed source does NOT redirect the victim's traffic (the peer stays reachable at its real endpoint). + - **restart recovery:** a peer killed and restarted mid-session (fresh ephemeral + newer ts) re-establishes within a bounded window and traffic resumes — previously stuck until timeout. + - **#36 regression:** the path-switch convergence test (`run-netns-pathswitch-rehandshake.sh`) still converges (now via fresh-Init rebuild, not ephemeral-preservation). +- **Regression:** the full 9a/#91/#36/#41 unit + netns suites stay green (this milestone modifies their admission/rekey/adoption paths — the suite is the net). + +## Risks + +- **Modifies the core handshake-admission + rekey-completion + #36/#95 relay-adoption code** — the highest-blast-radius change since 9a. Mitigation: the freshness gate is a *narrowing* (it only adds a reject condition to the new-session-build paths; retransmit/replay-cached paths are untouched), the 9a age gate it replaces is strictly subsumed, and the full merged netns/unit suite is the regression net. Land behind that suite; final whole-branch opus review focused on the admission decision tree. +- **Clock dependency** — a peer with a badly-backwards clock can't handshake. Accepted (WireGuard's model); documented. Not a concern for the anti-DPI/NTP-typical deployment. +- **#36 inversion touches just-shipped code** — removing the ephemeral-preservation + `cached_resp_init_eph` adoption is a real behavior change; the #36 netns test is rewritten to assert convergence-via-rebuild, and a new test asserts a replayed escalation Init is refused. +- **Wire break** — pre-#34 nodes can't handshake with #34 nodes (payload framing differs). Expected for a security milestone; a mixed fleet is a deploy concern, not a code one. + +## Non-goals + +- No cross-restart *counter* persistence (wall-clock ts needs none). +- No msg2 (response) anti-replay (the responder answers a fresh init; not needed). +- #37 (authenticated rendezvous registration) and #64 (obf discriminator) — separate issues that *build on* #34's authenticated endpoint but are out of scope here. +- No change to the rekey *schedule* (still ~120 s, glare-winner) — only how the responder *validates* a rekey/rebuild Init. + +## Success criteria + +1. A replayed `HandshakeInit` from a spoofed source cannot redirect a peer's endpoint or tear down / rebuild its live session (rejected by the freshness gate); proven by a netns money test. +2. A genuine peer restart (fresh ephemeral + newer ts) safely rebuilds the session within a bounded window; proven by a netns money test. +3. The 9a age gate is replaced by the ts freshness check; the retransmit/idempotent-convergence and 2a loss-wedge guarantees are unregressed. +4. #36 is inverted to fresh-Init + safe rebuild: its path-switch scenario still converges, its ephemeral-preservation + `cached_resp_init_eph` relay-adoption code is removed, and its accepted downgrade tradeoff is closed (a replayed escalation Init is refused). +5. The timestamp rides inside the encrypted Noise payload — no cleartext fingerprint, no `PacketType`/wire-framing change; `yip-crypto`/`yip-wire` unchanged. +6. `forbid-unsafe`; no `as` casts (except the pre-existing `PacketType::X as u8` idiom); no bare `#[allow]`; clippy `-D warnings` clean; both-driver netns green.