From 1349b47006274fd86a277bad0995d69b22f6fb6c Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Wed, 22 Jul 2026 17:05:01 -0400 Subject: [PATCH 01/14] =?UTF-8?q?docs(hardening):=20design=20spec=20?= =?UTF-8?q?=E2=80=94=20session-lifecycle=20hardening=20(#36=20+=20#41)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #36: preserve the in-flight Noise ephemeral across a path re-target (resend the existing init_pkt instead of begin_handshake minting a fresh one) so a responder already Established on the old path completes us via its cached_resp — closes the path-switch half-open black hole without touching the anti-hijack gate or needing #34. #41: re-verify the peer's cert in a received rekey Init (drop the session on failure) + a periodic cert-liveness sweep on Established mesh peers (roots exempt) so a revoked/expired member's session drops within a rekey interval rather than at process restart. Both no-ops when membership is off / no handshake in flight; no wire change. --- ...7-22-session-lifecycle-hardening-design.md | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-22-session-lifecycle-hardening-design.md diff --git a/docs/superpowers/specs/2026-07-22-session-lifecycle-hardening-design.md b/docs/superpowers/specs/2026-07-22-session-lifecycle-hardening-design.md new file mode 100644 index 0000000..afe10f2 --- /dev/null +++ b/docs/superpowers/specs/2026-07-22-session-lifecycle-hardening-design.md @@ -0,0 +1,112 @@ +# Session-lifecycle hardening (#36 + #41) — design spec + +**Date:** 2026-07-22 +**Status:** design (pending user review) +**Issues:** #36 (2b: path-switch re-initiation half-opens a session), #41 (2c: cert revocation lag exceeds cert lifetime without session rekey). +**Depends on:** session rekey (9a PR #90 + #91 PR #92, both merged to `main`) — the epoch/rekey machinery and the idempotent-ephemeral `cached_resp`/`accept_rekey_init` logic these fixes build on. +**Scope:** `bin/yipd/src/peer_manager.rs` (both fixes) + a small read-only helper on `bin/yipd/src/membership.rs` (#41). No wire-format change; `yip-crypto`/`yip-wire`/`handshake.rs` unchanged. Both fixes are **no-ops when membership is `None`** (#41) or when no handshake is in flight (#36) — 2a/2b/2c byte-identical otherwise. + +## Goal + +Two residual limitations, both filed as "needs session rekey," are now unblocked by the merged rekey machinery: + +- **#36** — a path switch (Punch→Relay, Punch(C1)→Punch(C2), Direct→Relay …) re-initiates the handshake with a **fresh** Noise ephemeral. If the remote already adopted the responder role and is `Established`, it only replays a `cached_resp` keyed to our *original* ephemeral; our `read_response` fails, we revert to `Idle` and re-escalate — a silent bidirectional black hole with the remote falsely "up," recoverable only by process restart. +- **#41** — a cert-admitted mesh peer's `Established` session persists for the daemon's lifetime. Rekey re-uses the session identity without re-checking the cert, and a revoked (non-renewed, cert-expired) member keeps its live session until process restart. + +Fix both so a path-switching peer converges (#36) and a revoked member's session drops within a rekey interval (#41). + +## Background (current state, from `peer_manager.rs`) + +- **`HandshakingState`** holds `hs: HandshakeState` (the Noise state, holding the one drawn ephemeral), `init_pkt: Vec` (the framed `[HandshakeInit]`, "resent verbatim on retry"), `target: SocketAddr`, plus retransmit bookkeeping. The peer's `relay: bool` selects relay-wrapped vs direct egress. The retransmit arm in `tick_dispatch` already resends `init_pkt` to `target` (relay-wrapped when `relay`). +- **Path re-target today:** the escalation arms (`PathAction::Probe(addr) if addr != target` ~2646; the `Probe`/`Relay` arms in the escalation helpers ~935/982/2197/2546) do `state = PeerState::Idle; begin_handshake(idx, new_addr, via_relay, now_ms)` — and `begin_handshake` calls `start_initiator`, drawing a **fresh** ephemeral. That fresh ephemeral is the #36 bug. +- **`rekey_init_core`** decision tree for an `Established` peer receiving a `[HandshakeInit]`: (1) `init_eph == cached_resp_init_eph` → replay `cached_resp` (cold-start retransmit); (2) `next_cached_resp_for(init_eph)` → replay cached rekey resp; (3) `!accept_rekey_init` (current < interval/2 old) → replay `cached_resp`, no new session; (4) else install `next`. So resending the **same** `init_pkt` (original ephemeral) over a new path hits case (1) and completes us — the mechanism #36's fix relies on. +- **`HANDSHAKE_TOTAL_MS`** (90 s) give-up already reverts a doomed attempt to `Idle`; the next attempt is then legitimately fresh. +- **Cert admission:** cold-start `handle_handshake_init` verifies the initiator's cert payload (`verify_cert` against `remote_static` at `now_secs()`) and admits/rejects pre-session; `responder_cert_ok(payload, peer_pub)` is the existing helper (returns `true` when membership is `None`). The **rekey** arms currently discard `_initiator_payload` — no re-verification. +- **Membership** (`membership.rs`): the directory holds `Record`s (each carrying a `Cert`) keyed by `node_id`, with an expiry sweep that evicts expired records. `verify_cert(&cert, &static_key, now)` and `resolve(&Ipv6Addr)` exist; there is no by-pubkey cert-validity check yet. + +## Design + +### Fix #36 — preserve the in-flight ephemeral across a path re-target + +Add a helper that re-points an **already in-flight** handshake at a new path without minting a fresh ephemeral: + +```rust +/// Re-point an in-flight handshake at `new_target` over the given path, +/// PRESERVING the Noise ephemeral (resend the existing `init_pkt`), 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). Returns the egress to emit, if any. +fn retarget_handshake( + &mut self, + idx: usize, + new_target: SocketAddr, + via_relay: bool, + now_ms: u64, +) -> Option>; +``` + +Behavior: +- **Peer is `Handshaking`:** mutate the existing `HandshakingState` in place — set `target = new_target`, set the peer's `relay = via_relay` — leaving `hs` and `init_pkt` untouched (the ephemeral is preserved). Emit the existing `init_pkt`: relay-wrapped via `relay_wrap(idx, init_pkt.clone())` when `via_relay` (a `None` skips this send; the retransmit arm retries), else `EgressDatagram { fate: 0, dst: new_target, bytes: init_pkt.clone() }`. Do **not** reset `started_ms` (the 90 s give-up clock keeps running across re-targets — a re-target does not buy a fresh 90 s). +- **Peer is `Idle` (or otherwise not `Handshaking`):** delegate to `begin_handshake(idx, new_target, via_relay, now_ms)` (fresh ephemeral) — unchanged from today. + +Replace the "`state = Idle; begin_handshake(new_addr)`" re-target pattern at the escalation/re-target arms with `retarget_handshake`. The cold-start entry points (`on_tun`'s Idle branch, initial `begin_handshake`) are unchanged — only *re-targets of an in-flight attempt* change. + +**Why this is safe (no new attack surface):** resending the same `init_pkt` is exactly what the retransmit arm already does every `HANDSHAKE_RETRY_MS`; the only change is that the destination/path may differ. A responder that is `Established` replays its `cached_resp` (case 1) and we complete; a responder that never adopted us treats it as a fresh cold-start `Init` and adopts us normally. No responder-side gate (`accept_rekey_init`) is touched, so the 9a anti-hijack posture and the #34 anti-replay question are untouched. Ephemeral reuse remains bounded by the unchanged 90 s `HANDSHAKE_TOTAL_MS` give-up. + +**Data flow (the reported #36 scenario, now converging):** A and B rendezvous-only; B adopts responder and is `Established`, but B→A `HandshakeResp` is lost through A's `PUNCH_MS` window. A escalates to relay: `retarget_handshake(idx, server, via_relay = true)` re-points A's in-flight `Handshaking(E1)` at the relay and resends `init_pkt(E1)` relay-wrapped. B receives it, `rekey_init_core` case (1) (`init_eph == cached_resp_init_eph`) replays `cached_resp(E1)` relay-wrapped, A `read_response(E1)` succeeds → A `Established`. Converged. + +### Fix #41 — cert re-verification on rekey + periodic liveness sweep + +Two complementary mechanisms; both are no-ops when `membership.is_none()`. + +**(a) Re-verify the cert in a received rekey `Init`.** In the `Established`/rekey arms of `handle_handshake_init` and `relayed_handshake_init`, stop discarding the initiator payload: run `responder_cert_ok(initiator_payload, remote_static)`. On failure (a revoked/expired member presenting a stale or invalid cert), **reject the rekey and drop the live session**: revert the peer to `PeerState::Idle` and remove its `current` `conn_tag` from `by_tag`. Do not emit a resp. A valid cert proceeds through the normal rekey path unchanged. + +**(b) Periodic cert-liveness sweep.** Add a read-only helper to `Membership`: + +```rust +/// Whether `pubkey` is still an admissible member at wall-clock `now`: +/// `true` if it is an always-admit root, OR the directory holds a valid +/// (unexpired, verifying) cert for it. `false` only when a non-root member's +/// record has been evicted (expired) or its cert no longer verifies — i.e. +/// revoked-by-non-renewal. Used by the Established-mesh-peer liveness sweep +/// (#41). Folding the root check in here keeps roots exempt (they have no +/// directory-cert dependency — they would otherwise be swept spuriously). +pub fn member_cert_valid(&self, pubkey: &[u8; 32], now: u64) -> bool; +``` + +In `tick`, on the same cadence the rekey scheduler already runs (a periodic sweep at roughly the rekey interval; exact cadence fixed in the plan), for each `Established` peer in a mesh deployment (`membership.is_some()`), call `member_cert_valid(&peer.pubkey, now_secs())`. If it returns `false`, **drop the session** (revert to `Idle`, remove the `by_tag` entry). This catches the case (a) misses: a revoked peer that is the rekey *loser* and never sends an `Init`. + +**Only cert-dependent peers are affected.** In mesh mode every `Established` peer was admitted by a verified cert (cold-start admission runs `verify_cert` when `membership.is_some()`), so `member_cert_valid` holds for every legitimately-admitted peer and returns `false` only once that cert lapses. Always-admit **roots** are exempt via the helper's root check (they have no directory-cert to expire). The sweep is a whole no-op when `membership.is_none()` — a pure 2a/2b deployment has no certs and no sweep. + +**Re-admission is already guarded.** A dropped revoked peer cannot re-establish: the cold-start admission path re-verifies the cert (`verify_cert` at `handle_handshake_init`), which a revoked/expired member fails. So dropping to `Idle` + clearing `by_tag` is sufficient; the peer simply can no longer handshake. + +## Error handling + +- **#36:** a `relay_wrap` `None` (no rendezvous) on re-target skips only that send — the peer stays `Handshaking`, the retransmit arm retries next tick, and the 90 s give-up still bounds the attempt. No panic; no session torn down (there is no live session — the peer is mid-handshake). +- **#41:** a malformed/missing cert payload on rekey → `responder_cert_ok` returns `false` → treated as an invalid cert → session dropped (fail-closed: an unverifiable rekey does not keep the session alive). The sweep's `member_cert_valid` returning `false` for a transiently-absent record (e.g. a not-yet-gossiped renewal) would drop a still-valid peer's session; mitigated by the existing `CERT_VALIDITY_SKEW` widening and the fact that a dropped valid peer simply re-handshakes and re-admits with its current cert (availability blip, not a black hole). Both fixes are no-ops when membership is disabled. + +## Testing / adversary + +- **#36 unit:** (1) a `Handshaking` peer re-targeted from direct to relay resends the **byte-identical** `init_pkt` (ephemeral preserved), not a fresh one, and updates `target`/`relay`; `started_ms` is unchanged. (2) An `Established` responder holding `cached_resp` for `E1` completes a re-targeted initiator that resends `init_pkt(E1)` over the relay. (3) An `Idle` peer re-targeted falls through to a fresh `begin_handshake`. +- **#41 unit:** (1) a rekey `Init` carrying an expired/invalid cert drops the session (peer → `Idle`, `by_tag` entry gone, no resp); a valid cert rekeys normally. (2) `member_cert_valid` is `true` for a live record, `false` after the record expires/evicts. (3) the tick sweep drops an `Established` mesh peer whose directory record expired and leaves a valid one untouched. (4) all #41 paths are no-ops when `membership` is `None`. +- **netns money tests:** (#36) reproduce the reported scenario — block B→A `HandshakeResp` through A's punch window, force the relay escalation — and assert A **converges** (ping succeeds, no black hole) instead of looping to give-up. (#41) a mesh member whose cert expires mid-session loses its session within a rekey interval (its traffic stops being delivered; re-handshake is rejected at admission). Both drivers (poll + `YIP_USE_URING=1`); release `yipd`. +- **Regression:** the 2a/2b/2c and 9a/#91 netns suites (triangle, relay, discovery, rekey, relay-rekey) stay green; membership-off runs are byte-identical. + +## Risks + +- **#36 touches the security-critical escalation path.** Mitigation: the change is a strict narrowing — re-target now preserves the ephemeral (resend the same `init_pkt`, which the retransmit arm already does) instead of minting a fresh one; no responder-side gate changes; the full 2b escalation netns suite is the regression net. The 90 s give-up (unchanged) still forces a fresh ephemeral for a genuinely dead attempt. +- **#41 sweep could drop a still-valid peer** if its renewed record has not yet gossiped in. Mitigation: `CERT_VALIDITY_SKEW` widening + the drop is a recoverable re-handshake, not a black hole; the sweep runs at the coarse rekey cadence, not per-packet. +- **#41 (a) and (b) overlap** for the rekey-winner case (both would drop a revoked initiator). Harmless — dropping an already-dropped/Idle session is idempotent; (b) exists for the loser case (a) cannot see. + +## Non-goals + +- #34 (anti-replay timestamp + authenticated endpoint) — not required by approach C for #36; the age gate is untouched. Filed separately. +- Active revocation lists / a revocation gossip message — #41 uses revocation-by-cert-expiry (the 2c model); an explicit revocation channel is out of scope. +- Session roaming (migrating an `Established` peer to a new path without a re-handshake) — a separate feature; #36 is specifically the mid-handshake escalation black hole. + +## Success criteria + +1. A path-switching peer whose responder is already `Established` **converges** (completes over the new path via the responder's `cached_resp`) instead of black-holing; ephemeral preserved across re-targets, fresh only after the unchanged 90 s give-up. +2. A rekey `Init` with an invalid/expired cert drops the live session; a periodic sweep drops an `Established` mesh peer whose cert has expired/been revoked — so a revoked member loses its session within a rekey interval, not at process restart. +3. Both fixes are no-ops with membership disabled and when no handshake is in flight (2a/2b/2c byte-identical); no wire-format change; `yip-crypto`/`yip-wire`/`handshake.rs` unchanged. +4. `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. From 3041629fbdfd56845de6275a9c84c3fb753a6197 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Wed, 22 Jul 2026 17:30:41 -0400 Subject: [PATCH 02/14] =?UTF-8?q?docs(hardening):=20implementation=20plan?= =?UTF-8?q?=20=E2=80=94=20session-lifecycle=20hardening=20(#36=20+=20#41)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4 tasks, subagent-driven, TDD: (1) #36 retarget_handshake helper + wire the two tick escalation arms (preserve the ephemeral, don't mint fresh); (2) #41 drop_session + re-verify cert on rekey Init; (3) #41 member_cert_valid sweep (roots exempt, throttled); (4) netns money tests both drivers + CI. --- .../2026-07-22-session-lifecycle-hardening.md | 539 ++++++++++++++++++ 1 file changed, 539 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-22-session-lifecycle-hardening.md diff --git a/docs/superpowers/plans/2026-07-22-session-lifecycle-hardening.md b/docs/superpowers/plans/2026-07-22-session-lifecycle-hardening.md new file mode 100644 index 0000000..7106d26 --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-session-lifecycle-hardening.md @@ -0,0 +1,539 @@ +# Session-lifecycle hardening (#36 + #41) 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:** Fix the #36 path-switch half-open black hole (preserve the in-flight Noise ephemeral across a path re-target) and the #41 cert-revocation lag (re-verify certs on rekey + a periodic liveness sweep), both now unblocked by the merged 9a/#91 rekey machinery. + +**Architecture:** Two independent fixes in `bin/yipd/src/peer_manager.rs`, plus one read-only helper on `bin/yipd/src/membership.rs`. #36 adds a `retarget_handshake` helper that re-points an in-flight `Handshaking` attempt at a new path **without** minting a fresh ephemeral (resend the existing `init_pkt`), and the two tick escalation arms call it instead of `Idle` + `begin_handshake`. #41 adds a `drop_session` helper, re-verifies the initiator's cert in the rekey arms (drop on failure), and adds a periodic `member_cert_valid` sweep over Established mesh peers (roots exempt). + +**Tech Stack:** Rust, `#![forbid(unsafe_code)]`; snow Noise-IK (handshake); `yip-membership` (certs/directory); netns integration tests under both the poll and `YIP_USE_URING=1` drivers. + +## 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; do NOT `--no-verify` to skip fmt. +- `cargo clippy -p yipd --all-targets -- -D warnings` must be clean. +- NO wire-format change. `yip-crypto`, `yip-wire`, `bin/yipd/src/handshake.rs` UNCHANGED. +- Both fixes are no-ops when `membership.is_none()` (#41) or no handshake is in flight (#36) — 2a/2b/2c byte-identical otherwise. +- `yipd` is a binary crate: test with `cargo test -p yipd --bin yipd` (NOT `--lib`). +- 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. + +--- + +### Task 1: #36 — `retarget_handshake` helper + wire the two escalation arms + +**Files:** +- Modify: `bin/yipd/src/peer_manager.rs` (add `retarget_handshake`; replace the two escalation arms in `tick_dispatch` at the in-flight-handshake block, ~2607 `PathAction::Relay` and ~2646 `PathAction::Probe(addr) if addr != target`) +- Test: `bin/yipd/src/peer_manager.rs` (the in-crate `#[cfg(test)] mod tests`) + +**Interfaces:** +- Consumes: `HandshakingState { hs, init_pkt: Vec, target: SocketAddr, started_ms, last_sent_ms, retry_ms, retries }`; `Peer.relay: bool`; `Peer.endpoint: Option`; `begin_handshake(&mut self, idx, target: SocketAddr, via_relay: bool, now_ms) -> Option>`; `relay_wrap(&mut self, idx, raw: Vec) -> Option`; `EgressDatagram { fate: u16, dst: SocketAddr, bytes: Vec }`. +- Produces: `retarget_handshake(&mut self, idx: usize, new_target: SocketAddr, via_relay: bool, now_ms: u64) -> Option>`. + +- [ ] **Step 1: Write the failing test — retarget preserves the ephemeral (byte-identical `init_pkt`) and updates target/relay** + +Add to the `tests` module. Use the existing test scaffolding — look at how nearby tests build a `PeerManager` with a rendezvous and drive a peer into `Handshaking` (e.g. `pm_with_mock_rdv` / the escalation tests). The assertion: a `Handshaking` peer re-targeted to relay resends the **same** `init_pkt` bytes it was already holding, and flips `relay` to `true`, and does NOT reset `started_ms`. + +```rust +#[test] +fn retarget_handshake_preserves_ephemeral_and_flips_relay() { + // 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), + _ => panic!("peer must be Handshaking"), + }; + let server = pm.server_addr(); + + // Re-target to the relay (Punch->Relay escalation). + let out = pm.retarget_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). + 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.target, server, "target must update to the new path"); + assert_ne!(h.target, orig_target); + } + _ => panic!("peer must stay Handshaking"), + } + assert!(pm.peers[idx].relay, "relay flag must be set for a relay re-target"); + assert!(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. + assert!(has_relayed_handshake_init(Some(&out)), "must emit a relay-wrapped Init"); +} +``` + +If `pm_handshaking_direct_peer` does not exist, add a small test helper alongside the other `pm_*` helpers that builds a `PeerManager` with a `MockRdv`, inserts one peer, and drives it to `Handshaking` on a direct endpoint (mirror the setup the existing escalation tests use). `has_relayed_handshake_init` already exists (used by the rekey tests). + +- [ ] **Step 2: Run to verify it fails** + +Run: `cargo test -p yipd --bin yipd retarget_handshake_preserves_ephemeral_and_flips_relay` +Expected: FAIL — `retarget_handshake` does not exist yet. + +- [ ] **Step 3: Implement `retarget_handshake`** + +Add this method to `impl PeerManager` (near `begin_handshake`): + +```rust +/// 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; + 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]); + } + Some(vec![EgressDatagram { + fate: 0, + dst: new_target, + bytes: init_pkt, + }]) +} +``` + +- [ ] **Step 4: Run the test — verify it passes** + +Run: `cargo test -p yipd --bin yipd retarget_handshake_preserves_ephemeral_and_flips_relay` +Expected: PASS. + +- [ ] **Step 5: Wire the two escalation arms to `retarget_handshake`** + +In `tick_dispatch`'s in-flight-handshake escalation block, replace the `PathAction::Relay` arm (currently sets `state = Idle`, clears `endpoint`, `begin_handshake(i, server, true)`) with: + +```rust +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. + let server = self.server_addr(); + if let Some(dgs) = self.retarget_handshake(i, server, true, now_ms) { + self.tick_egress.extend(dgs); + } + continue; +} +``` + +And replace the `PathAction::Probe(addr) if addr != target` arm (currently `state = Idle`, `begin_handshake(i, addr, false)`) with: + +```rust +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) { + self.tick_egress.extend(dgs); + } + continue; +} +``` + +- [ ] **Step 6: Write the failing test — an Established responder completes a re-targeted initiator via `cached_resp`** + +This is the end-to-end #36 mechanism at the unit level: a responder that is `Established` (holding `cached_resp` for ephemeral E1) replays that resp when the same Init (E1) arrives over the relay, and the initiator's re-target used the same E1. Model it as: build a responder `pm_r` that has completed as responder for initiator A (so `cached_resp` + `cached_resp_init_eph` are set and it is `Established`), then deliver A's **relay-wrapped** Init (same `init_pkt`/E1) and assert `pm_r` replays its cached resp (a `RelaySend`) rather than minting a new session or dropping. + +```rust +#[test] +fn established_responder_completes_retargeted_initiator_via_cached_resp() { + // Responder that adopted initiator A and is Established, caching resp for A's ephemeral. + let (mut pm_r, a_init_pkt) = responder_established_for_initiator([3u8; 32], [4u8; 32], 100); + let tag_before = established_tag(&pm_r, 0); + + // A re-targeted to relay and resent the SAME init (E1). It arrives relay-wrapped. + let relayed = wrap_relay_deliver(&pm_r, &a_init_pkt); // src = A's node + let out = pm_r.on_udp(mock_server(), &relayed, 5_000).map(<[EgressDatagram]>::to_vec); + + // Responder replays its cached resp (a RelaySend) — completing A — and does + // NOT churn a new session: current tag unchanged. + assert!(has_relayed_handshake_resp(out.as_deref()), "must replay cached resp over the relay"); + assert_eq!(established_tag(&pm_r, 0), tag_before, "current session must be untouched"); +} +``` + +Reuse existing helpers where they exist (`mock_server`, `established_tag`, the relay-deliver wrapping used by the Task-2 relay-rekey tests, `has_relayed_handshake_resp` or the equivalent used by those tests). Add small helpers only if none exists. + +- [ ] **Step 7: Run it — verify it passes (the cached-resp replay already works via `rekey_init_core` case 1)** + +Run: `cargo test -p yipd --bin yipd established_responder_completes_retargeted_initiator_via_cached_resp` +Expected: PASS — this asserts the responder side already replays `cached_resp` for a matching ephemeral (the mechanism #36's fix relies on); no responder-side code change is needed. If it fails, the test setup does not match `cached_resp_init_eph` — fix the setup, not the production code. + +- [ ] **Step 8: Full suite + clippy + fmt, then commit** + +```bash +cargo test -p yipd --bin yipd +cargo clippy -p yipd --all-targets -- -D warnings +cargo fmt +git add bin/yipd/src/peer_manager.rs +git commit -m "fix(hardening.36): preserve the in-flight ephemeral across a path re-target" +``` +Expected: all green. + +--- + +### Task 2: #41(a) — `drop_session` helper + re-verify the cert in a received rekey Init + +**Files:** +- Modify: `bin/yipd/src/peer_manager.rs` (add `drop_session`; re-verify in `handle_handshake_init`'s Established arm and `relayed_handshake_init`'s Established arm) +- Test: `bin/yipd/src/peer_manager.rs` (`tests` module) + +**Interfaces:** +- Consumes: `EpochSet { current: Box, next: Option, previous: Option> }`; `NextEpoch { dp: Box }`; `DataPlane::conn_tag(&self) -> u64`; `responder_cert_ok(&self, payload: &[u8], peer_pub: [u8; 32]) -> bool`; `by_tag: HashMap`. +- Produces: `drop_session(&mut self, idx: usize)`. + +- [ ] **Step 1: Write the failing test — a rekey Init with an invalid cert drops the session** + +A mesh (`membership.is_some()`) Established peer receiving a rekey Init whose payload is NOT a currently-valid cert loses its session (reverts to `Idle`, its `by_tag` entry gone, no resp emitted). Model an expired/invalid cert by passing a payload that `responder_cert_ok` rejects (e.g. a cert past `not_after`, or empty bytes when membership is enabled — `Cert::decode` fails → `responder_cert_ok` returns false). + +```rust +#[test] +fn rekey_init_with_invalid_cert_drops_session() { + // Mesh Established peer, current old enough that accept_rekey_init would pass. + 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)); + + // A rekey Init from that peer carrying an INVALID cert payload (membership on). + let init = rekey_init_with_payload(&pm, 0, /*payload=*/ b"not-a-valid-cert"); + let out = pm.on_udp(peer_src(&pm, 0), &init, 200_000).map(<[EgressDatagram]>::to_vec); + + // Session dropped: Idle, by_tag entry gone, no resp emitted. + assert!(matches!(pm.peers[0].state, PeerState::Idle), "invalid-cert rekey drops the session"); + assert!(!pm.by_tag.values().any(|&i| i == 0), "the peer's conn_tag is removed from by_tag"); + assert!(out.map_or(true, |o| o.is_empty()), "no resp for a revoked rekey"); +} +``` + +Use the existing mesh-peer test scaffolding (the 2c admission tests build a `PeerManager` with `membership: Some(..)` and mint certs via the CA test helpers). If a `pm_mesh_established_peer` / `rekey_init_with_payload` helper does not exist, add minimal ones next to the existing 2c/rekey test helpers. + +- [ ] **Step 2: Run to verify it fails** + +Run: `cargo test -p yipd --bin yipd rekey_init_with_invalid_cert_drops_session` +Expected: FAIL — the rekey path currently ignores the cert; the peer stays Established. + +- [ ] **Step 3: Implement `drop_session`** + +Add to `impl PeerManager` (collect tags first to avoid a `by_tag` vs `peers` borrow conflict): + +```rust +/// 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. +/// Re-admission is guarded by the cold-start cert check, so a revoked peer +/// cannot re-establish. +fn drop_session(&mut self, idx: usize) { + let tags: Vec = if let PeerState::Established(epochs) = &self.peers[idx].state { + let mut t = vec![epochs.current.conn_tag()]; + if let Some(n) = epochs.next.as_ref() { + t.push(n.dp.conn_tag()); + } + if let Some(p) = epochs.previous.as_ref() { + t.push(p.conn_tag()); + } + t + } else { + Vec::new() + }; + for tag in tags { + self.by_tag.remove(&tag); + } + self.peers[idx].state = PeerState::Idle; +} +``` + +- [ ] **Step 4: Re-verify the cert in both rekey Established arms** + +In `handle_handshake_init` (the direct path), the Established arm currently destructures `initiator_payload` and calls `handle_rekey_init(...)`. Before that call, guard on the cert: + +```rust +PeerState::Established(_) => { + // #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. + if !self.responder_cert_ok(&initiator_payload, remote_static) { + self.drop_session(idx); + return DispatchOut::None; + } + 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) +} +``` + +In `relayed_handshake_init`, the Established arm currently discards `_initiator_payload`. Un-discard it (rename to `initiator_payload` in the destructure at the top of the function) and add the same guard before the `rekey_init_core(...)` call: + +```rust +PeerState::Established(_) => { + if !self.responder_cert_ok(&initiator_payload, remote_static) { + self.drop_session(idx); + return DispatchOut::None; + } + let Some(init_eph) = crate::handshake::init_ephemeral(dg) else { + return DispatchOut::None; // malformed Init + }; + self.rekey_init_core(idx, established, resp_pkt, init_eph, now_ms, self.server_addr(), true) +} +``` + +`responder_cert_ok` returns `true` when `membership.is_none()`, so this is a no-op for pure 2a/2b. + +- [ ] **Step 5: Run the invalid-cert test + a valid-cert control** + +Add a control test asserting a VALID cert still rekeys normally (peer stays Established, its session rotates): + +```rust +#[test] +fn rekey_init_with_valid_cert_still_rekeys() { + let (mut pm, _tag) = pm_mesh_established_peer([5u8; 32], [6u8; 32], 100_000); + let init = rekey_init_with_payload(&pm, 0, &valid_cert_bytes(&pm, 0)); + let _ = pm.on_udp(peer_src(&pm, 0), &init, 200_000); + assert!(matches!(pm.peers[0].state, PeerState::Established(_)), "valid cert rekeys, no drop"); +} +``` + +Run: `cargo test -p yipd --bin yipd rekey_init_with_` +Expected: both PASS. + +- [ ] **Step 6: Full suite + clippy + fmt, then commit** + +```bash +cargo test -p yipd --bin yipd +cargo clippy -p yipd --all-targets -- -D warnings +cargo fmt +git add bin/yipd/src/peer_manager.rs +git commit -m "fix(hardening.41): re-verify cert on rekey Init, drop session on failure" +``` +Expected: all green. + +--- + +### Task 3: #41(b) — `Membership::member_cert_valid` + periodic liveness sweep + +**Files:** +- Modify: `bin/yipd/src/membership.rs` (add `member_cert_valid`) +- Modify: `bin/yipd/src/peer_manager.rs` (add the sweep to `tick_dispatch`; uses `drop_session` from Task 2) +- Test: both files' `tests` modules + +**Interfaces:** +- Consumes (membership.rs): `self.roots: RootSet` (`self.roots.roots: Vec<([u8;32], SocketAddr)>`); `self.directory: HashMap` (`Record.cert: Cert`); `self.verify_cert(&Cert, &[u8;32], u64) -> bool`; `yip_membership::node_id(&[u8;32]) -> NodeId`. +- Consumes (peer_manager.rs): `drop_session(&mut self, idx)`; `now_secs() -> u64` (real wall clock); `now_ms` (the `tick` monotonic clock); `Peer.pubkey: [u8;32]`; `membership: Option`; `rekey_interval_ms: u64`. +- Produces: `Membership::member_cert_valid(&self, pubkey: &[u8; 32], now: u64) -> bool`; a new `PeerManager.last_cert_sweep_ms: u64` field (init `0` in `new`). + +**Clock note:** the sweep verifies certs at `now_secs()` (real wall clock), so unit-test fixtures must encode expiry via the cert's `not_after` — an expired peer's cert gets `not_after` in the past (e.g. `1`), a valid peer's gets `not_after` far in the future — rather than trying to control the wall clock. The sweep *throttle* uses `now_ms` (the `tick` monotonic clock the test passes in). + +- [ ] **Step 1: Write the failing test — `member_cert_valid` is true for a live record / a root, false for an expired member** + +Add to `membership.rs`'s `tests` module (it already mints records/certs via CA test helpers): + +```rust +#[test] +fn member_cert_valid_tracks_directory_and_roots() { + let (m, live_pubkey, root_pubkey, expired_pubkey, now) = membership_with_live_root_and_expired(); + assert!(m.member_cert_valid(&live_pubkey, now), "a live directory record is valid"); + assert!(m.member_cert_valid(&root_pubkey, now), "a root is always admissible (exempt)"); + assert!(!m.member_cert_valid(&expired_pubkey, now), "an expired/absent member is invalid"); + let never_seen = [0xAAu8; 32]; + assert!(!m.member_cert_valid(&never_seen, now), "an unknown non-root member is invalid"); +} +``` + +Build the fixture from the existing test CA helpers: insert one still-valid record, include one pubkey in the `roots` set, and either omit the expired member's record or insert one whose cert `not_after < now`. + +- [ ] **Step 2: Run to verify it fails** + +Run: `cargo test -p yipd --bin yipd member_cert_valid_tracks_directory_and_roots` +Expected: FAIL — `member_cert_valid` does not exist. + +- [ ] **Step 3: Implement `member_cert_valid`** + +Add to `impl Membership` in `membership.rs`: + +```rust +/// Whether `pubkey` is still an admissible member at wall-clock `now`: +/// `true` if it is an always-admit root, OR the directory holds a valid +/// (unexpired, verifying) cert for it. `false` only when a non-root member's +/// record was evicted (expired) or its cert no longer verifies — i.e. +/// revoked-by-non-renewal. Folding the root check in here keeps roots exempt +/// from the #41 liveness sweep (they have no directory-cert dependency). +pub fn member_cert_valid(&self, pubkey: &[u8; 32], now: u64) -> bool { + if self.roots.roots.iter().any(|(pk, _)| pk == pubkey) { + return true; + } + match self.directory.get(&node_id(pubkey)) { + Some(rec) => self.verify_cert(&rec.cert, pubkey, now), + None => false, + } +} +``` + +- [ ] **Step 4: Run the membership test — verify it passes** + +Run: `cargo test -p yipd --bin yipd member_cert_valid_tracks_directory_and_roots` +Expected: PASS. + +- [ ] **Step 5: Write the failing test — the tick sweep drops an Established mesh peer whose cert expired, leaves a valid one** + +Add to `peer_manager.rs`'s `tests`: + +```rust +#[test] +fn tick_sweep_drops_established_peer_with_expired_cert() { + // Two Established mesh peers: peer 0's directory cert is expired, peer 1's is valid. + let (mut pm, tag1) = pm_mesh_two_established_one_expired([1u8; 32], [2u8; 32]); + pm.tick(500_000); // a tick past the sweep cadence, now_secs shows peer 0 expired + assert!(matches!(pm.peers[0].state, PeerState::Idle), "expired-cert peer's session is dropped"); + assert!(!pm.by_tag.values().any(|&i| i == 0), "its conn_tag is removed"); + assert_eq!(established_tag(&pm, 1), Some(tag1), "the valid peer is untouched"); +} + +#[test] +fn tick_sweep_is_noop_without_membership() { + // Pure 2a/2b: no membership -> no sweep, Established peers untouched. + let (mut pm, tag) = pm_with_established_peer([1u8; 32], [2u8; 32], 100); + pm.tick(500_000); + assert_eq!(established_tag(&pm, 0), Some(tag), "membership-off: sweep is a no-op"); +} +``` + +- [ ] **Step 6: Run to verify the sweep test fails** + +Run: `cargo test -p yipd --bin yipd tick_sweep_drops_established_peer_with_expired_cert` +Expected: FAIL — no sweep exists. + +- [ ] **Step 7: Implement the sweep in `tick_dispatch`** + +Add a periodic cert-liveness sweep to `tick_dispatch`, **throttled** to at most once per `rekey_interval_ms` (each `member_cert_valid` does an Ed25519 `verify_cert`, and `tick` can run often on the busy-poll path — do not verify every Established peer every tick). Add a `last_cert_sweep_ms: u64` field to `PeerManager` (init `0` in `new`), and gate the sweep on the interval. Two-phase collect-then-drop so the `membership` borrow ends before `drop_session`: + +```rust +// ── #41 cert-liveness sweep: drop any Established mesh peer whose cert has +// expired / been revoked (roots exempt), so a revoked member loses its +// session within a rekey interval rather than at process restart. Throttled +// to once per rekey interval (verify_cert is not free). No-op when membership +// is disabled (pure 2a/2b). +if self.membership.is_some() + && now_ms.saturating_sub(self.last_cert_sweep_ms) >= self.rekey_interval_ms +{ + self.last_cert_sweep_ms = now_ms; + let now_s = now_secs(); + let m = self.membership.as_ref().expect("checked is_some above"); + let stale: Vec = self + .peers + .iter() + .enumerate() + .filter(|(_, p)| { + matches!(p.state, PeerState::Established(_)) && !m.member_cert_valid(&p.pubkey, now_s) + }) + .map(|(i, _)| i) + .collect(); + for i in stale { + self.drop_session(i); + } +} +``` + +Place this block once per `tick_dispatch` invocation, before or after the per-peer escalation loop (not inside it). The `m` borrow ends when `stale` is built, so the subsequent `self.drop_session(i)` (which needs `&mut self`) does not conflict. Note the tests drive `tick` at a time well past `rekey_interval_ms` (default 120_000 ms, or set `YIP_REKEY_INTERVAL_MS` low) so the throttle does not suppress the swept behavior — `tick_sweep_drops_established_peer_with_expired_cert` calls `pm.tick(500_000)`, comfortably past the first interval from `last_cert_sweep_ms = 0`. + +- [ ] **Step 8: Run the sweep tests — verify they pass** + +Run: `cargo test -p yipd --bin yipd tick_sweep_` +Expected: both PASS. + +- [ ] **Step 9: Full suite + clippy + fmt, then commit** + +```bash +cargo test -p yipd --bin yipd +cargo clippy -p yipd --all-targets -- -D warnings +cargo clippy -p yipd --bin yipd -- -D warnings # covers membership.rs too +cargo fmt +git add bin/yipd/src/peer_manager.rs bin/yipd/src/membership.rs +git commit -m "fix(hardening.41): periodic member cert-liveness sweep (roots exempt)" +``` +Expected: all green. + +--- + +### Task 4: netns money tests (#36 convergence + #41 revocation) + CI + +**Files:** +- Create: `bin/yipd/tests/run-netns-pathswitch-rehandshake.sh` (#36) +- Create: `bin/yipd/tests/run-netns-cert-revocation.sh` (#41) +- Modify: `.github/workflows/integration.yml` +- Test: the two scripts (run under `sudo`, both drivers) + +**Interfaces:** +- Consumes: the release `yipd` binary + `yip-rendezvous` binary; the existing netns helpers in the sibling scripts (`run-netns-relay.sh` topology, `run-netns-rekey.sh` cadence + driver parameterization, `run-netns-discovery.sh` for the mesh/cert setup used by #41). + +- [ ] **Step 1: Read the fork sources** + +Read `bin/yipd/tests/run-netns-relay.sh` (relay-forced 3-netns topology: A/B/R, no direct A↔B, R drops punch → blind relay), `bin/yipd/tests/run-netns-rekey.sh` (both-driver parameterization via `YIP_USE_URING`, `set -euo pipefail`/`trap` cleanup, ping-loss parsing, `[PASS]`/`[FAIL]` conventions), and `bin/yipd/tests/run-netns-discovery.sh` (mesh CA/cert/roots config + `yip-ca` cert minting, short cert validity). + +- [ ] **Step 2: Write `run-netns-pathswitch-rehandshake.sh` (#36)** + +Reproduce the #36 scenario: A and B rendezvous-only; arrange for B to adopt the responder role and go `Established` while B→A's `HandshakeResp` is lost through A's punch window, forcing A to escalate to the relay. With the fix, A **converges** (its ping to B succeeds) instead of black-holing. Concretely: block the direct + punch A↔B paths (as `run-netns-relay.sh` does) but allow B to see A's initial Init and reply (so B goes Established) while dropping that first reply to A for the punch window; then a steady `ping -i 0.2 -c 50` A→B must reach ≥98% delivery once A relay-escalates and completes via B's cached_resp. `set -euo pipefail`, `trap` cleanup of the three namespaces + daemons. Assert: (1) A reaches `Established` (grep A's stderr for a session-up marker, or the ping succeeds), (2) ping ≥98% delivered (convergence), (3) fail (`exit 1`) if A never converges within the window. Run for BOTH drivers (read `YIP_USE_URING` from env, pass through to the daemons). + +If deterministically dropping "only B→A's first resp through the punch window" is impractical in netns, an acceptable equivalent: force the escalation path (block direct+punch so both peers must relay) and assert A converges over the relay with the SAME ephemeral it started with — grep both daemons' stderr to confirm A completed without a fresh cold-start cycle (no repeated give-up/restart log lines). Document which variant you implemented in the script header. + +- [ ] **Step 3: Write `run-netns-cert-revocation.sh` (#41)** + +Fork `run-netns-discovery.sh`'s mesh setup: two mesh members A and B with CA-signed certs (`yip-ca`), a short cert validity window (e.g. `not_after` a few seconds out) and a low `YIP_REKEY_INTERVAL_MS` (e.g. 2000). Establish A↔B (ping succeeds), then let A's cert **expire** (do not renew it in the directory). Within one rekey interval after expiry, B must **drop** its session to A: assert that a steady ping A→B that was flowing STOPS being delivered (or B's stderr logs the session drop) within `~rekey_interval + cert_validity` of expiry, and that A cannot re-establish (its re-handshake is rejected at admission). `set -euo pipefail`, `trap` cleanup. BOTH drivers. + +- [ ] **Step 4: Run both scripts under sudo (both drivers)** + +```bash +cargo build --release -p yipd +cargo build --release -p 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" +sudo bash bin/yipd/tests/run-netns-cert-revocation.sh "$(pwd)/target/release/yipd" "$(pwd)/target/release/yip-rendezvous" +sudo YIP_USE_URING=1 bash bin/yipd/tests/run-netns-cert-revocation.sh "$(pwd)/target/release/yipd" "$(pwd)/target/release/yip-rendezvous" +``` +Expected: `PASS`, exit 0 on all four. The arq/netns tests use the RELEASE binary — rebuild release after any yipd change. If the environment cannot run netns (sudo/namespace denied), capture the exact blocker and report DONE_WITH_CONCERNS. + +- [ ] **Step 5: Wire CI + commit** + +Add both scripts to `.github/workflows/integration.yml` alongside the sibling netns steps (both drivers, same SKIP/`[FAIL]` honesty guards as the `run-netns-rekey.sh` steps). + +```bash +chmod +x bin/yipd/tests/run-netns-pathswitch-rehandshake.sh bin/yipd/tests/run-netns-cert-revocation.sh +git add bin/yipd/tests/run-netns-pathswitch-rehandshake.sh bin/yipd/tests/run-netns-cert-revocation.sh .github/workflows/integration.yml +git commit -m "test(hardening): netns money tests — #36 path-switch convergence + #41 cert revocation" +``` + +--- + +## After all tasks + +- Final whole-branch review (opus) over the branch delta (base = `main` at the branch point). Focus: #36's ephemeral-preservation is behavior-preserving for the non-escalation paths (the Idle/cold path still `begin_handshake`s fresh; the 90 s give-up is unchanged); the relay re-target's `endpoint` clear preserves the anti-mismatch invariant; #41's `drop_session` removes ALL of a peer's `by_tag` entries (no stale tag routes to a dropped peer); both fixes are byte-identical no-ops with membership off / no handshake in flight; the sweep exempts roots. +- Push; open a PR based on `main`. Leave it for the user; do NOT merge; no "not merging" line. +- Update the `yip-control-plane-status` memory (#36 + #41 resolved). + +## Global test/verify discipline + +- Run build/clippy/fmt and the netns money tests under BOTH the poll and `YIP_USE_URING=1` drivers; the netns tests use the RELEASE `yipd` — rebuild release after every yipd change or you test a stale binary. +- The full 2a/2b/2c + 9a/#91 netns suite (triangle, relay, discovery, admission-reject, root-outage, rekey, relay-rekey) must stay green; membership-off runs must be byte-identical. From 4e5bd25b1fb286f69b418d10dcebe23e7bb24cb5 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Wed, 22 Jul 2026 17:50:19 -0400 Subject: [PATCH 03/14] fix(hardening.36): preserve the in-flight ephemeral across a path re-target --- bin/yipd/src/peer_manager.rs | 276 ++++++++++++++++++++++++++++++----- 1 file changed, 243 insertions(+), 33 deletions(-) diff --git a/bin/yipd/src/peer_manager.rs b/bin/yipd/src/peer_manager.rs index 854812a..90b3c78 100644 --- a/bin/yipd/src/peer_manager.rs +++ b/bin/yipd/src/peer_manager.rs @@ -752,6 +752,48 @@ 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; + 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 @@ -2605,45 +2647,21 @@ impl PeerManager { }; match self.peers[i].path.advance(now_ms) { PathAction::Relay => { - // Abandon the in-flight direct/punch handshake (drop its - // ephemeral) and begin a relay handshake. `pending_tun` - // is left intact — it drains when the relay session - // completes (strictly better than the 90s-then-clear - // give-up path). - self.peers[i].state = PeerState::Idle; - // Clear the stale direct/punch `endpoint` (the abandoned - // attempt's candidate `C`): a relayed peer routes egress - // via the `relay` flag through `rendezvous.relay`, NOT - // via `endpoint`, and the relay handshake completes - // through the `RdvEvent::Relayed` -> - // `relayed_handshake_resp` path, which does not use - // `endpoint` matching at all. Without this clear, a - // late-arriving direct `[HandshakeResp]` from `C` for the - // abandoned ephemeral (very plausible on a lossy/ - // high-latency link — a punch reply just past the - // PUNCH_MS window) would still match this peer in - // `handle_handshake_resp` (`p.endpoint == Some(src) && - // Handshaking`) and get fed into the *new* relay - // ephemeral's `read_response`, which fails - // cryptographically and silently discards the fresh - // relay attempt (reverting to `Idle` and re-escalating - // forever, since `PathStage` only moves forward). With - // `endpoint` cleared the stray reply matches no peer and - // is dropped harmlessly instead. (Preferring a late punch - // reply over the already-committed relay attempt would be - // a nicer recovery, but is out of 2b scope.) - self.peers[i].endpoint = None; + // 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. let server = self.server_addr(); - if let Some(dgs) = self.begin_handshake(i, server, true, now_ms) { + if let Some(dgs) = self.retarget_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 by - // abandoning the current attempt and probing `addr`. - self.peers[i].state = PeerState::Idle; - if let Some(dgs) = self.begin_handshake(i, addr, false, now_ms) { + // 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) { self.tick_egress.extend(dgs); } continue; @@ -4293,6 +4311,86 @@ mod tests { (pm, sent) } + /// Build a `PeerManager` (with a `MockRdv` rendezvous, so relay egress + /// works) whose sole peer has a configured direct `endpoint` and has + /// already been driven into `Handshaking` on that endpoint (a single TUN + /// packet, mirroring `on_tun`'s Idle branch — see + /// `punch_handshake_escalates_to_relay_at_punch_window_not_90s` for the + /// punch-stage sibling of this setup). Used by the `retarget_handshake` + /// tests below (#36 Task 1), which need a `Handshaking` peer holding a + /// real in-flight `init_pkt`/ephemeral to re-target. + fn pm_handshaking_direct_peer( + peer_pubkey: [u8; 32], + endpoint: &str, + started_ms: u64, + ) -> (PeerManager, usize) { + let local = generate_keypair(); + let ep: SocketAddr = endpoint.parse().expect("valid test endpoint"); + let peer = PeerConfig { + public_key: peer_pubkey, + endpoint: Some(ep), + }; + let (mut pm, _sent) = pm_with_mock_rdv(&local, &[peer]); + pm.on_tun(&dummy_tun_pkt(), started_ms); + assert!( + matches!(pm.peers[0].state, PeerState::Handshaking(_)), + "setup must drive the peer into Handshaking on the direct endpoint" + ); + (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`. + #[test] + fn retarget_handshake_preserves_ephemeral_and_flips_relay() { + // 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), + _ => panic!("peer must be Handshaking"), + }; + let server = pm.server_addr(); + + // Re-target to the relay (Punch->Relay escalation). + let out = pm + .retarget_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). + 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.target, server, "target must update to the new path"); + assert_ne!(h.target, orig_target); + } + _ => panic!("peer must stay Handshaking"), + } + assert!( + pm.peers[idx].relay, + "relay flag must be set for a relay re-target" + ); + assert!( + 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. + assert!( + has_relayed_handshake_init(Some(&out)), + "must emit a relay-wrapped Init" + ); + } + /// (a) A rendezvous-only peer (endpoint `None`) with a rendezvous /// configured emits a `Lookup` when TUN traffic first needs it. #[test] @@ -4712,6 +4810,118 @@ mod tests { ); } + /// `true` iff `out` carries a `[HandshakeResp]` relay-wrapped in a + /// `yip_rendezvous::Message::RelaySend` — the relay-path counterpart of + /// `has_relayed_handshake_init`, used to assert a responder replayed its + /// cached resp over the relay (#36 Task 1, Step 6). + fn has_relayed_handshake_resp(out: &DispatchOut<'_>) -> bool { + let egress: &[EgressDatagram] = match out { + DispatchOut::Udp(e) | DispatchOut::Both(_, e) => e, + _ => &[], + }; + egress.iter().any(|d| { + matches!( + yip_rendezvous::decode(&d.bytes), + Some(yip_rendezvous::Message::RelaySend { payload, .. }) + if payload.first() == Some(&(PacketType::HandshakeResp as u8)) + ) + }) + } + + /// Wrap `payload` as a `RelayDeliver` sourced from `pm`'s sole configured + /// peer (its `node`) — the relay-deliver counterpart of `relay_deliver` + /// for a test that already has a single-peer `PeerManager` in hand rather + /// than a raw `Keypair`. Used to redeliver an Init as if freshly + /// forwarded by the rendezvous server (#36 Task 1, Step 6). + fn wrap_relay_deliver(pm: &PeerManager, payload: &[u8]) -> Vec { + let mut buf = Vec::new(); + yip_rendezvous::encode( + &yip_rendezvous::Message::RelayDeliver { + src: pm.peers[0].node, + payload: payload.to_vec(), + }, + &mut buf, + ); + buf + } + + /// Build a responder `PeerManager` that has genuinely `Established` as + /// responder for a fresh initiator A, via a REAL (relayed) cold-start + /// Noise handshake — real `cached_resp`/`cached_resp_init_eph`, keyed to + /// A's actual ephemeral, and `relay == true` (required by + /// `relayed_handshake_init`'s `Established(_) if self.peers[idx].relay` + /// gate, #91 final review, for a *subsequent* relayed Init on this peer + /// to be admitted at all). This is exactly the state #36's fix depends on + /// downstream: a responder holding a `cached_resp` for A's ephemeral that + /// a re-targeted (but ephemeral-preserving) retry can complete against. + /// + /// `local_seed`/`peer_seed` are accepted for a readable, distinguishable + /// call shape at each call site; X25519 keys must be real key-exchange + /// material for the handshake to actually succeed (there is no + /// seeded-keygen primitive in `yip_crypto`), so both keypairs are freshly + /// generated with `generate_keypair()` and the seeds are not fed into the + /// crypto. + fn responder_established_for_initiator( + _local_seed: [u8; 32], + _peer_seed: [u8; 32], + now_ms: u64, + ) -> (PeerManager, Vec) { + let kp_r = generate_keypair(); + let kp_a = generate_keypair(); + let cfg_a = PeerConfig { + public_key: kp_a.public, + endpoint: None, + }; + 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 buf = relay_deliver(&kp_a, a_init_pkt.clone()); + let out = pm_r.on_udp(mock_server(), &buf, now_ms); + assert!( + has_relayed_handshake_resp(&out), + "cold-start relayed init must produce one relay-wrapped resp" + ); + assert!( + established_tag(&pm_r, 0).is_some(), + "responder must be Established" + ); + assert!(pm_r.peers[0].relay, "responder must be relay-reached for A"); + + (pm_r, a_init_pkt) + } + + /// #36 Task 1, Step 6/7: the end-to-end #36 mechanism at the unit level — + /// a responder that is `Established` (holding `cached_resp` for + /// initiator A's ephemeral E1) replays that resp when the SAME Init (E1) + /// arrives again over the relay, rather than churning a new session or + /// dropping it. This is the mechanism `retarget_handshake`'s + /// ephemeral-preserving re-target relies on: no responder-side change was + /// needed for #36 — `rekey_init_core` case 1 (cached_resp_init_eph match) + /// already replays. + #[test] + fn established_responder_completes_retargeted_initiator_via_cached_resp() { + // Responder that adopted initiator A and is Established, caching resp for A's ephemeral. + let (mut pm_r, a_init_pkt) = responder_established_for_initiator([3u8; 32], [4u8; 32], 100); + let tag_before = established_tag(&pm_r, 0); + + // A re-targeted to relay and resent the SAME init (E1). It arrives relay-wrapped. + let relayed = wrap_relay_deliver(&pm_r, &a_init_pkt); // src = A's node + let out = pm_r.on_udp(mock_server(), &relayed, 5_000); + + // Responder replays its cached resp (a RelaySend) — completing A — and does + // NOT churn a new session: current tag unchanged. + assert!( + has_relayed_handshake_resp(&out), + "must replay cached resp over the relay" + ); + assert_eq!( + established_tag(&pm_r, 0), + tag_before, + "current session must be untouched" + ); + } + // ── #91 Task 2: relay-path rekey completion ───────────────────────────── /// Build a `PeerManager` (with a `MockRdv` rendezvous, so `relay_wrap` From 897a84a2122ed76992f94c47de2bcbffa5f45ee9 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Wed, 22 Jul 2026 18:00:36 -0400 Subject: [PATCH 04/14] docs(hardening.36): add responder-side relay adoption (close the headline punch->relay scenario) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ephemeral preservation (Task 1) fixes A's side but #91's path-consistency gate blocks B's: a direct-established responder drops A's relayed cold-start Init. Add one adoption case — a relayed cold-start RETRANSMIT (init_eph == cached_resp_init_eph) against a relay==false peer adopts relay for egress + replays cached_resp over the relay. New-ephemeral relayed Inits vs a direct peer stay #91 fail-closed. Accepted tradeoff: an attacker replaying a captured Init forces a direct->relay path downgrade (data still reaches the real peer; rides with #34). --- ...7-22-session-lifecycle-hardening-design.md | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-07-22-session-lifecycle-hardening-design.md b/docs/superpowers/specs/2026-07-22-session-lifecycle-hardening-design.md index afe10f2..916e441 100644 --- a/docs/superpowers/specs/2026-07-22-session-lifecycle-hardening-design.md +++ b/docs/superpowers/specs/2026-07-22-session-lifecycle-hardening-design.md @@ -53,7 +53,32 @@ Replace the "`state = Idle; begin_handshake(new_addr)`" re-target pattern at the **Why this is safe (no new attack surface):** resending the same `init_pkt` is exactly what the retransmit arm already does every `HANDSHAKE_RETRY_MS`; the only change is that the destination/path may differ. A responder that is `Established` replays its `cached_resp` (case 1) and we complete; a responder that never adopted us treats it as a fresh cold-start `Init` and adopts us normally. No responder-side gate (`accept_rekey_init`) is touched, so the 9a anti-hijack posture and the #34 anti-replay question are untouched. Ephemeral reuse remains bounded by the unchanged 90 s `HANDSHAKE_TOTAL_MS` give-up. -**Data flow (the reported #36 scenario, now converging):** A and B rendezvous-only; B adopts responder and is `Established`, but B→A `HandshakeResp` is lost through A's `PUNCH_MS` window. A escalates to relay: `retarget_handshake(idx, server, via_relay = true)` re-points A's in-flight `Handshaking(E1)` at the relay and resends `init_pkt(E1)` relay-wrapped. B receives it, `rekey_init_core` case (1) (`init_eph == cached_resp_init_eph`) replays `cached_resp(E1)` relay-wrapped, A `read_response(E1)` succeeds → A `Established`. Converged. +**Responder-side relay adoption (required to close the headline scenario).** Ephemeral preservation fixes A's side, but #91's path-consistency gate blocks B's: `relayed_handshake_init`'s `Established` arm only completes when `peers[idx].relay` is already `true`, and B (adopted as responder over *punch*) is `relay == false`, so B fail-closed-drops A's relayed Init instead of replaying `cached_resp`. And even if B replayed, B's egress would stay direct to A's now-dead punch address (the reverse black hole). So the `Established` arm gains one adoption case: **a direct-established peer (`relay == false`) receiving a relayed cold-start RETRANSMIT of the Init that built our session — `init_eph == cached_resp_init_eph` — adopts the relay for our egress (`peers[idx].relay = true`) and lets `rekey_init_core` case (1) replay `cached_resp` over the relay.** So B→A data now flows over the relay too. A relayed Init with a *new* ephemeral (a genuine rekey, or an attack) against a direct peer is NOT adopted — it stays fail-closed (`DispatchOut::None`), preserving #91's guard for the session-churning case. `cached_resp_init_eph` is set only on a responder cold-start, so this applies exactly to the responder-adopter that #36 describes; an initiator-established peer (no cached resp) fails closed. + +The adopted arm becomes (replacing the two-arm gate): + +```rust +PeerState::Established(_) => { + let Some(init_eph) = crate::handshake::init_ephemeral(dg) else { + return DispatchOut::None; // malformed Init + }; + // #36: a relayed cold-start RETRANSMIT of the Init that built our session + // means the initiator moved to relay-only — adopt the relay for our egress + // so B->A also flows over it (else B keeps sending to A's dead punch addr). + if !self.peers[idx].relay && self.peers[idx].cached_resp_init_eph == Some(init_eph) { + self.peers[idx].relay = true; + } + if self.peers[idx].relay { + self.rekey_init_core(idx, established, resp_pkt, init_eph, now_ms, self.server_addr(), true) + } else { + DispatchOut::None // new-ephemeral relayed Init vs a direct peer: #91 fail-closed + } +} +``` + +**Security tradeoff (accepted).** An attacker who captures A's original punch Init (E1) can replay it over the relay to force B to adopt the relay for A (a path *downgrade*, direct→relay). This is a mild, bounded DoS — data still reaches the *real* A (`relay_wrap` addresses A's registered node via the server, not the attacker), no hijack — of the same class as the #34 anti-replay gap. Accepted for #36; a full fix rides with #34 (authenticated endpoint). A *rekey* Init arriving over the relay after A moved (new ephemeral, ~120 s later) is a distinct, rarer residual not covered here — it also rides with #34. + +**Data flow (the reported #36 scenario, now converging):** A and B rendezvous-only; B adopts responder over punch and is `Established` (`relay == false`, `cached_resp_init_eph == E1`), but B→A `HandshakeResp` is lost through A's `PUNCH_MS` window. A escalates to relay: `retarget_handshake(idx, server, via_relay = true)` re-points A's in-flight `Handshaking(E1)` at the relay and resends `init_pkt(E1)` relay-wrapped. B receives it; the `Established` arm sees `init_eph == cached_resp_init_eph` on a `relay == false` peer → adopts `relay = true` → `rekey_init_core` case (1) replays `cached_resp(E1)` relay-wrapped. A `read_response(E1)` succeeds → A `Established` (relay). Both directions now flow over the relay. Converged. ### Fix #41 — cert re-verification on rekey + periodic liveness sweep From e24587126407ea45ed1878f2b0f0a7dbd89bc85a Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Wed, 22 Jul 2026 18:08:41 -0400 Subject: [PATCH 05/14] fix(hardening.36): responder-side relay adoption on a relayed cold-start retransmit --- bin/yipd/src/peer_manager.rs | 150 ++++++++++++++++++++++++++++++++--- 1 file changed, 139 insertions(+), 11 deletions(-) diff --git a/bin/yipd/src/peer_manager.rs b/bin/yipd/src/peer_manager.rs index 90b3c78..853658a 100644 --- a/bin/yipd/src/peer_manager.rs +++ b/bin/yipd/src/peer_manager.rs @@ -1112,21 +1112,53 @@ impl PeerManager { // datagrams to the server — black-holing `current`. Fail-closed // drop instead; `current` (and the in-flight rekey, if any) // stays untouched. - PeerState::Established(_) if self.peers[idx].relay => { + // + // 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. + PeerState::Established(_) => { let Some(init_eph) = crate::handshake::init_ephemeral(dg) else { return DispatchOut::None; // malformed Init }; - self.rekey_init_core( - idx, - established, - resp_pkt, - init_eph, - now_ms, - self.server_addr(), - true, - ) + // #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) + { + self.peers[idx].relay = true; + } + if self.peers[idx].relay { + self.rekey_init_core( + idx, + established, + resp_pkt, + init_eph, + now_ms, + self.server_addr(), + true, + ) + } else { + DispatchOut::None + } } - PeerState::Established(_) => DispatchOut::None, PeerState::Handshaking(_) if self.local_pub < self.peers[idx].pubkey => { DispatchOut::None } @@ -4922,6 +4954,102 @@ mod tests { ); } + /// #36 Task 1b: build a responder `PeerManager` that adopted the + /// responder role over a DIRECT (non-relayed) path — real + /// `cached_resp`/`cached_resp_init_eph`, keyed to initiator A's actual + /// ephemeral, and `relay == false` — via a genuine cold-start Noise + /// handshake delivered straight to `on_udp` from a direct endpoint + /// address (mirroring `duplicate_init_after_established_does_not_tear_down_session`'s + /// direct completion, but with a `MockRdv` rendezvous so relay egress + /// works afterward). This is the peer state the headline #36 scenario + /// leaves B in: A escalated punch->relay after this handshake completed, + /// so B is Established-and-direct while A now only reaches it via relay. + fn responder_established_direct_for_initiator( + now_ms: u64, + ) -> (PeerManager, yip_crypto::Keypair, Vec) { + let kp_r = generate_keypair(); + let kp_a = generate_keypair(); + let ep_a: SocketAddr = "10.0.0.9:9000".parse().unwrap(); + let cfg_a = PeerConfig { + public_key: kp_a.public, + endpoint: Some(ep_a), + }; + 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 resp1 = resp_bytes(&pm_r.on_udp(ep_a, &a_init_pkt, now_ms)); + assert_eq!( + resp1.len(), + 1, + "cold-start direct init must produce one HandshakeResp" + ); + assert!( + established_tag(&pm_r, 0).is_some(), + "responder must be Established" + ); + assert!( + !pm_r.peers[0].relay, + "responder must be direct (not relay) for A" + ); + + (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 + /// sending to A's dead punch address — the reverse black hole) and + /// replay `cached_resp` over the relay, without churning a new session. + #[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)); + + 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)" + ); + } + + /// #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. + #[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 dropped = matches!( + pm_r.on_udp(mock_server(), &relayed, 5_000), + DispatchOut::None + ); + + assert!( + !pm_r.peers[0].relay, + "must NOT adopt relay for a new-ephemeral relayed init" + ); + assert!(dropped, "must fail-closed drop, mirroring #91's guard"); + } + // ── #91 Task 2: relay-path rekey completion ───────────────────────────── /// Build a `PeerManager` (with a `MockRdv` rendezvous, so `relay_wrap` From 209533c8b2d676cf547acf9923218ccdc05126e5 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Wed, 22 Jul 2026 18:21:10 -0400 Subject: [PATCH 06/14] =?UTF-8?q?fix(hardening.36):=20review=20follow-ups?= =?UTF-8?q?=20=E2=80=94=20relay-field=20doc,=20last=5Fsent=5Fms=20reset,?= =?UTF-8?q?=20spec=20tradeoff=20precision?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the complete #36 fix (APPROVED, 2 Important + 2 Minor, non-blocking): - Important: update Peer.relay field doc for the #36 Established-adoption exception. - Important: spec tradeoff prose now states the forced downgrade is persistent for the session AND degrades direct rekey (rides with #34). - Minor: retarget_handshake resets last_sent_ms=now so no immediate redundant retransmit (anti-DPI timing); test asserts it. - Minor: spec retarget behavior now documents the endpoint clear/re-stamp asymmetry. --- bin/yipd/src/peer_manager.rs | 19 +++++++++++++++++-- ...7-22-session-lifecycle-hardening-design.md | 8 ++++++-- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/bin/yipd/src/peer_manager.rs b/bin/yipd/src/peer_manager.rs index 853658a..b98a594 100644 --- a/bin/yipd/src/peer_manager.rs +++ b/bin/yipd/src/peer_manager.rs @@ -239,8 +239,14 @@ struct Peer { /// Whether this peer is currently reached via the relay (server) rather /// than directly: every egress datagram for it (handshake and data plane) /// is wrapped through `rendezvous.relay`. Set on a Relay-stage probe or on - /// admitting a relayed handshake; only mutated while the peer is - /// non-`Established` (anti-hijack). + /// admitting a relayed handshake. Mutated while the peer is + /// non-`Established` (anti-hijack), with ONE deliberate exception: the #36 + /// responder-side adoption in `relayed_handshake_init` flips it to `true` + /// for an Established peer that receives a relayed cold-start RETRANSMIT of + /// the Init that built our session (`cached_resp_init_eph` match) — the + /// initiator has moved to relay-only, so we adopt the relay for our egress + /// too. That path can never redirect egress to an attacker (`relay_wrap` + /// addresses the peer's fixed registered node), only downgrade the path. relay: bool, /// When we last emitted a `lookup` for this peer (debounces `NeedLookup`); /// `None` until the first lookup is sent. @@ -776,6 +782,11 @@ impl PeerManager { 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 { @@ -4403,6 +4414,10 @@ mod tests { 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" + ); assert_eq!(h.target, server, "target must update to the new path"); assert_ne!(h.target, orig_target); } diff --git a/docs/superpowers/specs/2026-07-22-session-lifecycle-hardening-design.md b/docs/superpowers/specs/2026-07-22-session-lifecycle-hardening-design.md index 916e441..4d99b98 100644 --- a/docs/superpowers/specs/2026-07-22-session-lifecycle-hardening-design.md +++ b/docs/superpowers/specs/2026-07-22-session-lifecycle-hardening-design.md @@ -46,7 +46,7 @@ fn retarget_handshake( ``` Behavior: -- **Peer is `Handshaking`:** mutate the existing `HandshakingState` in place — set `target = new_target`, set the peer's `relay = via_relay` — leaving `hs` and `init_pkt` untouched (the ephemeral is preserved). Emit the existing `init_pkt`: relay-wrapped via `relay_wrap(idx, init_pkt.clone())` when `via_relay` (a `None` skips this send; the retransmit arm retries), else `EgressDatagram { fate: 0, dst: new_target, bytes: init_pkt.clone() }`. Do **not** reset `started_ms` (the 90 s give-up clock keeps running across re-targets — a re-target does not buy a fresh 90 s). +- **Peer is `Handshaking`:** mutate the existing `HandshakingState` in place — set `target = new_target`, set the peer's `relay = via_relay` — leaving `hs` and `init_pkt` untouched (the ephemeral is preserved). Emit the existing `init_pkt`: relay-wrapped via `relay_wrap(idx, init_pkt.clone())` when `via_relay` (a `None` skips this send; the retransmit arm retries) **and clear `endpoint = None`** (a late direct Resp for this same ephemeral must not complete us onto a now-`relay`-flagged peer — the #91 mismatch class); else `EgressDatagram { fate: 0, dst: new_target, bytes: init_pkt.clone() }` **and re-stamp `endpoint = Some(new_target)`** (as `begin_handshake`'s direct branch does — `handle_handshake_resp` matches an inbound Resp against `endpoint == Some(src)`, so a Resp from the new candidate must find the peer). Do **not** reset `started_ms` (the 90 s give-up clock keeps running across re-targets — a re-target does not buy a fresh 90 s), but **do** reset `last_sent_ms = now_ms` so the retransmit arm does not fire an immediate redundant copy of the just-resent Init (which would also break anti-DPI timing jitter). - **Peer is `Idle` (or otherwise not `Handshaking`):** delegate to `begin_handshake(idx, new_target, via_relay, now_ms)` (fresh ephemeral) — unchanged from today. Replace the "`state = Idle; begin_handshake(new_addr)`" re-target pattern at the escalation/re-target arms with `retarget_handshake`. The cold-start entry points (`on_tun`'s Idle branch, initial `begin_handshake`) are unchanged — only *re-targets of an in-flight attempt* change. @@ -76,7 +76,11 @@ PeerState::Established(_) => { } ``` -**Security tradeoff (accepted).** An attacker who captures A's original punch Init (E1) can replay it over the relay to force B to adopt the relay for A (a path *downgrade*, direct→relay). This is a mild, bounded DoS — data still reaches the *real* A (`relay_wrap` addresses A's registered node via the server, not the attacker), no hijack — of the same class as the #34 anti-replay gap. Accepted for #36; a full fix rides with #34 (authenticated endpoint). A *rekey* Init arriving over the relay after A moved (new ephemeral, ~120 s later) is a distinct, rarer residual not covered here — it also rides with #34. +**Security tradeoff (accepted).** An attacker who captures A's original punch Init (E1) can replay it over the relay to force B to adopt the relay for A (a path *downgrade*, direct→relay). This is a bounded DoS — data still reaches the *real* A (`relay_wrap` addresses A's registered node via the server, not the attacker), no hijack, `current`'s session keys unchanged — of the same class as the #34 anti-replay gap. Two precise consequences, both accepted (they ride with #34, an authenticated endpoint): +- **The downgrade is persistent for the session's life,** not transient: `relay` is reset to `false` for a live peer only on a fresh `Handshaking → Established` transition over direct (a full re-establishment), which rekey never routes through. So once forced, B routes A over the relay until the session tears down. +- **It also degrades direct rekey.** Once `relay == true`, `handle_handshake_init`'s `PeerState::Established(_) if !self.peers[idx].relay` gate makes B fail-closed-drop A's *direct* rekey Inits, so rekey with A can only proceed over the relay (or, if A is the rekey initiator over direct, is denied — `current` survives, rotation is delayed). No data loss (A→B direct data still decrypts by `conn_tag` regardless of `relay`; B→A flows over the relay). + +A *rekey* Init arriving over the relay after A moved (new ephemeral, ~120 s later) is a distinct, rarer residual not covered here — it also rides with #34. **Data flow (the reported #36 scenario, now converging):** A and B rendezvous-only; B adopts responder over punch and is `Established` (`relay == false`, `cached_resp_init_eph == E1`), but B→A `HandshakeResp` is lost through A's `PUNCH_MS` window. A escalates to relay: `retarget_handshake(idx, server, via_relay = true)` re-points A's in-flight `Handshaking(E1)` at the relay and resends `init_pkt(E1)` relay-wrapped. B receives it; the `Established` arm sees `init_eph == cached_resp_init_eph` on a `relay == false` peer → adopts `relay = true` → `rekey_init_core` case (1) replays `cached_resp(E1)` relay-wrapped. A `read_response(E1)` succeeds → A `Established` (relay). Both directions now flow over the relay. Converged. From dd37648e25aae0138b9768a3141dab6603c81bd8 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Wed, 22 Jul 2026 18:40:23 -0400 Subject: [PATCH 07/14] fix(hardening.41): re-verify cert on rekey Init, drop session on failure --- bin/yipd/src/peer_manager.rs | 231 ++++++++++++++++++++++++++++++++++- 1 file changed, 230 insertions(+), 1 deletion(-) diff --git a/bin/yipd/src/peer_manager.rs b/bin/yipd/src/peer_manager.rs index b98a594..f680972 100644 --- a/bin/yipd/src/peer_manager.rs +++ b/bin/yipd/src/peer_manager.rs @@ -1087,7 +1087,7 @@ impl PeerManager { .as_ref() .map(Membership::own_cert_bytes) .unwrap_or_default(); - let (established, resp_pkt, remote_static, _initiator_payload) = + let (established, resp_pkt, remote_static, initiator_payload) = match HandshakeState::start_responder(&self.local_priv, dg, &resp_payload) { Ok(t) => t, Err(e) => { @@ -1137,6 +1137,15 @@ impl PeerManager { let Some(init_eph) = crate::handshake::init_ephemeral(dg) else { return DispatchOut::None; // malformed Init }; + // #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. + if !self.responder_cert_ok(&initiator_payload, 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 @@ -1556,6 +1565,30 @@ impl PeerManager { } } + /// 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. + /// Re-admission is guarded by the cold-start cert check, so a revoked + /// peer cannot re-establish. + fn drop_session(&mut self, idx: usize) { + let tags: Vec = if let PeerState::Established(epochs) = &self.peers[idx].state { + let mut t = vec![epochs.current.conn_tag()]; + if let Some(n) = epochs.next.as_ref() { + t.push(n.dp.conn_tag()); + } + if let Some(p) = epochs.previous.as_ref() { + t.push(p.conn_tag()); + } + t + } else { + Vec::new() + }; + for tag in tags { + self.by_tag.remove(&tag); + } + self.peers[idx].state = PeerState::Idle; + } + fn handle_handshake_init( &mut self, src: SocketAddr, @@ -1634,6 +1667,14 @@ impl PeerManager { // Fail-closed drop instead, mirroring the guard in // `relayed_handshake_init`; `current` stays untouched. PeerState::Established(_) if !self.peers[idx].relay => { + // #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. + if !self.responder_cert_ok(&initiator_payload, remote_static) { + self.drop_session(idx); + return DispatchOut::None; + } let init_eph = crate::handshake::init_ephemeral(dg).expect( "start_responder already parsed dg's msg1; its leading 32 bytes are `e`", ); @@ -5844,6 +5885,194 @@ mod tests { } } + // ── #41(a): re-verify the cert on a mid-session rekey Init ───────────── + // + // A registry mapping a peer's data-plane pubkey to its private key + + // Ed25519 signing-key seed, populated by `pm_mesh_established_peer` and + // read by `rekey_init_with_payload`/`valid_cert_bytes` so those helpers + // can mint further datagrams "from" that peer without threading its + // keys through every call site (the verbatim test bodies only pass + // `&pm`). Keyed (not thread-local) so it's safe under parallel test + // execution. + type PeerTestKeyRegistry = + std::sync::Mutex>; + + fn peer_test_key_registry() -> &'static PeerTestKeyRegistry { + static REG: std::sync::OnceLock = std::sync::OnceLock::new(); + REG.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new())) + } + + /// Build a mesh (`membership: Some`) `PeerManager` with a single peer + /// already `Established` over a direct (non-relay) cold-start handshake, + /// for exercising the #41(a) rekey cert-reverification guard. + /// `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). + fn pm_mesh_established_peer( + local_sign_seed: [u8; 32], + peer_sign_seed: [u8; 32], + interval_ms: u64, + ) -> (PeerManager, u64) { + let ca = test_ca(); + let local = generate_keypair(); + let peer = generate_keypair(); + let peer_ep: SocketAddr = "10.0.0.2:2000".parse().unwrap(); + + let local_sign = SigningKey::from_bytes(&local_sign_seed); + let local_cert = mk_cert(&ca, local.public, local_sign.verifying_key().to_bytes()); + let membership = Membership::new( + vec![ca.verifying_key().to_bytes()], + TEST_NET, + local_cert, + local_sign.to_bytes(), + empty_roots(), + vec!["10.0.0.1:51820".parse().unwrap()], + ); + + let cfg_peer = PeerConfig { + public_key: peer.public, + endpoint: Some(peer_ep), + }; + let mut pm = PeerManager::new( + local.private, + local.public, + &[cfg_peer], + TunnelMode::L3Tun, + None, + Some(membership), + false, + ); + pm.rekey_interval_ms = interval_ms; + + peer_test_key_registry() + .lock() + .unwrap() + .insert(peer.public, (peer.private, peer_sign_seed)); + + // Cold-start: the peer completes a direct handshake with `pm`. + // Admission is by preconfigured static-key match (`cfg_peer` above), + // so no cert is needed on this leg — only the mid-session rekey path + // (#41a) re-checks the cert. + let (_hs, init_pkt) = + HandshakeState::start_initiator(&peer.private, &local.public, &[]).unwrap(); + let out = pm.on_udp(peer_ep, &init_pkt, 0); + assert!( + matches!(out, DispatchOut::Udp(_)), + "cold-start init must produce a resp and establish the session" + ); + + let tag = established_tag(&pm, 0).expect("pm established with the peer"); + (pm, tag) + } + + /// The endpoint `pm` learned/was configured with for peer `idx`. + fn peer_src(pm: &PeerManager, idx: usize) -> SocketAddr { + pm.peers[idx] + .endpoint + .expect("peer has a learned/configured endpoint") + } + + /// 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. + 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() + .lock() + .unwrap() + .get(&peer_pub) + .expect("peer keypair registered by pm_mesh_established_peer"); + let (_hs, init_pkt) = + HandshakeState::start_initiator(&peer_priv, &pm.local_pub, payload).unwrap(); + init_pkt + } + + /// A freshly-minted, currently-valid CA-signed cert (encoded) covering + /// `pm.peers[idx]`'s static key — what a non-revoked member would present + /// on a rekey Init. + fn valid_cert_bytes(pm: &PeerManager, idx: usize) -> Vec { + let peer_pub = pm.peers[idx].pubkey; + let (_priv, sign_seed) = *peer_test_key_registry() + .lock() + .unwrap() + .get(&peer_pub) + .expect("peer keypair registered by pm_mesh_established_peer"); + let ca = test_ca(); + let sign = SigningKey::from_bytes(&sign_seed); + let cert = mk_cert(&ca, peer_pub, sign.verifying_key().to_bytes()); + let mut bytes = Vec::new(); + cert.encode(&mut bytes); + bytes + } + + /// A rekey Init whose payload is NOT a currently-valid cert (mesh mode) + /// must drop the session (revert to `Idle`, purge `by_tag`, no reply) + /// instead of completing the rekey — else a revoked/expired member keeps + /// 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. + 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)); + + // A rekey Init from that peer carrying an INVALID cert payload (membership on). + let init = rekey_init_with_payload(&pm, 0, /*payload=*/ b"not-a-valid-cert"); + let out = match pm.on_udp(peer_src(&pm, 0), &init, 200_000) { + DispatchOut::Udp(e) | DispatchOut::Both(_, e) => Some(e), + _ => None, + } + .map(<[EgressDatagram]>::to_vec); + + // Session dropped: Idle, by_tag entry gone, no resp emitted. + assert!( + matches!(pm.peers[0].state, PeerState::Idle), + "invalid-cert rekey drops the session" + ); + assert!( + !pm.by_tag.values().any(|&i| i == 0), + "the peer's conn_tag is removed from by_tag" + ); + assert!( + out.is_none_or(|o| o.is_empty()), + "no resp for a revoked rekey" + ); + } + + /// Control for the test above: a VALID cert on the rekey Init still + /// rekeys normally — the #41a guard must be a no-op on the legitimate + /// path. + #[test] + fn rekey_init_with_valid_cert_still_rekeys() { + let (mut pm, tag) = pm_mesh_established_peer([5u8; 32], [6u8; 32], 100_000); + let init = rekey_init_with_payload(&pm, 0, &valid_cert_bytes(&pm, 0)); + let out = pm.on_udp(peer_src(&pm, 0), &init, 200_000); + let resp = resp_bytes(&out); + assert_eq!( + resp.len(), + 1, + "a genuine rekey Init with a valid cert must produce a Resp, proving the \ + rekey actually proceeded rather than merely not dropping" + ); + assert_eq!( + established_tag(&pm, 0), + Some(tag), + "current stays on the OLD epoch until the initiator confirms (9a Task 4)" + ); + match &pm.peers[0].state { + PeerState::Established(epochs) => assert!( + epochs.next.is_some(), + "the valid-cert rekey Init installed a next epoch" + ), + _ => panic!("valid cert rekeys, no drop"), + } + } + /// (c) With NO membership configured, `on_tun` to an unknown mesh address is /// dropped and a `HandshakeInit` from an unconfigured key (even one bearing /// a cert) is not admitted — byte-identical to 2a/2b. From 32c3b243d327215a6bd64cf2fc10582f6abbd77c Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Wed, 22 Jul 2026 19:30:59 -0400 Subject: [PATCH 08/14] fix(hardening.41): re-admission cert gate for tabled mesh peers (roots exempt) --- bin/yipd/src/peer_manager.rs | 295 ++++++++++++++++++++++++++++++++++- 1 file changed, 289 insertions(+), 6 deletions(-) diff --git a/bin/yipd/src/peer_manager.rs b/bin/yipd/src/peer_manager.rs index f680972..31a3bbb 100644 --- a/bin/yipd/src/peer_manager.rs +++ b/bin/yipd/src/peer_manager.rs @@ -1183,6 +1183,20 @@ impl PeerManager { DispatchOut::None } PeerState::Idle | PeerState::Handshaking(_) => { + // #41: re-admission gate. A tabled mesh peer re-establishing + // (its session was dropped by the rekey re-verify or the + // liveness sweep) must present a currently-valid cert, else a + // revoked member reconnects by static-key match (flapping, not + // revocation). Always-admit ROOTS are exempt (as in the #41 + // sweep). No-op for pure 2a/2b: `responder_cert_ok` returns + // `true` when membership is `None`. + let is_root = self + .membership + .as_ref() + .is_some_and(|m| m.roots().iter().any(|(pk, _)| *pk == remote_static)); + if !is_root && !self.responder_cert_ok(&initiator_payload, remote_static) { + 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 @@ -1695,6 +1709,20 @@ impl PeerManager { // lazy establishment) or `Handshaking` with the larger key (adopt // responder role): admit this session. PeerState::Idle | PeerState::Handshaking(_) => { + // #41: re-admission gate. A tabled mesh peer re-establishing + // (its session was dropped by the rekey re-verify or the + // liveness sweep) must present a currently-valid cert, else a + // revoked member reconnects by static-key match (flapping, not + // revocation). Always-admit ROOTS are exempt (as in the #41 + // sweep). No-op for pure 2a/2b: `responder_cert_ok` returns + // `true` when membership is `None`. + let is_root = self + .membership + .as_ref() + .is_some_and(|m| m.roots().iter().any(|(pk, _)| *pk == remote_static)); + if !is_root && !self.responder_cert_ok(&initiator_payload, remote_static) { + 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( @@ -5953,12 +5981,19 @@ mod tests { .unwrap() .insert(peer.public, (peer.private, peer_sign_seed)); - // Cold-start: the peer completes a direct handshake with `pm`. - // Admission is by preconfigured static-key match (`cfg_peer` above), - // so no cert is needed on this leg — only the mid-session rekey path - // (#41a) re-checks the cert. - let (_hs, init_pkt) = - HandshakeState::start_initiator(&peer.private, &local.public, &[]).unwrap(); + // Cold-start: the peer completes a direct handshake with `pm`. Even + // though admission is by preconfigured static-key match (`cfg_peer` + // above), Task 2b's re-admission gate now also requires a + // currently-valid cert on THIS leg (mirroring the real initiator's + // `begin_handshake`, which always attaches `own_cert_bytes()` when + // membership is enabled) — so mint one here, exactly like a + // legitimate, non-revoked member would present. + let (_hs, init_pkt) = HandshakeState::start_initiator( + &peer.private, + &local.public, + &valid_cert_bytes(&pm, 0), + ) + .unwrap(); let out = pm.on_udp(peer_ep, &init_pkt, 0); assert!( matches!(out, DispatchOut::Udp(_)), @@ -6010,6 +6045,36 @@ mod tests { bytes } + /// A CA-signed but EXPIRED cert (encoded) covering `pm.peers[idx]`'s + /// static key — what a revoked/non-renewed member would present when + /// re-establishing after its session was dropped (#41/Task 2b). + /// `not_after: 1` is far enough in the past (wall-clock seconds since the + /// UNIX epoch) that it is expired even past `CLOCK_SKEW_SECS`. + fn expired_cert_bytes(pm: &PeerManager, idx: usize) -> Vec { + let peer_pub = pm.peers[idx].pubkey; + let (_priv, sign_seed) = *peer_test_key_registry() + .lock() + .unwrap() + .get(&peer_pub) + .expect("peer keypair registered by pm_mesh_established_peer"); + let ca = test_ca(); + let sign = SigningKey::from_bytes(&sign_seed); + let mut cert = Cert { + version: 1, + member_pubkey: peer_pub, + member_sign_pubkey: sign.verifying_key().to_bytes(), + network_id: TEST_NET, + not_before: 0, + not_after: 1, + tags: vec![], + ca_sig: [0u8; 64], + }; + cert.ca_sig = ca.sign(&cert_signing_body(&cert)).to_bytes(); + let mut bytes = Vec::new(); + cert.encode(&mut bytes); + bytes + } + /// A rekey Init whose payload is NOT a currently-valid cert (mesh mode) /// must drop the session (revert to `Idle`, purge `by_tag`, no reply) /// instead of completing the rekey — else a revoked/expired member keeps @@ -6073,6 +6138,224 @@ mod tests { } } + // ── #41(c)/Task 2b: re-admission gate on cold-start establishment ────── + // + // Closes the third piece of #41: a revoked (cert-expired, non-renewed) + // mesh member whose session was dropped (by the rekey re-verify above, or + // the future liveness sweep) stays TABLED in `self.peers`, just `Idle`. + // Before this gate, `handle_handshake_init`'s tabled-lookup + // (`Some(i) => i`) admitted it back in by static-key match alone — only + // the `None`/new-peer path verified a cert — so a revoked member could + // flap back in on every cold-start retry instead of staying revoked. + + /// A TABLED mesh peer whose live session was dropped is `Idle` but still + /// present by static-key match. Its next cold-start Init, carrying an + /// EXPIRED cert, must NOT be re-admitted: no session, no reply, state + /// stays `Idle`. Drives the real dispatch path (`on_udp` -> + /// `handle_handshake_init`). + #[test] + fn revoked_tabled_peer_cold_start_reinit_rejected() { + let (mut pm, _tag) = pm_mesh_established_peer([5u8; 32], [6u8; 32], 100_000); + // Session dropped (as the #41a rekey guard, or the liveness sweep, + // would do): the peer stays tabled, reverts to Idle. + pm.drop_session(0); + assert!(matches!(pm.peers[0].state, PeerState::Idle)); + assert!(pm.by_tag.is_empty()); + + let init = rekey_init_with_payload(&pm, 0, &expired_cert_bytes(&pm, 0)); + let out = pm.on_udp(peer_src(&pm, 0), &init, 300_000); + + assert!( + matches!(out, DispatchOut::None), + "an expired cert must not re-admit a tabled peer" + ); + assert!( + matches!(pm.peers[0].state, PeerState::Idle), + "peer stays Idle: no session was created for the rejected re-init" + ); + assert!( + pm.by_tag.is_empty(), + "no conn_tag was installed for the rejected re-init" + ); + } + + /// The `is_root` exemption: a peer whose pubkey IS in the signed root set + /// is always-admit (mirrors the future liveness sweep's root exemption). + /// Its cold-start Init is admitted even though its payload carries no + /// cert at all — proving the exemption, not merely a passing cert check. + #[test] + fn root_exempt_from_readmission_check() { + let ca = test_ca(); + let local = generate_keypair(); + let root = generate_keypair(); + let root_ep: SocketAddr = "198.51.100.1:51820".parse().unwrap(); + + let roots = RootSet { + roots: vec![(root.public, root_ep)], + version: 1, + ca_sig: [0u8; 64], + }; + let own_sign = SigningKey::from_bytes(&[200u8; 32]); + let own_cert = mk_cert(&ca, local.public, own_sign.verifying_key().to_bytes()); + let membership = Membership::new( + vec![ca.verifying_key().to_bytes()], + TEST_NET, + own_cert, + own_sign.to_bytes(), + roots, + vec!["10.0.0.1:51820".parse().unwrap()], + ); + let mut pm = PeerManager::new( + local.private, + local.public, + &[], + TunnelMode::L3Tun, + None, + Some(membership), + false, + ); + // `PeerManager::new` auto-admits the root: tabled, Idle. + assert_eq!(pm.peers.len(), 1); + assert_eq!(pm.peers[0].pubkey, root.public); + assert!(matches!(pm.peers[0].state, PeerState::Idle)); + + // 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 out = pm.on_udp(root_ep, &init_pkt, 0); + + assert_eq!( + resp_bytes(&out).len(), + 1, + "the root is admitted despite presenting no cert" + ); + assert!(matches!(pm.peers[0].state, PeerState::Established(_))); + } + + /// Control: with membership disabled (pure 2a/2b), the re-admission gate + /// is a no-op — `responder_cert_ok` returns `true` unconditionally, so a + /// configured peer re-establishing from `Idle` behaves byte-identically + /// to before this gate existed. + #[test] + fn readmission_check_is_noop_without_membership() { + let local = generate_keypair(); + let peer = generate_keypair(); + let peer_ep: SocketAddr = "10.0.0.2:2000".parse().unwrap(); + let cfg_peer = PeerConfig { + public_key: peer.public, + endpoint: Some(peer_ep), + }; + let mut pm = PeerManager::new( + local.private, + local.public, + &[cfg_peer], + TunnelMode::L3Tun, + None, + None, + false, + ); + + let (_hs, init1) = + HandshakeState::start_initiator(&peer.private, &local.public, &[]).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(_))); + + // Drop the session (as a real revocation/liveness event would), then + // re-establish from Idle with another empty-payload Init. + pm.drop_session(0); + assert!(matches!(pm.peers[0].state, PeerState::Idle)); + + let (_hs2, init2) = + HandshakeState::start_initiator(&peer.private, &local.public, &[]).unwrap(); + let out2 = pm.on_udp(peer_ep, &init2, 1_000); + assert_eq!( + resp_bytes(&out2).len(), + 1, + "re-establishment from Idle is unaffected by the gate when membership is None" + ); + assert!(matches!(pm.peers[0].state, PeerState::Established(_))); + } + + /// Relay-path counterpart of `revoked_tabled_peer_cold_start_reinit_rejected`: + /// a TABLED mesh peer reached over the relay, whose session was dropped, + /// must not be re-admitted by `relayed_handshake_init`'s Idle|Handshaking + /// arm when its cold-start re-Init carries an expired cert. + #[test] + fn revoked_tabled_relay_peer_cold_start_reinit_rejected() { + let ca = test_ca(); + let local = generate_keypair(); + let peer = generate_keypair(); + + let local_sign = SigningKey::from_bytes(&[5u8; 32]); + let local_cert = mk_cert(&ca, local.public, local_sign.verifying_key().to_bytes()); + let membership = Membership::new( + vec![ca.verifying_key().to_bytes()], + TEST_NET, + local_cert, + local_sign.to_bytes(), + empty_roots(), + vec!["10.0.0.1:51820".parse().unwrap()], + ); + let cfg_peer = PeerConfig { + public_key: peer.public, + endpoint: None, + }; + let sent = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let rdv: Box = Box::new(MockRdv { + server: mock_server(), + sent: sent.clone(), + }); + let mut pm = PeerManager::new( + local.private, + local.public, + &[cfg_peer], + TunnelMode::L3Tun, + Some(rdv), + Some(membership), + false, + ); + + peer_test_key_registry() + .lock() + .unwrap() + .insert(peer.public, (peer.private, [6u8; 32])); + + // Cold-start over the relay: establishes. Task 2b's re-admission gate + // requires a currently-valid cert on THIS leg too (mirroring the real + // initiator's `begin_handshake`, which always attaches + // `own_cert_bytes()` when membership is enabled), so mint one here. + let (_hs, init_pkt) = HandshakeState::start_initiator( + &peer.private, + &local.public, + &valid_cert_bytes(&pm, 0), + ) + .unwrap(); + let out = pm.on_udp(mock_server(), &relay_deliver(&peer, init_pkt), 0); + assert!( + has_relayed_handshake_resp(&out), + "cold-start relayed init must establish" + ); + assert!(pm.peers[0].relay); + + // Session dropped (revocation / sweep); the peer stays tabled, Idle. + pm.drop_session(0); + assert!(matches!(pm.peers[0].state, PeerState::Idle)); + + // 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 out2 = pm.on_udp(mock_server(), &relay_deliver(&peer, reinit_pkt), 300_000); + + assert!( + matches!(out2, DispatchOut::None), + "an expired cert must not re-admit a tabled relay peer" + ); + assert!(matches!(pm.peers[0].state, PeerState::Idle)); + } + /// (c) With NO membership configured, `on_tun` to an unknown mesh address is /// dropped and a `HandshakeInit` from an unconfigured key (even one bearing /// a cert) is not admitted — byte-identical to 2a/2b. From fd0954eff0182373d93e77366f12de3e53c51b89 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Wed, 22 Jul 2026 19:50:20 -0400 Subject: [PATCH 09/14] fix(hardening.41): exempt roots from rekey cert re-verify (shared is_root helper) --- bin/yipd/src/peer_manager.rs | 106 +++++++++++++++++++++++++++++++---- 1 file changed, 94 insertions(+), 12 deletions(-) diff --git a/bin/yipd/src/peer_manager.rs b/bin/yipd/src/peer_manager.rs index 31a3bbb..1fe39e7 100644 --- a/bin/yipd/src/peer_manager.rs +++ b/bin/yipd/src/peer_manager.rs @@ -1142,7 +1142,9 @@ impl PeerManager { // 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. - if !self.responder_cert_ok(&initiator_payload, remote_static) { + if !self.is_root(remote_static) + && !self.responder_cert_ok(&initiator_payload, remote_static) + { self.drop_session(idx); return DispatchOut::None; } @@ -1190,11 +1192,9 @@ impl PeerManager { // revocation). Always-admit ROOTS are exempt (as in the #41 // sweep). No-op for pure 2a/2b: `responder_cert_ok` returns // `true` when membership is `None`. - let is_root = self - .membership - .as_ref() - .is_some_and(|m| m.roots().iter().any(|(pk, _)| *pk == remote_static)); - if !is_root && !self.responder_cert_ok(&initiator_payload, remote_static) { + if !self.is_root(remote_static) + && !self.responder_cert_ok(&initiator_payload, remote_static) + { return DispatchOut::None; } let conn_tag = conn_tag_from_keys(&established.auth_key, &established.hp_key); @@ -1579,6 +1579,16 @@ impl PeerManager { } } + /// Whether `pk` is an always-admit root (in the signed root set). Roots are + /// exempt from cert-based revocation — they are trusted via the root set, not + /// a member cert (revoke a root by removing it from the root set). `false` + /// when membership is disabled. + fn is_root(&self, pk: [u8; 32]) -> bool { + self.membership + .as_ref() + .is_some_and(|m| m.roots().iter().any(|(rpk, _)| *rpk == pk)) + } + /// 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. @@ -1685,7 +1695,9 @@ impl PeerManager { // (mesh mode). A revoked/expired member presenting a stale cert // loses its session within a rekey interval instead of at process // restart. - if !self.responder_cert_ok(&initiator_payload, remote_static) { + if !self.is_root(remote_static) + && !self.responder_cert_ok(&initiator_payload, remote_static) + { self.drop_session(idx); return DispatchOut::None; } @@ -1716,11 +1728,9 @@ impl PeerManager { // revocation). Always-admit ROOTS are exempt (as in the #41 // sweep). No-op for pure 2a/2b: `responder_cert_ok` returns // `true` when membership is `None`. - let is_root = self - .membership - .as_ref() - .is_some_and(|m| m.roots().iter().any(|(pk, _)| *pk == remote_static)); - if !is_root && !self.responder_cert_ok(&initiator_payload, remote_static) { + if !self.is_root(remote_static) + && !self.responder_cert_ok(&initiator_payload, remote_static) + { return DispatchOut::None; } let conn_tag = conn_tag_from_keys(&established.auth_key, &established.hp_key); @@ -6233,6 +6243,78 @@ mod tests { assert!(matches!(pm.peers[0].state, PeerState::Established(_))); } + /// Task 2's rekey re-verify must exempt roots exactly like Task 2b's + /// re-admission gate does: an always-admit ROOT that is `Established` + /// and receives a further `[HandshakeInit]` with an EMPTY (no-cert) + /// payload — exactly what `root_exempt_from_readmission_check` proves a + /// root is allowed to present — must NOT have its live session torn + /// down. This arm also handles ordinary Init retransmits (lost Resp) + /// and peer restarts, not just genuine rekeys, so this is reachable in + /// normal operation. Before the fix, the rekey re-verify arm lacked the + /// `is_root` bypass the re-admission gate has, so a certless root's + /// session was wrongly dropped the moment any Init arrived from it. + #[test] + fn root_established_not_dropped_on_certless_rekey_init() { + let ca = test_ca(); + let local = generate_keypair(); + let root = generate_keypair(); + let root_ep: SocketAddr = "198.51.100.1:51820".parse().unwrap(); + + let roots = RootSet { + roots: vec![(root.public, root_ep)], + version: 1, + ca_sig: [0u8; 64], + }; + let own_sign = SigningKey::from_bytes(&[200u8; 32]); + let own_cert = mk_cert(&ca, local.public, own_sign.verifying_key().to_bytes()); + let membership = Membership::new( + vec![ca.verifying_key().to_bytes()], + TEST_NET, + own_cert, + own_sign.to_bytes(), + roots, + vec!["10.0.0.1:51820".parse().unwrap()], + ); + let mut pm = PeerManager::new( + local.private, + local.public, + &[], + TunnelMode::L3Tun, + None, + Some(membership), + false, + ); + pm.rekey_interval_ms = 100_000; + + // 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 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(_))); + let tag = established_tag(&pm, 0).expect("root established"); + + // A further `[HandshakeInit]` from the root (fresh ephemeral — an + // 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 _out2 = pm.on_udp(root_ep, &init_pkt2, 200_000); + + assert!( + matches!(pm.peers[0].state, PeerState::Established(_)), + "the root's session must NOT be dropped: roots are exempt from \ + cert-based revocation on rekey re-verify, same as the Task 2b gate" + ); + assert_eq!( + established_tag(&pm, 0), + Some(tag), + "by_tag / established_tag unchanged — no drop_session teardown occurred" + ); + } + /// Control: with membership disabled (pure 2a/2b), the re-admission gate /// is a no-op — `responder_cert_ok` returns `true` unconditionally, so a /// configured peer re-establishing from `Idle` behaves byte-identically From a09af668c44d79c591428c31aa0ed9f5576b0d57 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Wed, 22 Jul 2026 20:05:59 -0400 Subject: [PATCH 10/14] fix(hardening.41): periodic member cert-liveness sweep (roots exempt, throttled) --- bin/yipd/src/membership.rs | 105 ++++++++++++++++++++++ bin/yipd/src/peer_manager.rs | 168 +++++++++++++++++++++++++++++++++++ 2 files changed, 273 insertions(+) diff --git a/bin/yipd/src/membership.rs b/bin/yipd/src/membership.rs index e3c343c..8ac1873 100644 --- a/bin/yipd/src/membership.rs +++ b/bin/yipd/src/membership.rs @@ -290,6 +290,22 @@ impl Membership { &self.roots.roots } + /// Whether `pubkey` is still an admissible member at wall-clock `now`: + /// `true` if it is an always-admit root, OR the directory holds a valid + /// (unexpired, verifying) cert for it. `false` only when a non-root member's + /// record was evicted (expired) or its cert no longer verifies — i.e. + /// revoked-by-non-renewal. Folding the root check in here keeps roots exempt + /// from the #41 liveness sweep (they have no directory-cert dependency). + pub fn member_cert_valid(&self, pubkey: &[u8; 32], now: u64) -> bool { + if self.roots.roots.iter().any(|(pk, _)| pk == pubkey) { + return true; + } + match self.directory.get(&node_id(pubkey)) { + Some(rec) => self.verify_cert(&rec.cert, pubkey, now), + None => false, + } + } + // ── internal helpers ─────────────────────────────────────────────── /// Unconditionally (re-)insert `rec` into both indices. @@ -713,6 +729,95 @@ mod tests { assert_eq!(m.own_cert_bytes(), expected); } + // ── #41(b): `member_cert_valid` ───────────────────────────────────────── + + /// Build a `Membership` with: a live directory record (`live_pubkey`), a + /// root (`root_pubkey`, in the `RootSet`, no directory dependency), and + /// an expired-cert member (`expired_pubkey`) — inserted while its cert + /// was still valid (window `[100, 200)`, at `ingest` time `now=150`) so + /// `ingest_record` accepts it, but never re-swept, so it is still present + /// in the directory (holding its now-expired cert) at the returned `now`. + /// Returns `(membership, live_pubkey, root_pubkey, expired_pubkey, now)`. + fn membership_with_live_root_and_expired() -> (Membership, [u8; 32], [u8; 32], [u8; 32], u64) { + let ca = ca_key(1); + let net = [7u8; 16]; + + let root_pubkey = [200u8; 32]; + let roots = RootSet { + roots: vec![(root_pubkey, "10.0.0.99:51820".parse().unwrap())], + version: 0, + ca_sig: [0u8; 64], + }; + + let own_member_pk = [10u8; 32]; + let own_sign_key = SigningKey::from_bytes(&[11u8; 32]); + let own_sign_pub = own_sign_key.verifying_key().to_bytes(); + let own_cert = make_cert(&ca, own_member_pk, own_sign_pub, net, 0, 1_000_000); + let ca_pub = ca.verifying_key().to_bytes(); + let mut m = Membership::new( + vec![ca_pub], + net, + own_cert, + own_sign_key.to_bytes(), + roots, + vec!["10.0.0.1:51820".parse().unwrap()], + ); + + // A live member: valid essentially forever. + let live_pubkey = [20u8; 32]; + let live_sign_key = SigningKey::from_bytes(&[21u8; 32]); + let live_sign_pub = live_sign_key.verifying_key().to_bytes(); + let live_cert = make_cert(&ca, live_pubkey, live_sign_pub, net, 0, 1_000_000); + let live_rec = build_signed_record( + live_cert, + vec!["192.0.2.1:1111".parse().unwrap()], + 1, + &live_sign_key.to_bytes(), + ); + assert!(m.ingest_record(live_rec, 500)); + + // An expired member: window [100, 200) — insert while valid (now=150). + let expired_pubkey = [30u8; 32]; + let expired_sign_key = SigningKey::from_bytes(&[31u8; 32]); + let expired_sign_pub = expired_sign_key.verifying_key().to_bytes(); + let expired_cert = make_cert(&ca, expired_pubkey, expired_sign_pub, net, 100, 200); + let expired_rec = build_signed_record( + expired_cert, + vec!["192.0.2.2:2222".parse().unwrap()], + 1, + &expired_sign_key.to_bytes(), + ); + assert!(m.ingest_record(expired_rec, 150)); + + // now: well past expired_cert's not_after(200) + CLOCK_SKEW_SECS(300) + // = 500, but well within live_cert's window (not_after 1_000_000). + let now = 900u64; + (m, live_pubkey, root_pubkey, expired_pubkey, now) + } + + #[test] + fn member_cert_valid_tracks_directory_and_roots() { + let (m, live_pubkey, root_pubkey, expired_pubkey, now) = + membership_with_live_root_and_expired(); + assert!( + m.member_cert_valid(&live_pubkey, now), + "a live directory record is valid" + ); + assert!( + m.member_cert_valid(&root_pubkey, now), + "a root is always admissible (exempt)" + ); + assert!( + !m.member_cert_valid(&expired_pubkey, now), + "an expired/absent member is invalid" + ); + let never_seen = [0xAAu8; 32]; + assert!( + !m.member_cert_valid(&never_seen, now), + "an unknown non-root member is invalid" + ); + } + // (i) Fix-pass (Task 6): a `PullRequest` naming more `node_id`s than fit // in one `MAX_GOSSIP_RECORDS_PER_REPLY` batch must not produce one // unboundedly large `Records` reply — an amplification/CPU concern even diff --git a/bin/yipd/src/peer_manager.rs b/bin/yipd/src/peer_manager.rs index 1fe39e7..548974a 100644 --- a/bin/yipd/src/peer_manager.rs +++ b/bin/yipd/src/peer_manager.rs @@ -392,6 +392,12 @@ pub struct PeerManager { /// `YIP_REKEY_INTERVAL_MS` at construction so netns/unit tests can drive /// the schedule without a multi-minute real-time wait. rekey_interval_ms: u64, + /// MONOTONIC milliseconds at which `tick_dispatch` last ran the #41 cert- + /// liveness sweep (see there). `0` until the first sweep; throttles the + /// sweep to at most once per `rekey_interval_ms`, since each swept peer + /// costs an Ed25519 `verify_cert` and `tick` can run far more often than + /// that on the busy-poll path. + last_cert_sweep_ms: u64, } /// MTU budget (bytes) used to size obfuscation padding: handshakes are padded @@ -497,6 +503,7 @@ impl PeerManager { .ok() .and_then(|s| s.parse().ok()) .unwrap_or(crate::epoch::REKEY_INTERVAL_MS), + last_cert_sweep_ms: 0, }; // Roots are pre-vetted (CA-signed root set) and therefore always-admit, // exactly like configured peers: seed them into the peer table so an @@ -2949,6 +2956,32 @@ impl PeerManager { } } + // ── #41 cert-liveness sweep: drop any Established mesh peer whose cert has + // expired / been revoked (roots exempt), so a revoked member loses its + // session within a rekey interval rather than at process restart. Throttled + // to once per rekey interval (verify_cert is not free). No-op when membership + // is disabled (pure 2a/2b). + if self.membership.is_some() + && now_ms.saturating_sub(self.last_cert_sweep_ms) >= self.rekey_interval_ms + { + self.last_cert_sweep_ms = now_ms; + let now_s = now_secs(); + let m = self.membership.as_ref().expect("checked is_some above"); + let stale: Vec = self + .peers + .iter() + .enumerate() + .filter(|(_, p)| { + matches!(p.state, PeerState::Established(_)) + && !m.member_cert_valid(&p.pubkey, now_s) + }) + .map(|(i, _)| i) + .collect(); + for i in stale { + self.drop_session(i); + } + } + if self.tick_egress.is_empty() { None } else { @@ -7682,4 +7715,139 @@ mod tests { "a relay-reached peer must not receive a cover datagram, even when idle" ); } + + // ── #41(b): periodic cert-liveness sweep ──────────────────────────────── + + /// Build a mesh (`membership: Some`) `PeerManager` with TWO already- + /// `Established` (direct, non-relay) peers spliced in directly (like + /// `pm_with_established_peer`), whose mesh membership directory holds an + /// EXPIRED cert record for `expired_peer_pub` (peer 0) and a currently- + /// valid one for `valid_peer_pub` (peer 1) — the #41(b) sweep fixture. + /// Returns `(pm, established_tag_for_peer_1)`. + fn pm_mesh_two_established_one_expired( + expired_peer_pub: [u8; 32], + valid_peer_pub: [u8; 32], + ) -> (PeerManager, u64) { + let ca = test_ca(); + let local = generate_keypair(); + let local_sign = SigningKey::from_bytes(&[230u8; 32]); + let local_cert = mk_cert(&ca, local.public, local_sign.verifying_key().to_bytes()); + let mut membership = Membership::new( + vec![ca.verifying_key().to_bytes()], + TEST_NET, + local_cert, + local_sign.to_bytes(), + empty_roots(), + vec!["10.0.0.1:51820".parse().unwrap()], + ); + + // Peer 0's directory record: a cert that's ALREADY expired + // (`not_after: 1`) — inserted at `now=0`, when its `[0, 1)` window is + // (just barely) still open, so `ingest_record` accepts it; never + // re-swept, so it stays in the directory holding its now-expired cert. + let expired_sign = SigningKey::from_bytes(&[231u8; 32]); + let mut expired_cert = Cert { + version: 1, + member_pubkey: expired_peer_pub, + member_sign_pubkey: expired_sign.verifying_key().to_bytes(), + network_id: TEST_NET, + not_before: 0, + not_after: 1, + tags: vec![], + ca_sig: [0u8; 64], + }; + expired_cert.ca_sig = ca.sign(&cert_signing_body(&expired_cert)).to_bytes(); + let mut expired_rec = Record { + node_id: yip_membership::node_id(&expired_peer_pub), + cert: expired_cert, + endpoints: vec!["10.0.0.2:2000".parse().unwrap()], + seq: 1, + sig: [0u8; 64], + }; + let body = record_signing_body(&expired_rec); + expired_rec.sig = record_sign(&body, &expired_sign.to_bytes()); + assert!(membership.ingest_record(expired_rec, 0)); + + // Peer 1's directory record: an ordinary far-future-valid record. + let valid_rec = mk_record( + &ca, + 232, + valid_peer_pub, + vec!["10.0.0.3:3000".parse().unwrap()], + 1, + ); + assert!(membership.ingest_record(valid_rec, 0)); + + let cfg0 = PeerConfig { + public_key: expired_peer_pub, + endpoint: Some("10.0.0.2:2000".parse().unwrap()), + }; + let cfg1 = PeerConfig { + public_key: valid_peer_pub, + endpoint: Some("10.0.0.3:3000".parse().unwrap()), + }; + let mut pm = PeerManager::new( + local.private, + local.public, + &[cfg0, cfg1], + TunnelMode::L3Tun, + None, + Some(membership), + false, + ); + pm.rekey_interval_ms = 100_000; + + const TAG0: u64 = 0xAAAA_0000_0000_0001; + const TAG1: u64 = 0xBBBB_0000_0000_0002; + pm.peers[0].state = PeerState::Established(Box::new(crate::epoch::EpochSet::new( + Box::new(fake_established_dataplane( + TAG0, + "10.0.0.2:2000".parse().unwrap(), + )), + 0, + ))); + pm.by_tag.insert(TAG0, 0); + pm.peers[1].state = PeerState::Established(Box::new(crate::epoch::EpochSet::new( + Box::new(fake_established_dataplane( + TAG1, + "10.0.0.3:3000".parse().unwrap(), + )), + 0, + ))); + pm.by_tag.insert(TAG1, 1); + + (pm, TAG1) + } + + #[test] + fn tick_sweep_drops_established_peer_with_expired_cert() { + // Two Established mesh peers: peer 0's directory cert is expired, peer 1's is valid. + let (mut pm, tag1) = pm_mesh_two_established_one_expired([1u8; 32], [2u8; 32]); + pm.tick(500_000); // a tick past the sweep cadence, now_secs shows peer 0 expired + assert!( + matches!(pm.peers[0].state, PeerState::Idle), + "expired-cert peer's session is dropped" + ); + assert!( + !pm.by_tag.values().any(|&i| i == 0), + "its conn_tag is removed" + ); + assert_eq!( + established_tag(&pm, 1), + Some(tag1), + "the valid peer is untouched" + ); + } + + #[test] + fn tick_sweep_is_noop_without_membership() { + // Pure 2a/2b: no membership -> no sweep, Established peers untouched. + let (mut pm, tag, _ep) = pm_with_established_peer([1u8; 32], [2u8; 32], 100); + pm.tick(500_000); + assert_eq!( + established_tag(&pm, 0), + Some(tag), + "membership-off: sweep is a no-op" + ); + } } From fd9237ff7b84aa2bfbc6c96d47aa5d02eee4478e Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Wed, 22 Jul 2026 20:18:57 -0400 Subject: [PATCH 11/14] =?UTF-8?q?docs(hardening.41):=20sweep=20false-posit?= =?UTF-8?q?ive=20note=20=E2=80=94=20cold-start-admitted=20peers=20+=20accu?= =?UTF-8?q?rate=20recovery=20bound?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 3 review Minor: member_cert_valid is directory-keyed (populated only by gossip ingest_record, not admit_member), so a validly cold-start-admitted peer whose Record hasn't gossiped in can be swept. Bounded/recoverable (re-admission re-verifies the presented cert), but recovery is ~rekey_interval/2 via the responder's accept_rekey_init age gate, not instant. --- .../specs/2026-07-22-session-lifecycle-hardening-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-07-22-session-lifecycle-hardening-design.md b/docs/superpowers/specs/2026-07-22-session-lifecycle-hardening-design.md index 4d99b98..5aaf607 100644 --- a/docs/superpowers/specs/2026-07-22-session-lifecycle-hardening-design.md +++ b/docs/superpowers/specs/2026-07-22-session-lifecycle-hardening-design.md @@ -124,7 +124,7 @@ In `tick`, on the same cadence the rekey scheduler already runs (a periodic swee ## Risks - **#36 touches the security-critical escalation path.** Mitigation: the change is a strict narrowing — re-target now preserves the ephemeral (resend the same `init_pkt`, which the retransmit arm already does) instead of minting a fresh one; no responder-side gate changes; the full 2b escalation netns suite is the regression net. The 90 s give-up (unchanged) still forces a fresh ephemeral for a genuinely dead attempt. -- **#41 sweep could drop a still-valid peer** if its renewed record has not yet gossiped in. Mitigation: `CERT_VALIDITY_SKEW` widening + the drop is a recoverable re-handshake, not a black hole; the sweep runs at the coarse rekey cadence, not per-packet. +- **#41 sweep could drop a still-valid peer** whose gossip `Record` has not yet reached our directory — either a renewed record, or a peer validly cert-admitted via a *direct cold-start handshake* (its cert verified against the CA at admission, but `admit_member` does not insert a directory `Record` — only gossip `ingest_record` does). `member_cert_valid` is directory-keyed, so such a peer fails it and is swept. Mitigations: (1) roots are exempt; gossip-discovered peers already hold a `Record` (safe) — the exposure is a peer established shortly before a sweep whose `Record` hasn't yet converged; (2) the sweep runs only at the coarse rekey cadence (once per `rekey_interval_ms`), giving gossip (`GOSSIP_INTERVAL_MS` ~5 s) time to converge first; (3) the drop is a **recoverable re-handshake, not a black hole** — the re-admission gate re-verifies the peer's *presented* cert (still valid), independent of the directory, so a valid peer re-admits. Recovery is **bounded but not instant**: because a spuriously-dropped A re-inits with a fresh ephemeral against a responder B whose `current` epoch is comparably young, B's rekey-age gate (`accept_rekey_init`, ≥ `interval/2`) replays a stale `cached_resp` until B's session ages past `interval/2` — so the honest recovery bound is on the order of `rekey_interval_ms/2` (tens of seconds at the 120 s default), plus retry cycles, not a near-instant re-handshake. Accepted as a bounded one-time-at-join availability blip; a directory-independent variant (checking the peer's stored admission cert) is a possible future refinement. - **#41 (a) and (b) overlap** for the rekey-winner case (both would drop a revoked initiator). Harmless — dropping an already-dropped/Idle session is idempotent; (b) exists for the loser case (a) cannot see. ## Non-goals From 261bd77bf3369308148f71021176a662299d371a Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Wed, 22 Jul 2026 20:58:49 -0400 Subject: [PATCH 12/14] =?UTF-8?q?test(hardening):=20netns=20money=20tests?= =?UTF-8?q?=20=E2=80=94=20#36=20path-switch=20convergence=20+=20#41=20cert?= =?UTF-8?q?=20revocation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit yip-ca sign-cert gains --secs N (sub-day validity) for minting short-lived certs in the revocation test. Two netns money tests (both drivers, CI-wired): - run-netns-pathswitch-rehandshake.sh (#36): A escalates punch->relay while B is direct-established; asserts A converges over the relay carrying the SAME ephemeral (DISTINCT_INIT_EPHEMERALS=1, no cold-start churn) + relay-forwarded>0. - run-netns-cert-revocation.sh (#41): A's short-lived cert expires; asserts B drops A's session within a bounded window AND A cannot re-establish (re-admission gate refuses the expired cert). All four runs (2 tests x poll+uring) pass under sudo. --- .github/workflows/integration.yml | 81 ++++ bin/yip-ca/src/main.rs | 32 +- bin/yip-ca/tests/roundtrip.rs | 44 ++ bin/yipd/tests/run-netns-cert-revocation.sh | 421 +++++++++++++++++ .../tests/run-netns-pathswitch-rehandshake.sh | 423 ++++++++++++++++++ 5 files changed, 992 insertions(+), 9 deletions(-) create mode 100755 bin/yipd/tests/run-netns-cert-revocation.sh create mode 100755 bin/yipd/tests/run-netns-pathswitch-rehandshake.sh diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 22b87e8..5730757 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -254,6 +254,87 @@ jobs: exit 1 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 + # 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. + run: | + sudo bash bin/yipd/tests/run-netns-pathswitch-rehandshake.sh \ + "$(pwd)/target/release/yipd" \ + "$(pwd)/target/debug/yip-rendezvous" | tee /tmp/pathswitch-poll.log + if grep -q "^SKIP run-netns-pathswitch-rehandshake" /tmp/pathswitch-poll.log; then + echo "::error::hardening.36 path-switch money test (poll) skipped — expected root + tcpdump + the built witness tool in this job" + 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" + exit 1 + fi + + - name: Run the hardening.36 path-switch convergence money test under sudo (uring driver) + run: | + sudo -E env YIP_USE_URING=1 bash bin/yipd/tests/run-netns-pathswitch-rehandshake.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" + 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" + exit 1 + fi + + - name: Run the hardening.41 cert-revocation money test under sudo (poll driver) + # run-netns-cert-revocation.sh: proves the #41 fix -- a revoked + # (cert-expired) mesh member loses its session within a bounded + # window of expiry (not just at process restart) and cannot + # re-establish. Forks run-netns-discovery.sh's gossip-based mesh + # topology (required: the periodic cert-liveness sweep reads a + # peer's cert from the gossip-populated directory, not static + # config). NOTE: this test's own wait is dominated by + # membership.rs's hardcoded CLOCK_SKEW_SECS=300 grace window (see + # the script's header comment) -- it genuinely takes several minutes + # by design. Uses the release yipd and debug yip-ca/yip-rendezvous + # already built above in this job. + run: | + sudo bash bin/yipd/tests/run-netns-cert-revocation.sh \ + "$(pwd)/target/release/yipd" \ + "$(pwd)/target/debug/yip-ca" \ + "$(pwd)/target/debug/yip-rendezvous" | tee /tmp/cert-revocation-poll.log + if grep -q "^SKIP run-netns-cert-revocation" /tmp/cert-revocation-poll.log; then + echo "::error::hardening.41 cert-revocation money test (poll) skipped — expected root in this job" + exit 1 + fi + if grep -q "\[FAIL\]" /tmp/cert-revocation-poll.log; then + echo "::error::hardening.41 cert-revocation money test (poll) failed — a revoked peer's session was not dropped, or it was allowed to re-establish" + exit 1 + fi + + - name: Run the hardening.41 cert-revocation money test under sudo (uring driver) + run: | + sudo -E env YIP_USE_URING=1 bash bin/yipd/tests/run-netns-cert-revocation.sh \ + "$(pwd)/target/release/yipd" \ + "$(pwd)/target/debug/yip-ca" \ + "$(pwd)/target/debug/yip-rendezvous" | tee /tmp/cert-revocation-uring.log + if grep -q "^SKIP run-netns-cert-revocation" /tmp/cert-revocation-uring.log; then + echo "::error::hardening.41 cert-revocation money test (uring) skipped — expected root in this job" + exit 1 + fi + if grep -q "\[FAIL\]" /tmp/cert-revocation-uring.log; then + echo "::error::hardening.41 cert-revocation money test (uring) failed — a revoked peer's session was not dropped, or it was allowed to re-establish" + exit 1 + fi + dpi-undetectability: # The anti-DPI undetectability merge gate (3a Task 7): fails the build if # a wire/obfuscation change reintroduces a DPI-recognizable fingerprint. diff --git a/bin/yip-ca/src/main.rs b/bin/yip-ca/src/main.rs index c0b81cf..5b2b5d4 100644 --- a/bin/yip-ca/src/main.rs +++ b/bin/yip-ca/src/main.rs @@ -43,9 +43,14 @@ fn usage() -> String { "usage: yip-ca [args]\n\ \n\ yip-ca genkey\n\ - yip-ca sign-cert --member --member-sign --network --days [--ca-private ]\n\ + yip-ca sign-cert --member --member-sign --network (--days | --secs ) [--ca-private ]\n\ yip-ca sign-roots --roots --version [--ca-private ]\n\ \n\ + Exactly one of --days/--secs sets the cert's validity window: --days N\n\ + is N*86400 seconds, --secs N is exactly N seconds (overrides --days if\n\ + both are given). --secs is meant for minting short-lived certs in\n\ + revocation tests.\n\ + \n\ If --ca-private is omitted, the CA private key hex is read from stdin." .to_string() } @@ -67,19 +72,28 @@ fn cmd_sign_cert(args: &[String]) -> Result<(), String> { let member_pubkey = fixed32(&hex_decode(require(&flags, "member")?)?, "member")?; let member_sign_pubkey = fixed32(&hex_decode(require(&flags, "member-sign")?)?, "member-sign")?; let network_id = fixed16(&hex_decode(require(&flags, "network")?)?, "network")?; - let days_str = require(&flags, "days")?; - let days: u64 = days_str - .parse() - .map_err(|e| format!("bad --days {days_str:?}: {e}"))?; let ca_key = load_ca_private(flags.get("ca-private").map(String::as_str))?; let not_before = now_secs()?; - let validity = days - .checked_mul(SECS_PER_DAY) - .ok_or_else(|| "--days too large: overflow computing validity window".to_string())?; + // `--secs`, when present, overrides `--days`: an exact-seconds validity + // window instead of a whole-days one. Used to mint short-lived certs for + // revocation tests. Exactly one of the two must be given. + let validity = match (flags.get("secs"), flags.get("days")) { + (Some(secs_str), _) => secs_str + .parse::() + .map_err(|e| format!("bad --secs {secs_str:?}: {e}"))?, + (None, Some(days_str)) => { + let days: u64 = days_str + .parse() + .map_err(|e| format!("bad --days {days_str:?}: {e}"))?; + days.checked_mul(SECS_PER_DAY) + .ok_or_else(|| "--days too large: overflow computing validity window".to_string())? + } + (None, None) => return Err("missing required --days (or --secs)".to_string()), + }; let not_after = not_before .checked_add(validity) - .ok_or_else(|| "--days too large: overflow computing not_after".to_string())?; + .ok_or_else(|| "validity window too large: overflow computing not_after".to_string())?; let mut cert = Cert { version: 1, diff --git a/bin/yip-ca/tests/roundtrip.rs b/bin/yip-ca/tests/roundtrip.rs index d101811..2da7cbf 100644 --- a/bin/yip-ca/tests/roundtrip.rs +++ b/bin/yip-ca/tests/roundtrip.rs @@ -105,6 +105,50 @@ fn cert_issued_by_yip_ca_verifies_in_yip_membership() { assert!(verify_cert(&cert, &[other_pub], &network_id, &member_pubkey, now, 0).is_err()); } +#[test] +fn sign_cert_secs_flag_overrides_days_with_exact_seconds_validity() { + let key = genkey(); + + let member_hex = "66".repeat(32); + let member_sign_hex = "77".repeat(32); + let network_hex = "88".repeat(16); + + let cert_out = run(&[ + "sign-cert", + "--member", + &member_hex, + "--member-sign", + &member_sign_hex, + "--network", + &network_hex, + // --days is still required by the parser but must be ignored once + // --secs is present -- pick an outlandish days value to prove that. + "--days", + "365", + "--secs", + "5", + "--ca-private", + &key.ca_private, + ]); + let cert_bytes = hex_decode(cert_out.trim()); + let cert = Cert::decode(&cert_bytes).expect("emitted cert decodes"); + + assert_eq!( + cert.not_after - cert.not_before, + 5, + "--secs 5 must produce an exact 5-second validity window, not 365 days" + ); + + let member_pubkey: [u8; 32] = hex_decode(&member_hex).try_into().unwrap(); + let network_id: [u8; 16] = hex_decode(&network_hex).try_into().unwrap(); + let ca_pub: [u8; 32] = hex_decode(&key.ca_public).try_into().unwrap(); + let now = now_secs(); + assert_eq!( + verify_cert(&cert, &[ca_pub], &network_id, &member_pubkey, now, 0), + Ok(()) + ); +} + #[test] fn rootset_issued_by_yip_ca_verifies_in_yip_membership() { let key = genkey(); diff --git a/bin/yipd/tests/run-netns-cert-revocation.sh b/bin/yipd/tests/run-netns-cert-revocation.sh new file mode 100755 index 0000000..5cefaff --- /dev/null +++ b/bin/yipd/tests/run-netns-cert-revocation.sh @@ -0,0 +1,421 @@ +#!/usr/bin/env bash +# The hardening.41 money test: proves the #41 fix end-to-end — a revoked +# (cert-expired) mesh member must lose its session within a bounded window +# of its cert expiring, not just at process restart, and must not be able to +# re-establish afterward. +# +# Usage: run-netns-cert-revocation.sh +# +# ── topology: three netns, A / B / R, all on ONE shared bridge underlay ── +# Forked from run-netns-discovery.sh's mesh CA/cert/roots/gossip setup: R is +# a seed root (always-admit, named in the CA-signed root set A and B both +# load); A and B carry NO `[peer]` block for each other, only the roots +# file — they discover each other via R's periodic gossip digest, exactly +# like run-netns-discovery.sh. +# +# This is NOT incidental — it is required by the mechanism under test. +# `Membership::member_cert_valid` (the periodic cert-liveness sweep's +# predicate, bin/yipd/src/membership.rs) checks a peer's cert via the LOCAL +# DIRECTORY record for that peer (`self.directory.get(node_id(pubkey))`), +# not any statically-configured value. A purely static `[peer]`-block config +# (no gossip) would never populate that directory at all, and the sweep +# would then treat EVERY established mesh peer as unknown/invalid +# (`None => false`) regardless of its cert's real validity — the wrong +# behavior for this test to exercise. Gossip-based discovery is what +# actually seeds each side's directory with the other's cert (ingested while +# still valid), which is what the sweep re-checks against wall-clock time on +# every cadence tick. +# +# ── the two enforcement mechanisms this test exercises together ── +# (a) rekey Init cert re-verify (`responder_cert_ok` on the rekey Init's +# OWN attached cert payload) — drops the session the moment either +# side sends a rekey Init carrying an expired cert. +# (b) periodic cert-liveness sweep (`member_cert_valid` against the +# directory) — drops any Established mesh peer whose directory-cached +# cert has expired, independent of any inbound message, throttled to +# once per `rekey_interval_ms`. +# Both are driven by the SAME `Membership::verify_cert` / free `verify_cert` +# path and therefore the SAME `CLOCK_SKEW_SECS` widening (see next section). +# This test does not (and cannot, from the outside) distinguish which one +# fires first — either is a correct #41 fix; the observable is the same. +# +# ── CRITICAL TIMING NOTE: CLOCK_SKEW_SECS, not just cert validity ── +# `bin/yipd/src/membership.rs` widens EVERY cert-validity check (both +# mechanisms above) by a hardcoded `CLOCK_SKEW_SECS = 300` (5 minutes) — +# a cert is not treated as expired until `now > cert.not_after + 300`, and +# this constant is NOT configurable via env (unlike `YIP_REKEY_INTERVAL_MS`). +# A test that only waits `cert_secs + rekey_interval + a small margin` after +# minting will observe NO drop and fail confusingly. This script's post- +# expiry wait is `CERT_A_SECS + CLOCK_SKEW_SECS(300) + REKEY_INTERVAL_S + +# MARGIN` — i.e. the test genuinely takes several minutes past establishment +# by design, not by accident. If `CLOCK_SKEW_SECS` in membership.rs ever +# changes, update `CLOCK_SKEW_SECS` below to match. +# +# Assertions (any failure is non-zero exit, [PASS]/[FAIL] markers): +# 1. discovery: A's config has no `[peer]` block / no knowledge of B's key +# (load-bearing — proves the directory is populated by gossip, which +# the sweep depends on, not static config). +# 2. establishment: a steady ping A->B succeeds while A's cert is valid. +# 3. revocation: once well past A's cert's expiry (skew-widened), a fresh +# ping A->B FAILS — B dropped the session. +# 4. no re-admission: a further generous ping window still FAILS — A's +# expired cert is refused by the re-admission gate on any subsequent +# handshake attempt, not just coincidentally not-yet-retried. +# BOTH drivers (reads YIP_USE_URING from the caller's env; `ip netns exec`, +# unlike `sudo`, does not clear the environment, so it flows through to the +# daemons unmodified). +set -euo pipefail + +YIPD="${1:?Usage: $0 }" +YIPCA="${2:?Usage: $0 }" +RDV="${3:?Usage: $0 }" + +# ── 0. root 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 convention) ── +if [ "$(id -u)" -ne 0 ]; then + echo "SKIP run-netns-cert-revocation: needs root (netns + TUN)" + exit 0 +fi +if ! command -v ping >/dev/null 2>&1; then + echo "SKIP run-netns-cert-revocation: required tool 'ping' not found" + exit 0 +fi + +TMPDIR_TEST="$(mktemp -d /tmp/yipd-netns-cert-revocation-test.XXXXXX)" + +BR="brRev0" + +NS_A="yipRevA" +NS_B="yipRevB" +NS_R="yipRevR" + +VETH_A_H="vRevA0"; VETH_A_N="vRevA1" +VETH_B_H="vRevB0"; VETH_B_N="vRevB1" +VETH_R_H="vRevR0"; VETH_R_N="vRevR1" + +IP_A="10.93.0.1" +IP_B="10.93.0.2" +IP_R="10.93.0.3" +VETH_PREFIX="24" +PORT="51820" +TUN_DEV="yip0" +NETWORK_ID="c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3" + +# `bin/yipd/src/membership.rs`'s hardcoded, non-configurable clock-skew +# widening -- see the header comment above. Keep in sync with that constant. +CLOCK_SKEW_SECS=300 +# A's cert validity window (seconds). Generous enough to comfortably cover +# netns/CA setup + daemon startup + TUN wait + gossip discovery warm-up +# (run-netns-discovery.sh documents up to a 60s budget for that warm-up) +# before we need it to still be valid for the "establish while valid" step. +CERT_A_SECS=60 +# Extra slack past cert_secs+skew+rekey_interval before we check for the +# drop: covers scheduling jitter in the sweep's throttle and the rekey +# cadence (a few missed 2s cycles is nothing against this margin). +DROP_MARGIN_SECS=20 +YIP_REKEY_INTERVAL_MS=2000 +REKEY_INTERVAL_SECS=$((YIP_REKEY_INTERVAL_MS / 1000)) + +PID_A="" +PID_B="" +PID_R="" + +cleanup() { + echo "[cleanup] killing daemons and removing namespaces/bridge" + [ -n "$PID_A" ] && kill "$PID_A" 2>/dev/null || true + [ -n "$PID_B" ] && kill "$PID_B" 2>/dev/null || true + [ -n "$PID_R" ] && kill "$PID_R" 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_R" ] && kill -9 "$PID_R" 2>/dev/null || true + ip netns del "$NS_A" 2>/dev/null || true + ip netns del "$NS_B" 2>/dev/null || true + ip netns del "$NS_R" 2>/dev/null || true + ip link del "$BR" 2>/dev/null || true + rm -rf "$TMPDIR_TEST" +} +trap cleanup EXIT + +# ── 1. offline CA + per-node keys/certs + signed root set ──────────────────── +echo "[setup] minting CA" +CA_OUT="$("$YIPCA" genkey)" +CA_PRIV="$(echo "$CA_OUT" | grep '^ca_private=' | cut -d= -f2)" +CA_PUB="$(echo "$CA_OUT" | grep '^ca_public=' | cut -d= -f2)" + +# ` `, one line per node. +gen_node() { + local gk sk + gk="$("$YIPD" --genkey)" + sk="$("$YIPCA" genkey)" + local priv pub signpriv signpub + priv="$(echo "$gk" | grep '^private=' | cut -d= -f2)" + pub="$(echo "$gk" | grep '^public=' | cut -d= -f2)" + signpriv="$(echo "$sk" | grep '^ca_private=' | cut -d= -f2)" + signpub="$(echo "$sk" | grep '^ca_public=' | cut -d= -f2)" + echo "$priv $pub $signpriv $signpub" +} + +echo "[setup] generating per-node data-plane + record-signing keypairs" +read -r PRIV_A PUB_A SIGNPRIV_A SIGNPUB_A <<<"$(gen_node)" +read -r PRIV_B PUB_B SIGNPRIV_B SIGNPUB_B <<<"$(gen_node)" +read -r PRIV_R PUB_R SIGNPRIV_R SIGNPUB_R <<<"$(gen_node)" + +ADDR_A="$("$YIPD" --addr "$PUB_A")" +ADDR_B="$("$YIPD" --addr "$PUB_B")" +ADDR_R="$("$YIPD" --addr "$PUB_R")" +echo "[setup] node_addr A=$ADDR_A B=$ADDR_B R=$ADDR_R" + +# R and B get ordinary long-lived certs (R is a root and exempt from +# cert-based revocation entirely; B must stay valid for the whole test). +CERT_R_FILE="$TMPDIR_TEST/certR.hex" +echo "$CA_PRIV" | "$YIPCA" sign-cert \ + --member "$PUB_R" --member-sign "$SIGNPUB_R" \ + --network "$NETWORK_ID" --days 30 > "$CERT_R_FILE" + +CERT_B_FILE="$TMPDIR_TEST/certB.hex" +echo "$CA_PRIV" | "$YIPCA" sign-cert \ + --member "$PUB_B" --member-sign "$SIGNPUB_B" \ + --network "$NETWORK_ID" --days 30 > "$CERT_B_FILE" + +# A's cert: short-validity (`--secs`), minted last so as little of its +# window as possible is spent before daemons even start. MINT_TIME anchors +# every deadline computed below. +CERT_A_FILE="$TMPDIR_TEST/certA.hex" +echo "$CA_PRIV" | "$YIPCA" sign-cert \ + --member "$PUB_A" --member-sign "$SIGNPUB_A" \ + --network "$NETWORK_ID" --secs "$CERT_A_SECS" > "$CERT_A_FILE" +MINT_TIME=$(date +%s) +EXPIRY_TIME=$((MINT_TIME + CERT_A_SECS)) +DROP_CHECK_TIME=$((EXPIRY_TIME + CLOCK_SKEW_SECS + REKEY_INTERVAL_SECS + DROP_MARGIN_SECS)) +echo "[setup] A's cert minted at $MINT_TIME, expires at $EXPIRY_TIME (+${CERT_A_SECS}s); drop expected observable by $DROP_CHECK_TIME (+$((DROP_CHECK_TIME - MINT_TIME))s from mint)" + +ROOTS_IN="$TMPDIR_TEST/roots.in" +echo "$PUB_R ${IP_R}:${PORT}" > "$ROOTS_IN" +ROOTS_FILE="$TMPDIR_TEST/roots.hex" +echo "$CA_PRIV" | "$YIPCA" sign-roots --roots "$ROOTS_IN" --version 1 > "$ROOTS_FILE" + +# ── 2. write mesh config files — NO [peer] blocks anywhere (discovery) ─────── +CFG_A="$TMPDIR_TEST/yipA.conf" +CFG_B="$TMPDIR_TEST/yipB.conf" +CFG_R="$TMPDIR_TEST/yipR.conf" + +write_mesh_cfg() { + local file="$1" priv="$2" pub="$3" ip="$4" certfile="$5" signpriv="$6" + cat > "$file" <"$LOG_R" 2>&1 & +PID_R=$! + +echo "[start] starting yipRevA (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 yipRevB" +ip netns exec "$NS_B" "$YIPD" "$CFG_B" >"$LOG_B" 2>&1 & +PID_B=$! + +# ── 5. wait for TUN devices to appear in all three namespaces ───────────────── +TUN_WAIT=20 +INTERVAL=0.25 + +echo "[wait] waiting for TUN devices to appear (up to ${TUN_WAIT}s)" +elapsed=0 +while true; do + A_UP=0; B_UP=0; R_UP=0 + ip netns exec "$NS_A" ip link show "$TUN_DEV" >/dev/null 2>&1 && A_UP=1 || true + ip netns exec "$NS_B" ip link show "$TUN_DEV" >/dev/null 2>&1 && B_UP=1 || true + ip netns exec "$NS_R" ip link show "$TUN_DEV" >/dev/null 2>&1 && R_UP=1 || true + + if [ "$A_UP" -eq 1 ] && [ "$B_UP" -eq 1 ] && [ "$R_UP" -eq 1 ]; then + echo "[wait] all three TUN devices are up" + break + fi + + for pid_var_name in PID_A:yipRevA PID_B:yipRevB PID_R:yipRevR; do + pid_var="${pid_var_name%%:*}" + node_name="${pid_var_name##*:}" + pid="${!pid_var}" + if ! kill -0 "$pid" 2>/dev/null; then + echo "[error] $node_name daemon died unexpectedly" + dump_logs + exit 1 + fi + done + + elapsed=$(awk "BEGIN {print $elapsed + $INTERVAL}") + if awk "BEGIN {exit ($elapsed >= $TUN_WAIT) ? 0 : 1}"; then + echo "[error] timed out waiting for TUN devices" + dump_logs + exit 1 + fi + sleep "$INTERVAL" +done + +# ── 6. assign each TUN its own node_addr/128 + the mesh-prefix route ───────── +echo "[setup] assigning node_addr/128 + fd00::/8 route on each TUN" +assign_mesh() { + local ns="$1" addr="$2" + ip netns exec "$ns" ip -6 addr add "${addr}/128" dev "$TUN_DEV" 2>/dev/null || true + ip netns exec "$ns" ip -6 route add fd00::/8 dev "$TUN_DEV" 2>/dev/null || true + ip netns exec "$ns" ip link show "$TUN_DEV" | grep -q "UP" || \ + ip netns exec "$ns" ip link set "$TUN_DEV" up +} +assign_mesh "$NS_A" "$ADDR_A" +assign_mesh "$NS_B" "$ADDR_B" +assign_mesh "$NS_R" "$ADDR_R" + +# ── 7. load-bearing: A's config has no static knowledge of B whatsoever ────── +echo "[check] asserting A's config has no [peer] block and no knowledge of B's key" +if grep -q '\[peer\]' "$CFG_A"; then + echo "[FAIL] A's config unexpectedly contains a [peer] block — this test requires the gossip-populated directory the sweep depends on" + cat "$CFG_A" + exit 1 +fi +if grep -qi "$PUB_B" "$CFG_A"; then + echo "[FAIL] A's config unexpectedly contains B's public key — this test requires pure discovery" + cat "$CFG_A" + exit 1 +fi +echo "[PASS] A's config names neither a [peer] block nor B's public key" + +# ── 8. establish: a steady ping A->B succeeds WHILE A's cert is still valid ── +NOW=$(date +%s) +REMAINING=$((EXPIRY_TIME - NOW)) +echo "[check] ${REMAINING}s remain on A's cert before it expires — establishing now" +if [ "$REMAINING" -lt 10 ]; then + echo "[FAIL] setup + discovery warm-up ate too much of A's cert validity window (only ${REMAINING}s left) — raise CERT_A_SECS" + dump_logs + exit 1 +fi +echo "[test] pinging ${ADDR_B} from yipRevA (expect discovery+handshake, then success, while A's cert is valid)" +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) — could not establish A<->B before cert expiry" + dump_logs + exit 1 +fi +echo "[PASS] ping A->B succeeded: A<->B established while A's cert is valid" + +NOW=$(date +%s) +if [ "$NOW" -gt "$EXPIRY_TIME" ]; then + echo "[FAIL] A's cert already expired (now=$NOW > expiry=$EXPIRY_TIME) by the time establishment finished — raise CERT_A_SECS" + dump_logs + exit 1 +fi + +# ── 9. wait past expiry + CLOCK_SKEW_SECS + rekey_interval + margin ────────── +NOW=$(date +%s) +WAIT_SECS=$((DROP_CHECK_TIME - NOW)) +if [ "$WAIT_SECS" -gt 0 ]; then + echo "[wait] sleeping ${WAIT_SECS}s for A's cert to expire past the ${CLOCK_SKEW_SECS}s clock-skew grace + rekey cadence" + sleep "$WAIT_SECS" +fi + +for pid_var_name in PID_A:yipRevA PID_B:yipRevB PID_R:yipRevR; do + pid_var="${pid_var_name%%:*}" + node_name="${pid_var_name##*:}" + pid="${!pid_var}" + if ! kill -0 "$pid" 2>/dev/null; then + echo "[error] $node_name daemon died during the wait" + dump_logs + exit 1 + fi +done + +# ── 10. revocation: ping A->B must now FAIL — B dropped the session ───────── +echo "[test] pinging ${ADDR_B} from yipRevA again (expect FAIL: B has dropped A's revoked session)" +set +e +ip netns exec "$NS_A" ping -6 -c 10 -W 2 "$ADDR_B" +PING_STATUS=$? +set -e +if [ "$PING_STATUS" -eq 0 ]; then + echo "[FAIL] ping A->B unexpectedly SUCCEEDED after A's cert expired — B did not drop the revoked session" + dump_logs + exit 1 +fi +echo "[PASS] ping A->B failed (exit $PING_STATUS): B dropped A's session after cert expiry" + +# ── 11. no re-admission: a further generous window must STILL fail ────────── +# A keeps thinking it's Established (nothing tells it otherwise) and will +# keep re-attempting via its own rekey schedule with the SAME expired cert +# attached; B's re-admission gate must keep refusing every attempt, not just +# have not-yet-retried. This is the durable, black-box observable for "A +# cannot re-establish" — whether B's refusal is via the rekey re-verify path +# or the cold-start re-admission gate is an internal detail this test cannot +# see (no stderr markers exist for either), so it asserts the sustained +# outcome instead of the mechanism. +echo "[test] pinging ${ADDR_B} from yipRevA once more (expect FAIL: re-admission gate keeps refusing the expired cert)" +set +e +ip netns exec "$NS_A" ping -6 -c 15 -W 2 "$ADDR_B" +PING_STATUS=$? +set -e +if [ "$PING_STATUS" -eq 0 ]; then + echo "[FAIL] ping A->B unexpectedly SUCCEEDED on retry — A re-established with a revoked cert!" + dump_logs + exit 1 +fi +echo "[PASS] ping A->B still failed (exit $PING_STATUS): A cannot re-establish with its expired cert" + +echo "[PASS] run-netns-cert-revocation: A's session was dropped within a bounded window of cert expiry, and re-admission stays refused" diff --git a/bin/yipd/tests/run-netns-pathswitch-rehandshake.sh b/bin/yipd/tests/run-netns-pathswitch-rehandshake.sh new file mode 100755 index 0000000..87ae12e --- /dev/null +++ b/bin/yipd/tests/run-netns-pathswitch-rehandshake.sh @@ -0,0 +1,423 @@ +#!/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. +# +# Usage: run-netns-pathswitch-rehandshake.sh +# +# ── which #36 variant this implements ── +# 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). +# +# Deterministically dropping "only B->A's first resp, through exactly the +# punch window" is impractical to construct in netns (it requires +# millisecond-precision, one-shot loss on a specific reply, not a topology +# property). This script instead implements the brief's documented +# acceptable equivalent: fork run-netns-relay.sh's RELAY-FORCED topology +# (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. +# +# ── proving "the SAME 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 +# 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. +# +# ── 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 +# 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. +# 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. +set -euo pipefail + +YIPD="${1:?Usage: $0 }" +RDV="${2:?Usage: $0 }" +WITNESS_BIN="$(dirname "$YIPD")/examples/rekey_epoch_witness" + +# ── 0. root + tool preflight (invoked directly by CI, not through the +# tunnel_netns.rs Rust harness, so it does its own SKIP-gating per the +# run-netns-rekey.sh / run-netns-rekey-relay.sh convention) ── +if [ "$(id -u)" -ne 0 ]; then + echo "SKIP run-netns-pathswitch-rehandshake: needs root (netns + TUN + tcpdump)" + exit 0 +fi +for tool in tcpdump ping; do + if ! command -v "$tool" >/dev/null 2>&1; then + echo "SKIP run-netns-pathswitch-rehandshake: required tool '$tool' not found" + exit 0 + fi +done +if [ ! -x "$WITNESS_BIN" ]; then + echo "SKIP run-netns-pathswitch-rehandshake: rekey_epoch_witness not built at $WITNESS_BIN" + echo " build it with: cargo build --release -p yipd --example rekey_epoch_witness" + exit 0 +fi + +TMPDIR_TEST="$(mktemp -d /tmp/yipd-netns-pathswitch-test.XXXXXX)" + +NS_A="yipPswA" +NS_B="yipPswB" +NS_R="yipPswR" + +VETH_A_N="vPswA1"; VETH_A_R="vPswA0" # A<->R pair: A-side, R-side +VETH_B_N="vPswB1"; VETH_B_R="vPswB0" # B<->R pair: B-side, R-side + +IP_A="10.74.0.2" +IP_R_A="10.74.0.1" # R's address on A's subnet +IP_B="10.75.0.2" +IP_R_B="10.75.0.1" # R's address on B's subnet +PREFIX="24" + +PORT_A="51820" +PORT_B="51820" +RDV_PORT="51821" +TUN_DEV="yip0" + +PID_A="" +PID_B="" +PID_RDV="" +TCPDUMP_PID="" + +cleanup() { + echo "[cleanup] killing daemons/tcpdump, removing namespaces" + [ -n "$PID_A" ] && kill "$PID_A" 2>/dev/null || true + [ -n "$PID_B" ] && kill "$PID_B" 2>/dev/null || true + [ -n "$PID_RDV" ] && kill "$PID_RDV" 2>/dev/null || true + [ -n "$TCPDUMP_PID" ] && kill "$TCPDUMP_PID" 2>/dev/null || true + sleep 0.2 + [ -n "$PID_A" ] && kill -9 "$PID_A" 2>/dev/null || true + [ -n "$PID_B" ] && kill -9 "$PID_B" 2>/dev/null || true + [ -n "$PID_RDV" ] && kill -9 "$PID_RDV" 2>/dev/null || true + [ -n "$TCPDUMP_PID" ] && kill -9 "$TCPDUMP_PID" 2>/dev/null || true + ip netns del "$NS_A" 2>/dev/null || true + ip netns del "$NS_B" 2>/dev/null || true + ip netns del "$NS_R" 2>/dev/null || true + rm -rf "$TMPDIR_TEST" +} +trap cleanup EXIT + +# ── 1. generate keypairs, 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 (see the header comment above). 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" <R" +ip link add "$VETH_A_R" type veth peer name "$VETH_A_N" +ip link set "$VETH_A_N" netns "$NS_A" +ip link set "$VETH_A_R" netns "$NS_R" +ip netns exec "$NS_A" ip addr add "${IP_A}/${PREFIX}" dev "$VETH_A_N" +ip netns exec "$NS_A" ip link set "$VETH_A_N" up +ip netns exec "$NS_A" ip link set lo up +ip netns exec "$NS_R" ip addr add "${IP_R_A}/${PREFIX}" dev "$VETH_A_R" +ip netns exec "$NS_R" ip link set "$VETH_A_R" up + +echo "[setup] wiring B<->R" +ip link add "$VETH_B_R" type veth peer name "$VETH_B_N" +ip link set "$VETH_B_N" netns "$NS_B" +ip link set "$VETH_B_R" netns "$NS_R" +ip netns exec "$NS_B" ip addr add "${IP_B}/${PREFIX}" dev "$VETH_B_N" +ip netns exec "$NS_B" ip link set "$VETH_B_N" up +ip netns exec "$NS_B" ip link set lo up +ip netns exec "$NS_R" ip addr add "${IP_R_B}/${PREFIX}" dev "$VETH_B_R" +ip netns exec "$NS_R" ip link set "$VETH_B_R" up +ip netns exec "$NS_R" ip link set lo up + +# A's and B's only route beyond their own /24 is via R -- and R does NOT +# forward, so a cross-subnet packet dies silently at R (see +# run-netns-relay.sh's header comment for the full reasoning: this keeps the +# punch attempt a normal, silently-dropped packet rather than a synchronous +# socket error that would kill yipd's event loop). +ip netns exec "$NS_A" ip route add default via "$IP_R_A" dev "$VETH_A_N" +ip netns exec "$NS_B" ip route add default via "$IP_R_B" dev "$VETH_B_N" + +# Explicitly disable IPv4 forwarding in R (belt-and-suspenders: a fresh netns +# already defaults to this, but the isolation invariant this whole test +# rests on deserves to be asserted, not assumed). +ip netns exec "$NS_R" sysctl -q -w net.ipv4.ip_forward=0 +ip netns exec "$NS_R" sysctl -q -w net.ipv4.conf.all.forwarding=0 + +# ── 4. start yip-rendezvous in R, bound on both subnets ─────────────────────── +LOG_RDV="$TMPDIR_TEST/rdv.log" +echo "[start] starting yip-rendezvous in R on 0.0.0.0:${RDV_PORT}" +ip netns exec "$NS_R" "$RDV" "0.0.0.0:${RDV_PORT}" >"$LOG_RDV" 2>&1 & +PID_RDV=$! +sleep 0.3 + +LOG_A="$TMPDIR_TEST/yipA.log" +LOG_B="$TMPDIR_TEST/yipB.log" + +dump_logs() { + echo "=== rendezvous log ===" + cat "$LOG_RDV" || true + echo "=== yipPswA log ===" + cat "$LOG_A" || true + echo "=== yipPswB log ===" + cat "$LOG_B" || true +} + +# ── 5. start the capture BEFORE either daemon starts ────────────────────────── +# Must be attached before A's very first punch attempt (t=0, well before +# ~PUNCH_MS) or the proof below is incomplete. `src host $IP_A` restricts the +# capture to A's own outbound datagrams -- see the header comment for why +# (excludes B's unrelated loser-glare traffic, which would otherwise be +# double-counted as a spurious second INIT ephemeral). +PCAP="$TMPDIR_TEST/pathswitch.pcap" +echo "[capture] starting tcpdump on $VETH_A_N (udp, src host $IP_A) -> $PCAP" +ip netns exec "$NS_A" tcpdump -i "$VETH_A_N" -w "$PCAP" -U udp and src host "$IP_A" \ + >"$TMPDIR_TEST/tcpdump.log" 2>&1 & +TCPDUMP_PID=$! +sleep 0.3 + +# ── 6. start yipd in A and B ─────────────────────────────────────────────────── +echo "[start] starting yipPswA" +ip netns exec "$NS_A" "$YIPD" "$CFG_A" >"$LOG_A" 2>&1 & +PID_A=$! + +echo "[start] starting yipPswB" +ip netns exec "$NS_B" "$YIPD" "$CFG_B" >"$LOG_B" 2>&1 & +PID_B=$! + +# ── 7. wait for TUN devices to appear in A and B ────────────────────────────── +TUN_WAIT=20 +INTERVAL=0.25 + +echo "[wait] waiting for TUN devices to appear (up to ${TUN_WAIT}s)" +elapsed=0 +while true; do + A_UP=0; B_UP=0 + ip netns exec "$NS_A" ip link show "$TUN_DEV" >/dev/null 2>&1 && A_UP=1 || true + ip netns exec "$NS_B" ip link show "$TUN_DEV" >/dev/null 2>&1 && B_UP=1 || true + + if [ "$A_UP" -eq 1 ] && [ "$B_UP" -eq 1 ]; then + echo "[wait] both TUN devices are up" + break + fi + + if ! kill -0 "$PID_A" 2>/dev/null; then + echo "[error] yipPswA daemon died unexpectedly"; dump_logs; exit 1 + fi + if ! kill -0 "$PID_B" 2>/dev/null; then + echo "[error] yipPswB daemon died unexpectedly"; dump_logs; exit 1 + fi + if ! kill -0 "$PID_RDV" 2>/dev/null; then + echo "[error] yip-rendezvous died unexpectedly"; dump_logs; exit 1 + fi + + elapsed=$(awk "BEGIN {print $elapsed + $INTERVAL}") + if awk "BEGIN {exit ($elapsed >= $TUN_WAIT) ? 0 : 1}"; then + echo "[error] timed out waiting for TUN devices"; dump_logs; exit 1 + fi + sleep "$INTERVAL" +done + +# ── 8. assign each TUN its node_addr/128 + the mesh-prefix route ───────────── +echo "[setup] assigning node_addr/128 + fd00::/8 route on each TUN" +assign_mesh() { + local ns="$1" addr="$2" + ip netns exec "$ns" ip -6 addr add "${addr}/128" dev "$TUN_DEV" 2>/dev/null || true + ip netns exec "$ns" ip -6 route add fd00::/8 dev "$TUN_DEV" 2>/dev/null || true + ip netns exec "$ns" ip link show "$TUN_DEV" | grep -q "UP" || \ + ip netns exec "$ns" ip link set "$TUN_DEV" up +} +assign_mesh "$NS_A" "$ADDR_A" +assign_mesh "$NS_B" "$ADDR_B" + +# ── 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. +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" + dump_logs + exit 1 +fi +echo "[PASS] ping A->B converged over the relay" + +# ── 10. stop the capture ────────────────────────────────────────────────────── +sleep 0.3 +kill "$TCPDUMP_PID" 2>/dev/null || true +wait "$TCPDUMP_PID" 2>/dev/null || true +TCPDUMP_PID="" + +if ! kill -0 "$PID_A" 2>/dev/null; then + echo "[error] yipPswA daemon died during the test"; dump_logs; exit 1 +fi +if ! kill -0 "$PID_B" 2>/dev/null; then + echo "[error] yipPswB daemon died during the test"; dump_logs; exit 1 +fi + +# ── assertion: relay actually carried it — relay-forwarded=, N>0 ───────── +# One more sweep interval so R's final line reflects the traffic that just +# flowed (same convention as run-netns-relay.sh's money test). +sleep 5.5 +FINAL_COUNT="$(grep -oE 'relay-forwarded=[0-9]+' "$LOG_RDV" | tail -1 | cut -d= -f2)" +echo "[check] server's final relay-forwarded count: ${FINAL_COUNT:-}" +if [ -z "${FINAL_COUNT:-}" ] || [ "$FINAL_COUNT" -eq 0 ]; then + echo "[FAIL] relay-forwarded count is 0 (or missing) — traffic did not go through the relay" + dump_logs + exit 1 +fi +echo "[PASS] relay-forwarded=${FINAL_COUNT} (>0): the blind relay carried the traffic" + +# ── assertion: ephemeral preservation — the #36 headline proof ────────────── +if [ ! -s "$PCAP" ]; then + echo "[FAIL] ephemeral preservation: capture is empty or missing at $PCAP" + dump_logs + exit 1 +fi + +WITNESS_LOG="$TMPDIR_TEST/witness.log" +YIP_WITNESS_UNWRAP_RELAY=1 "$WITNESS_BIN" "$PCAP" >"$WITNESS_LOG" +cat "$WITNESS_LOG" + +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" + 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. +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" + 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)" +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" + dump_logs + exit 1 +fi + +echo "[PASS] run-netns-pathswitch-rehandshake: A converged over the relay, carrying the SAME ephemeral throughout" From 22f75bcddae8477c1140c7ba0f6326cb424fa97f Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Wed, 22 Jul 2026 21:12:45 -0400 Subject: [PATCH 13/14] docs(hardening): fix stale responder_cert_ok doc (used for initiator certs too; roots exempted by caller) Final-review Minor: the doc had an orphaned handle_handshake_init block + a 'responder's msg2' framing that this branch's new #41 call sites (initiator msg1 cert, is_root-exempt) made inaccurate. --- bin/yipd/src/peer_manager.rs | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/bin/yipd/src/peer_manager.rs b/bin/yipd/src/peer_manager.rs index 548974a..dcb02b0 100644 --- a/bin/yipd/src/peer_manager.rs +++ b/bin/yipd/src/peer_manager.rs @@ -1569,15 +1569,17 @@ impl PeerManager { // ── handshake admission ─────────────────────────────────────────────── - /// Handle an incoming `[HandshakeInit]`: run the responder step, admit - /// only if the recovered static key matches a *configured* peer, and on - /// admission transition that peer to `Established` (learning its - /// endpoint from `src`) and drain any buffered `pending_tun`. - /// Whether a responder's msg2 cert payload admits the peer whose static key - /// is `peer_pub`. With membership disabled the payload is ignored (returns - /// `true` — byte-identical to 2a/2b). With membership enabled the payload - /// must decode to a `Cert` that `verify_cert`s against `peer_pub` at the - /// current wall clock — mutual membership proof. + /// Whether a handshake `payload` carries a cert that admits the peer whose + /// static key is `peer_pub`. With membership disabled the payload is + /// ignored (returns `true` — byte-identical to 2a/2b). With membership + /// enabled the payload must decode to a `Cert` that `verify_cert`s against + /// `peer_pub` at the current wall clock. + /// + /// Named for its original use — the initiator checking the responder's + /// msg2 cert — but the check is symmetric and this branch also invokes it + /// against the *initiator's* msg1 cert: the #41 rekey re-verify and + /// re-admission gate (`is_root`-exempt) pass the initiator's cert + + /// `remote_static`. Roots are exempted by the caller (`is_root`), not here. fn responder_cert_ok(&self, payload: &[u8], peer_pub: [u8; 32]) -> bool { match self.membership.as_ref() { None => true, From 30ad3343d5a55212bb3e058a663feecb3c33f9d8 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Wed, 22 Jul 2026 22:56:01 -0400 Subject: [PATCH 14/14] fix(hardening.41): make the cert-revocation netns test fast + robust (CI fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI netns-tunnel-test failed: the #41 test (a) waited out the daemon's 300s CLOCK_SKEW grace (>5min) and (b) raced mesh discovery with a single establish ping that didn't converge on the slower CI runner. - membership.rs: add YIP_CERT_SKEW_SECS env override (default 300, like YIP_REKEY_INTERVAL_MS) so a cert can expire in seconds for the test; prod leaves it unset. Cached via OnceLock; 236 unit tests green at the default. - run-netns-cert-revocation.sh: set YIP_CERT_SKEW_SECS=3 (whole run ~90s vs ~382s) and POLL the establish ping until discovery converges within the cert window (fixes the single-ping race). - integration.yml: run #41 POLL-ONLY, matching its fork source run-netns- discovery.sh — gossip discovery convergence is flaky under io_uring and #41's cert-revocation logic is driver-agnostic. #36 stays both-driver. Verified locally: #41 poll passes ~85s; #36 both drivers still pass. --- .github/workflows/integration.yml | 31 ++++------ bin/yipd/src/membership.rs | 25 ++++++-- bin/yipd/tests/run-netns-cert-revocation.sh | 66 ++++++++++++++------- 3 files changed, 76 insertions(+), 46 deletions(-) diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 5730757..3ef16fb 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -301,11 +301,17 @@ jobs: # re-establish. Forks run-netns-discovery.sh's gossip-based mesh # topology (required: the periodic cert-liveness sweep reads a # peer's cert from the gossip-populated directory, not static - # config). NOTE: this test's own wait is dominated by - # membership.rs's hardcoded CLOCK_SKEW_SECS=300 grace window (see - # the script's header comment) -- it genuinely takes several minutes - # by design. Uses the release yipd and debug yip-ca/yip-rendezvous - # already built above in this job. + # config). The script sets YIP_CERT_SKEW_SECS low so the cert + # expires in seconds rather than waiting out membership.rs's 300s + # production grace, so the whole run is ~90s. + # + # POLL DRIVER ONLY: like its fork source run-netns-discovery.sh + # (also poll-only), gossip discovery convergence is flaky under the + # io_uring driver -- and #41's cert-revocation logic (the sweep / + # re-admission gate) is driver-agnostic, exercised identically by + # the poll run. The #36 money test above still covers both drivers. + # Uses the release yipd and debug yip-ca/yip-rendezvous already + # built above in this job. run: | sudo bash bin/yipd/tests/run-netns-cert-revocation.sh \ "$(pwd)/target/release/yipd" \ @@ -320,21 +326,6 @@ jobs: exit 1 fi - - name: Run the hardening.41 cert-revocation money test under sudo (uring driver) - run: | - sudo -E env YIP_USE_URING=1 bash bin/yipd/tests/run-netns-cert-revocation.sh \ - "$(pwd)/target/release/yipd" \ - "$(pwd)/target/debug/yip-ca" \ - "$(pwd)/target/debug/yip-rendezvous" | tee /tmp/cert-revocation-uring.log - if grep -q "^SKIP run-netns-cert-revocation" /tmp/cert-revocation-uring.log; then - echo "::error::hardening.41 cert-revocation money test (uring) skipped — expected root in this job" - exit 1 - fi - if grep -q "\[FAIL\]" /tmp/cert-revocation-uring.log; then - echo "::error::hardening.41 cert-revocation money test (uring) failed — a revoked peer's session was not dropped, or it was allowed to re-establish" - exit 1 - fi - dpi-undetectability: # The anti-DPI undetectability merge gate (3a Task 7): fails the build if # a wire/obfuscation change reintroduces a DPI-recognizable fingerprint. diff --git a/bin/yipd/src/membership.rs b/bin/yipd/src/membership.rs index 8ac1873..febe5fc 100644 --- a/bin/yipd/src/membership.rs +++ b/bin/yipd/src/membership.rs @@ -26,9 +26,26 @@ use yip_membership::{node_addr, node_id, Cert, GossipMsg, NodeId, Record, RootSe /// Cert validity is widened by this many WALL-CLOCK seconds on both ends to /// tolerate clock skew between nodes (matches the `yip-ca`/`yip-membership` /// convention of an explicit, small, documented skew rather than an -/// unbounded one). +/// unbounded one). Production default; see [`clock_skew_secs`] for the +/// test-only env override. const CLOCK_SKEW_SECS: u64 = 300; +/// The cert-validity clock-skew widening (seconds), read once from +/// `YIP_CERT_SKEW_SECS` (default [`CLOCK_SKEW_SECS`] = 300). Overridable only +/// so netns tests can make a cert expire in seconds instead of waiting out the +/// 5-minute production grace; production leaves the var unset. Cached, so the +/// value is stable for the process's lifetime (a mid-run change cannot shrink +/// an already-honored validity window). +fn clock_skew_secs() -> u64 { + static SKEW: std::sync::OnceLock = std::sync::OnceLock::new(); + *SKEW.get_or_init(|| { + std::env::var("YIP_CERT_SKEW_SECS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(CLOCK_SKEW_SECS) + }) +} + /// Minimum spacing, in MONOTONIC milliseconds, between digests emitted by /// `tick_digest` — pure gossip-chattiness control, unrelated to cert clocks. const GOSSIP_INTERVAL_MS: u64 = 5_000; @@ -167,7 +184,7 @@ impl Membership { &self.network_id, static_key, now, - CLOCK_SKEW_SECS, + clock_skew_secs(), ) .is_ok() } @@ -200,7 +217,7 @@ impl Membership { let mut changed = self.sweep_expired(now); if rec - .verify(&self.ca_pubkeys, &self.network_id, now, CLOCK_SKEW_SECS) + .verify(&self.ca_pubkeys, &self.network_id, now, clock_skew_secs()) .is_ok() { changed |= self.insert_if_newer(rec); @@ -357,7 +374,7 @@ impl Membership { &self.network_id, &rec.cert.member_pubkey, now, - CLOCK_SKEW_SECS, + clock_skew_secs(), ) .is_err() }) diff --git a/bin/yipd/tests/run-netns-cert-revocation.sh b/bin/yipd/tests/run-netns-cert-revocation.sh index 5cefaff..5babf96 100755 --- a/bin/yipd/tests/run-netns-cert-revocation.sh +++ b/bin/yipd/tests/run-netns-cert-revocation.sh @@ -39,17 +39,16 @@ # This test does not (and cannot, from the outside) distinguish which one # fires first — either is a correct #41 fix; the observable is the same. # -# ── CRITICAL TIMING NOTE: CLOCK_SKEW_SECS, not just cert validity ── +# ── TIMING NOTE: cert-validity clock-skew widening ── # `bin/yipd/src/membership.rs` widens EVERY cert-validity check (both -# mechanisms above) by a hardcoded `CLOCK_SKEW_SECS = 300` (5 minutes) — -# a cert is not treated as expired until `now > cert.not_after + 300`, and -# this constant is NOT configurable via env (unlike `YIP_REKEY_INTERVAL_MS`). -# A test that only waits `cert_secs + rekey_interval + a small margin` after -# minting will observe NO drop and fail confusingly. This script's post- -# expiry wait is `CERT_A_SECS + CLOCK_SKEW_SECS(300) + REKEY_INTERVAL_S + -# MARGIN` — i.e. the test genuinely takes several minutes past establishment -# by design, not by accident. If `CLOCK_SKEW_SECS` in membership.rs ever -# changes, update `CLOCK_SKEW_SECS` below to match. +# mechanisms above) by a clock-skew grace — production `CLOCK_SKEW_SECS = 300` +# (5 minutes), so a cert is not treated as expired until +# `now > cert.not_after + 300`. Waiting that out would make this test take +# >5 minutes, so it sets `YIP_CERT_SKEW_SECS` (an env override the daemon +# reads, like `YIP_REKEY_INTERVAL_MS`) to a few seconds. The observable-drop +# time is therefore `CERT_A_SECS + YIP_CERT_SKEW_SECS + REKEY_INTERVAL_S + +# MARGIN` past minting (~90s total). The `CLOCK_SKEW_SECS` value below MUST +# equal the exported `YIP_CERT_SKEW_SECS` so the wait math matches the daemon. # # Assertions (any failure is non-zero exit, [PASS]/[FAIL] markers): # 1. discovery: A's config has no `[peer]` block / no knowledge of B's key @@ -61,9 +60,12 @@ # 4. no re-admission: a further generous ping window still FAILS — A's # expired cert is refused by the re-admission gate on any subsequent # handshake attempt, not just coincidentally not-yet-retried. -# BOTH drivers (reads YIP_USE_URING from the caller's env; `ip netns exec`, -# unlike `sudo`, does not clear the environment, so it flows through to the -# daemons unmodified). +# The script supports both drivers (reads YIP_USE_URING from the caller's env; +# `ip netns exec`, unlike `sudo`, does not clear the environment, so it flows +# through to the daemons unmodified). CI runs it POLL-ONLY, matching its fork +# source run-netns-discovery.sh: gossip discovery convergence is flaky under +# io_uring, and #41's cert-revocation logic (sweep / re-admission gate) is +# driver-agnostic — the poll run exercises it identically. set -euo pipefail YIPD="${1:?Usage: $0 }" @@ -102,9 +104,11 @@ PORT="51820" TUN_DEV="yip0" NETWORK_ID="c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3" -# `bin/yipd/src/membership.rs`'s hardcoded, non-configurable clock-skew -# widening -- see the header comment above. Keep in sync with that constant. -CLOCK_SKEW_SECS=300 +# the daemon's cert-validity clock-skew widening. Production defaults to 300s +# (5 min), which would force this test to wait >5 min for a cert to expire; +# `YIP_CERT_SKEW_SECS` (exported to the daemons below) overrides it to this +# small value so the expiry wait is bounded to seconds. Keep the two in sync. +CLOCK_SKEW_SECS=3 # A's cert validity window (seconds). Generous enough to comfortably cover # netns/CA setup + daemon startup + TUN wait + gossip discovery warm-up # (run-netns-discovery.sh documents up to a 60s budget for that warm-up) @@ -116,6 +120,9 @@ CERT_A_SECS=60 DROP_MARGIN_SECS=20 YIP_REKEY_INTERVAL_MS=2000 REKEY_INTERVAL_SECS=$((YIP_REKEY_INTERVAL_MS / 1000)) +# Override the daemon's cert-validity skew (default 300s) so the cert expires +# in seconds, not minutes. Exported to every daemon below. +YIP_CERT_SKEW_SECS=$CLOCK_SKEW_SECS PID_A="" PID_B="" @@ -247,6 +254,7 @@ setup_leg "$NS_R" "$VETH_R_H" "$VETH_R_N" "$IP_R" # ── 4. start daemons: seed root first, then A and B, at a fast rekey cadence ── export YIP_REKEY_INTERVAL_MS +export YIP_CERT_SKEW_SECS LOG_A="$TMPDIR_TEST/yipA.log" LOG_B="$TMPDIR_TEST/yipB.log" @@ -347,12 +355,26 @@ if [ "$REMAINING" -lt 10 ]; then exit 1 fi echo "[test] pinging ${ADDR_B} from yipRevA (expect discovery+handshake, then success, while A's cert is valid)" -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) — could not establish A<->B before cert expiry" +# Poll rather than one-shot: mesh discovery (register -> gossip -> handshake) +# takes a variable number of GOSSIP_INTERVAL_MS (~5s) rounds and is slower on a +# loaded CI runner. Retry until a short ping burst converges, up to a deadline +# 5s before the cert expires (so the "still valid" invariant holds when it +# succeeds). This is the fix for the CI failure where a single 20-ping window +# raced discovery and lost. +ESTABLISHED=0 +while :; do + NOW=$(date +%s) + if [ "$NOW" -ge "$((EXPIRY_TIME - 5))" ]; then + break + fi + if ip netns exec "$NS_A" ping -6 -c 3 -W 2 "$ADDR_B" >/dev/null 2>&1; then + ESTABLISHED=1 + break + fi + sleep 1 +done +if [ "$ESTABLISHED" -ne 1 ]; then + echo "[FAIL] ping A->B did not converge — could not establish A<->B before cert expiry" dump_logs exit 1 fi