From b058368a7c11018cf32278d58f0688979a8e50d8 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Wed, 22 Jul 2026 23:42:26 -0400 Subject: [PATCH] fix(cert): exempt roots from the responder-cert checks (symmetric with the init-side gates) (#96) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up from the #36/#41 hardening (PR #95). That branch added is_root exemption to all five init-side cert gates. The three pre-existing responder-cert checks — an initiator verifying the responder's msg2 cert in handle_handshake_resp, relayed_handshake_resp, and rekey_resp_core — lacked it, so a root whose cert lapsed while still in the signed root set, acting as a responder, would be rejected by an initiator. Roots are trusted via the signed root set, not a member cert, so gate all three on !is_root(responder pubkey) too. New test initiator_admits_root_responder_despite_missing_cert (root establishes despite no cert); initiator_rejects_responder_with_bad_cert stays green (non-root still rejected). 237 unit green, clippy clean. --- bin/yipd/src/peer_manager.rs | 78 ++++++++++++++++++++++++++++++++++-- 1 file changed, 75 insertions(+), 3 deletions(-) diff --git a/bin/yipd/src/peer_manager.rs b/bin/yipd/src/peer_manager.rs index dcb02b0..37ed59f 100644 --- a/bin/yipd/src/peer_manager.rs +++ b/bin/yipd/src/peer_manager.rs @@ -1278,7 +1278,9 @@ impl PeerManager { }; match handshaking.hs.read_response(dg) { Ok((established, responder_payload)) => { - if !self.responder_cert_ok(&responder_payload, self.peers[idx].pubkey) { + if !self.is_root(self.peers[idx].pubkey) + && !self.responder_cert_ok(&responder_payload, self.peers[idx].pubkey) + { eprintln!("peer_manager: relayed responder cert rejected"); return DispatchOut::None; } @@ -2014,7 +2016,9 @@ impl PeerManager { match handshaking.hs.read_response(dg) { Ok((established, responder_payload)) => { - if !self.responder_cert_ok(&responder_payload, self.peers[idx].pubkey) { + if !self.is_root(self.peers[idx].pubkey) + && !self.responder_cert_ok(&responder_payload, self.peers[idx].pubkey) + { // Responder failed to prove membership: do not establish. // State was already reverted to `Idle`; `pending_tun` stays // queued for the next attempt. @@ -2164,7 +2168,9 @@ impl PeerManager { return DispatchOut::None; } }; - if !self.responder_cert_ok(&responder_payload, self.peers[idx].pubkey) { + if !self.is_root(self.peers[idx].pubkey) + && !self.responder_cert_ok(&responder_payload, self.peers[idx].pubkey) + { eprintln!("peer_manager: rekey responder cert rejected"); return DispatchOut::None; } @@ -6816,6 +6822,72 @@ mod tests { } } + /// #96 — symmetry with the init-side gates: an always-admit ROOT + /// responder is exempt from the responder-cert check. A root is trusted + /// via the signed root set, not a member cert (you revoke a root by + /// removing it from the root set), so an initiator completing a handshake + /// with a root establishes even if the root's msg2 carries no valid cert. + #[test] + fn initiator_admits_root_responder_despite_missing_cert() { + let ca = test_ca(); + let local = generate_keypair(); + let root = generate_keypair(); + let root_ep: SocketAddr = "198.51.100.7:51820".parse().unwrap(); + let cfg = PeerConfig { + public_key: root.public, + endpoint: Some(root_ep), + }; + let roots = RootSet { + roots: vec![(root.public, root_ep)], + version: 1, + ca_sig: [0u8; 64], + }; + let own_sign = SigningKey::from_bytes(&[200u8; 32]); + let own_cert = mk_cert(&ca, local.public, own_sign.verifying_key().to_bytes()); + let membership = Membership::new( + vec![ca.verifying_key().to_bytes()], + TEST_NET, + own_cert, + own_sign.to_bytes(), + roots, + vec!["10.0.0.1:51820".parse().unwrap()], + ); + let mut pm = PeerManager::new( + local.private, + local.public, + std::slice::from_ref(&cfg), + TunnelMode::L3Tun, + None, + Some(membership), + false, + ); + let idx = pm + .peers + .iter() + .position(|p| p.pubkey == root.public) + .expect("root is a peer"); + + // We initiate toward the root. + let init_out = pm.on_tun(&dummy_tun_pkt(), 0).to_vec(); + let init_pkt = init_out + .iter() + .find(|d| d.dst == root_ep) + .map(|d| d.bytes.clone()) + .expect("an Init toward the root"); + assert!(matches!(pm.peers[idx].state, PeerState::Handshaking(_))); + + // The root responds with NO cert in msg2 — rejected for a non-root + // peer, but the root is exempt. + let (_established, resp_pkt, _remote_static, _initiator_payload) = + HandshakeState::start_responder(&root.private, &init_pkt, &[]).unwrap(); + let _ = pm.on_udp(root_ep, &resp_pkt, 0); + + assert!( + matches!(pm.peers[idx].state, PeerState::Established(_)), + "a root responder is admitted despite a missing cert (is_root exemption)" + ); + } + // ── anti-DPI obfuscation (3a Task 3) ────────────────────────────────── /// Build a talking pair of `DataPlane`s (via an in-process Noise-IK