From 9c2ca3e63cb12b04c728e44d33aa22e8b6e465dc Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Thu, 23 Jul 2026 15:25:12 -0400 Subject: [PATCH 01/15] =?UTF-8?q?docs(auth-reachability):=20design=20spec?= =?UTF-8?q?=20=E2=80=94=20signed=20rendezvous=20registration=20(#37)=20+?= =?UTF-8?q?=20endpoint=20roaming=20(M2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...07-23-authenticated-reachability-design.md | 214 ++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-23-authenticated-reachability-design.md diff --git a/docs/superpowers/specs/2026-07-23-authenticated-reachability-design.md b/docs/superpowers/specs/2026-07-23-authenticated-reachability-design.md new file mode 100644 index 0000000..bffa947 --- /dev/null +++ b/docs/superpowers/specs/2026-07-23-authenticated-reachability-design.md @@ -0,0 +1,214 @@ +# Authenticated Reachability — design spec (#37 + M2) + +**Status:** approved (2026-07-23). Builds on #34 (handshake anti-replay + authenticated +endpoint learning, PR #99). One spec, two implementation plans / PRs across different +subsystems, both stacked on the #34 branch. + +## Problem + +Two gaps let an adversary or an ordinary network event deny a peer's reachability, both +rooted in trusting *unauthenticated* source/registration information: + +1. **#37 — unauthenticated rendezvous registration (overwrite DoS).** `Register { node, + counter }` carries no signature. `node` is a public hash of the member's key and + `counter` is guessable, so anyone can send `Register { node: victim, counter: huge }` + from their own address and overwrite the victim's entry (`server.rs:register_if_fresh` + accepts any strictly-greater counter). The victim becomes unreachable / misdirected. + +2. **M2 — no authenticated endpoint roaming.** After #34, a peer's `endpoint` is immovable + while Established: it is written only on a fresh cold-start accept (`peer_manager.rs` + ~1810), never on the rekey path. So a legitimate peer that changes address (NAT rebind, + mobility) is never followed. Because `endpoint` is also the demux key that selects a + peer's `session_obf_key` in `deobf_ingress` (`peer_manager.rs` ~2458), under the + obfuscation path B cannot even deobfuscate the roamed peer's data — an ingress + black-hole that persists until a full re-handshake, up to a rekey interval (~120 s). + This is the M2 follow-up filed in #34's final review, understated there. + +Both are "don't trust an unauthenticated source." The unifying fix is to require a +cryptographic proof of identity before acting on a claimed address. + +## Non-goals + +- No change to #34's Init anti-replay or the cold-start endpoint gate — that path stays + exactly as shipped. +- No new crypto primitives. #37 reuses `membership::Record` / `Record::verify`; M2 reuses + the existing AEAD replay window. +- No attempt to make the rendezvous server trusted for *address* integrity — address + correctness remains backstopped end-to-end by the 2b invariant (egress commits only on a + completed Noise handshake over the learned address). + +--- + +## Part A — #37: signed rendezvous registration (control-plane) + +### Key insight — the registration IS a `Record` + +`membership::Record { node_id, cert, endpoints, seq, sig }` is already a member-signed, +cert-carrying, `seq`-monotonic directory entry, and `Record::verify(ca_pubkeys, +network_id, now, skew)` already performs the entire chain we need: + +1. `verify_cert` — the embedded cert is CA-signed against the roots, matches `network_id`, + and is within its validity window; +2. the record signature verifies over `record_signing_body` under the cert's + record-signing key; +3. `node_id == node_id(cert.member_pubkey)` — the record is not claiming another identity + (**squatting closed**). + +So the rendezvous registration and the gossip directory entry become the same signed +object. `Record.seq` **is** the freshness counter. No new crypto is introduced. + +### Wire change + +The signed registration is an **additional** message, so the legacy unsigned path is left +untouched (non-mesh stays byte-identical): + +- Add `Message::RegisterSigned { record: Record }` (new `Tag = 7`). `record.node_id` is the + registered id; `record.seq` is the freshness counter. The existing + `Register { node, counter }` (`Tag = 0`) is unchanged. +- `Message::PeerInfo` gains an optional trailing `record: Option` (a presence byte + then the record when present). It is populated only in mesh mode; the legacy no-record + encoding is preserved when absent. + +`Record` already has `encode`/`decode` with length-prefixed codecs. This is a coordinated +rendezvous+client bump for mesh deployments — see Compatibility. + +### Trust model — Option 3 (defense in depth), full-CA server + +**Server (authoritative for reachability).** On `Register`, before touching the table: + +1. `record.verify(&self.roots, &self.network_id, now_secs, skew)` — reject on any error. +2. Monotonic seq: keep the existing `register_if_fresh` discriminator, keyed on + `record.seq` (first-seen/expired, or strictly greater than the last accepted). +3. On accept, store the **observed `src`** as the reachable address (unchanged) *and* the + verified `Record` (for `PeerInfo` responses). + +The server gains one new input: the mesh's CA root set + `network_id`. It is already +per-mesh infrastructure (the bootstrap seed), so this coupling is acceptable. This is the +load-bearing half — a forged/squatted register never enters the table, so the victim's +real entry survives and it stays reachable. + +**Peer (defense in depth for targeting).** `Lookup` responses (`PeerInfo`) carry the +`Record`; the looking-up peer runs `record.verify(...)` against *its own* roots before +spending a probe. A malicious or buggy server therefore cannot make a peer chase a phantom +or a non-member entry. Address integrity is still the handshake's job: the peer probes the +server-observed address, and if it is wrong the Noise handshake simply fails to complete +(no traffic is misdirected — the existing 2b commit-on-completion invariant). + +### What it closes + +- **Overwrite DoS** — an attacker cannot forge the victim's record signature, so + `record.verify` rejects the overwrite at ingress; the victim's entry survives. +- **Squatting** — `node_id` is bound to `cert.member_pubkey` inside `record.verify`, so an + attacker cannot pre-register a victim's node_id under its own key. +- **Server-injected phantoms** — the peer-side verify rejects any `PeerInfo` not backed by + a valid member record. + +### Mesh-only + +Registration signing is required **only when membership is configured** (mesh mode). A +non-mesh deployment has no CA and keeps the current unsigned `Register` path. Concretely: a +`yipd` with membership sends `RegisterSigned`; a rendezvous server started with roots +serves the mesh — it requires a valid `Record` via `RegisterSigned` and drops the legacy +unsigned `Register` (a mesh must not accept unauthenticated registrations); a server +started without roots keeps the legacy identity-agnostic behavior (accepts `Register`, +ignores `RegisterSigned`). A mesh client against a rootless server (or vice-versa) fails +closed — the expected message type is dropped, so no registration lands. Deploy +consistently per mesh. + +### Server configuration + +`bin/yip-rendezvous` gains `--roots ` (a signed `RootSet`, verified via +`verify_rootset` at load) and `--network-id `. Absent → legacy mode. The server +holds no private keys and no member certs beyond what each `Register` carries. + +--- + +## Part B — M2: authenticated endpoint roaming (data-plane) + +### The WireGuard rule + +Update a peer's `endpoint` to the source of a received packet **iff that packet both +decrypts under the session AEAD key and passes the replay window** — i.e. only for a fresh, +authentic, non-replayed data packet. In yip this signal already exists: a successful +`SessionKeys::open(counter, ciphertext)` (`yip-crypto/src/lib.rs` ~291) runs +`ReplayWindow::check_and_set` and returns `Ok` only for a fresh, authentic packet. A +replayed or spoofed packet returns `Err` and never reaches the update. + +### Change + +In the data-ingress path, thread the datagram's `src` down to the point of a successful +`open()`. On success, if `src != peers[idx].endpoint`, set `peers[idx].endpoint = +Some(src)` (direct peers only; a `relay` peer's endpoint is the rendezvous placeholder and +must not roam). Apply on both the `current` and `next` epochs (a roamed peer may complete a +rekey from the new address). No wire change. + +Because `endpoint` also keys `deobf_ingress`'s session-key selection and the handshake +demux (`peer_manager.rs` ~1464/2043/2458/2525), a single authenticated data packet from the +new address heals all of them at once. + +### Why it preserves #34's anti-hijack + +#34's property is that an *unauthenticated Init* cannot move an Established peer's endpoint. +That path is untouched: the cold-start endpoint gate and the rekey path still never learn +an endpoint from an Init's source. M2 moves `endpoint` **only** on an AEAD-authenticated, +non-replayed *data* packet — which an attacker cannot forge (no session key) and cannot +replay (replay window). The M1 on-path residual from #34 (copy-and-reinject a genuine +*Init*) does not apply: a data packet's ciphertext is bound to its counter, and replaying +it fails the window; injecting from a spoofed source with a valid, unseen counter requires +the session key. + +### Severity fixed + +Mid-session NAT rebind now recovers on the first authenticated packet from the new address +(sub-RTT), instead of black-holing the obfuscated ingress path for up to a rekey interval. + +--- + +## Compatibility + +- **#37** adds `RegisterSigned` (new tag) and an optional trailing `record` on `PeerInfo`, + leaving the legacy `Register`/no-record `PeerInfo` encodings untouched — non-mesh is + byte-identical to today. Mesh deployments bump the rendezvous server and clients together; + a mesh-mode ⇄ rootless-server mismatch fails closed (the expected message is dropped, no + registration) rather than silently accepting unsigned registers. +- **M2** is internal; no wire or format change; non-mesh and mesh behave identically. + +## Testing + +**#37 (unit + netns).** +- Unit (`yip-rendezvous`): forged signature rejected; a cert not signed by the roots + rejected; a record whose `node_id` ≠ `node_id(cert.member_pubkey)` rejected (squatting); + stale/equal `seq` rejected; a valid record accepted and stored; capacity/rate paths + unchanged. Peer-side: an unsigned or forged `PeerInfo` is rejected before probing. +- netns: attacker attempts to overwrite an established member's registration → server + refuses, victim stays reachable (lookup still returns the victim's real address, ping + succeeds). Both drivers. + +**M2 (unit + netns).** +- Unit (`yipd`): an authenticated data packet from a new src updates `endpoint`; a + *replayed* data packet from a spoofed src does **not** move it; an unauthenticated Init + from a spoofed src still does not move it (#34 regression guard); a `relay` peer does not + roam. +- netns: a direct, established A changes address mid-session (re-NAT) and B recovers on the + first authenticated packet, under the obfuscation path. Both drivers. + +**Regression net:** the full #34 / #41 / #91 / 9a unit + netns suites must stay green — +this milestone touches the admission, demux, and ingress paths they cover. + +## Accepted residuals + +- **#37:** the rendezvous server must be configured with CA roots in mesh mode (loses + identity-agnosticism). Accepted — it is per-mesh infrastructure. +- **#37:** the server-observed address is not covered by the signature (a NAT-reflexive + address is unknown to the registering peer). Address integrity is deferred to the + handshake-commit invariant, exactly as today. This is by design, not a gap. +- **M2:** endpoint follows the last authenticated peer, matching WireGuard's roaming model; + reordering under a symmetric NAT is absorbed by the replay window. + +## Global constraints + +`#![forbid(unsafe_code)]`; no `as` casts except `PacketType::X as u8`; no bare `#[allow]` +(use `#[expect(reason = …)]`); RUN `cargo fmt`; `clippy -- -D warnings` clean; `yipd` is a +BINARY — `cargo test -p yipd --bin yipd`. netns money tests use the RELEASE binaries under +both the poll and `YIP_USE_URING=1` drivers. Leave PRs for the user to review + merge; no +"not merging" line in PR descriptions. From 0435fdf106f41ad39cf3296d40b37b39a2de3f9e Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Thu, 23 Jul 2026 16:08:07 -0400 Subject: [PATCH 02/15] =?UTF-8?q?docs(auth-reachability):=20implementation?= =?UTF-8?q?=20plans=20=E2=80=94=20M2=20endpoint=20roaming=20+=20#37=20sign?= =?UTF-8?q?ed=20registration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-07-23-endpoint-roaming-m2.md | 318 ++++++++++++++ ...07-23-signed-rendezvous-registration-37.md | 389 ++++++++++++++++++ 2 files changed, 707 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-23-endpoint-roaming-m2.md create mode 100644 docs/superpowers/plans/2026-07-23-signed-rendezvous-registration-37.md diff --git a/docs/superpowers/plans/2026-07-23-endpoint-roaming-m2.md b/docs/superpowers/plans/2026-07-23-endpoint-roaming-m2.md new file mode 100644 index 0000000..907594c --- /dev/null +++ b/docs/superpowers/plans/2026-07-23-endpoint-roaming-m2.md @@ -0,0 +1,318 @@ +# Authenticated Endpoint Roaming (M2) 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:** Make a peer's `endpoint` follow a legitimate address change (NAT rebind / mobility), gated on cryptographic authentication, so a roamed peer recovers on the first authenticated packet instead of black-holing until re-handshake. + +**Architecture:** WireGuard's roaming rule — update `peers[idx].endpoint` to a datagram's source **only** when that datagram decrypts and passes the AEAD replay window (a successful `EpochSet::inbound_open`, which internally runs `SessionKeys::open` → `ReplayWindow::check_and_set`). A replayed or spoofed packet fails and cannot move the endpoint. Additionally, the obfuscated-ingress key selector (`deobf_ingress`) is given a trial-key fallback so a roamed peer's data can be deobfuscated when the `endpoint == src` fast-path misses — mirroring the plaintext demux's existing roaming fallback loop. + +**Tech Stack:** Rust, `bin/yipd` (binary crate), `crates/yip-obf`, `crates/yip-crypto` (unchanged — the replay window already exists). + +## Global Constraints + +- `#![forbid(unsafe_code)]`; no `as` casts except `PacketType::X as u8`; no bare `#[allow]` (use `#[expect(reason = …)]`). +- RUN `cargo fmt` (never `--no-verify`); `cargo clippy -- -D warnings` must be clean. +- `yipd` is a BINARY: test with `cargo test -p yipd --bin yipd`. +- netns money tests use the RELEASE binary under BOTH the poll and `YIP_USE_URING=1` drivers; rebuild release after every yipd change. +- Preserve #34: an unauthenticated Init still must never move an Established peer's `endpoint`. Only an AEAD-authenticated, non-replayed data/control packet may. +- Leave the PR for the user to review + merge; no "not merging" line. + +## File Structure + +- `bin/yipd/src/peer_manager.rs` — the roaming relearn (in `dispatch_established` + the roaming fallback loop in `handle_data_or_control`) and the `deobf_ingress` trial-key fallback. All changes live here. +- `bin/yipd/tests/run-netns-roaming.sh` — new netns money test (mid-session rebind recovery, obf path). +- `.github/workflows/integration.yml` — wire the new netns test. + +--- + +### Task 1: Relearn `endpoint` on an authenticated inbound packet + +**Files:** +- Modify: `bin/yipd/src/peer_manager.rs` — `dispatch_established` (~1471) and `handle_data_or_control`'s roaming fallback loop (~1535). +- Test: same file's `#[cfg(test)] mod tests`. + +**Interfaces:** +- Consumes: `fn route_data(&self, src: SocketAddr, dg: &[u8]) -> Option` (~1452); `fn dispatch_established(&mut self, idx: usize, dg: &[u8], now_ms: u64) -> DispatchOut<'_>` (~1471); `EpochSet::inbound_open` returns `EpochInbound` — a non-`None` variant means the packet authenticated and passed the replay window. +- Produces: a private helper `fn relearn_endpoint(&mut self, idx: usize, src: SocketAddr)` used at both authenticated-decrypt sites. + +**Context:** `dispatch_established` is called from `handle_data_or_control` (~1512) with the `idx` from `route_data(src, …)` — but `src` is not currently passed in. Thread `src` down so the relearn has it. `route_data` demuxes by `by_tag` (address-independent) first, so a roamed peer's plaintext data already reaches `dispatch_established`; the relearn heals the *stale endpoint* that would otherwise misdirect `drive_rekey_schedule`'s initiator rekeys and the obf key selector. + +- [ ] **Step 1: Write the failing test — authenticated packet from a new src updates endpoint** + +Add to the tests module. Use the existing test helpers that establish two peers (search the test module for an existing `establish_*`/`handshake_*` helper that yields a `PeerManager` with one `Established` direct peer at index 0 and a known `endpoint`; reuse it — do not invent a new establishment path). The test drives one authenticated data datagram from a NEW source address and asserts `endpoint` moved. + +```rust +#[test] +fn authenticated_data_from_new_src_roams_endpoint() { + // Two direct peers, established; peer[0].endpoint == old_ep. + let (mut pm_r, mut pm_i, old_ep, _new_ep) = established_pair_for_roaming(); + // Initiator sends a real data packet; capture its on-wire bytes. + let data = pm_i.on_tun_packet(&sample_inner_v6_packet()); // yields EgressDatagram(s) + let dg = first_udp_bytes(&data); + let new_src = addr("198.51.100.222:60000"); + assert_ne!(new_src, old_ep); + // Deliver from the NEW source. + let _ = pm_r.on_udp(new_src, &dg, 1_000); + assert_eq!( + pm_r.peers[0].endpoint, + Some(new_src), + "endpoint must follow an authenticated packet from a new source", + ); +} +``` + +If no `established_pair_for_roaming`/`sample_inner_v6_packet`/`first_udp_bytes` helper exists, write minimal ones next to the test by reusing the module's existing establishment helper and the existing `on_tun_packet` path (grep the test module for how other tests craft a data datagram — several already do). + +- [ ] **Step 2: Run it — expect FAIL** (`endpoint` stays `old_ep`). + +Run: `cargo test -p yipd --bin yipd authenticated_data_from_new_src_roams_endpoint` +Expected: FAIL — `endpoint` is still `Some(old_ep)`. + +- [ ] **Step 3: Add the relearn helper and call it at both authenticated-decrypt sites** + +Add the helper (place it next to `dispatch_established`): + +```rust +/// WireGuard-style roaming: after a datagram has AUTHENTICATED and passed +/// the replay window (a non-`None` `inbound_open`), point a direct peer's +/// `endpoint` at the observed source. A `relay` peer's `endpoint` is a +/// rendezvous placeholder and must not roam. Gated on `src` differing so +/// steady-state traffic is a no-op. This never runs for an unauthenticated +/// Init (that path does not call `inbound_open`), preserving #34. +fn relearn_endpoint(&mut self, idx: usize, src: SocketAddr) { + if !self.peers[idx].relay && self.peers[idx].endpoint != Some(src) { + self.peers[idx].endpoint = Some(src); + } +} +``` + +Thread `src` into `dispatch_established` and call the helper when `inbound_open` returns a non-`None` variant. Change the signature to `fn dispatch_established(&mut self, idx: usize, src: SocketAddr, dg: &[u8], now_ms: u64) -> DispatchOut<'_>` and, inside, capture whether the open authenticated before building the borrowed return: + +```rust +fn dispatch_established(&mut self, idx: usize, src: SocketAddr, dg: &[u8], now_ms: u64) -> DispatchOut<'_> { + let PeerState::Established(epochs) = &mut self.peers[idx].state else { + return DispatchOut::None; + }; + let opened = epochs.inbound_open(dg, now_ms); + if !matches!(opened, crate::epoch::EpochInbound::None) { + self.relearn_endpoint(idx, src); // authenticated → safe to roam + } + match opened { + crate::epoch::EpochInbound::None => DispatchOut::None, + crate::epoch::EpochInbound::Tun(buf) => { + self.tun_scratch = buf; + DispatchOut::Tun(&self.tun_scratch) + } + crate::epoch::EpochInbound::Send(dgs) => { + self.egress = dgs; + DispatchOut::Udp(&self.egress) + } + crate::epoch::EpochInbound::TunThenSend(buf, dgs) => { + self.tun_scratch = buf; + self.egress = dgs; + DispatchOut::Both(&self.tun_scratch, &self.egress) + } + } +} +``` + +Update its call site (~1512) to pass `src`: `return self.dispatch_established(idx, src, dg, now_ms);`. + +In the roaming fallback loop (~1535-1568), after a `hit` is found for `idx`, call `self.relearn_endpoint(idx, src);` before materializing the borrowed return (the `epochs` borrow has already ended by then). + +- [ ] **Step 4: Run — expect PASS.** + +Run: `cargo test -p yipd --bin yipd authenticated_data_from_new_src_roams_endpoint` +Expected: PASS. + +- [ ] **Step 5: Write the anti-hijack regression tests** + +```rust +#[test] +fn replayed_data_from_spoofed_src_does_not_roam_endpoint() { + let (mut pm_r, mut pm_i, old_ep, _new) = established_pair_for_roaming(); + let data = pm_i.on_tun_packet(&sample_inner_v6_packet()); + let dg = first_udp_bytes(&data); + // First delivery from the legit endpoint authenticates and is consumed. + let _ = pm_r.on_udp(old_ep, &dg, 1_000); + // REPLAY the same datagram from a spoofed source: fails the replay window. + let spoof = addr("203.0.113.66:5555"); + let _ = pm_r.on_udp(spoof, &dg, 1_001); + assert_eq!(pm_r.peers[0].endpoint, Some(old_ep), + "a replayed packet must not move the endpoint"); +} + +#[test] +fn relay_peer_endpoint_does_not_roam() { + // A relay-established peer: relearn_endpoint must be a no-op. + let (mut pm, relay_idx, placeholder) = established_relay_peer(); + // Any authenticated inbound from a different src must NOT move it. + pm.relearn_endpoint(relay_idx, addr("198.51.100.9:7000")); + assert_eq!(pm.peers[relay_idx].endpoint, placeholder); +} +``` + +Reuse existing relay-establishment test helpers if present (search for `relay` in the test module — the #91/#36 tests establish relay peers). If `established_relay_peer` does not exist, adapt the closest existing relay-peer test setup; the assertion is what matters — a relay peer's `endpoint` is unchanged by `relearn_endpoint`. + +Also assert #34 is preserved with an existing-style Init test: an unauthenticated Init from a spoofed src against the Established responder still does not move `endpoint` (the pre-existing `stale_replayed_cold_start_init_does_not_hijack_endpoint`-style guard already covers the Init path; add a one-line comment referencing it rather than duplicating, since M2 does not touch the Init path). + +- [ ] **Step 6: Run all three + the full suite.** + +Run: `cargo test -p yipd --bin yipd` +Expected: all pass, including the pre-existing #34/#91 endpoint tests (they must stay green — M2 must not regress the Init-path immovability). + +- [ ] **Step 7: fmt + clippy + commit** + +```bash +cargo fmt +cargo clippy -p yipd --bin yipd -- -D warnings +git add bin/yipd/src/peer_manager.rs +git commit -m "feat(roaming.m2): endpoint follows an authenticated, non-replayed inbound packet" +``` + +--- + +### Task 2: `deobf_ingress` trial-key fallback for a roamed peer + +**Files:** +- Modify: `bin/yipd/src/peer_manager.rs` — `deobf_ingress` (~2455). +- Test: same file's tests module. + +**Interfaces:** +- Consumes: `fn deobf_ingress(&self, src: SocketAddr, dg: &[u8]) -> Option>`; each peer's `session_obf_key: Option<[u8;32]>`; `yip_obf::deobfuscate(&key, dg) -> Option<(u8, Vec)>`; `yip_obf::JUNK_TYPE`; `PacketType::{Data,Control,Gossip}`. +- Produces: unchanged signature; the function now also finds the key by trial when the `endpoint == src` fast-path misses. + +**Context:** Under obfuscation, `deobf_ingress` step (a) selects a peer's `session_obf_key` by `p.endpoint == Some(src)`. A roamed peer's new src fails that match, and step (b) (network key) only accepts handshake types — so the roamed peer's Data is dropped before it can ever authenticate and relearn its endpoint. The fix: when (a) misses, trial every Established peer's `session_obf_key` for a `Data`/`Control`/`Gossip` result, exactly as the plaintext demux's roaming fallback loop trials every peer's codec. A wrong key yields `None`/garbage and the inner AEAD verify drops it safely (the function's own doc-comment already states this invariant), so the trial cannot mis-dispatch. + +- [ ] **Step 1: Write the failing test** + +```rust +#[test] +fn deobf_finds_roamed_peer_session_key_by_trial() { + // Established, obfuscation ON; peer[0].endpoint == old_ep with a session_obf_key. + let (pm, key, plaintext_data_dg) = established_obf_peer_with_data(); + // Obfuscate a Data datagram under the peer's session key. + let obf = yip_obf::obfuscate(&key, PacketType::Data as u8, &plaintext_data_dg).unwrap(); + // Arrive from a NEW src that does NOT match endpoint. + let roamed = addr("198.51.100.231:41111"); + let out = pm.deobf_ingress(roamed, &obf); + assert!(out.is_some(), "roamed peer's data must deobfuscate via trial fallback"); +} +``` + +Reuse an existing obf-enabled establishment helper if the test module has one (search for `session_obf_key` / `obf_key` in tests). If none, construct a minimal `PeerManager` with `obf_key = Some(..)` and one Established peer carrying a known `session_obf_key`, mirroring the closest existing obf test. + +- [ ] **Step 2: Run — expect FAIL** (returns `None`, key not found by endpoint match). + +Run: `cargo test -p yipd --bin yipd deobf_finds_roamed_peer_session_key_by_trial` +Expected: FAIL. + +- [ ] **Step 3: Add the trial fallback** + +After step (a)'s `endpoint == src` block returns nothing, and before (b)'s network-key block, insert a trial over Established peers' session keys: + +```rust +// (a') Roaming fallback: no endpoint match, but the datagram may be a +// roamed Established peer's Data/Control/Gossip under its session key. +// Trial each Established peer's session key; a wrong key yields None or a +// garbage type that the type-set + inner verify drop safely (same +// invariant as `handle_data_or_control`'s plaintext roaming loop). +for p in &self.peers { + if !matches!(p.state, PeerState::Established(_)) { + continue; + } + let Some(key) = p.session_obf_key else { continue }; + if let Some((ptype, body)) = yip_obf::deobfuscate(&key, dg) { + if ptype == yip_obf::JUNK_TYPE { + return None; + } + if ptype == PacketType::Data as u8 + || ptype == PacketType::Control as u8 + || ptype == PacketType::Gossip as u8 + { + return Some(reassemble(ptype, &body)); + } + } +} +``` + +Place this so it does not shadow the existing endpoint fast-path (which stays first for the common case). Keep (b)'s network-key handshake branch after it. + +- [ ] **Step 4: Run — expect PASS.** + +Run: `cargo test -p yipd --bin yipd deobf_finds_roamed_peer_session_key_by_trial` +Expected: PASS. + +- [ ] **Step 5: Guard test — a non-roamed junk/foreign datagram still drops** + +```rust +#[test] +fn deobf_trial_does_not_accept_foreign_datagram() { + let (pm, _key, _d) = established_obf_peer_with_data(); + let garbage = vec![0xABu8; 64]; + assert!(pm.deobf_ingress(addr("203.0.113.9:9"), &garbage).is_none(), + "a datagram under no known session key must not deobfuscate"); +} +``` + +- [ ] **Step 6: Run the full suite.** + +Run: `cargo test -p yipd --bin yipd` +Expected: all pass; the existing obf tests (`run-netns-obf-mismatch` peers, JUNK handling) stay green. + +- [ ] **Step 7: fmt + clippy + commit** + +```bash +cargo fmt +cargo clippy -p yipd --bin yipd -- -D warnings +git add bin/yipd/src/peer_manager.rs +git commit -m "feat(roaming.m2): deobf_ingress trials session keys so a roamed peer's data is found" +``` + +--- + +### Task 3: netns money test — mid-session NAT rebind recovery (obf) + CI + +**Files:** +- Create: `bin/yipd/tests/run-netns-roaming.sh` +- Modify: `.github/workflows/integration.yml` + +**Interfaces:** +- Consumes: the two-peer direct/punch netns harness pattern (fork `bin/yipd/tests/run-netns-punch.sh` or `run-netns-tunnel.sh`); the both-driver parameterization + `set -euo pipefail` + `trap` + `[PASS]`/`[FAIL]`/SKIP conventions from `run-netns-rekey.sh`. +- Produces: an exit-gated assertion that a rebound peer recovers. + +**Context:** A and B are direct, established, obfuscation ON. Change A's source address mid-session (re-NAT: move A's egress to a new address via a NAT/veth reconfiguration, or restart A's socket bound to a new port that the topology SNATs differently) WITHOUT tearing down the session, then keep A sending. Assert B keeps delivering (ping A→B resumes with ≤1% loss over a measured window that spans the rebind), proving B relearned A's endpoint and deobfuscated A's data from the new source. + +- [ ] **Step 1: Read the fork sources** + +Read `run-netns-punch.sh` (two-peer direct topology, obf config), `run-netns-tunnel.sh` (steady ping harness), and `run-netns-rekey.sh` (both-driver param + guards). Identify how existing tests enable obfuscation in the yipd config (`obf_psk`/`obf_key`) — the roaming test MUST run with obfuscation ON, since that is the path M2's Task 2 heals. + +- [ ] **Step 2: Write `run-netns-roaming.sh`** + +Topology: A and B in separate netns with a direct path, obf enabled. Establish, warm up a ping A→B. Then change A's observed source address (e.g. reconfigure the SNAT/veth so A's packets now arrive at B from a new address, or bounce A's UDP bind to a new port with a matching SNAT change) — the key is that B sees A's authenticated data arrive from a NEW `src` while the session (keys, conn_tag) is unchanged. Run a measured `ping A→B -i0.2 -c100` spanning the rebind. Assertions (each non-zero exit on failure, `[PASS]`/`[FAIL]` markers): (1) measured loss ≤1% (a separate one-time warm-up ping before the rebind absorbs any single-packet transition and is NOT counted); (2) B's stderr shows no persistent drop after the rebind (optional: assert the ping simply succeeds — the loss bound is the real gate). `set -euo pipefail`, `trap` cleanup, SKIP when not root. Parameterize BOTH drivers via `YIP_USE_URING`. + +- [ ] **Step 3: Run under sudo, both drivers** + +```bash +cargo build --release -p yipd -p yip-rendezvous +sudo bash bin/yipd/tests/run-netns-roaming.sh "$(pwd)/target/release/yipd" "$(pwd)/target/release/yip-rendezvous" +sudo YIP_USE_URING=1 bash bin/yipd/tests/run-netns-roaming.sh "$(pwd)/target/release/yipd" "$(pwd)/target/release/yip-rendezvous" +``` +Expected: PASS, exit 0 both. If the environment cannot run netns, capture the exact blocker and report DONE_WITH_CONCERNS. + +- [ ] **Step 4: Wire CI + commit** + +Add the new test to `.github/workflows/integration.yml` next to the sibling netns steps (both drivers, same SKIP/`[FAIL]` guards). `chmod +x`. + +```bash +chmod +x bin/yipd/tests/run-netns-roaming.sh +git add bin/yipd/tests/run-netns-roaming.sh .github/workflows/integration.yml +git commit -m "test(roaming.m2): netns — mid-session NAT rebind recovers on the obf path" +``` + +--- + +## After all tasks + +- Final whole-branch review (opus) over the M2 delta. Focus: the relearn fires ONLY on a non-`None` `inbound_open` (authenticated + replay-passed); relay peers never roam; the Init path is untouched (#34 immovability preserved); the `deobf_ingress` trial cannot mis-dispatch (wrong key → safe drop); no `unsafe`/`as`/bare-`allow`. +- This is one PR of the authenticated-reachability milestone (the other is #37). Leave it for the user; no "not merging" line. diff --git a/docs/superpowers/plans/2026-07-23-signed-rendezvous-registration-37.md b/docs/superpowers/plans/2026-07-23-signed-rendezvous-registration-37.md new file mode 100644 index 0000000..c0b50e2 --- /dev/null +++ b/docs/superpowers/plans/2026-07-23-signed-rendezvous-registration-37.md @@ -0,0 +1,389 @@ +# Signed Rendezvous Registration (#37) 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:** Close the rendezvous registration-overwrite DoS (#37): require a member-signed `Record` to register in a mesh, verified against the CA roots server-side (reachability) and re-verified peer-side on lookup (targeting integrity). + +**Architecture:** The registration IS a `membership::Record` (`{node_id, cert, endpoints, seq, sig}`) — already member-signed, cert-carrying, and `seq`-monotonic, with `Record::verify(ca_pubkeys, network_id, now, skew)` performing cert-vs-roots + signature + `node_id == node_id(cert.member_pubkey)` (squatting closed). A new `RegisterSigned` message carries it; `PeerInfo` gains an optional `record`. The rendezvous server, when configured with roots (mesh mode), verifies before storing and drops legacy unsigned `Register`; without roots it keeps the legacy identity-agnostic path (non-mesh byte-identical). Option 3 defense-in-depth: peers re-verify the returned record before probing. + +**Tech Stack:** Rust — `crates/yip-rendezvous` (proto + server), `crates/yip-membership` (Record/RootSet, reused), `bin/yip-rendezvous` (server binary/args), `bin/yipd` (client: `rendezvous.rs` + `peer_manager.rs` + `membership.rs`). + +## Global Constraints + +- `#![forbid(unsafe_code)]`; no `as` casts except `PacketType::X as u8`; no bare `#[allow]` (use `#[expect(reason = …)]`). +- RUN `cargo fmt`; `cargo clippy -- -D warnings` clean across the workspace. +- `yipd` is a BINARY: `cargo test -p yipd --bin yipd`. `yip-rendezvous` lib: `cargo test -p yip-rendezvous`. +- Clock split: TTL/rate use monotonic `now_ms`; cert/record validity uses wall-clock `now_secs`. Reuse the `YIP_CERT_SKEW_SECS`-driven skew convention from #41 where a skew is needed. +- No new crypto — reuse `Record`, `Record::verify`, `RootSet::verify_rootset`, `build_signed_record`. +- Mesh-only: signing is required only when membership/roots are configured. Non-mesh keeps the legacy unsigned `Register`, byte-identical. +- netns money tests use RELEASE binaries under BOTH poll and `YIP_USE_URING=1`. +- Leave the PR for the user; no "not merging" line. + +## File Structure + +- `crates/yip-rendezvous/src/proto.rs` — `Tag::RegisterSigned = 7`, `Message::RegisterSigned { record }`, optional `record` on `Message::PeerInfo`, codec + tests. +- `crates/yip-rendezvous/src/server.rs` — roots/network_id config, `new_with_roots`, `RegisterSigned` verify+store, legacy-drop-when-rooted, `PeerInfo` carries stored record. +- `crates/yip-membership/src/lib.rs` (+ re-exports) — nothing new required; `Record`/`build_signed_record` already exist. (Server uses `Record::verify` directly.) +- `bin/yipd/src/membership.rs` — `sign_registration(seq)` + `verify_record(record, now_secs)` accessors on `Membership`. +- `bin/yipd/src/rendezvous.rs` — `register` threads a signed `Record`; `parse` verifies `PeerInfo.record`. +- `bin/yipd/src/peer_manager.rs` — mint the registration record (monotonic seq) and pass it to `register`; give `parse`'s verifier the membership context. +- `bin/yip-rendezvous/src/main.rs` — `--roots ` + `--network-id ` args; load + `verify_rootset`; construct via `new_with_roots`. +- `bin/yipd/tests/run-netns-registration-hijack.sh` + `.github/workflows/integration.yml`. + +--- + +### Task 1: `RegisterSigned` message + optional `PeerInfo.record` (proto codec) + +**Files:** +- Modify: `crates/yip-rendezvous/src/proto.rs` +- Test: same file's `#[cfg(test)] mod tests`. + +**Interfaces:** +- Consumes: `membership::Record` with `Record::encode(&self, &mut Vec)` / `Record::decode(&[u8]) -> Option`; existing `put_addr`/`take_addr`, `Tag` enum, `encode`/`decode`. +- Produces: `Message::RegisterSigned { record: Record }` (`Tag = 7`); `Message::PeerInfo { node, reflexive, record: Option }`. + +**Context:** `yip-rendezvous` must depend on `yip-membership` (add to its `Cargo.toml` if not already a dependency — check first). `Record::encode` writes a self-delimiting form? It writes `record_signing_body` + sig; `Record::decode` requires an exact slice. For embedding in a message, length-prefix the encoded record with a `u16` (mirror how `record_signing_body` length-prefixes the cert), so decode can slice it exactly. + +- [ ] **Step 1: Add the dependency + tag.** Confirm `yip-membership` is in `crates/yip-rendezvous/Cargo.toml`; add it if missing. Add `RegisterSigned = 7` to `Tag`. + +- [ ] **Step 2: Write the failing roundtrip tests** + +```rust +#[test] +fn register_signed_roundtrips() { + let rec = sample_record(); // build a minimal valid-shaped Record (see helper note) + roundtrip(Message::RegisterSigned { record: rec }); +} + +#[test] +fn peerinfo_with_record_roundtrips() { + roundtrip(Message::PeerInfo { + node: [7u8; 16], + reflexive: addr("198.51.100.7:41000"), + record: Some(sample_record()), + }); +} + +#[test] +fn peerinfo_without_record_roundtrips_and_is_backward_compatible() { + roundtrip(Message::PeerInfo { + node: [7u8; 16], + reflexive: addr("198.51.100.7:41000"), + record: None, + }); +} +``` + +`sample_record()`: build a `Record` with a decode-valid shape (a real cert via the membership test helpers, or the smallest cert `Record::decode` accepts). Reuse `yip-membership`'s own test fixtures if exposed; otherwise construct one inline mirroring `record.rs`'s `make_signed_record`. + +- [ ] **Step 3: Run — expect FAIL** (variants/fields don't exist). `cargo test -p yip-rendezvous register_signed_roundtrips` + +- [ ] **Step 4: Implement the codec** + +In `encode`: for `RegisterSigned`, push `Tag::RegisterSigned as u8`, then encode the record into a scratch `Vec`, push its `u16` big-endian length, then the bytes. For `PeerInfo`, after the existing `node` + `reflexive`, push a presence byte (`1` then the length-prefixed record when `Some`, else `0`). + +In `decode`: add the `RegisterSigned` arm (read `u16` len, slice, `Record::decode`, `None` on failure — fail closed). For `PeerInfo`, read the trailing presence byte; if `1`, read the length-prefixed record (fail closed on truncation/invalid); if absent (legacy datagram with no trailing byte) treat as `None` — so an old-format `PeerInfo` still decodes. Guard every slice with a length check (`buf.get(range)?`), matching the file's existing panic-free decode style. + +- [ ] **Step 5: Run — expect PASS**, plus the existing `decode_rejects_garbage_and_truncation` / `decode_rejects_invalid_address_family` stay green. Add a truncation test: a `RegisterSigned` whose length prefix exceeds the buffer returns `None`. + +- [ ] **Step 6: fmt + clippy + commit** + +```bash +cargo fmt && cargo clippy -p yip-rendezvous -- -D warnings +git add crates/yip-rendezvous/src/proto.rs crates/yip-rendezvous/Cargo.toml +git commit -m "feat(rdv.37): RegisterSigned message + optional PeerInfo.record (codec)" +``` + +--- + +### Task 2: Server verifies `RegisterSigned` against roots; legacy-drop when rooted + +**Files:** +- Modify: `crates/yip-rendezvous/src/server.rs` +- Test: same file's tests module. + +**Interfaces:** +- Consumes: `Record::verify(&[[u8;32]], &[u8;16], now_secs, skew) -> Result<(), CertError>`; `register_if_fresh(node, counter, src, now_ms) -> bool`; `Reg { addr, expiry_ms, last_counter }`. +- Produces: `RendezvousServer::new_with_roots(now_ms, ca_pubkeys: Vec<[u8;32]>, network_id: [u8;16]) -> Self`; `Reg` gains `record: Option`; `handle` gains a wall-clock `now_secs` parameter (thread it from callers) OR store it — see step 3. + +**Context:** The server currently has no wall-clock. `Record::verify` needs `now_secs` for validity. Add `now_secs` as a parameter to `handle` (update all call sites, including the binary and tests) — cleanest and keeps the monotonic/wall split explicit. Store `Option<(Vec<[u8;32]>, [u8;16])>` as `roots_cfg`. Reuse the #41 skew source for the skew value (a `clock_skew_secs()`-style constant/env; if the rendezvous crate has no access to it, define a local `REGISTRATION_SKEW_SECS` mirroring the default and document the parity). + +- [ ] **Step 1: Write the failing tests** + +```rust +#[test] +fn rooted_server_accepts_valid_signed_register_and_serves_it() { + let (ca_pub, network_id, rec, member_src) = valid_registration(); // helper mints a Record + its CA + let mut s = RendezvousServer::new_with_roots(0, vec![ca_pub], network_id); + let _ = s.handle(member_src, Message::RegisterSigned { record: rec.clone() }, 0, now_secs(0)); + let out = s.handle(addr("203.0.113.9:5"), Message::Lookup { node: rec.node_id }, 10, now_secs(10)); + assert!(out.iter().any(|(_, m)| matches!(m, + Message::PeerInfo { reflexive, record: Some(r), .. } + if *reflexive == member_src && r.node_id == rec.node_id))); +} + +#[test] +fn rooted_server_rejects_forged_signature() { + let (ca_pub, network_id, mut rec, member_src) = valid_registration(); + rec.sig[0] ^= 0xFF; // corrupt the signature + let mut s = RendezvousServer::new_with_roots(0, vec![ca_pub], network_id); + let _ = s.handle(member_src, Message::RegisterSigned { record: rec.clone() }, 0, now_secs(0)); + // Not stored → lookup yields NotFound. + let out = s.handle(addr("203.0.113.9:5"), Message::Lookup { node: rec.node_id }, 10, now_secs(10)); + assert!(out.iter().all(|(_, m)| !matches!(m, Message::PeerInfo { .. }))); +} + +#[test] +fn rooted_server_rejects_overwrite_by_non_holder() { + // Victim registers; attacker sends RegisterSigned for the victim's node_id + // signed by a DIFFERENT (valid-CA) member key → node_id != node_id(attacker cert) → rejected. + let (ca_pub, network_id, victim, victim_src) = valid_registration(); + let attacker = registration_for_other_member(ca_pub, network_id, victim.node_id); // node_id mismatch + let mut s = RendezvousServer::new_with_roots(0, vec![ca_pub], network_id); + let _ = s.handle(victim_src, Message::RegisterSigned { record: victim.clone() }, 0, now_secs(0)); + let _ = s.handle(addr("203.0.113.66:9"), Message::RegisterSigned { record: attacker }, 1, now_secs(1)); + let out = s.handle(addr("203.0.113.9:5"), Message::Lookup { node: victim.node_id }, 2, now_secs(2)); + assert!(out.iter().any(|(_, m)| matches!(m, + Message::PeerInfo { reflexive, .. } if *reflexive == victim_src)), + "victim's real registration must survive the overwrite attempt"); +} + +#[test] +fn rooted_server_drops_legacy_unsigned_register() { + let (ca_pub, network_id, _rec, _src) = valid_registration(); + let mut s = RendezvousServer::new_with_roots(0, vec![ca_pub], network_id); + let _ = s.handle(addr("203.0.113.66:9"), Message::Register { node: [1u8;16], counter: 9 }, 0, now_secs(0)); + let out = s.handle(addr("203.0.113.9:5"), Message::Lookup { node: [1u8;16] }, 1, now_secs(1)); + assert!(out.iter().all(|(_, m)| !matches!(m, Message::PeerInfo { .. })), + "a rooted (mesh) server must not accept unsigned registrations"); +} + +#[test] +fn rootless_server_keeps_legacy_register() { + let mut s = RendezvousServer::new(0); // no roots + let a = [1u8;16]; + let _ = s.handle(addr("198.51.100.7:41000"), Message::Register { node: a, counter: 1 }, 0, now_secs(0)); + let out = s.handle(addr("203.0.113.9:5"), Message::Lookup { node: a }, 1, now_secs(1)); + assert!(out.iter().any(|(_, m)| matches!(m, Message::PeerInfo { .. }))); +} +``` + +Provide `valid_registration()` / `registration_for_other_member()` / `now_secs()` helpers in the test module, minting real certs+records with a test CA (reuse `yip-membership` record/cert test patterns; a `mk_ca`/`mk_cert`/`build_signed_record` chain like `peer_manager.rs`'s test helpers). + +- [ ] **Step 2: Run — expect FAIL** (`new_with_roots`, `record` field, `now_secs` param don't exist). + +- [ ] **Step 3: Implement** + +Add `roots_cfg: Option<(Vec<[u8; 32]>, [u8; 16])>` to `RendezvousServer`; `new` sets `None`; add `new_with_roots`. Add `record: Option` to `Reg`. Thread `now_secs: u64` through `handle`. In the match: + +- `Message::RegisterSigned { record }`: only meaningful when `roots_cfg.is_some()`. Verify `record.verify(&ca_pubkeys, &network_id, now_secs, REGISTRATION_SKEW_SECS)`; on `Ok`, call `register_if_fresh(record.node_id, record.seq, src, now_ms)` and, if it accepted, store the `record` in that node's `Reg`. On `Err` or when rootless, drop (no store, no reply). +- `Message::Register { node, counter }`: if `roots_cfg.is_some()`, DROP (mesh requires signed). Else behave exactly as today. +- `Message::Lookup { node }`: build `PeerInfo { node, reflexive: reg.addr, record: reg.record.clone() }`. + +Keep rate-limit + capacity checks unchanged and applied before verification (cheap DoS guard first). + +- [ ] **Step 4: Run — expect PASS.** Update any existing `handle(...)` test call sites to pass `now_secs`. + +- [ ] **Step 5: fmt + clippy + commit** + +```bash +cargo fmt && cargo clippy -p yip-rendezvous -- -D warnings +git add crates/yip-rendezvous/src/server.rs +git commit -m "feat(rdv.37): rooted server verifies RegisterSigned, drops unsigned; PeerInfo carries the record" +``` + +--- + +### Task 3: Server binary — `--roots` / `--network-id`; wall-clock threading + +**Files:** +- Modify: `bin/yip-rendezvous/src/main.rs` (and wherever `server.handle(...)` is invoked in the UDP loop / `conn_tunnel.rs` if it calls `handle`). +- Test: an arg-parse unit test if the binary has a testable parse fn; otherwise a smoke assertion. + +**Interfaces:** +- Consumes: `RootSet::decode(&[u8]) -> Option`, `RootSet::verify_rootset(&[[u8;32]]) -> bool`, `RootSet.roots: Vec<([u8;32], SocketAddr)>`, `RendezvousServer::new_with_roots`. +- Produces: server started in mesh mode when `--roots` + `--network-id` are given. + +**Context:** The roots file is a signed `RootSet` (same artifact yipd loads). The CA pubkeys are `roots.roots.iter().map(|(pk, _)| *pk).collect()`. The rootset is self-consistency-checked with `verify_rootset(&ca_pubkeys)` at load (it is self-signed by one of its own CA keys — mirror how yipd validates it; check `bin/yipd/src/config.rs` / `membership.rs` for the exact load+verify sequence and replicate it). + +- [ ] **Step 1:** Read `bin/yipd`'s roots load+verify path (`config.rs` / `membership.rs`) to copy the exact decode + `verify_rootset` sequence and the `--network-id` hex parsing (16 bytes). + +- [ ] **Step 2:** Add `--roots ` and `--network-id ` to the arg parser. When both present: read the file, `RootSet::decode`, derive `ca_pubkeys`, `verify_rootset` (exit with a clear error if it fails), parse the network id, and construct `RendezvousServer::new_with_roots(now_ms, ca_pubkeys, network_id)`. When absent: `RendezvousServer::new(now_ms)` (legacy). Thread the wall-clock `now_secs` into each `handle` call in the receive loop (monotonic `now_ms` stays for TTL/rate). + +- [ ] **Step 3:** Build + a quick manual/smoke check. + +```bash +cargo build -p yip-rendezvous +cargo test -p yip-rendezvous +``` + +- [ ] **Step 4: commit** + +```bash +cargo fmt && cargo clippy -p yip-rendezvous -- -D warnings +git add bin/yip-rendezvous/src/main.rs +git commit -m "feat(rdv.37): server --roots/--network-id enable mesh-mode signed registration" +``` + +--- + +### Task 4: `Membership` accessors — mint + verify a registration record + +**Files:** +- Modify: `bin/yipd/src/membership.rs` +- Test: same file's tests module. + +**Interfaces:** +- Consumes: existing `build_signed_record(cert, endpoints, seq, sign_priv) -> Record`, `self.own_cert: Cert`, `self.sign_priv: [u8;32]`, `self.network_id: [u8;16]`, `self.roots: RootSet`, `self.own_node_id`. +- Produces: `pub fn sign_registration(&self, seq: u64) -> Record`; `pub fn verify_record(&self, r: &Record, now_secs: u64) -> bool`. + +- [ ] **Step 1: Write the failing tests** + +```rust +#[test] +fn sign_registration_is_verifiable_against_own_roots() { + let m = test_membership(); // existing helper that builds a Membership with a test CA + let rec = m.sign_registration(5); + assert_eq!(rec.node_id, m.own_node_id()); + assert!(m.verify_record(&rec, now_secs())); +} + +#[test] +fn verify_record_rejects_foreign_ca() { + let m = test_membership(); + let foreign = record_signed_by_unrelated_ca(); + assert!(!m.verify_record(&foreign, now_secs())); +} +``` + +Add `own_node_id()` accessor if not present (there may already be one). Reuse the module's existing membership test constructor. + +- [ ] **Step 2: Run — expect FAIL.** + +- [ ] **Step 3: Implement** + +```rust +/// Mint a fresh, self-signed registration `Record` at `seq` (the rendezvous +/// freshness counter). Reuses the gossip record machinery — same cert, same +/// endpoints, monotonic `seq`. +pub fn sign_registration(&self, seq: u64) -> Record { + build_signed_record(self.own_cert.clone(), self.own_endpoints(), seq, &self.sign_priv) +} + +/// Verify a registration/directory `Record` against our own roots + network, +/// at wall-clock `now_secs`. True iff the cert chains to a root, the record +/// signature is valid, and the node_id binds the cert (squatting closed). +pub fn verify_record(&self, r: &Record, now_secs: u64) -> bool { + let ca_pubkeys: Vec<[u8; 32]> = self.roots.roots.iter().map(|(pk, _)| *pk).collect(); + r.verify(&ca_pubkeys, &self.network_id, now_secs, clock_skew_secs()).is_ok() +} +``` + +If `own_endpoints` is not stored separately (it was moved into `own_record` at construction), either store `own_endpoints` on the struct or mint from `self.own_record.endpoints.clone()`. Use `clock_skew_secs()` (the #41 skew source); import/reuse it. + +- [ ] **Step 4: Run — expect PASS**, plus the full `cargo test -p yipd --bin yipd` membership suite. + +- [ ] **Step 5: commit** + +```bash +cargo fmt && cargo clippy -p yipd --bin yipd -- -D warnings +git add bin/yipd/src/membership.rs +git commit -m "feat(membership.37): sign_registration + verify_record accessors" +``` + +--- + +### Task 5: Client — send `RegisterSigned`; verify `PeerInfo.record` before probing + +**Files:** +- Modify: `bin/yipd/src/rendezvous.rs`, `bin/yipd/src/peer_manager.rs` +- Test: `rendezvous.rs` tests module + a `peer_manager.rs` integration-style test. + +**Interfaces:** +- Consumes: `Rendezvous::register(&mut self, node: NodeId) -> Option` (trait, ~38), `parse(&self, dg: &[u8]) -> RdvEvent` (~41), `RdvEvent::PeerCandidate`; `Membership::sign_registration` / `verify_record` (Task 4). +- Produces: `register` carries a signed `Record` in mesh mode; `parse` drops a `PeerInfo` whose `record` fails verification (mesh mode). + +**Context:** The trait's `register(node)` cannot build a record itself (no membership). Two coherent options — pick **(A)**: change the trait to `register(&mut self, node: NodeId, signed: Option) -> Option`; `PeerManager` mints `signed` from `self.membership` with a monotonic per-registration seq and passes it. In mesh mode `signed` is `Some` → emit `RegisterSigned`; else `None` → legacy `Register { node, counter }`. For `parse`, the `Rendezvous` impl cannot verify (no roots), so it must surface the raw `record` to `PeerManager`, which verifies via `Membership::verify_record` before acting — add the `record` to `RdvEvent::PeerCandidate` (or verify inside `PeerManager`'s rdv-event handling where membership is in scope) and drop the candidate when verification fails. + +- [ ] **Step 1: Write failing tests** + +```rust +// rendezvous.rs +#[test] +fn register_emits_signed_when_record_present() { + let mut r = ConfiguredServerRendezvous::new(server()); + let me = [3u8; 16]; + let rec = sample_record_with_node(me); + let dg = r.register(me, Some(rec.clone())).expect("Some"); + let msg = decode_to_server(&dg); + assert!(matches!(msg, Some(Message::RegisterSigned { record }) if record.node_id == me)); +} + +#[test] +fn register_falls_back_to_unsigned_without_record() { + let mut r = ConfiguredServerRendezvous::new(server()); + let me = [3u8; 16]; + let dg = r.register(me, None).expect("Some"); + assert!(matches!(decode_to_server(&dg), Some(Message::Register { node, .. }) if node == me)); +} +``` + +For `PeerManager`: a test that a `PeerInfo` carrying a record failing `verify_record` does NOT produce a peer candidate / probe, and one carrying a valid record does. Reuse the peer_manager rdv test helpers (search for `RdvEvent`/`PeerCandidate`/`on_rdv` in tests). + +- [ ] **Step 2: Run — expect FAIL** (signature change / verification not wired). + +- [ ] **Step 3: Implement** + +Change the `Rendezvous::register` signature to take `signed: Option`; update both impls (`ConfiguredServerRendezvous`, the TLS-relay `register` returns `None` as today) and the `PeerManager` call site. In `PeerManager`, hold a monotonic `reg_seq: u64` (bump per registration emit); when membership is present, `signed = Some(self.membership.as_ref().unwrap().sign_registration(reg_seq))`. Surface `PeerInfo.record` through `parse`/`RdvEvent` and, in the `PeerManager` handler that turns a `PeerCandidate` into a probe, call `membership.verify_record(&record, now_secs)` and drop on failure (mesh mode only; non-mesh has no record to verify). + +- [ ] **Step 4: Run — expect PASS**, plus full `cargo test -p yipd --bin yipd`. + +- [ ] **Step 5: commit** + +```bash +cargo fmt && cargo clippy -p yipd --bin yipd -- -D warnings +git add bin/yipd/src/rendezvous.rs bin/yipd/src/peer_manager.rs +git commit -m "feat(rdv.37): client sends RegisterSigned + verifies PeerInfo.record before probing" +``` + +--- + +### Task 6: netns money test — registration-overwrite refused, victim reachable + CI + +**Files:** +- Create: `bin/yipd/tests/run-netns-registration-hijack.sh` +- Modify: `.github/workflows/integration.yml` + +**Interfaces:** +- Consumes: the mesh discovery netns harness (fork `run-netns-discovery.sh`, which already bootstraps a CA/roots mesh via rendezvous); `yip-ca` for minting certs; the both-driver + guard conventions. +- Produces: an exit-gated assertion that a forged overwrite is refused and the victim stays reachable. + +**Context:** Start a rooted rendezvous server (`--roots`/`--network-id`). Two members A and B establish via signed registration + discovery. An attacker process (a third netns, holding NO valid member cert, or a valid member cert for a DIFFERENT node_id) sends a `RegisterSigned`/`Register` claiming A's node_id from its own address. Assert: (1) the server refuses it (A's lookup still returns A's real address); (2) B→A connectivity is uninterrupted (steady ping ≤1% loss across the attack window). Both drivers. + +- [ ] **Step 1:** Read `run-netns-discovery.sh` for the mesh bootstrap (CA, roots file, per-node certs, rendezvous startup) and `run-netns-cert-revocation.sh` for the both-driver/guard pattern and `yip-ca` usage. + +- [ ] **Step 2:** Write `run-netns-registration-hijack.sh`: bootstrap a rooted rendezvous + A + B (signed registration path), establish + warm ping. From an attacker netns, replay/forge a `Register`/`RegisterSigned` for A's node_id. Assert the server drops it (A's `Lookup` reflexive is unchanged — capture via a small probe or via A staying pingable from B) and the measured ping B→A stays ≤1% loss. `set -euo pipefail`, `trap`, SKIP-when-not-root, both drivers. + +- [ ] **Step 3: Run under sudo, both drivers** + +```bash +cargo build --release -p yipd -p yip-rendezvous +sudo bash bin/yipd/tests/run-netns-registration-hijack.sh "$(pwd)/target/release/yipd" "$(pwd)/target/release/yip-rendezvous" +sudo YIP_USE_URING=1 bash bin/yipd/tests/run-netns-registration-hijack.sh "$(pwd)/target/release/yipd" "$(pwd)/target/release/yip-rendezvous" +``` +Expected: PASS both. If netns cannot run here, capture the blocker and report DONE_WITH_CONCERNS. + +- [ ] **Step 4: Wire CI + commit** + +```bash +chmod +x bin/yipd/tests/run-netns-registration-hijack.sh +git add bin/yipd/tests/run-netns-registration-hijack.sh .github/workflows/integration.yml +git commit -m "test(rdv.37): netns — forged registration refused, victim stays reachable" +``` + +--- + +## After all tasks + +- Final whole-branch review (opus) over the #37 delta. Focus: `Record::verify` is applied before any store (server) and before any probe (peer); the rooted server drops BOTH forged signed and legacy unsigned registers; squatting is closed (node_id binds the cert inside `Record::verify`); the reflexive address stays server-observed (not signed — backstopped by handshake-commit) and this is intended; non-mesh path is byte-identical (legacy `Register`/no-record `PeerInfo`); clock split correct (secs for validity, ms for TTL/rate); no `unsafe`/`as`/bare-`allow`; panic-free decode. +- This is one PR of the authenticated-reachability milestone (the other is M2). Leave it for the user; no "not merging" line. From ba14e2fa8cb9144766ef53e9ee7f2ddfe4cc861c Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Thu, 23 Jul 2026 20:31:40 -0400 Subject: [PATCH 03/15] feat(roaming.m2): endpoint follows an authenticated, non-replayed inbound packet --- bin/yipd/src/peer_manager.rs | 171 ++++++++++++++++++++++++++++++++++- 1 file changed, 168 insertions(+), 3 deletions(-) diff --git a/bin/yipd/src/peer_manager.rs b/bin/yipd/src/peer_manager.rs index 82932bb..bbb1b78 100644 --- a/bin/yipd/src/peer_manager.rs +++ b/bin/yipd/src/peer_manager.rs @@ -1464,11 +1464,29 @@ impl PeerManager { .position(|p| p.endpoint == Some(src) && matches!(p.state, PeerState::Established(_))) } + /// WireGuard-style roaming: after a datagram has AUTHENTICATED and passed + /// the replay window (a non-`None` `inbound_open`), point a direct peer's + /// `endpoint` at the observed source. A `relay` peer's `endpoint` is a + /// rendezvous placeholder and must not roam. Gated on `src` differing so + /// steady-state traffic is a no-op. This never runs for an unauthenticated + /// Init (that path does not call `inbound_open`), preserving #34. + fn relearn_endpoint(&mut self, idx: usize, src: SocketAddr) { + if !self.peers[idx].relay && self.peers[idx].endpoint != Some(src) { + self.peers[idx].endpoint = Some(src); + } + } + /// Dispatch a `Data`/`Control` datagram to peer `idx`'s `EpochSet` (via /// `inbound_open`) and re-map its `EpochInbound` into a `DispatchOut`. /// Returns `DispatchOut::None` if `idx` is not (or no longer) /// `Established`. - fn dispatch_established(&mut self, idx: usize, dg: &[u8], now_ms: u64) -> DispatchOut<'_> { + fn dispatch_established( + &mut self, + idx: usize, + src: SocketAddr, + dg: &[u8], + now_ms: u64, + ) -> DispatchOut<'_> { let PeerState::Established(epochs) = &mut self.peers[idx].state else { return DispatchOut::None; }; @@ -1478,7 +1496,12 @@ impl PeerManager { // for relay-established peers (their `DataPlane::peer_addr` is a // `server_addr()` stand-in; `endpoint` may hold an unconfirmed // candidate or `None`). - match epochs.inbound_open(dg, now_ms) { + let opened = epochs.inbound_open(dg, now_ms); + if !matches!(opened, crate::epoch::EpochInbound::None) { + // Authenticated + non-replayed (M2 roaming) — safe to roam. + self.relearn_endpoint(idx, src); + } + match opened { crate::epoch::EpochInbound::None => DispatchOut::None, crate::epoch::EpochInbound::Tun(buf) => { self.tun_scratch = buf; @@ -1509,7 +1532,7 @@ impl PeerManager { if dg[0] == PacketType::Data as u8 { self.peers[idx].last_activity_ms = now_ms; } - return self.dispatch_established(idx, dg, now_ms); + return self.dispatch_established(idx, src, dg, now_ms); } // No address/tag match at all (e.g. the peer roamed) — try every // Established peer's codec once each. Safe (see module doc): a @@ -1547,6 +1570,8 @@ impl PeerManager { let Some((tun, udp)) = hit else { continue; }; + // Authenticated + non-replayed (M2 roaming) — safe to roam. + self.relearn_endpoint(idx, src); // `udp` already carries each datagram's real `dst`/`fate` (see // `EpochInbound`); no reconstruction from `self.peers[idx].endpoint` // needed (that placeholder is wrong for relay-established peers). @@ -8672,4 +8697,144 @@ mod tests { "membership-off: sweep is a no-op" ); } + + // ── M2 Task 1: endpoint roaming on authenticated inbound ──────────────── + + /// Build a two-`PeerManager` Established pair via `established_pm_pair` + /// (a large `rekey_interval_ms` so `on_tun`'s drive-rekey-schedule never + /// fires), returning `(pm_i, pm_r, old_ep)`: `pm_i` is the initiator + /// (sends data), `pm_r` is the responder whose `peers[0].endpoint` is + /// under test, and `old_ep` is `pm_r`'s currently-learned address for + /// `pm_i` (`established_pm_pair`'s `ep_a`). + fn established_pair_for_roaming() -> (PeerManager, PeerManager, SocketAddr) { + let (pm_i, pm_r, ep_i, _ep_r, _kp_i, _kp_r) = established_pm_pair(1_000_000); + assert_eq!(pm_r.peers[0].endpoint, Some(ep_i)); + (pm_i, pm_r, ep_i) + } + + #[test] + fn authenticated_data_from_new_src_roams_endpoint() { + // Two direct peers, established; pm_r.peers[0].endpoint == old_ep. + let (mut pm_i, mut pm_r, old_ep) = established_pair_for_roaming(); + + // The initiator sends a real Data packet; capture its on-wire bytes. + // (Systematic FEC may emit extra parity datagrams alongside the + // source symbol at index 0 — the source symbol alone is a complete, + // independently-reconstructable frame, exactly like the existing + // `rekey_resp_promotes_initiator_and_keeps_previous_for_grace` test's + // use of `on_tun(..)[0]`.) + let data = pm_i.on_tun(&dummy_tun_pkt(), 0).to_vec(); + assert_eq!(data[0].bytes[0], PacketType::Data as u8); + let dg = data[0].bytes.clone(); + + let new_src: SocketAddr = "198.51.100.222:60000".parse().unwrap(); + assert_ne!(new_src, old_ep); + + // Deliver from the NEW source. For real (masked) traffic `dg[1..9]` + // is per-datagram garbage (see the module doc), so `by_tag` misses, + // and `new_src != endpoint` so the address match misses too — + // `route_data` returns `None` and this is handled by the OTHER + // authenticated-decrypt site, the roaming fallback loop in + // `handle_data_or_control` (same site `replayed_data_from_spoofed_src_does_not_roam_endpoint` + // exercises for the rejection case). The datagram still + // authenticates there via `inbound_open`. + let out = pm_r.on_udp(new_src, &dg, 1_000); + assert!( + !matches!(out, DispatchOut::None), + "a genuine Data datagram must authenticate" + ); + assert_eq!( + pm_r.peers[0].endpoint, + Some(new_src), + "endpoint must follow an authenticated packet from a new source", + ); + } + + #[test] + fn replayed_data_from_spoofed_src_does_not_roam_endpoint() { + let (mut pm_i, mut pm_r, old_ep) = established_pair_for_roaming(); + let data = pm_i.on_tun(&dummy_tun_pkt(), 0).to_vec(); + let dg = data[0].bytes.clone(); + + // First delivery from the legit endpoint authenticates and is + // consumed (advances the replay window). `src == old_ep` matches + // `route_data`'s address check directly, reaching + // `dispatch_established`. + let out1_is_none = matches!(pm_r.on_udp(old_ep, &dg, 1_000), DispatchOut::None); + assert!(!out1_is_none, "the first delivery must authenticate"); + assert_eq!(pm_r.peers[0].endpoint, Some(old_ep)); + + // REPLAY the exact same datagram from a spoofed source: `dg[1..9]` + // is masked per-datagram (see the module doc), so it does not match + // `by_tag`, and `spoof != old_ep` so it does not match by address + // either — `route_data` returns `None` and this exercises the OTHER + // authenticated-decrypt site, the roaming fallback loop in + // `handle_data_or_control`. The replay window there must reject it — + // this must fail if the replay were (incorrectly) accepted. + let spoof: SocketAddr = "203.0.113.66:5555".parse().unwrap(); + let out2_is_none = matches!(pm_r.on_udp(spoof, &dg, 1_001), DispatchOut::None); + assert!( + out2_is_none, + "a replayed datagram must be rejected, not accepted" + ); + assert_eq!( + pm_r.peers[0].endpoint, + Some(old_ep), + "a replayed packet must not move the endpoint" + ); + } + + #[test] + fn dispatch_established_relearns_endpoint_directly() { + // The `route_data` address-match path always calls + // `dispatch_established` with `src == endpoint` (by construction of + // the match), so `on_udp` alone can never exercise + // `dispatch_established`'s own relearn call with a DIFFERING `src` — + // real masked traffic that roams is only ever caught by the fallback + // loop (see `authenticated_data_from_new_src_roams_endpoint`'s + // comment). This test calls `dispatch_established` directly (it's a + // private inherent method, reachable from `mod tests`) with a src + // that differs from the learned endpoint, proving that authenticated- + // decrypt site's own relearn call (added per the brief as + // defense-in-depth for a future/hand-built `by_tag` hit) is wired + // correctly, independent of which routing path reaches it. + let (mut pm_i, mut pm_r, old_ep) = established_pair_for_roaming(); + let dg = pm_i.on_tun(&dummy_tun_pkt(), 0).to_vec()[0].bytes.clone(); + let new_src: SocketAddr = "198.51.100.7:5000".parse().unwrap(); + assert_ne!(new_src, old_ep); + + let out_is_none = matches!( + pm_r.dispatch_established(0, new_src, &dg, 1_000), + DispatchOut::None + ); + assert!(!out_is_none, "a genuine Data datagram must authenticate"); + assert_eq!( + pm_r.peers[0].endpoint, + Some(new_src), + "dispatch_established's own relearn call must move the endpoint" + ); + } + + #[test] + fn relay_peer_endpoint_does_not_roam() { + // A relay-established peer (`established_relay_pm`: `relay = true`, + // `endpoint` is whatever placeholder the config left it as). + // `relearn_endpoint` must be a no-op regardless of `src`. + let (mut pm, _local, _peer_kp, _old_tag) = established_relay_pm(100); + assert!(pm.peers[0].relay); + let placeholder = pm.peers[0].endpoint; + + pm.relearn_endpoint(0, "198.51.100.9:7000".parse().unwrap()); + + assert_eq!( + pm.peers[0].endpoint, placeholder, + "a relay peer's endpoint must never roam" + ); + } + + // #34 preservation: an unauthenticated Init from a spoofed source against + // an Established responder still must not move `endpoint` — M2 does not + // touch the Init path (it never calls `inbound_open`), so the existing + // guard covers this; see `stale_replayed_cold_start_init_does_not_hijack_endpoint` + // and `stale_replayed_init_is_rejected_and_endpoint_unchanged` above. } From bd59df520d4208f58d7bf9bb6c3eda79b26b7a5b Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Thu, 23 Jul 2026 20:46:31 -0400 Subject: [PATCH 04/15] feat(roaming.m2): deobf_ingress trials session keys so a roamed peer's data is found --- bin/yipd/src/peer_manager.rs | 108 ++++++++++++++++++++++++++++++++++- 1 file changed, 105 insertions(+), 3 deletions(-) diff --git a/bin/yipd/src/peer_manager.rs b/bin/yipd/src/peer_manager.rs index bbb1b78..cabb147 100644 --- a/bin/yipd/src/peer_manager.rs +++ b/bin/yipd/src/peer_manager.rs @@ -2470,9 +2470,14 @@ impl PeerManager { /// Order (matches the addendum): /// (a) If `src` is a known `Established` peer, try that peer's /// `session_obf_key`; accept only `Data`/`Control`/`Gossip`. - /// (b) Otherwise (or if (a) did not yield one of those types), try the - /// network `obf_key`; accept only `HandshakeInit`/`HandshakeResp` — this - /// covers a brand-new peer's `Init` AND a re-handshake from a known src. + /// (a') Otherwise (the peer may have roamed to a new source), trial every + /// `Established` peer's `session_obf_key` in turn; accept only + /// `Data`/`Control`/`Gossip` — mirrors `handle_data_or_control`'s + /// plaintext roaming fallback loop, one layer up. + /// (b) Otherwise (or if (a)/(a') did not yield one of those types), try + /// the network `obf_key`; accept only `HandshakeInit`/`HandshakeResp` + /// — this covers a brand-new peer's `Init` AND a re-handshake from a + /// known src. /// /// A wrong key yields `None` or a garbage `(ptype, body)`; the type-set /// filters and, ultimately, the inner Noise/AEAD/frame verify make every @@ -2498,6 +2503,30 @@ impl PeerManager { } } } + // (a') Roaming fallback: no endpoint match, but the datagram may be a + // roamed Established peer's Data/Control/Gossip under its session key. + // Trial each Established peer's session key; a wrong key yields None or a + // garbage type that the type-set + inner verify drop safely (same + // invariant as `handle_data_or_control`'s plaintext roaming loop). + for p in &self.peers { + if !matches!(p.state, PeerState::Established(_)) { + continue; + } + let Some(key) = p.session_obf_key else { + continue; + }; + if let Some((ptype, body)) = yip_obf::deobfuscate(&key, dg) { + if ptype == yip_obf::JUNK_TYPE { + return None; + } + if ptype == PacketType::Data as u8 + || ptype == PacketType::Control as u8 + || ptype == PacketType::Gossip as u8 + { + return Some(reassemble(ptype, &body)); + } + } + } // (b) pre-session network key → handshakes only. if let Some(key) = self.obf_key { if let Some((ptype, body)) = yip_obf::deobfuscate(&key, dg) { @@ -7849,6 +7878,79 @@ mod tests { assert!(pm.by_tag.is_empty()); } + // ── M2 roaming: deobf_ingress trial fallback ──────────────────────────── + + /// Build an obf-on `PeerManager` with a single `Established` peer at a + /// known endpoint and a known `session_obf_key`, plus a plaintext + /// `[Data][body]` datagram as `deobf_ingress` would return it — the + /// shared fixture for the roaming trial-fallback tests below. + fn established_obf_peer_with_data() -> (PeerManager, [u8; 16], Vec) { + const TAG: u64 = 0x9999_1111_2222_3333; + let peer_ep: SocketAddr = "10.0.0.30:3030".parse().unwrap(); + let peer = peer_cfg(30, "10.0.0.30:3030"); + let mut pm = PeerManager::new( + [31u8; 32], + [32u8; 32], + &[peer], + TunnelMode::L3Tun, + None, + None, + false, + ); + pm.set_obf_psk(Some([0xCCu8; 32])); + + pm.peers[0].state = PeerState::Established(Box::new(crate::epoch::EpochSet::new( + Box::new(fake_established_dataplane(TAG, peer_ep)), + 0, + ))); + let sess = [0xDDu8; 16]; + pm.peers[0].session_obf_key = Some(sess); + pm.by_tag.insert(TAG, 0); + + let mut plaintext = vec![PacketType::Data as u8]; + plaintext.extend_from_slice(&[0x55u8; 40]); + (pm, sess, plaintext) + } + + /// With obfuscation on, a `Data` datagram obfuscated under an + /// `Established` peer's session key, but arriving from a source that does + /// NOT match that peer's recorded `endpoint` (the peer roamed to a new + /// NAT mapping), still deobfuscates: step (a)'s endpoint fast-path misses, + /// but the (a') roaming trial fallback tries every `Established` peer's + /// session key and finds this one. + #[test] + fn deobf_finds_roamed_peer_session_key_by_trial() { + let (pm, sess, plaintext) = established_obf_peer_with_data(); + let obf = yip_obf::obfuscate(&sess, PacketType::Data as u8, &plaintext[1..], 0); + let roamed: SocketAddr = "198.51.100.231:41111".parse().unwrap(); + assert_ne!( + pm.peers[0].endpoint, + Some(roamed), + "fixture invariant: the roamed src must not match the peer's endpoint" + ); + + let out = pm.deobf_ingress(roamed, &obf); + assert_eq!( + out.as_deref(), + Some(plaintext.as_slice()), + "roamed peer's data must deobfuscate via the trial fallback" + ); + } + + /// A datagram that decodes under no known key at all — not the (a) + /// endpoint match, not the (a') trial over `Established` peers' session + /// keys, not the (b) network key — is dropped, never mis-dispatched. + #[test] + fn deobf_trial_does_not_accept_foreign_datagram() { + let (pm, _sess, _plaintext) = established_obf_peer_with_data(); + let garbage = vec![0xABu8; 64]; + let src: SocketAddr = "203.0.113.9:9".parse().unwrap(); + assert!( + pm.deobf_ingress(src, &garbage).is_none(), + "a datagram under no known session key must not deobfuscate" + ); + } + /// (3b-a) `build_junk()` produces a PLAINTEXT `[JUNK_TYPE][body]` /// datagram, `JUNK_MIN_LEN..=JUNK_MAX_LEN` bytes of body — it must NOT /// pre-obfuscate, since the caller's `obf_egress` pass wraps it exactly From 0950b88f8c486968f05abdfc6a5f6b9972960a69 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Fri, 24 Jul 2026 16:17:48 -0400 Subject: [PATCH 05/15] =?UTF-8?q?feat(roaming.m2):=20egress=20follows=20th?= =?UTF-8?q?e=20roam=20=E2=80=94=20update=20DataPlane=20peer=5Faddr=20on=20?= =?UTF-8?q?relearn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relearning peers[idx].endpoint heals ingress demux/deobfuscation, but egress datagrams are stamped from each epoch's DataPlane::peer_addr, not endpoint. So a roam that updated endpoint alone left return traffic targeting the peer's stale (post-rebind, dead) address — bidirectional recovery failed. relearn_endpoint now also pushes the new source into the live EpochSet (current/next/previous) via DataPlane::set_peer_addr. New unit test roam_redirects_egress_to_the_new_source. --- bin/yipd/src/dataplane.rs | 10 +++++++ bin/yipd/src/epoch.rs | 14 ++++++++++ bin/yipd/src/peer_manager.rs | 53 +++++++++++++++++++++++++++++++++--- 3 files changed, 73 insertions(+), 4 deletions(-) diff --git a/bin/yipd/src/dataplane.rs b/bin/yipd/src/dataplane.rs index 6d50bf9..d0bec2c 100644 --- a/bin/yipd/src/dataplane.rs +++ b/bin/yipd/src/dataplane.rs @@ -216,6 +216,16 @@ impl DataPlane { self.conn_tag } + /// Redirect this data plane's egress to a new peer address — WireGuard-style + /// roaming, after the peer's authenticated source moved (M2). Every + /// subsequently-built egress datagram (data, ARQ retransmit, tick feedback) + /// is stamped with `addr`. Callers gate this on a cryptographically + /// authenticated, non-replayed inbound packet, so it cannot be steered by a + /// spoofed source. + pub fn set_peer_addr(&mut self, addr: SocketAddr) { + self.peer_addr = addr; + } + /// Seal `inner`, FEC-encode, frame each symbol, and return the resulting /// egress datagrams as a borrow of an internal reused scratch buffer. /// diff --git a/bin/yipd/src/epoch.rs b/bin/yipd/src/epoch.rs index 7fbc022..052ed2d 100644 --- a/bin/yipd/src/epoch.rs +++ b/bin/yipd/src/epoch.rs @@ -96,6 +96,20 @@ impl EpochSet { &mut self.current } + /// Point every live epoch's egress at `addr` — WireGuard-style roaming after + /// the peer's authenticated source moved (M2). Applies to `current`, the + /// unconfirmed `next`, and the grace `previous`, so a roam is not undone by a + /// pending or recently-rotated epoch. + pub fn set_peer_addr(&mut self, addr: std::net::SocketAddr) { + self.current.set_peer_addr(addr); + if let Some(n) = self.next.as_mut() { + n.dp.set_peer_addr(addr); + } + if let Some(p) = self.previous.as_mut() { + p.set_peer_addr(addr); + } + } + /// Convert a borrowed `Outcome` into the owned `EpochInbound` (same copies /// the caller already performed). Returns `None` for `Outcome::None`. fn own(outcome: Outcome<'_>) -> EpochInbound { diff --git a/bin/yipd/src/peer_manager.rs b/bin/yipd/src/peer_manager.rs index cabb147..42c3f19 100644 --- a/bin/yipd/src/peer_manager.rs +++ b/bin/yipd/src/peer_manager.rs @@ -1466,13 +1466,23 @@ impl PeerManager { /// WireGuard-style roaming: after a datagram has AUTHENTICATED and passed /// the replay window (a non-`None` `inbound_open`), point a direct peer's - /// `endpoint` at the observed source. A `relay` peer's `endpoint` is a - /// rendezvous placeholder and must not roam. Gated on `src` differing so - /// steady-state traffic is a no-op. This never runs for an unauthenticated - /// Init (that path does not call `inbound_open`), preserving #34. + /// `endpoint` at the observed source AND redirect its egress there. A + /// `relay` peer's `endpoint` is a rendezvous placeholder and must not roam. + /// Gated on `src` differing so steady-state traffic is a no-op. This never + /// runs for an unauthenticated Init (that path does not call `inbound_open`), + /// preserving #34. + /// + /// Updating `endpoint` alone heals ingress demux/deobfuscation, but egress + /// datagrams are stamped from each epoch's `DataPlane::peer_addr` (not + /// `endpoint`), so the roam must also be pushed into the live `EpochSet` or + /// return traffic keeps targeting the peer's stale (post-rebind, dead) + /// address. fn relearn_endpoint(&mut self, idx: usize, src: SocketAddr) { if !self.peers[idx].relay && self.peers[idx].endpoint != Some(src) { self.peers[idx].endpoint = Some(src); + if let PeerState::Established(epochs) = &mut self.peers[idx].state { + epochs.set_peer_addr(src); + } } } @@ -8852,6 +8862,41 @@ mod tests { ); } + #[test] + fn roam_redirects_egress_to_the_new_source() { + // Relearning `endpoint` alone is a half-fix: egress datagrams are + // stamped from the `EpochSet`'s `DataPlane::peer_addr`, not `endpoint`. + // After an authenticated roam, the responder's OWN outbound data must + // target the new source, or return traffic keeps hitting the peer's + // stale (post-rebind, dead) address. + let (mut pm_i, mut pm_r, old_ep) = established_pair_for_roaming(); + + // Pre-roam: pm_r's egress targets the original endpoint. + let before = pm_r.on_tun(&dummy_tun_pkt(), 500).to_vec(); + assert_eq!( + before[0].dst, old_ep, + "egress targets the original endpoint" + ); + + // pm_i sends an authenticated Data packet from a NEW source; pm_r roams. + let data = pm_i.on_tun(&dummy_tun_pkt(), 0).to_vec(); + let dg = data[0].bytes.clone(); + let new_src: SocketAddr = "198.51.100.222:60000".parse().unwrap(); + assert_ne!(new_src, old_ep); + let out = pm_r.on_udp(new_src, &dg, 1_000); + assert!( + !matches!(out, DispatchOut::None), + "the roam packet must authenticate" + ); + + // Post-roam: pm_r's egress now targets the new source (not just `endpoint`). + let after = pm_r.on_tun(&dummy_tun_pkt(), 2_000).to_vec(); + assert_eq!( + after[0].dst, new_src, + "egress must follow the roam to the new source, not the stale addr", + ); + } + #[test] fn replayed_data_from_spoofed_src_does_not_roam_endpoint() { let (mut pm_i, mut pm_r, old_ep) = established_pair_for_roaming(); From 93ae1b962baea03eea0e9515a32f21f9692dc1d0 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Fri, 24 Jul 2026 16:18:16 -0400 Subject: [PATCH 06/15] =?UTF-8?q?test(roaming.m2):=20netns=20=E2=80=94=20m?= =?UTF-8?q?id-session=20NAT=20rebind=20recovers=20on=20the=20obf=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run-netns-roaming.sh: A (wildcard-bound, obf on) changes its on-wire source mid-session while its session/keys/conn_tag stay put; B must relearn A's endpoint from an authenticated inbound packet and redirect egress there. Asserts steady ping A->B stays <=1% loss across the rebind and the relay was not used. Both drivers, wired into integration.yml next to the anti-replay.34 money test. --- .github/workflows/integration.yml | 38 +++ bin/yipd/tests/run-netns-roaming.sh | 373 ++++++++++++++++++++++++++++ 2 files changed, 411 insertions(+) create mode 100755 bin/yipd/tests/run-netns-roaming.sh diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index fc58a05..d0fec3e 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -344,6 +344,44 @@ jobs: exit 1 fi + - name: Run the roaming.m2 NAT-rebind money test under sudo (poll driver) + # run-netns-roaming.sh: proves M2 authenticated endpoint roaming + # end-to-end. A direct, obfuscation-on peer A changes its on-wire + # source address mid-session (a NAT rebind: A binds 0.0.0.0 and the + # script adds a second address + flips the route's preferred source, + # so A's session/keys/conn_tag are untouched). B must relearn A's + # endpoint from an authenticated, non-replayed inbound packet AND + # redirect its egress there (endpoint alone is insufficient: egress is + # stamped from the epoch's peer_addr), so a steady ping A->B stays + # <=1% loss across the rebind. Uses the release yipd and debug + # yip-rendezvous already built above in this job. + run: | + sudo bash bin/yipd/tests/run-netns-roaming.sh \ + "$(pwd)/target/release/yipd" \ + "$(pwd)/target/debug/yip-rendezvous" | tee /tmp/roaming-poll.log + if grep -q "^SKIP run-netns-roaming" /tmp/roaming-poll.log; then + echo "::error::roaming.m2 NAT-rebind money test (poll) skipped — expected root + TUN in this job" + exit 1 + fi + if grep -q "\[FAIL\]" /tmp/roaming-poll.log; then + echo "::error::roaming.m2 NAT-rebind money test (poll) failed — B did not recover A's roamed, obfuscated source across the mid-session rebind" + exit 1 + fi + + - name: Run the roaming.m2 NAT-rebind money test under sudo (uring driver) + run: | + sudo -E env YIP_USE_URING=1 bash bin/yipd/tests/run-netns-roaming.sh \ + "$(pwd)/target/release/yipd" \ + "$(pwd)/target/debug/yip-rendezvous" | tee /tmp/roaming-uring.log + if grep -q "^SKIP run-netns-roaming" /tmp/roaming-uring.log; then + echo "::error::roaming.m2 NAT-rebind money test (uring) skipped — expected root + TUN in this job" + exit 1 + fi + if grep -q "\[FAIL\]" /tmp/roaming-uring.log; then + echo "::error::roaming.m2 NAT-rebind money test (uring) failed — B did not recover A's roamed, obfuscated source across the mid-session rebind" + 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 diff --git a/bin/yipd/tests/run-netns-roaming.sh b/bin/yipd/tests/run-netns-roaming.sh new file mode 100755 index 0000000..452af11 --- /dev/null +++ b/bin/yipd/tests/run-netns-roaming.sh @@ -0,0 +1,373 @@ +#!/usr/bin/env bash +# The M2 endpoint-roaming money test: a direct, established, obfuscation-ON +# peer whose SOURCE ADDRESS changes mid-session (a NAT rebind) must keep +# being delivered to — B relearns A's new endpoint from an authenticated, +# non-replayed inbound packet (Task 1, `PeerManager::relearn_endpoint`) and +# deobfuscates A's data arriving from that new source by trialling +# Established peers' session keys (Task 2, `deobf_ingress`'s (a') step). +# +# Usage: run-netns-roaming.sh +# +# ── why obfuscation MUST be ON ── +# Under plaintext, a roamed peer's Data already gets found via the +# `handle_data_or_control` fallback loop's raw per-peer decrypt attempts — +# that path predates M2. Under `obf_psk`, the wire bytes are masked and the +# ONLY way to find a roamed peer's session is `deobf_ingress`'s (a') trial +# loop added in Task 2; without it, roamed obfuscated traffic black-holes +# even though the underlying session is perfectly healthy. This script +# therefore hardcodes `obf_psk` on both peers AND on yip-rendezvous +# unconditionally — this is not the optional `[ -n "${OBF_PSK:-}" ]` pattern +# sibling scripts use, because for THIS test obf off would prove nothing. +# +# ── topology (forked from run-netns-punch.sh's A-T-B punch topology) ── +# A --10.86.0.0/24-- T --10.87.0.0/24-- B +# T forwards between the two client subnets (ip_forward=1 + FORWARD ACCEPT +# both directions, same as run-netns-punch.sh), so A and B establish DIRECTLY +# through T (T is a transparent router here, not a relay) — the punch path +# converges immediately, exactly like run-netns-punch.sh's own money +# assertion (relay-forwarded stays 0), which this script re-checks after the +# rebind too. Unlike run-netns-punch.sh, A's and B's netns do NOT get a local +# MASQUERADE rule: that rule is decorative there (single-homed netns, a +# provable no-op per its own header comment) and would actively interfere +# here, since A's netns becomes dual-addressed for the rebind (MASQUERADE's +# conntrack-based source rewrite would fight the explicit route-`src` +# mechanism below). +# +# ── the rebind mechanic: a real on-wire source-address change, no NAT box ── +# A's yipd binds `listen=0.0.0.0:` (wildcard) rather than a fixed local +# IP. Its UDP socket is unconnected (recvfrom/sendto, addressed per +# datagram — see tunnel.rs's `bind_dataplane_udp` doc comment), so with a +# wildcard bind the kernel selects the OUTBOUND SOURCE ADDRESS for each +# sendto() from the matching route's preferred-source hint, not from any +# fixed socket-level address. A's netns starts with a single address +# (10.86.0.2); mid-session, this script adds a SECOND address (10.86.0.3) to +# A's veth and does `ip route replace default ... src 10.86.0.3` — one +# atomic command that flips every subsequent packet A's yipd sends toward B +# onto the new source address, with A's yipd PROCESS untouched (no restart, +# no re-handshake, no socket rebind) and B's session state for A (keys, +# `conn_tag`) completely unchanged. This is the "change the SNAT mapping / +# bounce A's egress" mechanic the task brief documents, realized without a +# NAT box: T's FORWARD rules key on interface, not source address, and the +# new address is already inside T's connected route for A's /24, so no +# topology change is needed anywhere except inside A's own netns. +# +# Assertions (each a non-zero exit on failure, [PASS]/[FAIL] markers): +# 1. warm-up: ping -6 -c 20 -W 2 A->B succeeds BEFORE any rebind (confirms +# direct punch convergence; its own warm-up loss is not counted). +# 2. rebind continuity: a SEPARATE measured `ping -i 0.2 -c 100` A->B, +# started AFTER the warm-up above, with the rebind triggered partway +# through its run (so the window genuinely spans the transition) — +# packet loss must be <=1%. This is the money assertion: 1% (1 of 100) +# is exactly enough budget to absorb the single-packet transition the +# brief calls out, not a sustained black hole. +# 3. relay-forwarded stays 0 (checked once at the end, covering traffic +# both before and after the rebind) — proving B's recovery is via +# Task 1/2's endpoint-relearn + obf-trial machinery on the still-direct +# path, not a fallback escalation to the relay masking the result. +set -euo pipefail + +YIPD="${1:?Usage: $0 }" +RDV="${2:?Usage: $0 }" + +# ── 0. root + tool preflight (invoked directly by CI, not through the +# tunnel_netns.rs Rust harness, so it does its own SKIP-gating per the +# run-netns-rekey.sh / run-netns-pathswitch-rehandshake.sh convention) ── +if [ "$(id -u)" -ne 0 ]; then + echo "SKIP run-netns-roaming: needs root (netns + TUN)" + exit 0 +fi +for tool in ip iptables ping; do + if ! command -v "$tool" >/dev/null 2>&1; then + echo "SKIP run-netns-roaming: required tool '$tool' not found" + exit 0 + fi +done + +TMPDIR_TEST="$(mktemp -d /tmp/yipd-netns-roaming-test.XXXXXX)" + +NS_A="yipRoamA" +NS_B="yipRoamB" +NS_T="yipRoamT" + +VETH_A_N="vRmA1"; VETH_A_T="vRmA0" # A<->T pair: A-side, T-side +VETH_B_N="vRmB1"; VETH_B_T="vRmB0" # B<->T pair: B-side, T-side + +IP_A="10.86.0.2" # A's address before the rebind +IP_A2="10.86.0.3" # A's address after the rebind (added mid-session) +IP_T_A="10.86.0.1" # T's address on A's subnet +IP_B="10.87.0.2" +IP_T_B="10.87.0.1" # T's address on B's subnet +PREFIX="24" + +PORT_A="51820" +PORT_B="51820" +RDV_PORT="51821" +TUN_DEV="yip0" + +# Obfuscation MUST be on for this test (see header comment) — hardcoded, not +# gated behind an opt-in env var like sibling scripts' OBF_PSK. +OBF_PSK="00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff" + +PID_A="" +PID_B="" +PID_RDV="" + +cleanup() { + echo "[cleanup] killing daemons and 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 + 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 + ip netns del "$NS_A" 2>/dev/null || true + ip netns del "$NS_B" 2>/dev/null || true + ip netns del "$NS_T" 2>/dev/null || true + rm -rf "$TMPDIR_TEST" +} +trap cleanup EXIT + +# ── 1. generate keypairs ────────────────────────────────────────────────────── +echo "[setup] generating keypairs" +GENKEY_A="$("$YIPD" --genkey)" +GENKEY_B="$("$YIPD" --genkey)" + +PRIV_A="$(echo "$GENKEY_A" | grep '^private=' | cut -d= -f2)" +PUB_A="$(echo "$GENKEY_A" | grep '^public=' | cut -d= -f2)" +PRIV_B="$(echo "$GENKEY_B" | grep '^private=' | cut -d= -f2)" +PUB_B="$(echo "$GENKEY_B" | grep '^public=' | cut -d= -f2)" + +ADDR_A="$("$YIPD" --addr "$PUB_A")" +ADDR_B="$("$YIPD" --addr "$PUB_B")" +echo "[setup] node_addr A=$ADDR_A B=$ADDR_B" + +# ── 2. write config files (rendezvous-only peers: public_key, no endpoint) ──── +CFG_A="$TMPDIR_TEST/yipA.conf" +CFG_B="$TMPDIR_TEST/yipB.conf" + +# A binds the WILDCARD address (0.0.0.0), not a fixed local IP: this is what +# lets the rebind below change A's on-wire source without touching A's +# process at all (see header comment). +cat > "$CFG_A" < "$CFG_B" <T" +ip link add "$VETH_A_T" type veth peer name "$VETH_A_N" +ip link set "$VETH_A_N" netns "$NS_A" +ip link set "$VETH_A_T" netns "$NS_T" +ip netns exec "$NS_A" ip addr add "${IP_A}/${PREFIX}" dev "$VETH_A_N" +ip netns exec "$NS_A" ip link set "$VETH_A_N" up +ip netns exec "$NS_A" ip link set lo up +ip netns exec "$NS_T" ip addr add "${IP_T_A}/${PREFIX}" dev "$VETH_A_T" +ip netns exec "$NS_T" ip link set "$VETH_A_T" up + +echo "[setup] wiring B<->T" +ip link add "$VETH_B_T" type veth peer name "$VETH_B_N" +ip link set "$VETH_B_N" netns "$NS_B" +ip link set "$VETH_B_T" netns "$NS_T" +ip netns exec "$NS_B" ip addr add "${IP_B}/${PREFIX}" dev "$VETH_B_N" +ip netns exec "$NS_B" ip link set "$VETH_B_N" up +ip netns exec "$NS_B" ip link set lo up +ip netns exec "$NS_T" ip addr add "${IP_T_B}/${PREFIX}" dev "$VETH_B_T" +ip netns exec "$NS_T" ip link set "$VETH_B_T" up +ip netns exec "$NS_T" ip link set lo up + +# A's and B's only route beyond their own /24 is via T. No NAT/MASQUERADE +# rule here (see header comment for why: it would fight the rebind's +# explicit route `src` below). +ip netns exec "$NS_A" ip route add default via "$IP_T_A" dev "$VETH_A_N" +ip netns exec "$NS_B" ip route add default via "$IP_T_B" dev "$VETH_B_N" + +# T routes between the two client subnets: this is what makes each peer's +# server-observed reflexive addr directly reachable, so the punch succeeds +# without ever needing the relay -- same as run-netns-punch.sh. +ip netns exec "$NS_T" sysctl -q -w net.ipv4.ip_forward=1 +ip netns exec "$NS_T" iptables -P FORWARD ACCEPT +ip netns exec "$NS_T" iptables -A FORWARD -i "$VETH_A_T" -o "$VETH_B_T" -j ACCEPT +ip netns exec "$NS_T" iptables -A FORWARD -i "$VETH_B_T" -o "$VETH_A_T" -j ACCEPT + +# ── 4. start yip-rendezvous in T, bound on both subnets, obf ON ────────────── +LOG_RDV="$TMPDIR_TEST/rdv.log" +echo "[start] starting yip-rendezvous in T on 0.0.0.0:${RDV_PORT} (obf on)" +ip netns exec "$NS_T" "$RDV" "0.0.0.0:${RDV_PORT}" --obf-psk "${OBF_PSK}" \ + >"$LOG_RDV" 2>&1 & +PID_RDV=$! +sleep 0.3 + +# ── 5. start yipd in A and B ─────────────────────────────────────────────────── +LOG_A="$TMPDIR_TEST/yipA.log" +LOG_B="$TMPDIR_TEST/yipB.log" + +dump_logs() { + echo "=== rendezvous log ===" + cat "$LOG_RDV" || true + echo "=== yipRoamA log ===" + cat "$LOG_A" || true + echo "=== yipRoamB log ===" + cat "$LOG_B" || true +} + +echo "[start] starting yipRoamA" +ip netns exec "$NS_A" "$YIPD" "$CFG_A" >"$LOG_A" 2>&1 & +PID_A=$! + +echo "[start] starting yipRoamB" +ip netns exec "$NS_B" "$YIPD" "$CFG_B" >"$LOG_B" 2>&1 & +PID_B=$! + +# ── 6. wait for TUN devices to appear in A and B ────────────────────────────── +TUN_WAIT=20 +INTERVAL=0.25 + +echo "[wait] waiting for TUN devices to appear (up to ${TUN_WAIT}s)" +elapsed=0 +while true; do + A_UP=0; B_UP=0 + ip netns exec "$NS_A" ip link show "$TUN_DEV" >/dev/null 2>&1 && A_UP=1 || true + ip netns exec "$NS_B" ip link show "$TUN_DEV" >/dev/null 2>&1 && B_UP=1 || true + + if [ "$A_UP" -eq 1 ] && [ "$B_UP" -eq 1 ]; then + echo "[wait] both TUN devices are up" + break + fi + + if ! kill -0 "$PID_A" 2>/dev/null; then + echo "[error] yipRoamA daemon died unexpectedly"; dump_logs; exit 1 + fi + if ! kill -0 "$PID_B" 2>/dev/null; then + echo "[error] yipRoamB daemon died unexpectedly"; dump_logs; exit 1 + fi + if ! kill -0 "$PID_RDV" 2>/dev/null; then + echo "[error] yip-rendezvous died unexpectedly"; dump_logs; exit 1 + fi + + elapsed=$(awk "BEGIN {print $elapsed + $INTERVAL}") + if awk "BEGIN {exit ($elapsed >= $TUN_WAIT) ? 0 : 1}"; then + echo "[error] timed out waiting for TUN devices"; dump_logs; exit 1 + fi + sleep "$INTERVAL" +done + +# ── 7. assign each TUN its node_addr/128 + the mesh-prefix route ───────────── +echo "[setup] assigning node_addr/128 + fd00::/8 route on each TUN" +assign_mesh() { + local ns="$1" addr="$2" + ip netns exec "$ns" ip -6 addr add "${addr}/128" dev "$TUN_DEV" 2>/dev/null || true + ip netns exec "$ns" ip -6 route add fd00::/8 dev "$TUN_DEV" 2>/dev/null || true + ip netns exec "$ns" ip link show "$TUN_DEV" | grep -q "UP" || \ + ip netns exec "$ns" ip link set "$TUN_DEV" up +} +assign_mesh "$NS_A" "$ADDR_A" +assign_mesh "$NS_B" "$ADDR_B" + +echo "[check] interface state in yipRoamA:" +ip netns exec "$NS_A" ip -6 addr show "$TUN_DEV" +echo "[check] interface state in yipRoamB:" +ip netns exec "$NS_B" ip -6 addr show "$TUN_DEV" + +# ── 8. warm-up ping A->B: confirm direct/obf-established convergence ───────── +# NOT counted toward the rebind-continuity bound below; this only absorbs +# ordinary lookup/handshake/obf warm-up, same tolerance run-netns-punch.sh +# documents. +echo "[test] warm-up: pinging ${ADDR_B} from yipRoamA (obf on, expect direct success)" +set +e +ip netns exec "$NS_A" ping -6 -c 20 -W 2 "$ADDR_B" +WARMUP_STATUS=$? +set -e +if [ "$WARMUP_STATUS" -ne 0 ]; then + echo "[FAIL] warm-up ping A->B did not succeed (exit $WARMUP_STATUS)" + dump_logs + exit 1 +fi +echo "[PASS] warm-up ping A->B succeeded (session established, obf on)" + +# ── 9. measured ping A->B spanning a mid-session rebind of A's source ──────── +PING_LOG="$TMPDIR_TEST/rebind_ping.log" +echo "[test] measured ping ${ADDR_B} from yipRoamA (-i 0.2 -c 100), rebind triggered mid-stream" +set +e +ip netns exec "$NS_A" ping -6 -i 0.2 -c 100 -W 1 "$ADDR_B" >"$PING_LOG" 2>&1 & +PING_PID=$! +set -e + +# Let ~20 packets go out on the pre-rebind source before flipping it. +sleep 4 + +echo "[rebind] adding ${IP_A2} to yipRoamA's veth and flipping the default route's preferred source" +ip netns exec "$NS_A" ip addr add "${IP_A2}/${PREFIX}" dev "$VETH_A_N" +ip netns exec "$NS_A" ip route replace default via "$IP_T_A" dev "$VETH_A_N" src "$IP_A2" +echo "[rebind] done — yipRoamA's session (keys, conn_tag) is untouched; only its on-wire source address changed" + +set +e +wait "$PING_PID" +PING_STATUS=$? +set -e +cat "$PING_LOG" + +if ! kill -0 "$PID_A" 2>/dev/null; then + echo "[error] yipRoamA daemon died during the rebind ping"; dump_logs; exit 1 +fi +if ! kill -0 "$PID_B" 2>/dev/null; then + echo "[error] yipRoamB daemon died during the rebind ping"; dump_logs; exit 1 +fi + +# ── assertion: rebind_continuity — <=1% loss across the rebind ────────────── +LOSS_PCT="$(grep -oE '[0-9]+(\.[0-9]+)?% packet loss' "$PING_LOG" | grep -oE '^[0-9]+(\.[0-9]+)?' || true)" +if [ -z "$LOSS_PCT" ]; then + echo "[FAIL] rebind_continuity: could not parse packet loss from ping output" + dump_logs + exit 1 +fi +echo "[metric] rebind_continuity: packet loss = ${LOSS_PCT}%" +if awk "BEGIN {exit ($LOSS_PCT <= 1.0) ? 0 : 1}"; then + echo "[PASS] rebind_continuity: ${LOSS_PCT}% loss (<=1%) across the mid-session NAT rebind" +else + echo "[FAIL] rebind_continuity: ${LOSS_PCT}% loss (>1%) — B did not recover A's roamed, obfuscated source" + dump_logs + exit 1 +fi +if [ "$PING_STATUS" -ne 0 ] && [ "$LOSS_PCT" != "100" ]; then + echo "[note] ping exited $PING_STATUS despite <=1% loss (non-fatal; proceeding)" +fi + +# ── assertion: relay was NOT used, before or after the rebind ─────────────── +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 [ -n "${FINAL_COUNT:-}" ] && [ "$FINAL_COUNT" -ne 0 ]; then + echo "[FAIL] relay-forwarded=${FINAL_COUNT} (expected 0) — traffic went through the relay, not the still-direct path" + dump_logs + exit 1 +fi +echo "[PASS] relay-forwarded=${FINAL_COUNT:-0}: the direct path carried the rebind recovery, relay unused" + +echo "[PASS] run-netns-roaming: B kept delivering to A across a mid-session, obfuscation-on NAT rebind" From be939089c0edeba421b469e09f826d9b3adb5999 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Fri, 24 Jul 2026 16:32:48 -0400 Subject: [PATCH 07/15] fix(roaming.m2): redirect an in-flight rekey's target on roam (review Important) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EpochSet::set_peer_addr updated current/next/previous but not rekey.target. A RekeyInFlight captures its target from the pre-roam endpoint, and on completion rekey_resp_core/rekey_init_core stamp the promoted epoch's peer_addr from that target — so a roam coinciding with this node's in-flight initiator rekey would be undone on the next rekey completion (a fresh ~120s black hole), and would not self-heal (relearn_endpoint only fires when src != the already-updated endpoint). set_peer_addr now also updates rekey.target. New test set_peer_addr_redirects_current_and_in_flight_rekey. --- bin/yipd/src/epoch.rs | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/bin/yipd/src/epoch.rs b/bin/yipd/src/epoch.rs index 052ed2d..59893b9 100644 --- a/bin/yipd/src/epoch.rs +++ b/bin/yipd/src/epoch.rs @@ -100,6 +100,14 @@ impl EpochSet { /// the peer's authenticated source moved (M2). Applies to `current`, the /// unconfirmed `next`, and the grace `previous`, so a roam is not undone by a /// pending or recently-rotated epoch. + /// + /// Also redirects any in-flight rekey's `target`: a `RekeyInFlight` captured + /// its `target` from the pre-roam `endpoint`, and when it completes, + /// `rekey_resp_core`/`rekey_init_core` stamp the promoted epoch's `peer_addr` + /// from that `target`. Without this the roam would be undone on the next + /// rekey completion (a fresh black hole until the following rekey), and it + /// would not self-heal because `relearn_endpoint` only fires when the + /// observed `src` differs from the already-updated `endpoint`. pub fn set_peer_addr(&mut self, addr: std::net::SocketAddr) { self.current.set_peer_addr(addr); if let Some(n) = self.next.as_mut() { @@ -108,6 +116,9 @@ impl EpochSet { if let Some(p) = self.previous.as_mut() { p.set_peer_addr(addr); } + if let Some(rk) = self.rekey.as_mut() { + rk.target = addr; + } } /// Convert a borrowed `Outcome` into the owned `EpochInbound` (same copies @@ -520,4 +531,31 @@ mod tests { assert!(!set.needs_rekey(1500, false, interval)); assert!(set.needs_rekey(2000, false, interval)); } + + #[test] + fn set_peer_addr_redirects_current_and_in_flight_rekey() { + let (_peer, us) = epoch_pair(); + let mut set = EpochSet::new(us, 0); + let old: SocketAddr = "203.0.113.9:1".parse().unwrap(); + let new: SocketAddr = "198.51.100.7:41000".parse().unwrap(); + // A rekey is in flight, its target captured from the pre-roam endpoint. + set.rekey = Some(rekey_in_flight(old)); + + set.set_peer_addr(new); + + // The in-flight rekey's target follows the roam, so the epoch it + // promotes will egress to the new source (not the stale one). + assert_eq!( + set.rekey.as_ref().unwrap().target, + new, + "an in-flight rekey's target must follow the roam", + ); + // And the current epoch's egress is redirected too. + let dg = set + .current_mut() + .on_tun_packet(&[0x60, 0, 0, 0, 0, 0, 0, 64], 1) + .to_vec(); + assert!(!dg.is_empty()); + assert_eq!(dg[0].dst, new, "current epoch egress must follow the roam"); + } } From 0e5fd9db2e30f4e65358febef957458ae8ea168d Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Fri, 24 Jul 2026 16:50:52 -0400 Subject: [PATCH 08/15] feat(rdv.37): RegisterSigned message + optional PeerInfo.record (codec) Task 1 of the #37 signed-rendezvous-registration milestone: the wire codec in crates/yip-rendezvous/src/proto.rs adds Message::RegisterSigned { record: yip_membership::Record } (Tag = 7) and an optional, backward- compatible record: Option field on Message::PeerInfo. Adding a required field to PeerInfo and a new Message variant breaks every downstream construction/match site in the workspace (exhaustive matches, struct literals). Beyond proto.rs + Cargo.toml, this commit also includes mechanical, non-functional compile shims so `cargo test --workspace` and `cargo clippy --workspace --all-targets` (the pre-commit gate) stay green: - crates/yip-rendezvous/src/server.rs: PeerInfo's Lookup reply gets `record: None`; a `RegisterSigned { .. } => Vec::new()` no-op arm. Task 2 replaces both with real verify+store logic. - bin/yipd/src/rendezvous.rs, bin/yipd/src/peer_manager.rs: PeerInfo match arms widened with `..`; PeerInfo constructors in tests get `record: None`. Task 5 threads the real record through for verification. - bin/yip-rendezvous/tests/smoke.rs: same `..` widening in two match arms. None of these shims add behavior; they only keep the enum change from breaking compilation ahead of the tasks that implement the real logic. --- Cargo.lock | 3 + bin/yip-rendezvous/tests/smoke.rs | 8 +- bin/yipd/src/peer_manager.rs | 19 ++- bin/yipd/src/rendezvous.rs | 8 +- crates/yip-rendezvous/Cargo.toml | 5 + crates/yip-rendezvous/src/proto.rs | 195 +++++++++++++++++++++++++++- crates/yip-rendezvous/src/server.rs | 9 +- 7 files changed, 233 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2b4bcca..c2637a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2084,6 +2084,9 @@ name = "yip-rendezvous" version = "0.1.0" dependencies = [ "blake2", + "ed25519-dalek", + "rand_core 0.6.4", + "yip-membership", ] [[package]] diff --git a/bin/yip-rendezvous/tests/smoke.rs b/bin/yip-rendezvous/tests/smoke.rs index d5f08b7..33bf235 100644 --- a/bin/yip-rendezvous/tests/smoke.rs +++ b/bin/yip-rendezvous/tests/smoke.rs @@ -109,7 +109,9 @@ fn register_lookup_relay_over_udp() { let mut rx = [0u8; 2048]; let (n, _) = b.recv_from(&mut rx).expect("B receives PeerInfo"); match decode(&rx[..n]) { - Some(Message::PeerInfo { node, reflexive }) => { + Some(Message::PeerInfo { + node, reflexive, .. + }) => { assert_eq!(node, a_id); assert_eq!(reflexive, a.local_addr().unwrap()); } @@ -231,7 +233,9 @@ fn register_lookup_relay_over_udp_with_obf_psk() { send_wrapped(&b, &Message::Lookup { node: a_id }); let mut rx = [0u8; 2048]; match recv_wrapped(&b, &mut rx) { - Message::PeerInfo { node, reflexive } => { + Message::PeerInfo { + node, reflexive, .. + } => { assert_eq!(node, a_id); assert_eq!(reflexive, a.local_addr().unwrap()); } diff --git a/bin/yipd/src/peer_manager.rs b/bin/yipd/src/peer_manager.rs index 42c3f19..04bc3e1 100644 --- a/bin/yipd/src/peer_manager.rs +++ b/bin/yipd/src/peer_manager.rs @@ -5017,12 +5017,12 @@ mod tests { } fn parse(&self, dg: &[u8]) -> RdvEvent { match yip_rendezvous::decode(dg) { - Some(yip_rendezvous::Message::PeerInfo { node, reflexive }) => { - RdvEvent::PeerCandidate { - node, - addr: reflexive, - } - } + Some(yip_rendezvous::Message::PeerInfo { + node, reflexive, .. + }) => RdvEvent::PeerCandidate { + node, + addr: reflexive, + }, Some(yip_rendezvous::Message::PunchHint { node, reflexive }) => RdvEvent::PunchTo { node, addr: reflexive, @@ -5210,6 +5210,7 @@ mod tests { &yip_rendezvous::Message::PeerInfo { node: node_id(&peer_kp.public), reflexive: candidate, + record: None, }, &mut buf, ); @@ -5298,6 +5299,7 @@ mod tests { &yip_rendezvous::Message::PeerInfo { node: node_id(&peer_kp.public), reflexive: hijack, + record: None, }, &mut buf, ); @@ -5354,6 +5356,7 @@ mod tests { &yip_rendezvous::Message::PeerInfo { node: node_id(&peer_kp.public), reflexive: candidate, + record: None, }, &mut buf, ); @@ -5495,6 +5498,7 @@ mod tests { &yip_rendezvous::Message::PeerInfo { node: node_id(&peer_kp.public), reflexive: candidate, + record: None, }, &mut buf, ); @@ -6320,6 +6324,7 @@ mod tests { &yip_rendezvous::Message::PeerInfo { node: node_id(&peer_kp.public), reflexive: candidate, + record: None, }, &mut buf, ); @@ -8413,6 +8418,7 @@ mod tests { &yip_rendezvous::Message::PeerInfo { node: node_id(&peer_kp.public), reflexive: candidate, + record: None, }, &mut plain, ); @@ -8474,6 +8480,7 @@ mod tests { &yip_rendezvous::Message::PeerInfo { node: node_id(&peer_kp.public), reflexive: candidate, + record: None, }, &mut plain, ); diff --git a/bin/yipd/src/rendezvous.rs b/bin/yipd/src/rendezvous.rs index e1f2a1f..1db5314 100644 --- a/bin/yipd/src/rendezvous.rs +++ b/bin/yipd/src/rendezvous.rs @@ -83,7 +83,12 @@ impl Rendezvous for ConfiguredServerRendezvous { } fn parse(&self, dg: &[u8]) -> RdvEvent { match decode(dg) { - Some(Message::PeerInfo { node, reflexive }) => RdvEvent::PeerCandidate { + // TODO(#37 task 5): surface `record` to the caller so + // `PeerManager` can verify it against membership roots before + // probing (codec-only in task 1). + Some(Message::PeerInfo { + node, reflexive, .. + }) => RdvEvent::PeerCandidate { node, addr: reflexive, }, @@ -217,6 +222,7 @@ mod tests { &Message::PeerInfo { node: n, reflexive: a, + record: None, }, &mut buf, ); diff --git a/crates/yip-rendezvous/Cargo.toml b/crates/yip-rendezvous/Cargo.toml index d2aa38c..f51c07f 100644 --- a/crates/yip-rendezvous/Cargo.toml +++ b/crates/yip-rendezvous/Cargo.toml @@ -7,6 +7,11 @@ repository.workspace = true [dependencies] blake2 = { workspace = true } +yip-membership = { path = "../yip-membership" } + +[dev-dependencies] +ed25519-dalek = { version = "2.1", features = ["rand_core"] } +rand_core = { version = "0.6", features = ["getrandom"] } [lints] workspace = true diff --git a/crates/yip-rendezvous/src/proto.rs b/crates/yip-rendezvous/src/proto.rs index 797fce6..6e25d4a 100644 --- a/crates/yip-rendezvous/src/proto.rs +++ b/crates/yip-rendezvous/src/proto.rs @@ -3,6 +3,7 @@ use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use blake2::digest::{Update, VariableOutput}; use blake2::Blake2sVar; +use yip_membership::Record; /// Domain separation so node-id can't collide with the mesh-address derivation. const DOMAIN: &[u8] = b"yip-rdv-v1"; @@ -31,6 +32,7 @@ enum Tag { PunchHint = 4, RelaySend = 5, RelayDeliver = 6, + RegisterSigned = 7, } /// A rendezvous/relay control message. See the 2b spec for direction/semantics. @@ -49,6 +51,10 @@ pub enum Message { PeerInfo { node: NodeId, reflexive: SocketAddr, + /// The requested node's signed directory record, when the relay has + /// one on file. `None` for legacy datagrams (no trailing presence + /// byte) or when no record is available. + record: Option, }, NotFound { node: NodeId, @@ -66,6 +72,34 @@ pub enum Message { src: NodeId, payload: Vec, }, + /// A self-authenticating registration: the relay verifies `record` + /// itself rather than trusting an unauthenticated `counter`. + RegisterSigned { + record: Record, + }, +} + +/// Encode `record` onto `out` as a big-endian `u16` length prefix followed +/// by its `Record::encode` bytes, mirroring how `record_signing_body` +/// length-prefixes the embedded cert (`Record::decode` requires an exact +/// slice, so a self-delimiting length prefix is required to embed one +/// record inside another message). +fn put_record(out: &mut Vec, record: &Record) { + let mut bytes = Vec::new(); + record.encode(&mut bytes); + let len = u16::try_from(bytes.len()).expect("record fits in u16"); + out.extend_from_slice(&len.to_be_bytes()); + out.extend_from_slice(&bytes); +} + +/// Parse a length-prefixed record from the front of `buf`. Returns the +/// record and the number of bytes consumed, or `None` if the length prefix, +/// slice, or record contents are invalid/truncated (fail-closed). +fn take_record(buf: &[u8]) -> Option<(Record, usize)> { + let len = usize::from(u16::from_be_bytes(buf.get(..2)?.try_into().ok()?)); + let record_bytes = buf.get(2..2 + len)?; + let record = Record::decode(record_bytes)?; + Some((record, 2 + len)) } fn put_addr(out: &mut Vec, addr: &SocketAddr) { @@ -112,10 +146,21 @@ pub fn encode(msg: &Message, out: &mut Vec) { out.push(Tag::Lookup as u8); out.extend_from_slice(node); } - Message::PeerInfo { node, reflexive } => { + Message::PeerInfo { + node, + reflexive, + record, + } => { out.push(Tag::PeerInfo as u8); out.extend_from_slice(node); put_addr(out, reflexive); + match record { + Some(r) => { + out.push(1); + put_record(out, r); + } + None => out.push(0), + } } Message::NotFound { node } => { out.push(Tag::NotFound as u8); @@ -137,6 +182,10 @@ pub fn encode(msg: &Message, out: &mut Vec) { out.extend_from_slice(src); out.extend_from_slice(payload); } + Message::RegisterSigned { record } => { + out.push(Tag::RegisterSigned as u8); + put_record(out, record); + } } } @@ -158,8 +207,24 @@ pub fn decode(buf: &[u8]) -> Option { }), t if t == Tag::PeerInfo as u8 => { let node = node16(rest)?; - let (reflexive, _) = take_addr(rest.get(16..)?)?; - Some(Message::PeerInfo { node, reflexive }) + let (reflexive, used) = take_addr(rest.get(16..)?)?; + let after_addr = rest.get(16 + used..)?; + let record = match after_addr.first() { + Some(0) => None, + Some(1) => { + let (record, _) = take_record(after_addr.get(1..)?)?; + Some(record) + } + Some(_) => return None, + // No trailing presence byte: a legacy datagram. Treat as + // `None` for backward compatibility. + None => None, + }; + Some(Message::PeerInfo { + node, + reflexive, + record, + }) } t if t == Tag::PunchHint as u8 => { let node = node16(rest)?; @@ -182,6 +247,10 @@ pub fn decode(buf: &[u8]) -> Option { payload: rest.get(16..)?.to_vec(), }) } + t if t == Tag::RegisterSigned as u8 => { + let (record, _) = take_record(rest)?; + Some(Message::RegisterSigned { record }) + } _ => None, } } @@ -230,10 +299,12 @@ mod tests { roundtrip(Message::PeerInfo { node: n, reflexive: v4, + record: None, }); roundtrip(Message::PeerInfo { node: n, reflexive: v6, + record: None, }); roundtrip(Message::NotFound { node: n }); roundtrip(Message::PunchHint { @@ -260,10 +331,15 @@ mod tests { &Message::PeerInfo { node: [2u8; 16], reflexive: "1.2.3.4:5".parse().unwrap(), + record: None, }, &mut buf, ); - buf.truncate(buf.len() - 1); + // Cut 2 bytes: the trailing `record: None` presence byte plus the + // last byte of the port, so the address itself is genuinely + // truncated (not just the presence byte, which legacy decode + // tolerates). + buf.truncate(buf.len() - 2); assert_eq!(decode(&buf), None); // truncated addr } @@ -287,4 +363,115 @@ mod tests { buf.extend_from_slice(&[0u8; 4]); assert_eq!(decode(&buf), None); } + + fn addr(s: &str) -> SocketAddr { + s.parse().unwrap() + } + + /// Build a decode-valid `Record`: a CA-signed `Cert` binding a member + /// key, and the record itself signed with the member's record-signing + /// key. Mirrors `yip_membership::record`'s own `make_signed_record` test + /// fixture (not exposed outside that crate, so reconstructed here). + fn sample_record() -> Record { + use ed25519_dalek::{Signer, SigningKey}; + use rand_core::OsRng; + use yip_membership::cert::cert_signing_body; + use yip_membership::record::{record_signing_body, sign}; + use yip_membership::{node_id, Cert}; + + let ca = SigningKey::generate(&mut OsRng); + let member_pubkey = [1u8; 32]; + let member_sign_key = SigningKey::generate(&mut OsRng); + let member_sign_pubkey = member_sign_key.verifying_key().to_bytes(); + let network_id = [7u8; 16]; + + let mut cert = Cert { + version: 1, + member_pubkey, + member_sign_pubkey, + network_id, + not_before: 100, + not_after: 200, + tags: vec![], + ca_sig: [0u8; 64], + }; + cert.ca_sig = ca.sign(&cert_signing_body(&cert)).to_bytes(); + + let mut record = Record { + node_id: node_id(&member_pubkey), + cert, + endpoints: vec![addr("192.0.2.1:8080")], + seq: 1, + sig: [0u8; 64], + }; + let body = record_signing_body(&record); + record.sig = sign( + &body, + member_sign_key.to_bytes().as_ref().try_into().unwrap(), + ); + record + } + + #[test] + fn register_signed_roundtrips() { + let rec = sample_record(); + roundtrip(Message::RegisterSigned { record: rec }); + } + + #[test] + fn peerinfo_with_record_roundtrips() { + roundtrip(Message::PeerInfo { + node: [7u8; 16], + reflexive: addr("198.51.100.7:41000"), + record: Some(sample_record()), + }); + } + + #[test] + fn peerinfo_without_record_roundtrips_and_is_backward_compatible() { + roundtrip(Message::PeerInfo { + node: [7u8; 16], + reflexive: addr("198.51.100.7:41000"), + record: None, + }); + } + + #[test] + fn peerinfo_legacy_datagram_with_no_presence_byte_decodes_as_none() { + // A legacy encoder that predates the `record` field never writes the + // trailing presence byte at all. Decode must still accept it and + // treat it as `record: None`. + let mut buf = vec![Tag::PeerInfo as u8]; + buf.extend_from_slice(&[7u8; 16]); // node + put_addr(&mut buf, &addr("198.51.100.7:41000")); // no presence byte after + assert_eq!( + decode(&buf), + Some(Message::PeerInfo { + node: [7u8; 16], + reflexive: addr("198.51.100.7:41000"), + record: None, + }) + ); + } + + #[test] + fn register_signed_length_prefix_exceeding_buffer_is_none() { + // A `RegisterSigned` whose u16 length prefix claims more bytes than + // are actually present must fail closed, not panic. + let mut buf = vec![Tag::RegisterSigned as u8]; + buf.extend_from_slice(&0xFFFFu16.to_be_bytes()); // claims 65535 bytes + buf.extend_from_slice(&[0u8; 4]); // far fewer bytes actually follow + assert_eq!(decode(&buf), None); + } + + #[test] + fn peerinfo_record_length_prefix_exceeding_buffer_is_none() { + let mut buf = vec![Tag::PeerInfo as u8]; + buf.extend_from_slice(&[7u8; 16]); // node + put_addr(&mut buf, &addr("198.51.100.7:41000")); + buf.push(1); // presence byte: record follows + buf.extend_from_slice(&0xFFFFu16.to_be_bytes()); // claims 65535 bytes + buf.extend_from_slice(&[0u8; 4]); // far fewer bytes actually follow + assert_eq!(decode(&buf), None); + } } diff --git a/crates/yip-rendezvous/src/server.rs b/crates/yip-rendezvous/src/server.rs index e42ad71..ae9b04a 100644 --- a/crates/yip-rendezvous/src/server.rs +++ b/crates/yip-rendezvous/src/server.rs @@ -211,6 +211,10 @@ impl RendezvousServer { Message::PeerInfo { node, reflexive: peer_addr, + // TODO(#37 task 2): serve the stored signed + // `Record` here once the server verifies and + // stores registrations. + record: None, }, )]; // Tell the looked-up peer to punch back toward the requester. @@ -247,6 +251,9 @@ impl RendezvousServer { | Message::NotFound { .. } | Message::PunchHint { .. } | Message::RelayDeliver { .. } => Vec::new(), + // TODO(#37 task 2): verify against roots and store; dropped for + // now (codec-only in this task). + Message::RegisterSigned { .. } => Vec::new(), } } } @@ -291,7 +298,7 @@ mod tests { let out = s.handle(addr("203.0.113.9:52000"), Message::Lookup { node: a }, 10); // one reply to B (PeerInfo), one to A (PunchHint) assert!(out.iter().any(|(d, m)| *d == addr("203.0.113.9:52000") - && matches!(m, Message::PeerInfo { node, reflexive } if *node == a && *reflexive == addr("198.51.100.7:41000")))); + && matches!(m, Message::PeerInfo { node, reflexive, .. } if *node == a && *reflexive == addr("198.51.100.7:41000")))); assert!(out.iter().any(|(d, m)| *d == addr("198.51.100.7:41000") && matches!(m, Message::PunchHint { reflexive, .. } if *reflexive == addr("203.0.113.9:52000")))); } From 73c1ddca1306303d4ee06fe74c7dc44b88e5fecd Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Fri, 24 Jul 2026 18:30:11 -0400 Subject: [PATCH 09/15] feat(rdv.37): rooted server verifies RegisterSigned, drops unsigned; PeerInfo carries the record --- bin/yip-rendezvous/src/main.rs | 11 +- crates/yip-rendezvous/src/server.rs | 415 ++++++++++++++++++++++++++-- 2 files changed, 403 insertions(+), 23 deletions(-) diff --git a/bin/yip-rendezvous/src/main.rs b/bin/yip-rendezvous/src/main.rs index 23be5df..ee86fd2 100644 --- a/bin/yip-rendezvous/src/main.rs +++ b/bin/yip-rendezvous/src/main.rs @@ -503,6 +503,15 @@ async fn run_udp( ) -> std::io::Result<()> { let now_ms = |base: Instant| -> u64 { u64::try_from(base.elapsed().as_millis()).unwrap_or(u64::MAX) }; + // WALL-CLOCK seconds, used ONLY by `handle`'s `RegisterSigned` verify + // path to check a cert's `not_before`/`not_after` window — distinct + // from `now_ms`'s monotonic `Instant`-based clock used for TTL/rate. + let now_secs = || -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) + }; let mut rx = [0u8; 2048]; let mut sweep = tokio::time::interval_at(tokio::time::Instant::now() + SWEEP_INTERVAL, SWEEP_INTERVAL); @@ -516,7 +525,7 @@ async fn run_udp( if let Some(msg) = decode_inbound(obf_key.as_ref(), &rx[..n]) { let replies = { let mut s = server.lock().await; - s.handle(src, msg, now_ms(base)) + s.handle(src, msg, now_ms(base), now_secs()) }; for (dst, reply) in replies { let wire = wrap_reply(obf_key.as_ref(), &reply); diff --git a/crates/yip-rendezvous/src/server.rs b/crates/yip-rendezvous/src/server.rs index ae9b04a..9c22417 100644 --- a/crates/yip-rendezvous/src/server.rs +++ b/crates/yip-rendezvous/src/server.rs @@ -6,8 +6,18 @@ use std::net::SocketAddr; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; +use yip_membership::Record; + use crate::proto::{Message, NodeId}; +/// Clock-skew grace applied when verifying a `RegisterSigned` record's +/// embedded cert/signature window. Mirrors `bin/yipd/src/membership.rs`'s +/// `CLOCK_SKEW_SECS` production default (300s / `YIP_CERT_SKEW_SECS` +/// override) — this crate has no access to that binary-local constant, so +/// the value is duplicated here rather than reused; keep the two in sync by +/// hand if the #41 default ever changes. +const REGISTRATION_SKEW_SECS: u64 = 300; + /// Registration lifetime; clients refresh well within this. pub const REG_TTL_MS: u64 = 60_000; /// Hard cap on concurrent registrations (memory bound). @@ -26,6 +36,11 @@ struct Reg { addr: SocketAddr, expiry_ms: u64, last_counter: u64, + /// The signed directory record backing this registration, when it + /// arrived via `RegisterSigned` and verified. `None` for a legacy + /// unsigned `Register` (rootless servers only) — `Lookup` then serves + /// `record: None` in `PeerInfo`, same as before this task. + record: Option, } struct Rate { @@ -53,6 +68,13 @@ pub struct RendezvousServer { /// a cloned handle ([`forwarded_handle`](Self::forwarded_handle)) without /// acquiring the global `server` mutex per relayed frame (#68). forwarded: Arc, + /// `Some((ca_pubkeys, network_id))` when this server is running "rooted" + /// (mesh mode): `RegisterSigned` records are verified against these + /// roots before being trusted, and legacy unsigned `Register` is + /// dropped outright (a mesh must not accept an unauthenticated + /// registration). `None` (via [`new`](Self::new)) is the rootless/legacy + /// mode, byte-identical to pre-#37 behavior. + roots_cfg: Option<(Vec<[u8; 32]>, [u8; 16])>, } impl RendezvousServer { @@ -62,6 +84,20 @@ impl RendezvousServer { rates: HashMap::new(), tls_seen: HashMap::new(), forwarded: Arc::new(AtomicU64::new(0)), + roots_cfg: None, + } + } + + /// A rooted (mesh-mode) server: `RegisterSigned` records are verified + /// against `ca_pubkeys`/`network_id`, and legacy unsigned `Register` is + /// dropped (see [`roots_cfg`](Self::roots_cfg)). + pub fn new_with_roots(_now_ms: u64, ca_pubkeys: Vec<[u8; 32]>, network_id: [u8; 16]) -> Self { + Self { + regs: HashMap::new(), + rates: HashMap::new(), + tls_seen: HashMap::new(), + forwarded: Arc::new(AtomicU64::new(0)), + roots_cfg: Some((ca_pubkeys, network_id)), } } @@ -162,6 +198,13 @@ impl RendezvousServer { addr: src, expiry_ms: now_ms.saturating_add(REG_TTL_MS), last_counter: counter, + // Every insert (fresh or counter-advancing refresh) starts + // with no record here; the `RegisterSigned` arm in `handle` + // sets it via `get_mut` immediately after this call + // succeeds, so the record always tracks the latest + // verified registration rather than lingering from a + // previous one. + record: None, }, ); true @@ -189,20 +232,60 @@ impl RendezvousServer { } /// Process one received message; return datagrams to send as `(dst, msg)`. + /// `now_ms` is the monotonic clock used for TTL/rate-limit bookkeeping + /// (unchanged from before this task); `now_secs` is WALL-CLOCK seconds, + /// used ONLY to validate a `RegisterSigned` record's embedded cert + /// window — the two are intentionally distinct clocks and must not be + /// conflated (a monotonic clock has no relation to a cert's + /// `not_before`/`not_after`, which are wall-clock UNIX timestamps). pub fn handle( &mut self, src: SocketAddr, msg: Message, now_ms: u64, + now_secs: u64, ) -> Vec<(SocketAddr, Message)> { if !self.rate_ok(src, now_ms) { return Vec::new(); } match msg { Message::Register { node, counter } => { + // A rooted (mesh) server requires every registration to be + // signed and verified against its roots; an unauthenticated + // legacy `Register` is dropped outright rather than trusted. + if self.roots_cfg.is_some() { + return Vec::new(); + } self.register_if_fresh(node, counter, src, now_ms); Vec::new() } + Message::RegisterSigned { record } => { + let Some((ca_pubkeys, network_id)) = self.roots_cfg.as_ref() else { + // Rootless server: `RegisterSigned` has no roots to + // verify against, so it is meaningless here — drop + // rather than trust it unverified. + return Vec::new(); + }; + if record + .verify(ca_pubkeys, network_id, now_secs, REGISTRATION_SKEW_SECS) + .is_err() + { + // Forged signature, expired/not-yet-valid cert, untrusted + // CA, or a node_id that doesn't match the signer's own + // key (squatting) — drop; no store, no reply, and + // crucially no touch of any EXISTING registration under + // this node_id (an attacker can't clobber a victim's + // real entry with a record that fails verification). + return Vec::new(); + } + let node = record.node_id; + if self.register_if_fresh(node, record.seq, src, now_ms) { + if let Some(reg) = self.regs.get_mut(&node) { + reg.record = Some(record); + } + } + Vec::new() + } Message::Lookup { node } => match self.regs.get(&node) { Some(reg) if reg.expiry_ms > now_ms => { let peer_addr = reg.addr; @@ -211,10 +294,7 @@ impl RendezvousServer { Message::PeerInfo { node, reflexive: peer_addr, - // TODO(#37 task 2): serve the stored signed - // `Record` here once the server verifies and - // stores registrations. - record: None, + record: reg.record.clone(), }, )]; // Tell the looked-up peer to punch back toward the requester. @@ -251,9 +331,6 @@ impl RendezvousServer { | Message::NotFound { .. } | Message::PunchHint { .. } | Message::RelayDeliver { .. } => Vec::new(), - // TODO(#37 task 2): verify against roots and store; dropped for - // now (codec-only in this task). - Message::RegisterSigned { .. } => Vec::new(), } } } @@ -262,12 +339,22 @@ impl RendezvousServer { mod tests { use super::*; use crate::proto::{node_id, Message}; + use ed25519_dalek::{Signer, SigningKey}; + use rand_core::OsRng; use std::net::SocketAddr; + use yip_membership::Cert; fn addr(s: &str) -> SocketAddr { s.parse().unwrap() } + /// Wall-clock seconds for `handle`'s `now_secs` param. The pre-#37 tests + /// below never touch cert/record validity, so any monotone value works; + /// reusing the test's `now_ms` value keeps each call site simple. + fn now_secs(t: u64) -> u64 { + t + } + /// Synthesize a distinct `SocketAddr` from an index, without relying on /// string formatting (kept fast for large-`i` loops) or `as` casts. fn synth_addr(i: u32) -> SocketAddr { @@ -291,11 +378,17 @@ mod tests { counter: 1, }, 0, + now_secs(0), ); assert!(out.is_empty(), "register produces no reply"); // B looks up A: gets A's reflexive via PeerInfo, and A gets a PunchHint // carrying B's reflexive. - let out = s.handle(addr("203.0.113.9:52000"), Message::Lookup { node: a }, 10); + let out = s.handle( + addr("203.0.113.9:52000"), + Message::Lookup { node: a }, + 10, + now_secs(10), + ); // one reply to B (PeerInfo), one to A (PunchHint) assert!(out.iter().any(|(d, m)| *d == addr("203.0.113.9:52000") && matches!(m, Message::PeerInfo { node, reflexive, .. } if *node == a && *reflexive == addr("198.51.100.7:41000")))); @@ -307,7 +400,12 @@ mod tests { fn lookup_unregistered_returns_notfound() { let mut s = RendezvousServer::new(0); let a = node_id(&[1u8; 32]); - let out = s.handle(addr("203.0.113.9:52000"), Message::Lookup { node: a }, 0); + let out = s.handle( + addr("203.0.113.9:52000"), + Message::Lookup { node: a }, + 0, + now_secs(0), + ); assert_eq!( out, vec![(addr("203.0.113.9:52000"), Message::NotFound { node: a })] @@ -325,12 +423,14 @@ mod tests { counter: 1, }, 0, + now_secs(0), ); s.sweep(REG_TTL_MS + 1); let out = s.handle( addr("203.0.113.9:52000"), Message::Lookup { node: a }, REG_TTL_MS + 2, + now_secs(REG_TTL_MS + 2), ); assert!(matches!(out.as_slice(), [(_, Message::NotFound { .. })])); } @@ -347,6 +447,7 @@ mod tests { counter: 1, }, 0, + now_secs(0), ); // A registered // B relays a payload to A -> A gets RelayDeliver{src=B, payload}. let out = s.handle( @@ -357,6 +458,7 @@ mod tests { payload: vec![9, 9], }, 5, + now_secs(5), ); assert_eq!( out, @@ -384,6 +486,7 @@ mod tests { payload: vec![1], }, 0, + now_secs(0), ); assert!(out.is_empty()); assert_eq!(s.forwarded_count(), 0); @@ -397,7 +500,9 @@ mod tests { // Exceed the per-window cap; excess Lookups must produce no replies. let mut replies = 0; for _ in 0..(MAX_MSGS_PER_WINDOW + 10) { - replies += s.handle(src, Message::Lookup { node: a }, 0).len(); + replies += s + .handle(src, Message::Lookup { node: a }, 0, now_secs(0)) + .len(); } // Only up to the cap are serviced (each serviced Lookup -> 1 NotFound). assert!( @@ -413,7 +518,7 @@ mod tests { // Comfortably below MAX_RATE_ENTRIES: exercises normal growth and // confirms the map only ever holds one entry per distinct source. for i in 0..2_000u32 { - s.handle(synth_addr(i), Message::Lookup { node: a }, 0); + s.handle(synth_addr(i), Message::Lookup { node: a }, 0, now_secs(0)); } assert_eq!(s.rates.len(), 2_000); assert!(s.rates.len() <= MAX_RATE_ENTRIES); @@ -432,6 +537,7 @@ mod tests { counter: 5, }, 0, + now_secs(0), ); assert!(s.is_registered(&n, 0), "counter 5 accepted"); // Replay at counter 5 is rejected: a Lookup still resolves to the @@ -444,8 +550,9 @@ mod tests { counter: 5, }, 1, + now_secs(1), ); - let out = s.handle(a, Message::Lookup { node: n }, 2); + let out = s.handle(a, Message::Lookup { node: n }, 2, now_secs(2)); match &out[0].1 { Message::PeerInfo { reflexive, .. } => assert_eq!(*reflexive, a), other => panic!("expected PeerInfo, got {other:?}"), @@ -458,8 +565,9 @@ mod tests { counter: 6, }, 3, + now_secs(3), ); - let out = s.handle(a, Message::Lookup { node: n }, 4); + let out = s.handle(a, Message::Lookup { node: n }, 4, now_secs(4)); match &out[0].1 { Message::PeerInfo { reflexive, .. } => assert_eq!(*reflexive, a2), other => panic!("expected PeerInfo, got {other:?}"), @@ -540,7 +648,12 @@ mod tests { ); // The UDP path must have no idea this node exists. - let out = s.handle(addr("203.0.113.9:52000"), Message::Lookup { node }, 0); + let out = s.handle( + addr("203.0.113.9:52000"), + Message::Lookup { node }, + 0, + now_secs(0), + ); assert_eq!( out, vec![(addr("203.0.113.9:52000"), Message::NotFound { node })], @@ -584,13 +697,15 @@ mod tests { src: n, payload: vec![1, 2, 3] }, - 0 + 0, + now_secs(0), ) .is_empty(), "RelayDeliver is server->client only" ); assert!( - s.handle(a, Message::NotFound { node: n }, 0).is_empty(), + s.handle(a, Message::NotFound { node: n }, 0, now_secs(0)) + .is_empty(), "NotFound is server->client only" ); } @@ -604,15 +719,21 @@ mod tests { let n = node_id(&[4u8; 32]); // Exhaust the window at t=0 (Lookups return NotFound but still count). for _ in 0..MAX_MSGS_PER_WINDOW { - s.handle(a, Message::Lookup { node: n }, 0); + s.handle(a, Message::Lookup { node: n }, 0, now_secs(0)); } assert!( - s.handle(a, Message::Lookup { node: n }, 0).is_empty(), + s.handle(a, Message::Lookup { node: n }, 0, now_secs(0)) + .is_empty(), "over budget within the window ⇒ dropped" ); assert!( - !s.handle(a, Message::Lookup { node: n }, RATE_WINDOW_MS) - .is_empty(), + !s.handle( + a, + Message::Lookup { node: n }, + RATE_WINDOW_MS, + now_secs(RATE_WINDOW_MS) + ) + .is_empty(), "window reset after RATE_WINDOW_MS ⇒ reply flows again" ); } @@ -643,7 +764,7 @@ mod tests { // so it would return a NotFound reply) and the map would grow past // the cap. let new_src = addr("198.51.100.50:9000"); - let out = s.handle(new_src, Message::Lookup { node: a }, 0); + let out = s.handle(new_src, Message::Lookup { node: a }, 0, now_secs(0)); assert!(out.is_empty(), "new source over capacity must be dropped"); assert_eq!( s.rates.len(), @@ -654,10 +775,260 @@ mod tests { // An already-tracked source must still be serviced normally even // while the map is at capacity. let existing_src = synth_addr(0); - let out = s.handle(existing_src, Message::Lookup { node: a }, 0); + let out = s.handle(existing_src, Message::Lookup { node: a }, 0, now_secs(0)); assert!( !out.is_empty(), "already-tracked source must still be serviced" ); } + + // ── #37 task 2: RegisterSigned verification + rooted/rootless mode ── + + /// A fixed, deterministic CA signing key shared by every helper below. + /// `valid_registration` and `registration_for_other_member` each need to + /// mint an INDEPENDENTLY-signed cert under the SAME trusted CA (so a + /// squatting attempt has a genuinely valid cert chain, just a mismatched + /// `node_id`), and threading a real private key through the brief's + /// `registration_for_other_member(ca_pub, ..)` signature (public key + /// only) isn't possible — a deterministic shared key sidesteps that + /// without changing the helper signatures. + fn ca_signing_key() -> SigningKey { + SigningKey::from_bytes(&[0x42u8; 32]) + } + + /// Mint a CA-signed `Cert` for `member_pubkey`/`member_sign_pub`, wide + /// open (`not_before: 0, not_after: u64::MAX`) so it validates under any + /// `now_secs` these tests use (mirrors `bin/yipd/src/peer_manager.rs`'s + /// `mk_cert` test helper). + fn mk_cert( + ca: &SigningKey, + member_pubkey: [u8; 32], + member_sign_pub: [u8; 32], + network_id: [u8; 16], + ) -> Cert { + let mut c = Cert { + version: 1, + member_pubkey, + member_sign_pubkey: member_sign_pub, + network_id, + not_before: 0, + not_after: u64::MAX, + tags: vec![], + ca_sig: [0u8; 64], + }; + c.ca_sig = ca + .sign(&yip_membership::cert::cert_signing_body(&c)) + .to_bytes(); + c + } + + /// Build and sign a `Record` whose `node_id` correctly derives from + /// `cert.member_pubkey` (mirrors `bin/yipd/src/membership.rs`'s + /// `build_signed_record` test helper). + fn build_signed_record( + cert: Cert, + endpoints: Vec, + seq: u64, + member_sign_priv: &[u8; 32], + ) -> Record { + let mut r = Record { + node_id: node_id(&cert.member_pubkey), + cert, + endpoints, + seq, + sig: [0u8; 64], + }; + let body = yip_membership::record::record_signing_body(&r); + r.sig = yip_membership::record::sign(&body, member_sign_priv); + r + } + + /// Mint a fresh, validly-signed `Record` under a fresh member key, + /// plus the CA pubkey/network_id needed to verify it and the reflexive + /// `SocketAddr` the registration arrives from. + fn valid_registration() -> ([u8; 32], [u8; 16], Record, SocketAddr) { + let ca = ca_signing_key(); + let ca_pub = ca.verifying_key().to_bytes(); + let network_id = [7u8; 16]; + let member_pub = [1u8; 32]; + let member_sign_key = SigningKey::generate(&mut OsRng); + let member_sign_pub = member_sign_key.verifying_key().to_bytes(); + let cert = mk_cert(&ca, member_pub, member_sign_pub, network_id); + let member_sign_priv: [u8; 32] = member_sign_key.to_bytes(); + let rec = build_signed_record(cert, vec![addr("192.0.2.1:9")], 1, &member_sign_priv); + (ca_pub, network_id, rec, addr("198.51.100.42:41000")) + } + + /// Mint a Record signed by a DIFFERENT (but still validly CA-issued) + /// member key that CLAIMS `claim_node_id` — the target's node_id — + /// rather than the one that actually derives from its own + /// `cert.member_pubkey`. A genuinely valid cert chain and a genuinely + /// valid signature, but `record.node_id != node_id(cert.member_pubkey)`: + /// exactly the squatting case `Record::verify` closes. `seq` is set + /// STRICTLY GREATER than the victim's so `register_if_fresh`'s + /// freshness gate alone would have accepted this record — isolating + /// that it's the node_id-binding check in `verify`, not staleness, that + /// saves the victim. + fn registration_for_other_member( + ca_pub: [u8; 32], + network_id: [u8; 16], + claim_node_id: NodeId, + ) -> Record { + let ca = ca_signing_key(); + assert_eq!( + ca.verifying_key().to_bytes(), + ca_pub, + "test helper CA must match the CA `valid_registration` used" + ); + let attacker_pub = [2u8; 32]; // distinct from valid_registration's member + let attacker_sign_key = SigningKey::generate(&mut OsRng); + let attacker_sign_pub = attacker_sign_key.verifying_key().to_bytes(); + let cert = mk_cert(&ca, attacker_pub, attacker_sign_pub, network_id); + let mut r = Record { + node_id: claim_node_id, // squatting: claims someone else's node_id + cert, + endpoints: vec![addr("203.0.113.66:9")], + seq: 2, // > victim's seq (1) + sig: [0u8; 64], + }; + let body = yip_membership::record::record_signing_body(&r); + let attacker_sign_priv: [u8; 32] = attacker_sign_key.to_bytes(); + r.sig = yip_membership::record::sign(&body, &attacker_sign_priv); + r + } + + #[test] + fn rooted_server_accepts_valid_signed_register_and_serves_it() { + let (ca_pub, network_id, rec, member_src) = valid_registration(); + let mut s = RendezvousServer::new_with_roots(0, vec![ca_pub], network_id); + let _ = s.handle( + member_src, + Message::RegisterSigned { + record: rec.clone(), + }, + 0, + now_secs(0), + ); + let out = s.handle( + addr("203.0.113.9:5"), + Message::Lookup { node: rec.node_id }, + 10, + now_secs(10), + ); + assert!(out.iter().any(|(_, m)| matches!(m, + Message::PeerInfo { reflexive, record: Some(r), .. } + if *reflexive == member_src && r.node_id == rec.node_id))); + } + + #[test] + fn rooted_server_rejects_forged_signature() { + let (ca_pub, network_id, mut rec, member_src) = valid_registration(); + rec.sig[0] ^= 0xFF; // corrupt the signature + let mut s = RendezvousServer::new_with_roots(0, vec![ca_pub], network_id); + let _ = s.handle( + member_src, + Message::RegisterSigned { + record: rec.clone(), + }, + 0, + now_secs(0), + ); + // Not stored → lookup yields NotFound. + let out = s.handle( + addr("203.0.113.9:5"), + Message::Lookup { node: rec.node_id }, + 10, + now_secs(10), + ); + assert!(out + .iter() + .all(|(_, m)| !matches!(m, Message::PeerInfo { .. }))); + } + + #[test] + fn rooted_server_rejects_overwrite_by_non_holder() { + // Victim registers; attacker sends RegisterSigned for the victim's + // node_id signed by a DIFFERENT (valid-CA) member key → node_id != + // node_id(attacker cert) → rejected. + let (ca_pub, network_id, victim, victim_src) = valid_registration(); + let attacker = registration_for_other_member(ca_pub, network_id, victim.node_id); + let mut s = RendezvousServer::new_with_roots(0, vec![ca_pub], network_id); + let _ = s.handle( + victim_src, + Message::RegisterSigned { + record: victim.clone(), + }, + 0, + now_secs(0), + ); + let _ = s.handle( + addr("203.0.113.66:9"), + Message::RegisterSigned { record: attacker }, + 1, + now_secs(1), + ); + let out = s.handle( + addr("203.0.113.9:5"), + Message::Lookup { + node: victim.node_id, + }, + 2, + now_secs(2), + ); + assert!( + out.iter() + .any(|(_, m)| matches!(m, Message::PeerInfo { reflexive, .. } if *reflexive == victim_src)), + "victim's real registration must survive the overwrite attempt" + ); + } + + #[test] + fn rooted_server_drops_legacy_unsigned_register() { + let (ca_pub, network_id, _rec, _src) = valid_registration(); + let mut s = RendezvousServer::new_with_roots(0, vec![ca_pub], network_id); + let _ = s.handle( + addr("203.0.113.66:9"), + Message::Register { + node: [1u8; 16], + counter: 9, + }, + 0, + now_secs(0), + ); + let out = s.handle( + addr("203.0.113.9:5"), + Message::Lookup { node: [1u8; 16] }, + 1, + now_secs(1), + ); + assert!( + out.iter() + .all(|(_, m)| !matches!(m, Message::PeerInfo { .. })), + "a rooted (mesh) server must not accept unsigned registrations" + ); + } + + #[test] + fn rootless_server_keeps_legacy_register() { + let mut s = RendezvousServer::new(0); // no roots + let a = [1u8; 16]; + let _ = s.handle( + addr("198.51.100.7:41000"), + Message::Register { + node: a, + counter: 1, + }, + 0, + now_secs(0), + ); + let out = s.handle( + addr("203.0.113.9:5"), + Message::Lookup { node: a }, + 1, + now_secs(1), + ); + assert!(out + .iter() + .any(|(_, m)| matches!(m, Message::PeerInfo { .. }))); + } } From 541dd96b6e7008a9ee982990f3e8cf1fe312c9d5 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Fri, 24 Jul 2026 18:48:37 -0400 Subject: [PATCH 10/15] feat(rdv.37): server --roots/--network-id enable mesh-mode signed registration --- Cargo.lock | 1 + bin/yip-rendezvous/Cargo.toml | 5 + bin/yip-rendezvous/src/main.rs | 188 ++++++++++++++++++++++++++++++++- 3 files changed, 191 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c2637a9..dac8f30 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2102,6 +2102,7 @@ dependencies = [ "tokio-boring", "x25519-dalek", "x509-parser", + "yip-membership", "yip-obf", "yip-rendezvous", "yip-utls", diff --git a/bin/yip-rendezvous/Cargo.toml b/bin/yip-rendezvous/Cargo.toml index 20d7292..4161f92 100644 --- a/bin/yip-rendezvous/Cargo.toml +++ b/bin/yip-rendezvous/Cargo.toml @@ -11,6 +11,11 @@ path = "src/main.rs" [dependencies] yip-rendezvous = { path = "../../crates/yip-rendezvous" } +# rdv.37 Task 3: `--roots`/`--network-id` load a signed `RootSet` (same +# artifact `yipd` loads) to run this server in mesh mode — RootSet is not +# re-exported by the `yip-rendezvous` lib crate, so this binary needs the +# direct dependency. +yip-membership = { path = "../../crates/yip-membership" } yip-obf = { path = "../../crates/yip-obf" } yip-utls = { path = "../../crates/yip-utls" } getrandom = "0.2" diff --git a/bin/yip-rendezvous/src/main.rs b/bin/yip-rendezvous/src/main.rs index ee86fd2..e9158a7 100644 --- a/bin/yip-rendezvous/src/main.rs +++ b/bin/yip-rendezvous/src/main.rs @@ -18,6 +18,7 @@ use std::time::{Duration, Instant}; use tokio::sync::Mutex; use tls_front::RealityCfg; +use yip_membership::RootSet; use yip_rendezvous::{decode, encode, Message, RendezvousServer}; const SWEEP_INTERVAL: Duration = Duration::from_secs(5); @@ -130,6 +131,69 @@ fn hex_nibble(b: u8) -> Result { } } +/// Decode a 32-char hex string into 16 bytes (`--network-id `). +/// Mirrors `yipd::config::hex_to_16` — kept local to this binary for the same +/// reason as `hex_to_32`/`hex_to_8` above (separate crates). +fn hex_to_16(hex: &str) -> Result<[u8; 16], String> { + if hex.len() != 32 { + return Err(format!("expected 32 hex chars, got {}", hex.len())); + } + let mut out = [0u8; 16]; + for (i, chunk) in hex.as_bytes().chunks(2).enumerate() { + let hi = hex_nibble(chunk[0])?; + let lo = hex_nibble(chunk[1])?; + out[i] = (hi << 4) | lo; + } + Ok(out) +} + +/// Decode an arbitrary-length hex string into bytes — the `--roots ` +/// file contents (a `RootSet::encode` blob is variable-length, unlike the +/// fixed-size 32/16/8-byte keys the other `hex_to_*` helpers handle). Mirrors +/// `yipd::config::hex_decode_vec`. +fn hex_decode_vec(hex: &str) -> Result, String> { + if !hex.len().is_multiple_of(2) { + return Err("odd-length hex string".to_string()); + } + let mut out = Vec::with_capacity(hex.len() / 2); + for chunk in hex.as_bytes().chunks(2) { + let hi = hex_nibble(chunk[0])?; + let lo = hex_nibble(chunk[1])?; + out.push((hi << 4) | lo); + } + Ok(out) +} + +/// Build the mesh-mode roots config (CA pubkeys + network id) from the raw +/// `--roots` file contents (hex-encoded `RootSet::encode`, one line — same +/// artifact `yipd` loads, see `bin/yipd/src/config.rs::load_roots_file`) and +/// the raw `--network-id` hex string. +/// +/// Pure (no I/O) so it is unit-testable directly: decodes the root set, +/// derives the CA pubkeys from its own entries +/// (`roots.roots.iter().map(|(pk, _)| *pk)`), verifies the root set is +/// self-consistent (`RootSet::verify_rootset`, signed by one of its own CA +/// keys — mirrors `yipd`'s config-load verification), and parses the network +/// id. Any failure returns a `String` describing what's wrong; callers must +/// fail loudly (exit), never fall back to rootless. +fn roots_cfg_from( + roots_hex: &str, + network_id_hex: &str, +) -> Result<(Vec<[u8; 32]>, [u8; 16]), String> { + let bytes = hex_decode_vec(roots_hex.trim())?; + let roots = RootSet::decode(&bytes).ok_or_else(|| "failed to decode root set".to_string())?; + let ca_pubkeys: Vec<[u8; 32]> = roots.roots.iter().map(|(pk, _)| *pk).collect(); + if !roots.verify_rootset(&ca_pubkeys) { + return Err( + "root set signature does not verify against its own CA pubkeys (self-consistency \ + check failed)" + .to_string(), + ); + } + let network_id = hex_to_16(network_id_hex).map_err(|e| format!("invalid --network-id: {e}"))?; + Ok((ca_pubkeys, network_id)) +} + /// If `port` is a canonical VPN/tunnel default port that DPI port-matches, /// return the protocol it makes the relay look like; `None` for a plausible /// port. Mirrors `yipd::config::fingerprinted_vpn_port` — kept local to this @@ -168,9 +232,12 @@ fn usage_exit() -> ! { [--reality-dest --reality-private-key \ [--reality-short-id ]... [--reality-server-name ]... \ [--reality-cert-refresh-secs ] [--reality-cert-max-stale-secs ] \ - [--reality-replay-max-bucket ] [--reality-max-inflight ]]]\n\ + [--reality-replay-max-bucket ] [--reality-max-inflight ]]] \ + [--roots --network-id ]\n\ (--reality-dest supersedes --decoy when both are set; --tls-cert/--tls-key are \ - optional when --reality-dest is set — REALITY forges its own per-SNI cert)\n\ + optional when --reality-dest is set — REALITY forges its own per-SNI cert; \ + --roots/--network-id are both-or-neither and switch the server into mesh mode, \ + requiring every registration to carry a verified signed record)\n\ e.g. 0.0.0.0:51821" ); std::process::exit(2); @@ -204,6 +271,13 @@ async fn main() -> std::io::Result<()> { let mut reality_cert_max_stale_secs: u64 = 21600; let mut reality_replay_max_bucket: usize = 16384; let mut reality_max_inflight: usize = 1024; + // Mesh mode (rdv.37 Task 3), both opt-in and both-or-neither: a signed + // `RootSet` file (`--roots`) plus the mesh network id (`--network-id`) + // switch the server from legacy rootless registration to + // `RendezvousServer::new_with_roots`, which requires every registration + // to carry a verified `RegisterSigned` record. + let mut roots_path: Option = None; + let mut network_id_hex: Option = None; while let Some(arg) = args.next() { match arg.as_str() { @@ -332,6 +406,20 @@ async fn main() -> std::io::Result<()> { std::process::exit(2); }); } + "--roots" => { + let Some(v) = args.next() else { + eprintln!("--roots requires a path argument"); + std::process::exit(2); + }; + roots_path = Some(v); + } + "--network-id" => { + let Some(v) = args.next() else { + eprintln!("--network-id requires a 32-char hex argument"); + std::process::exit(2); + }; + network_id_hex = Some(v); + } _ if listen.is_none() => listen = Some(arg), other => { eprintln!("unexpected argument: {other}"); @@ -431,10 +519,47 @@ async fn main() -> std::io::Result<()> { ); } + // Mesh mode (rdv.37 Task 3): `--roots` and `--network-id` are + // both-or-neither — an operator passing only one clearly wants mesh and + // silently falling back to rootless would be a security downgrade, so + // that mismatch is rejected up front, before any network I/O. + let mesh_cfg: Option<(Vec<[u8; 32]>, [u8; 16])> = match (roots_path, network_id_hex) { + (Some(path), Some(net_hex)) => { + let contents = std::fs::read_to_string(&path).unwrap_or_else(|e| { + eprintln!("failed to read --roots file {path:?}: {e}"); + std::process::exit(2); + }); + let cfg = roots_cfg_from(&contents, &net_hex).unwrap_or_else(|e| { + eprintln!("fatal: invalid --roots/--network-id: {e}"); + std::process::exit(1); + }); + eprintln!( + "yip-rendezvous: mesh mode ({} root{})", + cfg.0.len(), + if cfg.0.len() == 1 { "" } else { "s" } + ); + Some(cfg) + } + (None, None) => None, + (Some(_), None) => { + eprintln!("--roots requires --network-id (mesh mode needs both)"); + std::process::exit(2); + } + (None, Some(_)) => { + eprintln!("--network-id requires --roots (mesh mode needs both)"); + std::process::exit(2); + } + }; + // Millisecond clock from a monotonic base (Instant), so `now_ms` never goes // backwards and needs no wall clock. let base = Instant::now(); - let server = Arc::new(Mutex::new(RendezvousServer::new(0))); + let server = Arc::new(Mutex::new(match mesh_cfg { + None => RendezvousServer::new(0), + Some((ca_pubkeys, network_id)) => { + RendezvousServer::new_with_roots(0, ca_pubkeys, network_id) + } + })); let sock = tokio::net::UdpSocket::bind(&listen).await?; eprintln!("yip-rendezvous listening on {listen} (udp)"); @@ -603,6 +728,63 @@ mod cli_tests { } } +/// `--roots`/`--network-id` mesh-mode construction: covers `hex_to_16` and +/// the pure `roots_cfg_from` failure paths directly. The positive +/// (successfully-verifying, real CA-signed root set) path is exercised as a +/// real-binary smoke check instead — building a signed `RootSet` here would +/// need ed25519 signing dev-dependencies for a single assertion already +/// covered end-to-end by `crates/yip-rendezvous/src/server.rs`'s +/// `rooted_server_*` tests plus the `yip-ca`-generated smoke fixture (see +/// `task-3-report.md`). +#[cfg(test)] +mod roots_cfg_tests { + use super::{hex_to_16, roots_cfg_from}; + + /// A valid 32-char hex string decodes to the expected 16 bytes. + #[test] + fn hex_to_16_valid() { + assert_eq!( + hex_to_16("00112233445566778899aabbccddeeff"), + Ok([ + 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, + 0xee, 0xff + ]) + ); + } + + /// Wrong-length input (too short / too long) is rejected. + #[test] + fn hex_to_16_wrong_length() { + assert!(hex_to_16("00112233445566778899aabbccddeef").is_err()); // 31 chars + assert!(hex_to_16("00112233445566778899aabbccddeeff00").is_err()); // 34 chars + } + + /// Non-hex characters are rejected. + #[test] + fn hex_to_16_non_hex() { + assert!(hex_to_16("0011223344556677889zaabbccddeeff").is_err()); + } + + /// Garbage (non-hex) `--roots` file contents are rejected before any + /// `RootSet::decode` is attempted. + #[test] + fn roots_cfg_from_bad_hex_is_rejected() { + let err = roots_cfg_from("not hex at all", "00112233445566778899aabbccddeeff").unwrap_err(); + assert!(!err.is_empty()); + } + + /// Well-formed hex that isn't a valid `RootSet` encoding is rejected. + /// (`roots_cfg_from` decodes+verifies the root set before ever touching + /// `--network-id`, so a malformed network-id string can't be reached + /// from here with an undecodable/unverified root set — that final step + /// is already covered directly by the `hex_to_16_*` tests above.) + #[test] + fn roots_cfg_from_undecodable_roots_is_rejected() { + let err = roots_cfg_from("deadbeef", "00112233445566778899aabbccddeeff").unwrap_err(); + assert!(err.contains("decode"), "error names the failure: {err}"); + } +} + #[cfg(test)] mod obf_tests { use super::{decode_inbound, wrap_reply}; From ef1bcc8f94c79cf979f32430779080e500c16a22 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Fri, 24 Jul 2026 19:03:25 -0400 Subject: [PATCH 11/15] feat(membership.37): sign_registration + verify_record accessors --- bin/yipd/src/membership.rs | 107 +++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/bin/yipd/src/membership.rs b/bin/yipd/src/membership.rs index febe5fc..1cb5e5a 100644 --- a/bin/yipd/src/membership.rs +++ b/bin/yipd/src/membership.rs @@ -96,6 +96,16 @@ pub struct Membership { /// concern, not a reason to panic — an expired own cert is still /// returned as-is and left for the peer to reject. own_cert: Cert, + /// This node's own currently-known reachable endpoints, kept + /// independent of `directory`/`own_record` for the same reason as + /// `own_cert` — `sign_registration` (#37 Task 4) mints fresh + /// registration `Record`s from this without depending on directory + /// churn. + own_endpoints: Vec, + /// This node's record-signing private key, kept so `sign_registration` + /// (#37 Task 4) can mint fresh, freshly-`seq`'d registration `Record`s + /// on demand without re-deriving/re-plumbing the key from the caller. + sign_priv: [u8; 32], /// WALL-CLOCK-seconds timestamp is not stored here; only the MONOTONIC /// millisecond mark of the last digest we emitted, for `tick_digest`'s /// debounce. @@ -146,6 +156,7 @@ impl Membership { ) -> Self { let own_node_id = node_id(&own_cert.member_pubkey); let own_cert_stored = own_cert.clone(); + let own_endpoints_stored = own_endpoints.clone(); let own_record = build_signed_record(own_cert, own_endpoints, INITIAL_SEQ, &own_sign_priv); let mut m = Membership { @@ -156,6 +167,8 @@ impl Membership { roots, own_node_id, own_cert: own_cert_stored, + own_endpoints: own_endpoints_stored, + sign_priv: own_sign_priv, last_digest_ms: None, digest_ms: GOSSIP_INTERVAL_MS, }; @@ -163,6 +176,37 @@ impl Membership { m } + /// This node's own `node_id` (derived from its member pubkey). + pub fn own_node_id(&self) -> NodeId { + self.own_node_id + } + + /// Mint a fresh, self-signed registration `Record` at `seq` (the + /// rendezvous freshness counter). Reuses the gossip record machinery — + /// same cert, same endpoints, monotonic `seq` — so the registration and + /// the gossip directory entry are the same signed object (#37). + pub fn sign_registration(&self, seq: u64) -> Record { + build_signed_record( + self.own_cert.clone(), + self.own_endpoints.clone(), + seq, + &self.sign_priv, + ) + } + + /// Verify a registration/directory `Record` against our own roots + + /// network, at wall-clock `now_secs`. True iff the cert chains to a + /// root, the record signature is valid, and the `node_id` binds the + /// cert (squatting closed). Mirrors `bin/yip-rendezvous`'s own + /// `roots.roots.iter().map(|(pk, _)| *pk)` convention: the signed + /// `RootSet`'s member pubkeys double as the trusted CA set for this + /// verification (#37). + pub fn verify_record(&self, r: &Record, now_secs: u64) -> bool { + let ca_pubkeys: Vec<[u8; 32]> = self.roots.roots.iter().map(|(pk, _)| *pk).collect(); + r.verify(&ca_pubkeys, &self.network_id, now_secs, clock_skew_secs()) + .is_ok() + } + /// Directory lookup by mesh address, via the `node_addr -> node_id` /// secondary index. No clock involved. pub fn resolve(&self, addr: &Ipv6Addr) -> Option { @@ -893,4 +937,67 @@ mod tests { assert!(got_ids.contains(nid), "missing requested record {nid:?}"); } } + + // ── #37 Task 4: `sign_registration` / `verify_record` ─────────────── + + /// Build a fresh `Membership` whose `roots` (the `RootSet` consulted by + /// `verify_record`, mirroring `bin/yip-rendezvous`'s own + /// `roots.roots.iter().map(|(pk, _)| *pk)` convention) contains the + /// trusted CA's own pubkey as a root entry — so a record minted under + /// that CA verifies against `m.verify_record`. + fn test_membership() -> Membership { + let ca = ca_key(1); + let net = [7u8; 16]; + let ca_pub = ca.verifying_key().to_bytes(); + let roots = RootSet { + roots: vec![(ca_pub, "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); + Membership::new( + vec![ca_pub], + net, + own_cert, + own_sign_key.to_bytes(), + roots, + vec!["10.0.0.1:51820".parse().unwrap()], + ) + } + + fn now_secs() -> u64 { + 500 + } + + /// Build a `Record` signed under a CA UNRELATED to `test_membership`'s + /// trusted root — must be rejected by `verify_record`. + fn record_signed_by_unrelated_ca() -> Record { + let unrelated_ca = ca_key(200); + let net = [7u8; 16]; + let member_pk = [123u8; 32]; + let member_sign_key = SigningKey::from_bytes(&[124u8; 32]); + let member_sign_pub = member_sign_key.verifying_key().to_bytes(); + let cert = make_cert(&unrelated_ca, member_pk, member_sign_pub, net, 0, 1_000_000); + let endpoints = vec!["192.0.2.77:7777".parse().unwrap()]; + build_signed_record(cert, endpoints, 1, &member_sign_key.to_bytes()) + } + + #[test] + fn sign_registration_is_verifiable_against_own_roots() { + let m = test_membership(); + let rec = m.sign_registration(5); + assert_eq!(rec.node_id, m.own_node_id()); + assert!(m.verify_record(&rec, now_secs())); + } + + #[test] + fn verify_record_rejects_foreign_ca() { + let m = test_membership(); + let foreign = record_signed_by_unrelated_ca(); + assert!(!m.verify_record(&foreign, now_secs())); + } } From ad047e95c7e44c5f402fa913dbb435a84103d5a0 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Fri, 24 Jul 2026 19:22:16 -0400 Subject: [PATCH 12/15] feat(rdv.37): client sends RegisterSigned + verifies PeerInfo.record before probing --- bin/yipd/src/peer_manager.rs | 218 +++++++++++++++++++++++++++++++++-- bin/yipd/src/rendezvous.rs | 116 ++++++++++++++++--- 2 files changed, 310 insertions(+), 24 deletions(-) diff --git a/bin/yipd/src/peer_manager.rs b/bin/yipd/src/peer_manager.rs index 04bc3e1..350f8ea 100644 --- a/bin/yipd/src/peer_manager.rs +++ b/bin/yipd/src/peer_manager.rs @@ -350,6 +350,12 @@ pub struct PeerManager { /// promptly rather than waiting a full [`REG_REFRESH_MS`] interval — the /// loop clock starts at 0). registered_once: bool, + /// Monotonic freshness counter for signed registrations (#37 Task 5): + /// bumped every time `tick_dispatch` emits a registration, and passed to + /// `Membership::sign_registration` when membership is configured. Never + /// reset for the process's lifetime, so a captured `RegisterSigned` can + /// never be replayed to look fresher than a subsequent one. + reg_seq: u64, /// Reused scratch for `on_udp`/`on_tun` return values. egress: Vec, /// Reused scratch for `tick`'s return value. @@ -503,6 +509,7 @@ impl PeerManager { last_register_ms: 0, reg_refresh_ms: REG_REFRESH_MS, registered_once: false, + reg_seq: 0, egress: Vec::new(), tick_egress: Vec::new(), tun_scratch: Vec::new(), @@ -995,10 +1002,22 @@ impl PeerManager { None => return DispatchOut::None, }; match ev { - RdvEvent::PeerCandidate { node, addr } => { - if let Some(&idx) = self.by_node.get(&node) { - if !matches!(self.peers[idx].state, PeerState::Established(_)) { - self.peers[idx].path.on_peer_candidate(addr, now_ms); + RdvEvent::PeerCandidate { node, addr, record } => { + // Mesh mode (membership configured): a candidate carrying a + // record must verify against our roots before we act on it — + // an unverifiable/forged record drops the candidate entirely + // (no probe). Non-mesh (no membership) or a candidate with no + // record (legacy server) keeps today's unauthenticated + // behavior (#37 Task 5). + let record_ok = match (self.membership.as_ref(), &record) { + (Some(m), Some(r)) => m.verify_record(r, now_secs()), + _ => true, + }; + if record_ok { + if let Some(&idx) = self.by_node.get(&node) { + if !matches!(self.peers[idx].state, PeerState::Established(_)) { + self.peers[idx].path.on_peer_candidate(addr, now_ms); + } } } DispatchOut::None @@ -2860,8 +2879,17 @@ impl PeerManager { || now_ms.saturating_sub(self.last_register_ms) >= self.reg_refresh_ms) { let node = self.local_node_id; + // Mesh mode (membership configured): mint a fresh signed + // registration record at the current `reg_seq` so the server can + // verify authenticity instead of trusting an unauthenticated + // `counter` (#37 Task 5). Non-mesh keeps the legacy `Register`. + let signed = self + .membership + .as_ref() + .map(|m| m.sign_registration(self.reg_seq)); + self.reg_seq = self.reg_seq.saturating_add(1); if let Some(r) = self.rendezvous.as_mut() { - if let Some(dg) = r.register(node) { + if let Some(dg) = r.register(node, signed) { self.tick_egress.push(dg); } } @@ -5001,9 +5029,20 @@ mod tests { } impl Rendezvous for MockRdv { - fn register(&mut self, node: NodeId) -> Option { - // counter bumped per-registration in 3c.4; 0 is accepted as first-seen - Some(self.to_server(yip_rendezvous::Message::Register { node, counter: 0 })) + fn register( + &mut self, + node: NodeId, + signed: Option, + ) -> Option { + match signed { + Some(record) => { + Some(self.to_server(yip_rendezvous::Message::RegisterSigned { record })) + } + // counter bumped per-registration in 3c.4; 0 is accepted as first-seen + None => { + Some(self.to_server(yip_rendezvous::Message::Register { node, counter: 0 })) + } + } } fn lookup(&mut self, node: NodeId) -> EgressDatagram { self.to_server(yip_rendezvous::Message::Lookup { node }) @@ -5018,10 +5057,13 @@ mod tests { fn parse(&self, dg: &[u8]) -> RdvEvent { match yip_rendezvous::decode(dg) { Some(yip_rendezvous::Message::PeerInfo { - node, reflexive, .. + node, + reflexive, + record, }) => RdvEvent::PeerCandidate { node, addr: reflexive, + record: record.map(Box::new), }, Some(yip_rendezvous::Message::PunchHint { node, reflexive }) => RdvEvent::PunchTo { node, @@ -5069,6 +5111,35 @@ mod tests { (pm, sent) } + /// Like `pm_with_mock_rdv`, but with `membership` configured (mesh + /// mode) — used by the `PeerCandidate`-record-verification tests (#37 + /// Task 5), which need the `MockRdv` to decode a real `PeerInfo.record` + /// AND a `Membership` to verify it against. + fn pm_with_mock_rdv_and_membership( + local: &yip_crypto::Keypair, + peers: &[PeerConfig], + membership: Membership, + ) -> ( + PeerManager, + std::rc::Rc>>, + ) { + 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 pm = PeerManager::new( + local.private, + local.public, + peers, + TunnelMode::L3Tun, + Some(rdv), + Some(membership), + false, + ); + (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 @@ -5237,6 +5308,135 @@ mod tests { assert!(matches!(pm.peers[0].state, PeerState::Handshaking(_))); } + /// Build a mesh (`membership: Some`) `Membership` for `local_pub` trusting + /// `ca`, whose `roots` names `ca`'s own pubkey — the convention + /// `Membership::verify_record` relies on (it treats `roots.roots`' + /// pubkeys as the trusted CA set, mirroring `bin/yip-rendezvous`'s own + /// rooted-server convention). Shared by the two `PeerCandidate`-record + /// verification tests below (#37 Task 5). + fn membership_trusting_ca_via_roots(ca: &SigningKey, local_pub: [u8; 32]) -> Membership { + let ca_pub = ca.verifying_key().to_bytes(); + let own_sign = SigningKey::from_bytes(&[201u8; 32]); + let own_cert = mk_cert(ca, local_pub, own_sign.verifying_key().to_bytes()); + let roots = RootSet { + roots: vec![(ca_pub, "10.0.0.99:51820".parse().unwrap())], + version: 0, + ca_sig: [0u8; 64], + }; + Membership::new( + vec![ca_pub], + TEST_NET, + own_cert, + own_sign.to_bytes(), + roots, + vec!["10.0.0.1:51820".parse().unwrap()], + ) + } + + /// #37 Task 5: a `PeerCandidate` carrying a record that VERIFIES against + /// our membership roots is accepted — the candidate is set and a + /// subsequent tick probes it with a handshake `Init`, exactly like the + /// no-record (`peer_candidate_then_tick_probes_candidate_with_init`) + /// case above. + #[test] + fn peer_candidate_with_valid_signed_record_is_accepted_and_probed() { + let ca = test_ca(); + let local = generate_keypair(); + let peer_kp = generate_keypair(); + let peer = PeerConfig { + public_key: peer_kp.public, + endpoint: None, + }; + let membership = membership_trusting_ca_via_roots(&ca, local.public); + let (mut pm, _sent) = pm_with_mock_rdv_and_membership(&local, &[peer], membership); + + let candidate: SocketAddr = "198.51.100.7:41000".parse().unwrap(); + let valid_rec = mk_record(&ca, 55, peer_kp.public, vec![candidate], 1); + let mut buf = Vec::new(); + yip_rendezvous::encode( + &yip_rendezvous::Message::PeerInfo { + node: node_id(&peer_kp.public), + reflexive: candidate, + record: Some(valid_rec), + }, + &mut buf, + ); + assert!(matches!( + pm.on_udp(mock_server(), &buf, 0), + DispatchOut::None + )); + assert_eq!( + pm.peers[0].path.candidate(), + Some(candidate), + "a validly-signed record must set the candidate" + ); + + let out = pm.tick(1).map(<[_]>::to_vec).unwrap_or_default(); + let init = out + .iter() + .find(|d| d.dst == candidate) + .expect("a handshake Init is emitted toward the verified candidate"); + assert_eq!( + init.bytes[0], + PacketType::HandshakeInit as u8, + "the datagram to the candidate is a handshake Init" + ); + assert!(matches!(pm.peers[0].state, PeerState::Handshaking(_))); + } + + /// #37 Task 5: a `PeerCandidate` carrying a record that FAILS + /// `verify_record` (signed by a CA outside our membership roots — a + /// forged/foreign record) must be DROPPED: no candidate is set and no + /// probe is ever sent toward it, even after a tick. This is the + /// discriminating half of the pair — it genuinely rejects, it doesn't + /// just happen to also pass. + #[test] + fn peer_candidate_with_invalid_record_is_dropped_no_probe() { + let ca = test_ca(); + let foreign_ca = SigningKey::from_bytes(&[177u8; 32]); // NOT in this membership's roots + let local = generate_keypair(); + let peer_kp = generate_keypair(); + let peer = PeerConfig { + public_key: peer_kp.public, + endpoint: None, + }; + let membership = membership_trusting_ca_via_roots(&ca, local.public); + let (mut pm, _sent) = pm_with_mock_rdv_and_membership(&local, &[peer], membership); + + let candidate: SocketAddr = "198.51.100.7:41000".parse().unwrap(); + // Signed under `foreign_ca`, not the trusted `ca` — a forged record + // claiming to be `peer_kp`'s. + let forged = mk_record(&foreign_ca, 55, peer_kp.public, vec![candidate], 1); + let mut buf = Vec::new(); + yip_rendezvous::encode( + &yip_rendezvous::Message::PeerInfo { + node: node_id(&peer_kp.public), + reflexive: candidate, + record: Some(forged), + }, + &mut buf, + ); + assert!(matches!( + pm.on_udp(mock_server(), &buf, 0), + DispatchOut::None + )); + assert_eq!( + pm.peers[0].path.candidate(), + None, + "an unverifiable record must NOT set the candidate" + ); + + let out = pm.tick(1).map(<[_]>::to_vec).unwrap_or_default(); + assert!( + !out.iter().any(|d| d.dst == candidate), + "no probe may be sent toward a candidate carrying an unverifiable record" + ); + assert!( + matches!(pm.peers[0].state, PeerState::Idle), + "the peer must stay Idle: the forged candidate was never acted on" + ); + } + /// (c) With NO rendezvous configured, a peer with a direct endpoint behaves /// exactly as 2a: the first TUN packet emits an `Init` to the configured /// endpoint (no server-addr demux, no path-SM escalation). diff --git a/bin/yipd/src/rendezvous.rs b/bin/yipd/src/rendezvous.rs index 1db5314..42e6cc8 100644 --- a/bin/yipd/src/rendezvous.rs +++ b/bin/yipd/src/rendezvous.rs @@ -6,6 +6,7 @@ use std::net::SocketAddr; use yip_io::poll::EgressDatagram; +use yip_membership::Record; use yip_rendezvous::{decode, encode, Message, NodeId}; /// A parsed inbound rendezvous datagram, normalized for the path SM. @@ -14,8 +15,18 @@ use yip_rendezvous::{decode, encode, Message, NodeId}; /// path state machine. #[derive(Debug, Clone, PartialEq, Eq)] pub enum RdvEvent { - /// The server told us where a peer is (answer to our `lookup`). - PeerCandidate { node: NodeId, addr: SocketAddr }, + /// The server told us where a peer is (answer to our `lookup`). `record` + /// is the peer's signed directory record when the server has one on file + /// (mesh mode) — `parse` cannot verify it (no roots), so it is surfaced + /// as-is for `PeerManager` to verify against `Membership::verify_record` + /// before acting on the candidate (#37 Task 5). Boxed: `Record` (a `Cert` + /// plus endpoints/sig) is far larger than the other variants, and + /// clippy's `large_enum_variant` flags an unboxed `Option` here. + PeerCandidate { + node: NodeId, + addr: SocketAddr, + record: Option>, + }, /// The server asked us to punch toward a peer that looked us up. PunchTo { node: NodeId, addr: SocketAddr }, /// A relayed tunnel datagram from `src`; `payload` is fed to the peer path. @@ -34,8 +45,12 @@ pub enum RdvEvent { pub trait Rendezvous { /// Emit a registration datagram, or `None` if registration is handled /// elsewhere (the 3c.4 relay thread sends `Register` itself). UDP impls - /// return `Some`. - fn register(&mut self, node: NodeId) -> Option; + /// return `Some`. `signed` is `Some(record)` when the caller (mesh mode, + /// membership configured) has minted a fresh signed registration record + /// via `Membership::sign_registration` — emitted as `RegisterSigned`. + /// `None` (non-mesh) falls back to the legacy unauthenticated `Register` + /// (#37 Task 5). + fn register(&mut self, node: NodeId, signed: Option) -> Option; fn lookup(&mut self, node: NodeId) -> EgressDatagram; fn relay(&mut self, src: NodeId, dst: NodeId, payload: &[u8]) -> EgressDatagram; fn parse(&self, dg: &[u8]) -> RdvEvent; @@ -67,9 +82,12 @@ impl ConfiguredServerRendezvous { } impl Rendezvous for ConfiguredServerRendezvous { - fn register(&mut self, node: NodeId) -> Option { - // counter bumped per-registration in 3c.4; 0 is accepted as first-seen - Some(self.to_server(&Message::Register { node, counter: 0 })) + fn register(&mut self, node: NodeId, signed: Option) -> Option { + match signed { + Some(record) => Some(self.to_server(&Message::RegisterSigned { record })), + // counter bumped per-registration in 3c.4; 0 is accepted as first-seen + None => Some(self.to_server(&Message::Register { node, counter: 0 })), + } } fn lookup(&mut self, node: NodeId) -> EgressDatagram { self.to_server(&Message::Lookup { node }) @@ -83,14 +101,14 @@ impl Rendezvous for ConfiguredServerRendezvous { } fn parse(&self, dg: &[u8]) -> RdvEvent { match decode(dg) { - // TODO(#37 task 5): surface `record` to the caller so - // `PeerManager` can verify it against membership roots before - // probing (codec-only in task 1). Some(Message::PeerInfo { - node, reflexive, .. + node, + reflexive, + record, }) => RdvEvent::PeerCandidate { node, addr: reflexive, + record: record.map(Box::new), }, Some(Message::PunchHint { node, reflexive }) => RdvEvent::PunchTo { node, @@ -129,7 +147,7 @@ impl TlsRelayRendezvous { } impl Rendezvous for TlsRelayRendezvous { - fn register(&mut self, _node: NodeId) -> Option { + fn register(&mut self, _node: NodeId, _signed: Option) -> Option { None // the relay thread owns Register (first-on-connect + keepalive) } fn lookup(&mut self, _node: NodeId) -> EgressDatagram { @@ -166,7 +184,7 @@ mod tests { fn register_targets_server_with_our_node_id() { let mut r = ConfiguredServerRendezvous::new(server()); let me = node_id(&[1u8; 32]); - let dg = r.register(me).expect("UDP register is always Some"); + let dg = r.register(me, None).expect("UDP register is always Some"); assert_eq!(dg.dst, server()); assert_eq!( yip_rendezvous::decode(&dg.bytes), @@ -177,6 +195,74 @@ mod tests { ); } + /// Decode an `EgressDatagram`'s bytes as a rendezvous `Message` (test + /// helper mirroring `register`/`relay`'s wire framing). + fn decode_to_server(dg: &EgressDatagram) -> Option { + yip_rendezvous::decode(&dg.bytes) + } + + /// Build a decode-valid `Record` whose `node_id` is exactly `node` (a + /// test fixture, not a correctly-derived-from-pubkey record — `register` + /// passes `signed` through unverified, so only the wire round-trip + /// matters here). Mirrors `yip-rendezvous/src/proto.rs`'s own + /// `sample_record` fixture, with deterministic (non-OsRng) keys since + /// `yipd`'s dev-deps don't pull in `rand_core`. + fn sample_record_with_node(node: NodeId) -> Record { + use ed25519_dalek::{Signer, SigningKey}; + use yip_membership::cert::cert_signing_body; + use yip_membership::record::{record_signing_body, sign}; + use yip_membership::Cert; + + let ca = SigningKey::from_bytes(&[9u8; 32]); + let member_pubkey = [1u8; 32]; + let member_sign_key = SigningKey::from_bytes(&[2u8; 32]); + let member_sign_pubkey = member_sign_key.verifying_key().to_bytes(); + let network_id = [7u8; 16]; + + let mut cert = Cert { + version: 1, + member_pubkey, + member_sign_pubkey, + network_id, + not_before: 100, + not_after: 200, + tags: vec![], + ca_sig: [0u8; 64], + }; + cert.ca_sig = ca.sign(&cert_signing_body(&cert)).to_bytes(); + + let mut record = Record { + node_id: node, + cert, + endpoints: vec!["192.0.2.1:8080".parse().unwrap()], + seq: 1, + sig: [0u8; 64], + }; + let body = record_signing_body(&record); + record.sig = sign(&body, &member_sign_key.to_bytes()); + record + } + + #[test] + fn register_emits_signed_when_record_present() { + let mut r = ConfiguredServerRendezvous::new(server()); + let me = [3u8; 16]; + let rec = sample_record_with_node(me); + let dg = r.register(me, Some(rec.clone())).expect("Some"); + let msg = decode_to_server(&dg); + assert!(matches!(msg, Some(Message::RegisterSigned { record }) if record.node_id == me)); + } + + #[test] + fn register_falls_back_to_unsigned_without_record() { + let mut r = ConfiguredServerRendezvous::new(server()); + let me = [3u8; 16]; + let dg = r.register(me, None).expect("Some"); + assert!( + matches!(decode_to_server(&dg), Some(Message::Register { node, .. }) if node == me) + ); + } + #[test] fn lookup_targets_server_with_queried_node_id() { let mut r = ConfiguredServerRendezvous::new(server()); @@ -227,7 +313,7 @@ mod tests { &mut buf, ); assert!( - matches!(r.parse(&buf), RdvEvent::PeerCandidate { node, addr } if node == n && addr == a) + matches!(r.parse(&buf), RdvEvent::PeerCandidate { node, addr, record } if node == n && addr == a && record.is_none()) ); buf.clear(); encode( @@ -260,7 +346,7 @@ mod tests { let mut r = TlsRelayRendezvous::new(addr); let n = node_id(&[7u8; 32]); assert!( - r.register(n).is_none(), + r.register(n, None).is_none(), "thread owns Register on the TLS path" ); let dg = r.relay(node_id(&[1u8; 32]), n, b"payload"); From 35ac0a2e559e68351d3ec7ac291fdb6aa6850558 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Fri, 24 Jul 2026 19:31:30 -0400 Subject: [PATCH 13/15] fix(rdv.37): bind PeerInfo.record to the resolved node before probing (review Minor) verify_record proves a record is a valid member record but not that it is FOR the peer being resolved (PeerInfo.node and record.node_id are independent on the wire). Without the binding, a server could answer Lookup(Y) with a genuine record belonging to some other member X and steer a probe for Y at an arbitrary address. record_ok now also requires record.node_id == node. New test peer_candidate_with_valid_record_for_wrong_node_is_dropped. --- bin/yipd/src/peer_manager.rs | 58 +++++++++++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/bin/yipd/src/peer_manager.rs b/bin/yipd/src/peer_manager.rs index 350f8ea..a8fe5c8 100644 --- a/bin/yipd/src/peer_manager.rs +++ b/bin/yipd/src/peer_manager.rs @@ -1009,8 +1009,18 @@ impl PeerManager { // (no probe). Non-mesh (no membership) or a candidate with no // record (legacy server) keeps today's unauthenticated // behavior (#37 Task 5). + // + // The record must also be FOR the peer we resolved: `verify_record` + // proves the record is a valid member record (cert chains to a + // root, signature valid, node_id binds its own cert), but the + // outer `PeerInfo.node` and `record.node_id` are independent on + // the wire. Binding them here stops a server from answering + // `Lookup(Y)` with a genuine record belonging to some other + // member X — otherwise a valid-but-wrong-identity record would + // pass and let the server steer a probe for Y at an arbitrary + // address. let record_ok = match (self.membership.as_ref(), &record) { - (Some(m), Some(r)) => m.verify_record(r, now_secs()), + (Some(m), Some(r)) => r.node_id == node && m.verify_record(r, now_secs()), _ => true, }; if record_ok { @@ -5437,6 +5447,52 @@ mod tests { ); } + #[test] + fn peer_candidate_with_valid_record_for_wrong_node_is_dropped() { + // A record can be a GENUINE, trusted-CA member record and still be the + // wrong one: the server answered Lookup(peer) with a valid record that + // belongs to some OTHER member. `verify_record` passes it (it's real), + // so the node_id binding is what must drop it. + let ca = test_ca(); + let local = generate_keypair(); + let peer_kp = generate_keypair(); + let other_kp = generate_keypair(); // a different, validly-certed member + let peer = PeerConfig { + public_key: peer_kp.public, + endpoint: None, + }; + let membership = membership_trusting_ca_via_roots(&ca, local.public); + let (mut pm, _sent) = pm_with_mock_rdv_and_membership(&local, &[peer], membership); + + let candidate: SocketAddr = "198.51.100.7:41000".parse().unwrap(); + // Validly signed by the TRUSTED ca — but for `other_kp`, not `peer_kp`. + let wrong_id = mk_record(&ca, 55, other_kp.public, vec![candidate], 1); + let mut buf = Vec::new(); + yip_rendezvous::encode( + &yip_rendezvous::Message::PeerInfo { + node: node_id(&peer_kp.public), + reflexive: candidate, + record: Some(wrong_id), + }, + &mut buf, + ); + assert!(matches!( + pm.on_udp(mock_server(), &buf, 0), + DispatchOut::None + )); + assert_eq!( + pm.peers[0].path.candidate(), + None, + "a valid record binding a DIFFERENT identity must NOT set the candidate", + ); + let out = pm.tick(1).map(<[_]>::to_vec).unwrap_or_default(); + assert!( + !out.iter().any(|d| d.dst == candidate), + "no probe toward a candidate whose record binds a different identity", + ); + assert!(matches!(pm.peers[0].state, PeerState::Idle)); + } + /// (c) With NO rendezvous configured, a peer with a direct endpoint behaves /// exactly as 2a: the first TUN packet emits an `Init` to the configured /// endpoint (no server-addr demux, no path-SM escalation). From aaa5b2b38e4acd150543f7834b37e5ce4d271b61 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Fri, 24 Jul 2026 19:51:18 -0400 Subject: [PATCH 14/15] =?UTF-8?q?test(rdv.37):=20netns=20=E2=80=94=20forge?= =?UTF-8?q?d=20registration=20refused,=20victim=20stays=20reachable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/integration.yml | 38 ++ .../tests/run-netns-registration-hijack.sh | 573 ++++++++++++++++++ 2 files changed, 611 insertions(+) create mode 100755 bin/yipd/tests/run-netns-registration-hijack.sh diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index d0fec3e..1701373 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -414,6 +414,44 @@ jobs: exit 1 fi + - name: Run the rdv.37 registration-hijack money test under sudo (poll driver) + # run-netns-registration-hijack.sh: proves the #37 fix -- a ROOTED + # (mesh-mode, --roots/--network-id) standalone yip-rendezvous server + # refuses a forged, unsigned `Register` claiming an established + # member's (A's) node_id from a third, uncertified netns (S), and + # B<->A stays reachable (<=1% loss) throughout the attack. Forks + # run-netns-discovery.sh's CA/cert/roots minting and + # run-netns-punch.sh's rendezvous-only `[peer]` config style; new in + # this milestone is combining full mesh-cert config with a rooted + # standalone rendezvous server (no gossip root at all). + # + # POLL DRIVER ONLY: the property under test (the forged Register's + # refusal) lives entirely in the rendezvous SERVER + # (crates/yip-rendezvous/src/server.rs's `handle`, the + # `roots_cfg.is_some()` drop) -- plain tokio, no yipd I/O driver + # involved, so `YIP_USE_URING` cannot change that assertion's + # outcome. The only uring-sensitive part of this script is A/B's + # signed-registration + Lookup establishment timing, which is the + # same discovery-convergence risk run-netns-discovery.sh / + # run-netns-cert-revocation.sh already document as flaky under + # io_uring -- so, like cert-revocation.sh, this runs poll-only rather + # than add a spurious-failure vector for zero additional coverage of + # the actual security property. Uses the release yipd and debug + # yip-ca/yip-rendezvous already built above in this job. + run: | + sudo bash bin/yipd/tests/run-netns-registration-hijack.sh \ + "$(pwd)/target/release/yipd" \ + "$(pwd)/target/debug/yip-ca" \ + "$(pwd)/target/debug/yip-rendezvous" | tee /tmp/registration-hijack-poll.log + if grep -q "^SKIP run-netns-registration-hijack" /tmp/registration-hijack-poll.log; then + echo "::error::rdv.37 registration-hijack money test (poll) skipped — expected root in this job" + exit 1 + fi + if grep -q "\[FAIL\]" /tmp/registration-hijack-poll.log; then + echo "::error::rdv.37 registration-hijack money test (poll) failed — the rooted server accepted a forged registration, or it disrupted B<->A connectivity" + 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/tests/run-netns-registration-hijack.sh b/bin/yipd/tests/run-netns-registration-hijack.sh new file mode 100755 index 0000000..63e01a8 --- /dev/null +++ b/bin/yipd/tests/run-netns-registration-hijack.sh @@ -0,0 +1,573 @@ +#!/usr/bin/env bash +# The rdv.37 money test: proves the #37 fix end-to-end -- a ROOTED (mesh-mode) +# yip-rendezvous server, started with --roots/--network-id, refuses a forged +# registration attempt that claims an established member's identity, and the +# victim stays reachable throughout. +# +# Usage: run-netns-registration-hijack.sh \ +# +# +# ── topology: four netns, A / B / S / R, all on ONE shared bridge underlay ── +# Forked from run-netns-discovery.sh's CA/cert/roots minting (`yip-ca genkey` +# + per-node `yipd --genkey` + `sign-cert`) and run-netns-punch.sh's +# rendezvous-only peer config style (`rendezvous=`, `[peer] +# public_key=` with no endpoint -- no static knowledge of the peer's +# address). NEW here (this milestone's Task 3 wired it): R runs the +# STANDALONE `yip-rendezvous` binary in MESH mode (`--roots +# --network-id `), not a `yipd` seed root as run-netns-discovery.sh +# uses -- that combination (mesh-cert config + `rendezvous=` + a rooted +# standalone server) had no existing netns test before this task. +# +# ── the roots file's pubkey entry: the CA's own key, not a bootstrap peer ── +# `RootSet.roots` is a generic `Vec<(pubkey, addr)>` reused for two BY-DESIGN +# different purposes depending on deployment: +# - run-netns-discovery.sh's gossip-bootstrap deployment: the pubkey is a +# root PEER's data-plane key (used to seed the initial handshake). +# - this rendezvous-only deployment (no gossip, no root peer -- everyone +# reaches everyone only via the rendezvous server + direct handshake): +# `Membership::verify_record` (bin/yipd/src/membership.rs) and +# `yip-rendezvous`'s own `roots_cfg_from` (bin/yip-rendezvous/src/main.rs) +# BOTH derive their trusted-CA set as `roots.roots.iter().map(|(pk,_)| +# pk)` -- i.e. the roots file's pubkey entries must be the CA's OWN +# Ed25519 key for `verify_record` (which gates trusting a `PeerCandidate` +# record from the server) to ever succeed. Confirmed against +# `.superpowers/sdd/task-3-report.md`'s real-binary smoke check, which +# documents exactly this: "CA's own pubkey as a root entry (verify_rootset +# needs the CA's own pubkey inside the rootset's own listed entries)". +# The address half of that pair is unused by this deployment (no +# bootstrap dial happens) and is a placeholder. +# `ca_public=` (yipd's config, separate from `roots=`) is what feeds +# handshake-payload cert verification (`Membership::verify_cert`) and gossip +# ingestion; it is set to the SAME CA_PUB for both A and B. +# +# ── the attacker (S) ── +# S runs NO yipd process and holds no cert at all -- it is a bare UDP sender. +# A genuine `yipd`, even with its own valid cert, can only ever sign a +# registration for ITS OWN node_id (`Membership::sign_registration` always +# signs the node's own cert/endpoints -- bin/yipd/src/membership.rs); there is +# no config knob or code path that lets a real `yipd` register as someone +# else's identity, so a second real daemon cannot be coaxed into attacking A +# at all. S instead crafts the ONE forgery that IS a real off-path threat and +# needs no cryptography to construct: a legacy, UNSIGNED `Register { node: +# A's rendezvous node_id, counter: }` (crates/yip-rendezvous/src/proto.rs +# tag 0 -- a bare `[0x00][node:16][counter:8 BE]`, 25 bytes). Pre-#37 this +# would silently steal A's directory slot; post-#37 a ROOTED server drops +# every unsigned `Register` outright, unconditionally, before even looking at +# the node id (crates/yip-rendezvous/src/server.rs's `handle`, +# `self.roots_cfg.is_some()` arm) -- what this test proves end-to-end. +# +# The companion forgery -- a `RegisterSigned` carrying a record signed by a +# DIFFERENT member's own valid key but claiming A's node_id (the +# `Record::verify` node_id-binding / "squatting" check) -- needs a real +# ed25519 signature to construct, which requires either a crypto-capable +# scripting dependency not guaranteed present on the runner, or a bespoke +# Rust helper this task's scope (shell + CI yaml) doesn't call for. That path +# is already proven, adversarially, at the crate unit level by +# `rooted_server_rejects_overwrite_by_non_holder` (crates/yip-rendezvous/src/ +# server.rs) and `wrong_node_id_fails_verify` (crates/yip-membership/src/ +# record.rs) -- both construct exactly this attack (valid CA-signed cert for +# the attacker's OWN key, record.node_id forged to the victim's) and assert +# it is rejected without disturbing the victim's real entry. This script's +# unsigned-Register attack is the wire-level, no-crypto-needed sibling of the +# same property, and is the form the task brief explicitly endorses as the +# "simplest credible attacker" when raw signed-packet crafting is impractical. +# +# ── how the forged Register's rejection is observed (black-box) ── +# The server never replies to a `Register` (signed or not, accepted or +# refused -- see `handle`), so silence alone cannot distinguish "accepted" +# from "refused". Instead: a small python3 probe (no external deps, stdlib +# `socket`/`hashlib`/`struct` only) sends a raw `Lookup` for A's rendezvous +# node_id and decodes the `PeerInfo` reply's reflexive address directly off +# the wire, BOTH before and after the attack. If the forged Register had been +# accepted, A's directory entry would now point at S's address; assertion (a) +# below is that the reflexive address is IDENTICAL (still A's real +# `IP_A:PORT`) across the attack. `hashlib.blake2s(..., digest_size=16)` is +# confirmed bit-for-bit equal to `crates/yip-rendezvous/src/proto.rs`'s +# `Blake2sVar::new(16)` node-id derivation (both are standard-compliant +# BLAKE2s with the digest length folded into the parameter block) -- +# verified directly against the crate's own `node_id_is_deterministic_and_ +# 16_bytes` fixture before this script was written. +# +# ── obfuscation deliberately OFF ── +# Same reasoning as run-netns-replay-hijack.sh: the attack and probe scripts +# send/parse the PLAIN rendezvous wire format; under obf, that framing rides +# inside an obfuscation envelope this test does not construct. +# +# Assertions (any failure is non-zero exit, [PASS]/[FAIL] markers): +# 1. establishment: a steady ping B->A (over the mesh v6 addr) succeeds -- +# A and B found each other purely via signed registration + Lookup +# against the rooted server (no `[peer]` endpoint, no gossip). +# 2. no_overwrite: A's `Lookup` reflexive address is byte-identical before +# and after the forged-Register attack -- the server did not accept it. +# 3. no_disruption: a steady ping B->A spanning the attack window stays +# <=1% loss -- the forged registrations, even though refused, did not +# disrupt the live B<->A session either. +# 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 sources run-netns-discovery.sh / run-netns-cert-revocation.sh: +# mesh discovery/registration convergence is flaky under io_uring, and the +# rooted server's forged-registration refusal is a rendezvous-server-side +# property (`crates/yip-rendezvous/src/server.rs`, no yipd I/O driver +# involved at all) -- the poll run exercises it identically. +set -euo pipefail + +YIPD="${1:?Usage: $0 }" +YIPCA="${2:?Usage: $0 }" +RDV="${3:?Usage: $0 }" + +# ── 0. root + tool preflight (invoked directly by CI, not through the +# tunnel_netns.rs Rust harness, so it does its own SKIP-gating per the +# run-netns-cert-revocation.sh / run-netns-replay-hijack.sh convention) ── +if [ "$(id -u)" -ne 0 ]; then + echo "SKIP run-netns-registration-hijack: needs root (netns + TUN)" + exit 0 +fi +for tool in python3 ping; do + if ! command -v "$tool" >/dev/null 2>&1; then + echo "SKIP run-netns-registration-hijack: required tool '$tool' not found" + exit 0 + fi +done + +TMPDIR_TEST="$(mktemp -d /tmp/yipd-netns-registration-hijack-test.XXXXXX)" + +BR="brRegHijk0" + +NS_A="yipHijkA" +NS_B="yipHijkB" +NS_S="yipHijkS" # the attacker -- no yipd, no cert, no identity +NS_R="yipHijkR" # the rooted standalone yip-rendezvous server + +VETH_A_H="vHijkA0"; VETH_A_N="vHijkA1" +VETH_B_H="vHijkB0"; VETH_B_N="vHijkB1" +VETH_S_H="vHijkS0"; VETH_S_N="vHijkS1" +VETH_R_H="vHijkR0"; VETH_R_N="vHijkR1" + +IP_A="10.99.0.1" +IP_B="10.99.0.2" +IP_S="10.99.0.3" +IP_R="10.99.0.4" +VETH_PREFIX="24" +PORT="51820" +RDV_PORT="51821" +TUN_DEV="yip0" +NETWORK_ID="abadcafeabadcafeabadcafeabadcafe" + +PID_A="" +PID_B="" +PID_RDV="" + +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_RDV" ] && kill "$PID_RDV" 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 + ip netns del "$NS_A" 2>/dev/null || true + ip netns del "$NS_B" 2>/dev/null || true + ip netns del "$NS_S" 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 + a roots file naming the CA itself ── +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)" + +ADDR_A="$("$YIPD" --addr "$PUB_A")" +ADDR_B="$("$YIPD" --addr "$PUB_B")" +echo "[setup] node_addr A=$ADDR_A B=$ADDR_B" + +sign_cert() { + local member_pub="$1" member_sign_pub="$2" + echo "$CA_PRIV" | "$YIPCA" sign-cert \ + --member "$member_pub" --member-sign "$member_sign_pub" \ + --network "$NETWORK_ID" --days 30 +} +CERT_A_FILE="$TMPDIR_TEST/certA.hex" +CERT_B_FILE="$TMPDIR_TEST/certB.hex" +sign_cert "$PUB_A" "$SIGNPUB_A" > "$CERT_A_FILE" +sign_cert "$PUB_B" "$SIGNPUB_B" > "$CERT_B_FILE" + +# The roots file's pubkey entry is the CA's OWN key (see header comment) -- +# NOT a bootstrap peer's data-plane key. The address half is unused by this +# rendezvous-only deployment (no gossip bootstrap dial ever happens); any +# well-formed placeholder is fine. +ROOTS_IN="$TMPDIR_TEST/roots.in" +echo "$CA_PUB 127.0.0.1:1" > "$ROOTS_IN" +ROOTS_FILE="$TMPDIR_TEST/roots.hex" +echo "$CA_PRIV" | "$YIPCA" sign-roots --roots "$ROOTS_IN" --version 1 > "$ROOTS_FILE" + +# ── 2. write mesh + rendezvous-only configs for A and B ────────────────────── +CFG_A="$TMPDIR_TEST/yipA.conf" +CFG_B="$TMPDIR_TEST/yipB.conf" + +write_cfg() { + local file="$1" priv="$2" pub="$3" ip="$4" certfile="$5" signpriv="$6" peer_pub="$7" + cat > "$file" <"$LOG_RDV" 2>&1 & +PID_RDV=$! +sleep 0.3 +if ! kill -0 "$PID_RDV" 2>/dev/null; then + echo "[error] yip-rendezvous failed to start" + cat "$LOG_RDV" || true + exit 1 +fi +if ! grep -q "mesh mode" "$LOG_RDV"; then + echo "[FAIL] yip-rendezvous did not report mesh mode on startup" + cat "$LOG_RDV" || true + exit 1 +fi +echo "[check] yip-rendezvous confirmed mesh mode: $(grep 'mesh mode' "$LOG_RDV")" + +# ── 5. start yipd A and B ────────────────────────────────────────────────── +LOG_A="$TMPDIR_TEST/yipA.log" +LOG_B="$TMPDIR_TEST/yipB.log" + +dump_logs() { + echo "=== yip-rendezvous log ===" + cat "$LOG_RDV" || true + echo "=== yipHijkA log ===" + cat "$LOG_A" || true + echo "=== yipHijkB log ===" + cat "$LOG_B" || true +} + +echo "[start] starting yipHijkA" +ip netns exec "$NS_A" "$YIPD" "$CFG_A" >"$LOG_A" 2>&1 & +PID_A=$! + +echo "[start] starting yipHijkB" +ip netns exec "$NS_B" "$YIPD" "$CFG_B" >"$LOG_B" 2>&1 & +PID_B=$! + +# ── 6. wait for TUN devices to appear in A and B ────────────────────────────── +TUN_WAIT=20 +INTERVAL=0.25 + +echo "[wait] waiting for TUN devices to appear (up to ${TUN_WAIT}s)" +elapsed=0 +while true; do + A_UP=0; B_UP=0 + ip netns exec "$NS_A" ip link show "$TUN_DEV" >/dev/null 2>&1 && A_UP=1 || true + ip netns exec "$NS_B" ip link show "$TUN_DEV" >/dev/null 2>&1 && B_UP=1 || true + + if [ "$A_UP" -eq 1 ] && [ "$B_UP" -eq 1 ]; then + echo "[wait] both TUN devices are up" + break + fi + + for pid_var_name in PID_A:yipHijkA PID_B:yipHijkB PID_RDV:yip-rendezvous; 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 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 + +# ── 7. 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" + +# ── 8. establishment: poll until a steady ping B->A converges ──────────────── +# Neither side has the other's endpoint statically (rendezvous-only [peer] +# blocks) -- A and B must each register (signed) with R, then Lookup the +# other and verify the returned record against the shared roots before +# admitting/handshaking (#37 Task 5). Poll rather than one-shot, mirroring +# run-netns-cert-revocation.sh's establishment step: this exact mesh-cert + +# rooted-rendezvous combination is new in this milestone. +echo "[test] establishing B<->A via signed registration + rooted rendezvous (up to 60s)" +ESTABLISHED=0 +DEADLINE=$(($(date +%s) + 60)) +while [ "$(date +%s)" -lt "$DEADLINE" ]; do + for pid_var_name in PID_A:yipHijkA PID_B:yipHijkB PID_RDV:yip-rendezvous; 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 died during establishment" + dump_logs + exit 1 + fi + done + if ip netns exec "$NS_B" ping -6 -c 3 -W 2 "$ADDR_A" >/dev/null 2>&1; then + ESTABLISHED=1 + break + fi + sleep 1 +done +if [ "$ESTABLISHED" -ne 1 ]; then + echo "[FAIL] establishment: ping B->A did not converge within 60s" + dump_logs + exit 1 +fi +echo "[PASS] establishment: B<->A established via signed registration + discovery" + +# ── 9. the forged-registration probe/attack tool (S has no yipd, no cert) ──── +# stdlib-only (socket/hashlib/struct); mirrors crates/yip-rendezvous/src/ +# proto.rs's Message codec exactly (see header comment for the full +# rationale). No obf: plain wire bytes only. +PROBE_PY="$TMPDIR_TEST/rdv_probe.py" +cat > "$PROBE_PY" <<'PYEOF' +import hashlib +import socket +import struct +import sys + +DOMAIN = b"yip-rdv-v1" + + +def node_id_hex(pubkey_hex): + pk = bytes.fromhex(pubkey_hex) + assert len(pk) == 32, f"pubkey must be 32 bytes, got {len(pk)}" + h = hashlib.blake2s(digest_size=16) + h.update(DOMAIN) + h.update(pk) + return h.hexdigest() + + +def cmd_nodeid(argv): + print(node_id_hex(argv[0])) + + +def cmd_register(argv): + rdv_ip, rdv_port, node_hex, counter = argv[0], int(argv[1]), argv[2], int(argv[3]) + node = bytes.fromhex(node_hex) + assert len(node) == 16 + pkt = bytes([0]) + node + struct.pack(">Q", counter) + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.sendto(pkt, (rdv_ip, rdv_port)) + s.close() + + +def cmd_lookup(argv): + rdv_ip, rdv_port, node_hex = argv[0], int(argv[1]), argv[2] + timeout = float(argv[3]) if len(argv) > 3 else 2.0 + node = bytes.fromhex(node_hex) + assert len(node) == 16 + pkt = bytes([1]) + node + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.settimeout(timeout) + s.sendto(pkt, (rdv_ip, rdv_port)) + try: + data, _ = s.recvfrom(2048) + except socket.timeout: + print("TIMEOUT") + return + finally: + s.close() + if len(data) < 1: + print("TIMEOUT") + return + tag = data[0] + if tag == 3: + print("NOTFOUND") + return + if tag != 2: + print(f"UNEXPECTED tag={tag}") + return + # PeerInfo: [2][node:16][fam:1][ip...][port:2 BE][record presence...] + off = 1 + 16 + fam = data[off] + off += 1 + if fam == 4: + ip = socket.inet_ntop(socket.AF_INET, data[off:off + 4]) + off += 4 + elif fam == 6: + ip = socket.inet_ntop(socket.AF_INET6, data[off:off + 16]) + off += 16 + else: + print(f"BADFAMILY {fam}") + return + port = struct.unpack(">H", data[off:off + 2])[0] + print(f"REFLEXIVE={ip}:{port}") + + +CMDS = {"nodeid": cmd_nodeid, "register": cmd_register, "lookup": cmd_lookup} + + +def main(): + if len(sys.argv) < 2 or sys.argv[1] not in CMDS: + print(f"usage: {sys.argv[0]} ...", file=sys.stderr) + sys.exit(2) + CMDS[sys.argv[1]](sys.argv[2:]) + + +if __name__ == "__main__": + main() +PYEOF + +A_NODE_ID="$(ip netns exec "$NS_S" python3 "$PROBE_PY" nodeid "$PUB_A")" +echo "[setup] A's rendezvous node_id (independently derived by S): $A_NODE_ID" + +# ── 10. baseline: S's own Lookup for A resolves to A's real address ───────── +# Also validates the probe's node_id derivation is actually correct -- a +# wrong node_id here would come back NOTFOUND, not a false pass. +BASELINE="$(ip netns exec "$NS_S" python3 "$PROBE_PY" lookup "$IP_R" "$RDV_PORT" "$A_NODE_ID" 3)" +echo "[check] baseline Lookup(A) from S: $BASELINE" +EXPECT_REFLEXIVE="REFLEXIVE=${IP_A}:${PORT}" +if [ "$BASELINE" != "$EXPECT_REFLEXIVE" ]; then + echo "[FAIL] baseline Lookup(A) did not resolve to A's real address (expected $EXPECT_REFLEXIVE, got $BASELINE)" + dump_logs + exit 1 +fi +echo "[PASS] baseline: A's registration resolves to its real reflexive address" + +# ── 11. attack: forged unsigned Register(node=A) from S, concurrently with a +# steady ping B->A ───────────────────────────────────────────────────────── +HIJACK_PING_LOG="$TMPDIR_TEST/hijack_ping.log" +echo "[test] pinging ${ADDR_A} from B while S sends forged unsigned Register(A) at R" +set +e +ip netns exec "$NS_B" ping -6 -i 0.2 -c 40 -W 1 "$ADDR_A" >"$HIJACK_PING_LOG" 2>&1 & +PING_PID=$! +sleep 1.0 +echo "[attack] S (${IP_S}) sending forged unsigned Register claiming A's node_id ($A_NODE_ID) at R" +for counter in 1 100000 999999999; do + ip netns exec "$NS_S" python3 "$PROBE_PY" register "$IP_R" "$RDV_PORT" "$A_NODE_ID" "$counter" + ATTACK_STATUS=$? + if [ "$ATTACK_STATUS" -ne 0 ]; then + echo "[FAIL] the forged Register send from S failed (exit $ATTACK_STATUS)" + kill "$PING_PID" 2>/dev/null || true + dump_logs + exit 1 + fi + sleep 0.3 +done +wait "$PING_PID" +PING_STATUS=$? +set -e +cat "$HIJACK_PING_LOG" + +for pid_var_name in PID_A:yipHijkA PID_B:yipHijkB PID_RDV:yip-rendezvous; 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 died during the attack"; dump_logs; exit 1 + fi +done + +LOSS_PCT="$(grep -oE '[0-9]+(\.[0-9]+)?% packet loss' "$HIJACK_PING_LOG" | grep -oE '^[0-9]+(\.[0-9]+)?' || true)" +if [ -z "$LOSS_PCT" ]; then + echo "[FAIL] no_disruption: could not parse packet loss from ping output" + dump_logs + exit 1 +fi +echo "[metric] no_disruption: packet loss during attack = ${LOSS_PCT}%" +if awk "BEGIN {exit ($LOSS_PCT <= 1.0) ? 0 : 1}"; then + echo "[PASS] no_disruption: ${LOSS_PCT}% loss (<=1%) across the attack -- B's live session with A was not disrupted" +else + echo "[FAIL] no_disruption: ${LOSS_PCT}% loss (>1%) -- the forged registration may have disrupted B's session with A" + dump_logs + exit 1 +fi +if [ "$PING_STATUS" -ne 0 ] && [ "$LOSS_PCT" != "100" ]; then + echo "[note] ping exited $PING_STATUS despite <=1% loss (non-fatal; proceeding)" +fi + +# ── 12. no_overwrite: A's Lookup reflexive is unchanged after the attack ──── +AFTER="$(ip netns exec "$NS_S" python3 "$PROBE_PY" lookup "$IP_R" "$RDV_PORT" "$A_NODE_ID" 3)" +echo "[check] post-attack Lookup(A) from S: $AFTER" +if [ "$AFTER" != "$EXPECT_REFLEXIVE" ]; then + echo "[FAIL] no_overwrite: A's registration changed after the forged Register (expected $EXPECT_REFLEXIVE, got $AFTER) -- the rooted server accepted a forged overwrite!" + dump_logs + exit 1 +fi +echo "[PASS] no_overwrite: A's registration is unchanged after the forged-Register attack -- the rooted server refused it" + +echo "[PASS] run-netns-registration-hijack: the rooted server refused the forged registration; B<->A stayed reachable throughout" From 841fb18dde4470be496cd786c7f0ea189e496ac2 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Fri, 24 Jul 2026 20:14:25 -0400 Subject: [PATCH 15/15] docs(auth-reachability): correct wire-compat + deobf-trial claims (final review Minors) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PeerInfo gains a 1-byte record-presence field, so a rootless Lookup reply is +1 byte, not literally byte-identical — corrected the spec to 'wire-compatible' (legacy decoders ignore it; obf masks it; no anti-DPI signature). - deobf_ingress (a') trial is an unauthenticated keystream XOR, not AEAD-gated like the plaintext roaming loop it was compared to; corrected the doc comment to say the authenticated gate is downstream (inbound_open) and a rare trial false-positive is absorbed by FEC/ARQ + self-heals. Comment/spec only, no logic change. --- bin/yipd/src/peer_manager.rs | 10 ++++++++-- ...026-07-23-authenticated-reachability-design.md | 15 ++++++++++----- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/bin/yipd/src/peer_manager.rs b/bin/yipd/src/peer_manager.rs index a8fe5c8..188cfe0 100644 --- a/bin/yipd/src/peer_manager.rs +++ b/bin/yipd/src/peer_manager.rs @@ -2545,8 +2545,14 @@ impl PeerManager { // (a') Roaming fallback: no endpoint match, but the datagram may be a // roamed Established peer's Data/Control/Gossip under its session key. // Trial each Established peer's session key; a wrong key yields None or a - // garbage type that the type-set + inner verify drop safely (same - // invariant as `handle_data_or_control`'s plaintext roaming loop). + // garbage type that the type-set + the downstream inner Noise/AEAD verify + // drop safely. NOTE: `deobfuscate` is an unauthenticated keystream XOR, so + // a wrong key CAN spuriously produce a Data/Control/Gossip type here — the + // authenticated gate is downstream (`inbound_open`/`route_data`), unlike + // `handle_data_or_control`'s plaintext loop which is itself AEAD-gated. A + // genuine roamed datagram is therefore dropped with tiny probability when + // an interfering peer's trial false-positives; FEC/ARQ absorb it, and the + // endpoint self-heals on the next datagram that reaches (a). for p in &self.peers { if !matches!(p.state, PeerState::Established(_)) { continue; diff --git a/docs/superpowers/specs/2026-07-23-authenticated-reachability-design.md b/docs/superpowers/specs/2026-07-23-authenticated-reachability-design.md index bffa947..c9e043e 100644 --- a/docs/superpowers/specs/2026-07-23-authenticated-reachability-design.md +++ b/docs/superpowers/specs/2026-07-23-authenticated-reachability-design.md @@ -166,11 +166,16 @@ Mid-session NAT rebind now recovers on the first authenticated packet from the n ## Compatibility -- **#37** adds `RegisterSigned` (new tag) and an optional trailing `record` on `PeerInfo`, - leaving the legacy `Register`/no-record `PeerInfo` encodings untouched — non-mesh is - byte-identical to today. Mesh deployments bump the rendezvous server and clients together; - a mesh-mode ⇄ rootless-server mismatch fails closed (the expected message is dropped, no - registration) rather than silently accepting unsigned registers. +- **#37** adds `RegisterSigned` (new tag) and an optional trailing `record` on `PeerInfo`. + The legacy `Register` message is untouched (byte-identical). `PeerInfo` gains one + trailing record-presence byte (`0x00` when absent), so a rootless/non-mesh `Lookup` reply + is one byte longer than before — **wire-compatible, not literally byte-identical**: legacy + decoders ignore the trailing byte, and the reverse (a legacy no-byte datagram) decodes as + `record: None`. The single byte is on the control-plane reply only, masks to random under + obfuscation, and carries no anti-DPI signature. Mesh deployments bump the rendezvous + server and clients together; a mesh-mode ⇄ rootless-server mismatch fails closed (the + expected message is dropped, no registration) rather than silently accepting unsigned + registers. - **M2** is internal; no wire or format change; non-mesh and mesh behave identically. ## Testing