Skip to content

Milestone 2c: decentralized discovery (completes sub-project #2) - #42

Merged
vxfemboy merged 14 commits into
mainfrom
feat/discovery-2c
Jul 7, 2026
Merged

Milestone 2c: decentralized discovery (completes sub-project #2)#42
vxfemboy merged 14 commits into
mainfrom
feat/discovery-2c

Conversation

@vxfemboy

@vxfemboy vxfemboy commented Jul 7, 2026

Copy link
Copy Markdown
Member

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_strict verification.
  • bin/yip-ca (new offline binary): genkey / sign-cert / sign-roots. Holds the CA signing key; never a service, never part of yipd.
  • 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_tun resolves an unknown mesh address via the directory and admits the peer at runtime, then reuses 2b's lazy handshake + NAT path; gossip rides PacketType::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

  • Unit: yip-membership (cert/record/rootset verify matrices, gossip codec, anti-entropy convergence), yip-ca round-trips, 103 yipd (cert admission, mutual-proof rejection, gossip source-restriction, route_tun_index regression).
  • netns money tests (both poll and io_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

vxfemboy and others added 14 commits July 6, 2026 19:36
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.
@vxfemboy
vxfemboy merged commit deefee8 into main Jul 7, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant