Authenticated reachability: endpoint roaming (M2) + signed rendezvous registration (#37)#100
Merged
Merged
Conversation
…#37) + endpoint roaming (M2)
…37 signed registration
…r on relearn 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.
…path 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.
… Important) 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.
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<Record> 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.
…PeerInfo carries the record
… (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.
…nal review Minors) - 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.
…y for up-to-date # Conflicts: # bin/yipd/src/membership.rs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #37. Resolves the M2 follow-up filed in #34's review.
One milestone, two subsystems, stacked on #34 (PR #99). Both are "don't trust unauthenticated source/registration information."
M2 — authenticated endpoint roaming (data plane)
A peer's
endpointnow follows a legitimate address change (NAT rebind / mobility), gated on cryptographic authentication (WireGuard's model): endpoint moves only on a datagram that both decrypts and passes the AEAD replay window (a non-NoneEpochSet::inbound_open), and only for!relaypeers. A replayed/spoofed packet fails and cannot move it. #34's Init-path anti-hijack is untouched (the Init path never callsinbound_open).EpochSet::set_peer_addr(current/next/previous DataPlanes and any in-flightrekey.target) — egress is stamped fromDataPlane::peer_addr, notendpoint, so updatingendpointalone left return traffic targeting the dead address.deobf_ingressgained a trial-key fallback so a roamed peer's obfuscated data is found when theendpoint==srcfast-path misses.Fixes the ~120s ingress black-hole a mid-session NAT rebind otherwise caused under the obfuscation path.
#37 — signed rendezvous registration (control plane)
The registration is a
membership::Record(reusesRecord::verify— cert-vs-roots + signature +node_id-binds-cert, so squatting is closed; no new crypto). A newRegisterSignedmessage carries it;PeerInfogained an optionalrecord.--roots/--network-id) verifies before storing and drops legacy unsignedRegisterin mesh mode → the victim's real registration survives a forged-overwrite attempt. Rootless is unchanged.RegisterSignedwhen membership is configured, and verifies a returnedPeerInfo.recordand bindsrecord.node_id == nodebefore probing (Option-3 defense-in-depth). Address integrity is still backstopped by the 2b handshake-commit invariant.Testing
yipdunit + 32yip-rendezvouslib/bin tests;clippy -D warnings+fmtclean;#![forbid(unsafe_code)].run-netns-roaming.sh— a mid-session NAT rebind (obf on) recovers at 0% loss; a raw before/after check proves the egress redirect.run-netns-registration-hijack.sh— a rooted server refuses a forged registration; a rawLookup(A)probe returns A's real address before and after the attack (would flip to the attacker's address if the overwrite were accepted), and B↔A stays reachable.endpoint/peer_addrwriter) and the 2b: rendezvous server registration is unauthenticated (registration-overwrite DoS) #37 verify-before-mutation invariant both hold.Follow-ups (non-blocking, from the final review)
reg_seqrestart residual: a client's registration counter resets to 0 on restart, so up to ~REG_TTL(~60s) of stale-rejected re-registrations until the old entry expires; a replay-capable eavesdropper can extend that within a capture window. Fix: wall-clock/TAI64N-seedreg_seq(mirrors Handshake anti-replay (timestamp) + authenticated endpoint learning #34's timestamp).PeerInfo: a mesh client currently probes a candidate that carries no record without verification (a malicious server could omit it to skip defense-in-depth). Backstopped by server-side verify + handshake-commit; consider requiring a record in mesh mode.tls_seenmap, never the servable directory — not the overwrite-DoS surface). Consider signing it for completeness.Stacked on #34 (#99) — merge after it. This milestone's design lives in
docs/superpowers/specs/2026-07-23-authenticated-reachability-design.md.