Handshake anti-replay + authenticated endpoint (#34) #99

Merged
vxfemboy merged 12 commits from feat/handshake-anti-replay-34 into main 2026-07-25 01:05:38 +00:00
vxfemboy commented 2026-07-23 14:14:36 +00:00 (Migrated from github.com)

Closes #34.

Adds a WireGuard-style anti-replay token to the Noise-IK handshake and makes endpoint learning authenticated, closing the on-path Init-replay endpoint-hijack and letting an Established responder safely rebuild on a genuine peer restart. This is the anchor that retires the #36 accepted-downgrade tradeoff.

Design

A 12-byte TAI64N wall-clock timestamp (8-byte BE 2^62+unix_secs ‖ 4-byte BE nanos — byte-comparable = chronological) is prefixed to the msg1 cert payload and rides inside the encrypted Noise payload — no new cleartext field, no wire-format change, no anti-DPI fingerprint (yip-crypto / yip-wire untouched).

Per-peer last_accepted_init_ts + accept_fresh_init(ts > last) becomes the single "build a new session" discriminator, replacing the 9a accept_rekey_init age gate (removed from epoch.rs). Consequences:

  • Replay refused — a captured Init with a stale ts is silently dropped (DispatchOut::None).
  • Retransmit / cached-response paths bypass the gate — steady-state retransmits and the 9a/#91 idempotent rekey are unaffected (they run before the gate on a seen ephemeral).
  • Endpoint learned only on a fresh acceptpeers[idx].endpoint = Some(src) is behind the gate on the cold-start arm; the rekey path never writes endpoint. Off-path replay can't hijack a peer's endpoint.
  • Safe restart — a genuine restart (fresh ephemeral + newer ts) rebuilds; a replayed old Init cannot.

#36 retired / inverted: on path escalation the peer now draws a fresh ephemeral (state→Idle → begin_handshake) instead of preserving its in-flight one; retarget_handshake is deleted; relay adoption is re-gated on !relay && last_accepted_init_ts.is_some() && new-ephemeral && accept_fresh_init. A replayed escalation Init can no longer downgrade a direct peer to relay.

Tasks

  1. TAI64N + msg1 payload framing helpers (handshake.rs, pure/unit-tested)
  2. Frame [ts‖cert] into msg1 build + responder strip
  3. Freshness gate replaces the age gate; endpoint-learning gated; by_tag eviction on rebuild
  4. #36 retirement — fresh-Init + freshness-gated rebuild; downgrade closed
  5. netns money tests (replay refused + no session disruption + restart recovery; #36 path-switch converges via rebuild) + CI, both drivers

Testing

  • 244/244 yipd unit tests; clippy -D warnings + cargo fmt clean; #![forbid(unsafe_code)].
  • netns money tests exit 0 under both drivers (poll + YIP_USE_URING=1): run-netns-replay-hijack.sh (0% loss across the replay, freshness-gate refusal marker observed, restart reconverged) and run-netns-pathswitch-rehandshake.sh (fresh-ephemeral rebuild, ≥98% convergence, relay-forwarded>0). Both wired into CI.
  • Regression net green: 9a/#91 relay-rekey, #36/#41 path-switch + cert-revocation netns suites.

Final whole-branch opus review: READY WITH FOLLOW-UPS, no Critical/Important — the admission tree, endpoint gating, #36 downgrade closure, and #41/#91 interactions all traced clean.

Follow-ups (non-blocking)

  • M2: on a cross-address (NAT-rebind) restart, peers[idx].endpoint is not relearned on the fresh-ts rebuild, so B's own initiator-role rekeys keep targeting the dead address until an A-initiated rekey recovers. Data plane never black-holes (direct egress is stamped from DataPlane.peer_addr, not endpoint). Pre-existing; relearn endpoint on a fresh-ts rebuild.
  • Enables #37 / #64 (which build on authenticated freshness).
Closes #34. Adds a WireGuard-style anti-replay token to the Noise-IK handshake and makes endpoint learning authenticated, closing the on-path Init-replay endpoint-hijack and letting an Established responder safely rebuild on a genuine peer restart. This is the anchor that retires the #36 accepted-downgrade tradeoff. ## Design A 12-byte **TAI64N wall-clock timestamp** (8-byte BE `2^62+unix_secs` ‖ 4-byte BE nanos — byte-comparable = chronological) is prefixed to the msg1 cert payload and **rides inside the encrypted Noise payload** — no new cleartext field, no wire-format change, no anti-DPI fingerprint (yip-crypto / yip-wire untouched). Per-peer `last_accepted_init_ts` + `accept_fresh_init(ts > last)` becomes the single "build a new session" discriminator, **replacing** the 9a `accept_rekey_init` age gate (removed from `epoch.rs`). Consequences: - **Replay refused** — a captured Init with a stale ts is silently dropped (`DispatchOut::None`). - **Retransmit / cached-response paths bypass the gate** — steady-state retransmits and the 9a/#91 idempotent rekey are unaffected (they run before the gate on a seen ephemeral). - **Endpoint learned only on a fresh accept** — `peers[idx].endpoint = Some(src)` is behind the gate on the cold-start arm; the rekey path never writes endpoint. Off-path replay can't hijack a peer's endpoint. - **Safe restart** — a genuine restart (fresh ephemeral + newer ts) rebuilds; a replayed old Init cannot. **#36 retired / inverted:** on path escalation the peer now draws a **fresh** ephemeral (state→Idle → `begin_handshake`) instead of preserving its in-flight one; `retarget_handshake` is deleted; relay adoption is re-gated on `!relay && last_accepted_init_ts.is_some() && new-ephemeral && accept_fresh_init`. A replayed escalation Init can no longer downgrade a direct peer to relay. ## Tasks 1. TAI64N + msg1 payload framing helpers (`handshake.rs`, pure/unit-tested) 2. Frame `[ts‖cert]` into msg1 build + responder strip 3. Freshness gate replaces the age gate; endpoint-learning gated; `by_tag` eviction on rebuild 4. #36 retirement — fresh-Init + freshness-gated rebuild; downgrade closed 5. netns money tests (replay refused + no session disruption + restart recovery; #36 path-switch converges via rebuild) + CI, both drivers ## Testing - 244/244 `yipd` unit tests; `clippy -D warnings` + `cargo fmt` clean; `#![forbid(unsafe_code)]`. - netns money tests exit 0 under **both** drivers (poll + `YIP_USE_URING=1`): `run-netns-replay-hijack.sh` (0% loss across the replay, freshness-gate refusal marker observed, restart reconverged) and `run-netns-pathswitch-rehandshake.sh` (fresh-ephemeral rebuild, ≥98% convergence, relay-forwarded>0). Both wired into CI. - Regression net green: 9a/#91 relay-rekey, #36/#41 path-switch + cert-revocation netns suites. Final whole-branch opus review: **READY WITH FOLLOW-UPS**, no Critical/Important — the admission tree, endpoint gating, #36 downgrade closure, and #41/#91 interactions all traced clean. ## Follow-ups (non-blocking) - **M2:** on a cross-address (NAT-rebind) restart, `peers[idx].endpoint` is not relearned on the fresh-ts rebuild, so B's own initiator-role rekeys keep targeting the dead address until an A-initiated rekey recovers. Data plane never black-holes (direct egress is stamped from `DataPlane.peer_addr`, not `endpoint`). Pre-existing; relearn endpoint on a fresh-ts rebuild. - Enables #37 / #64 (which build on authenticated freshness).
Sign in to join this conversation.
No description provided.