Milestone 2c: decentralized discovery (completes sub-project #2) - #42
Merged
Conversation
Approved brainstorming output for sub-project #2 milestone 2c (follows 2b, completes #2). Private membership mesh: a node is a valid peer iff it holds a CA-signed membership cert. Offline Ed25519 CA (yip-ca tool) + CA-signed root set (gossip-seed nodes). Gossip-replicated signed directory (node_addr → {pubkey, cert, endpoints}) via anti-entropy over Noise sessions, bootstrapped from roots. Admission moves from static allowlist to cert-in-handshake (present-your-cert; pre-session, self-contained, no bootstrap race); directory is for discovery. New crates/yip-membership lib + bin/yip-ca + membership.rs; PeerManager gains runtime admit_member. Composes with 2b for reachability. No 2a/2b regression when mesh config is absent. Threshold CA -> research backlog. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
7-task TDD plan from the approved 2c spec. Lib-first: yip-membership (cert/record/rootset + Ed25519 verify, gossip wire), then the offline yip-ca binary, the Noise handshake cert-payload seam (yip-crypto write/read_message gain a payload), the membership.rs directory + gossip anti-entropy + mesh config, the PeerManager crux (runtime admit_member, on_tun resolve-and-admit, handle_handshake_init cert admission, gossip demux), and netns money tests (dynamic discovery, admission rejection, root-outage tolerance, no-regression), gated both drivers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add roundtrip and verification tests for CA-signed root set: - rootset_roundtrips: multi-root (IPv4+IPv6) encode/decode - rootset_verifies_and_rejects_wrong_ca_and_tampering: CA verification Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…(2c) Add Record (member-signed cert+endpoints+seq directory entry) and GossipMsg (Digest/PullRequest/Records anti-entropy wire codec) to yip-membership. Record::verify chains cert validity, Ed25519 sig over record_signing_body under cert.member_sign_pubkey, and a node_id-to-cert.member_pubkey identity check. Widen cert::put_addr/ take_addr to pub(crate) so Record reuses Task 1's SocketAddr codec.
Adds the bin/yip-ca operator tool: an offline Ed25519 CA that never runs as a service and is never linked into yipd. genkey prints a fresh ca_private/ca_public hex pair; sign-cert issues a Cert (member data-plane key + record-signing key bound to a network_id and a --days validity window) signed over yip-membership's cert_signing_body; sign-roots parses a `pubkey_hex endpoint` file into a RootSet signed over rootset_signing_body. Both print the encoded artifact as hex for config loading (Task 5). A round-trip integration test spawns the real binary end-to-end (genkey -> sign-cert / sign-roots) and decodes+verifies the emitted bytes against yip-membership's own verify_cert/verify_rootset, proving a yip-ca-issued cert is accepted by the library that will consume it. No visibility changes were needed in yip-membership -- cert_signing_body and rootset_signing_body were already pub.
…enkey→sign-cert test load_ca_private() now reads only the first line of stdin before stripping the optional ca_private= prefix and hex-decoding. This fixes the pipe workflow `yip-ca genkey | yip-ca sign-cert ...` where genkey outputs two lines (ca_private=<hex> and ca_public=<hex>) — the second line was causing hex_decode to fail with "odd-length hex string" error. Add test genkey_piped_into_sign_cert_works that pipes full two-line genkey output into sign-cert stdin, verifying the cert decodes and verifies against the CA public key. Test reuses existing hex_decode and verify_cert helpers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…c cert seam) write_message/read_message now take/return an app payload (buffers bumped 1024->4096); handshake.rs's step-functions thread an optional payload through start_initiator/start_responder/read_response so a membership cert can ride msg1/msg2 in a later task. All current callers pass &[] and bind new payload/tuple elements to _ (Task 6 wires the real cert). Empty payloads are byte-compatible with the prior wire format: build/clippy/fmt clean, all yip-crypto/yipd unit tests green, and all 14 netns cells (7 tests x 2 drivers) pass with no handshake regression.
…(2c) Adds Membership (bin/yipd/src/membership.rs): a two-index HashMap directory of CA-verified gossip Records with seq-supersession, wall-clock cert-expiry sweeping, and the Digest/PullRequest/Records anti-entropy protocol, plus the mesh config fields (ca_public/cert/roots/member_sign_private/network_id) in config.rs. Pure logic, no sockets, no PeerManager wiring (Task 6).
…cord sweep_expired evicted any expired directory entry with no special-case for the node's own record; once wall-clock time passed the own cert's not_after+CLOCK_SKEW_SECS, own_cert_bytes()'s directory lookup would find nothing and panic on its .expect. Store the own cert in a dedicated Membership field so own_cert_bytes() never depends on directory state, and exempt own_node_id from sweep_expired's eviction filter so the own record stays present for gossip consistency too. Adds a regression test that fails pre-fix (panics on the own-record survival assertion / the .expect) and passes post-fix.
…er (2c) Wire the mesh membership directory into PeerManager as the integration crux of milestone 2c. A peer presents its CA-signed cert in the Noise handshake (Task 4's payload seam); admission verifies it via membership.verify_cert. TUN traffic to an unknown mesh address resolves the peer via the gossip directory and admits it at runtime; gossip rides PacketType::Gossip = 4 as self-verifying plaintext records (in-session encryption deferred to #3). - PeerManager gains membership: Option<Membership> + a wall-clock now_secs helper (distinct from the monotonic now_ms). - admit_member: runtime peer-table mutation (Idle peer + PathState seeded from endpoints; idempotent). Roots seeded as always-admit at new(). - Cert-in-handshake on all 4 sites: initiator/responder present own_cert_bytes in msg1/msg2; responder admits by cert (or configured/root), initiator verifies the responder cert (mutual). None membership => &[] => byte-identical. - on_tun: unknown inner dst => membership.resolve => admit_member => existing lazy handshake. - PacketType::Gossip: on_udp demux => on_gossip => bounded reply; tick emits a debounced tick_digest to roots + a sample + bootstrap-handshakes a root. - Anti-hijack unchanged: membership supplies only a candidate; the Noise handshake gates the session; an Established peer is never redirected. - tunnel.rs builds Option<Membership> from config (cert+ca_public+roots+ member_sign_private+network_id all present). 5 mock-Membership unit tests added. All 91 pre-existing yipd unit tests plus both netns drivers (7 tests x 2) stay green (membership None => byte-identical 2a/2b). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…s replies (2c Task 6) Gossip previously processed a PacketType::Gossip datagram from ANY src and replied to it, with per-record Ed25519 verification and no record-count bound -- an unauthenticated reflection/amplification + CPU-DoS surface (a spoofed PullRequest could reflect a large Records reply at a forged victim, and every inbound Records cost unbounded verifies). Membership integrity was already fine (forged records self-verify and can't ingest); this closes the transport-level DoS gap. - PeerManager::on_gossip now only processes a datagram whose src matches a currently Established peer's endpoint; anything else is dropped before decode, before any per-record verify, and before any reply. - Membership::on_gossip's PullRequest reply is capped at MAX_GOSSIP_RECORDS_PER_REPLY (32) records per Records message, splitting a large answer across multiple bounded messages instead of one unboundedly large one. - Updated the two existing gossip tests that sent gossip from a non- Established src to instead source it from a spliced-in Established peer (the new correct behavior), and added gossip_from_non_established_src_is_dropped plus pull_request_reply_is_capped_and_split covering the fix directly. - Added initiator_rejects_responder_with_bad_cert: covers the mutual-proof rejection path on the initiator side (handle_handshake_resp's responder_cert_ok guard), which existing tests only covered from the responder side. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…both drivers (2c)
The 2c money tests, plus two discovery-enabling production fixes found by
spiking real end-to-end discovery in netns (each with a regression test):
- config.rs: Config::parse accepted no peers only via [peer]/legacy keys — a
pure mesh node has ZERO static peers (bootstraps via the signed root set,
discovers everyone via gossip). Now allow an empty peer list when the full
mesh config (ca_public+cert+roots+member_sign_private+network_id) is present,
single-sourced with tunnel.rs's Membership::new gate.
- peer_manager.rs route_tun_index: the 2a/2b single-peer fallback
(peers.len()==1 => Some(0)) fired unconditionally, so a mesh node holding
only its seed root (the post-bootstrap state) routed EVERY not-yet-discovered
destination to the root instead of resolving via gossip — dynamic discovery
could never engage. Now, with membership enabled, an IPv6 dst that matches no
known peer falls through to on_tun's resolve path; without membership the
sole-peer fallback is byte-identical to 2a/2b.
Tests: run-netns-{discovery,admission-reject,root-outage}.sh + harness tests.
discovery_dynamic_ping (A+B never configured as peers, bootstrap to seed root,
gossip converges, A resolves+cert-handshakes B — asserts B absent from A's
config); admission_rejects_uncertified (uncertified peer refused, ping must
fail); discovery_survives_root_outage (kill root after convergence, A<->B
persists). Gated in CI under both drivers (+ cargo build -p yip-ca).
All 3 pass under poll and io_uring; 2a/2b netns suite unchanged. 102 yipd unit
tests (incl. the two regression tests).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Config::parse decoded a roots=<path> RootSet without ever calling RootSet::verify_rootset against the configured ca_public, so a tampered or wrong-CA roots file was silently accepted and seeded as always-admit bootstrap peers/gossip seeds. Reject at parse time instead, closing the gap between the spec's CA-signed-root-set promise and what was actually enforced. Also fixes two mesh-config tests that declared placeholder ca_public values inconsistent with the CA that actually signed their minted RootSet (would now fail under the new check), and adds roots_with_wrong_ca_is_parse_error to cover the rejection path.
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.
Makes the yip peer set dynamic: a node discovers other CA-certified members via a gossip-replicated signed directory and admits them by verifying a membership certificate presented in the handshake — no static per-peer config. Completes the sub-project #2 control plane (2a multi-peer data plane + 2b NAT traversal). Design spec:
docs/superpowers/specs/2026-07-06-decentralized-discovery-design.md.What's in it
crates/yip-membership(new lib):Cert(Ed25519 CA-signed membership cert binding an X25519 identity key + an Ed25519 record-signing key),RootSet(signed bootstrap roots),Record(member-signed directory entry: cert + endpoints + seq),GossipMsg(Digest/PullRequest/Records) — all with encode/decode +verify_strictverification.bin/yip-ca(new offline binary):genkey/sign-cert/sign-roots. Holds the CA signing key; never a service, never part ofyipd.bin/yipd/src/membership.rs: the gossip directory (seq-supersession, expiry eviction),resolve(node_addr),verify_cert, anti-entropy (on_gossip/tick_digest).crates/yip-crypto+handshake.rs: the Noise-IK handshake now carries an app payload (the cert), empty-payload byte-identical to before.bin/yipd/src/peer_manager.rs: the crux — cert presented in the handshake (initiator msg1, responder msg2), admission verifies it against the CA and binds it to the authenticated Noise static key (pre-session, on all four direct/relayed × init/resp variants);on_tunresolves an unknown mesh address via the directory and admits the peer at runtime, then reuses 2b's lazy handshake + NAT path; gossip ridesPacketType::Gossip, source-restricted to Established peers.Trust model
Offline Ed25519 CA signs member certs + the root set (its private key never touches an internet-exposed node — a root compromise cannot forge membership). Membership is CA-gated (no cert → no session, no directory entry), so sybil resistance is free. Discovery only ever supplies a candidate identity+cert+address; the Noise handshake still gates every session (the 2b anti-hijack invariant is preserved).
Testing
yip-membership(cert/record/rootset verify matrices, gossip codec, anti-entropy convergence),yip-caround-trips, 103yipd(cert admission, mutual-proof rejection, gossip source-restriction,route_tun_indexregression).pollandio_uring), green:discovery_dynamic_ping(A + B never configured as peers bootstrap to a seed root, gossip converges, A resolves + cert-handshakes B — asserts B absent from A's config);admission_rejects_uncertified(uncertified peer refused, ping must fail);discovery_survives_root_outage(kill the root after convergence, A↔B persists). All 2a/2b + 2b netns tests stay green under both drivers (no mesh config ⇒ byte-identical).Review
Built task-by-task (subagent-driven), each task spec+quality reviewed, then a whole-branch opus review (READY TO MERGE, cert admission confirmed sound on every variant). Findings resolved in-branch: an own-cert-expiry daemon crash, a gossip reflection/amplification + CPU-DoS surface (source-restricted to Established peers + capped replies), and the root-set CA signature not being verified at config-load (F1, now verified). Two follow-ups filed: #40 (relayed gossip is dropped — convergence still holds via direct/root paths) and #41 (cert revocation lag needs the deferred session-rekey #9).
Deferred (non-goals for 2c)
Threshold/decentralized CA (#39), signed revocation records, LAN-multicast discovery, Kademlia DHT, in-session gossip encryption / gossip-graph metadata privacy (#3 / anonymity), PoW sybil-hardening, Yggdrasil-tree routing (would break 2a's
node_addr).🤖 Generated with Claude Code