From 2615e17eea40dea843eb0f046cea2f0bf6aed655 Mon Sep 17 00:00:00 2001 From: Teodor Calin Date: Sat, 25 Jul 2026 17:07:00 +0300 Subject: [PATCH] daemon: enforce peer pubkey binding on the runtime data plane + panic-recover Mirrors the handshake trust key-binding at the daemon: inbound data-plane frames are authorized against the trusted peer's bound public key (IsTrustedWithKey with the cached peer key) rather than node ID alone, so a node-ID reuse with a fresh key is not silently trusted. Adds panic recovery on the tunnel dispatch path and fuzz + attack-replay coverage for punch reflection and tunnel dispatch. Co-Authored-By: Claude Opus 5 --- pkg/daemon/daemon.go | 43 ++- pkg/daemon/ipc.go | 2 +- pkg/daemon/keyexchange/keyexchange.go | 10 + pkg/daemon/services.go | 2 +- pkg/daemon/tunnel.go | 7 + .../zz_attack_replay_punch_reflection_test.go | 254 +++++++++++++++++ pkg/daemon/zz_fuzz_tunnel_dispatch_test.go | 268 ++++++++++++++++++ pkg/daemon/zz_trust_key_binding_test.go | 161 +++++++++++ 8 files changed, 742 insertions(+), 5 deletions(-) create mode 100644 pkg/daemon/zz_attack_replay_punch_reflection_test.go create mode 100644 pkg/daemon/zz_fuzz_tunnel_dispatch_test.go create mode 100644 pkg/daemon/zz_trust_key_binding_test.go diff --git a/pkg/daemon/daemon.go b/pkg/daemon/daemon.go index ccf9b5b8..8231e53a 100644 --- a/pkg/daemon/daemon.go +++ b/pkg/daemon/daemon.go @@ -2991,8 +2991,45 @@ func redactID(s string) string { return hex.EncodeToString(sum[:8]) } +// keyBoundHandshakeService is the optional half of HandshakeService for +// trust stores that record which peer key each grant was made against. +// Implementations that predate it (test fakes, older plugin builds) are +// answered by node ID alone via handshakeTrusts. +type keyBoundHandshakeService interface { + IsTrustedWithKey(nodeID uint32, pubKeyB64 string) bool +} + +// cachedPeerKeyB64 returns the peer's Ed25519 key as base64, matching +// crypto.EncodePublicKey, or "" when none is cached. Cache-only — no +// registry lookup — so it is safe to call per packet. +func (d *Daemon) cachedPeerKeyB64(nodeID uint32) string { + if d.tunnels == nil { + return "" + } + pk, ok := d.tunnels.peerPubKeyCached(nodeID) + if !ok || len(pk) == 0 { + return "" + } + return base64.StdEncoding.EncodeToString(pk) +} + +// handshakeTrusts consults the handshake trust store for srcNode, +// passing along the peer's currently-known key so the store can check +// it against the key the grant was bound to. A node ID whose key has +// changed since trust was granted does not inherit that trust. +func (d *Daemon) handshakeTrusts(srcNode uint32) bool { + if d.handshakes == nil { + return false + } + kb, ok := d.handshakes.(keyBoundHandshakeService) + if !ok { + return d.handshakes.IsTrusted(srcNode) + } + return kb.IsTrustedWithKey(srcNode, d.cachedPeerKeyB64(srcNode)) +} + func (d *Daemon) isTrustedPeer(srcNode uint32) bool { - trusted := d.handshakes != nil && d.handshakes.IsTrusted(srcNode) + trusted := d.handshakeTrusts(srcNode) if !trusted && d.reg() != nil { var err error trusted, err = d.reg().CheckTrust(d.NodeID(), srcNode) @@ -3088,7 +3125,7 @@ func (d *Daemon) handleStreamPacket(pkt *protocol.Packet) { // Runs before rate limiting so untrusted sources cannot waste rate-limit tokens. if !d.config.Public { srcNode := pkt.Src.Node - trusted := d.handshakes != nil && d.handshakes.IsTrusted(srcNode) + trusted := d.handshakeTrusts(srcNode) if !trusted && d.reg() != nil { // Fall back to registry trust check (covers admin-set trust pairs + shared networks) var err error @@ -3514,7 +3551,7 @@ func (d *Daemon) handleDatagramPacket(pkt *protocol.Packet) { // Trust gate: private nodes only accept datagrams from trusted or same-network peers if !d.config.Public { srcNode := pkt.Src.Node - trusted := d.handshakes != nil && d.handshakes.IsTrusted(srcNode) + trusted := d.handshakeTrusts(srcNode) if !trusted && d.reg() != nil { var err error trusted, err = d.reg().CheckTrust(d.NodeID(), srcNode) diff --git a/pkg/daemon/ipc.go b/pkg/daemon/ipc.go index 6da95b00..75ffeb5b 100644 --- a/pkg/daemon/ipc.go +++ b/pkg/daemon/ipc.go @@ -1717,7 +1717,7 @@ func (s *IPCServer) handleVerifyEnvelope(conn *ipcConn, reqID uint64, payload [] "node_id": e.Node, "address": protocol.Addr{Network: e.Network, Node: e.Node}.String(), "verified_via": verifiedVia, - "trusted": s.daemon.handshakes != nil && s.daemon.handshakes.IsTrusted(e.Node), + "trusted": s.daemon.handshakeTrusts(e.Node), } if req.CheckStanding { s.addEnvelopeStanding(resp, e) diff --git a/pkg/daemon/keyexchange/keyexchange.go b/pkg/daemon/keyexchange/keyexchange.go index 63b6c6a2..144a4837 100644 --- a/pkg/daemon/keyexchange/keyexchange.go +++ b/pkg/daemon/keyexchange/keyexchange.go @@ -451,6 +451,16 @@ func (m *Manager) recordNegPubKey(nodeID uint32) { m.pubKeysMu.Unlock() } +// PeerPubKeyCached returns the cached Ed25519 public key for a peer and +// whether one is present. Unlike GetPeerPubKey it never falls back to +// verifyFunc, so packet-path callers cannot block on a registry RPC. +func (m *Manager) PeerPubKeyCached(nodeID uint32) (ed25519.PublicKey, bool) { + m.pubKeysMu.RLock() + defer m.pubKeysMu.RUnlock() + pk, ok := m.peerPubKeys[nodeID] + return pk, ok +} + // SetPeerPubKey installs a cache entry directly. Used by handle paths // after verifying a packet-carried Ed25519 pubkey against the registry. func (m *Manager) SetPeerPubKey(nodeID uint32, pk ed25519.PublicKey) { diff --git a/pkg/daemon/services.go b/pkg/daemon/services.go index 1cd4ec81..1d40b0cf 100644 --- a/pkg/daemon/services.go +++ b/pkg/daemon/services.go @@ -170,7 +170,7 @@ func (d *Daemon) handleEchoConn(conn *Connection) { // private nodes filter inbound by trust). On private daemons, refuse to // echo for untrusted peers; self-pings are always allowed. if !d.config.Public && d.handshakes != nil && conn.RemoteAddr.Node != d.NodeID() { - if !d.handshakes.IsTrusted(conn.RemoteAddr.Node) { + if !d.handshakeTrusts(conn.RemoteAddr.Node) { slog.Debug("echo refused: peer not trusted (private node)", "peer_node_id", conn.RemoteAddr.Node) d.CloseConnection(conn) diff --git a/pkg/daemon/tunnel.go b/pkg/daemon/tunnel.go index df1039e4..b8947c08 100644 --- a/pkg/daemon/tunnel.go +++ b/pkg/daemon/tunnel.go @@ -1066,6 +1066,13 @@ func (tm *TunnelManager) getPeerPubKey(nodeID uint32) (ed25519.PublicKey, error) return tm.kx.GetPeerPubKey(nodeID) } +// peerPubKeyCached returns a peer's Ed25519 public key from the cache +// only, never falling back to the registry. Thin shim over +// keyexchange.Manager.PeerPubKeyCached. +func (tm *TunnelManager) peerPubKeyCached(nodeID uint32) (ed25519.PublicKey, bool) { + return tm.kx.PeerPubKeyCached(nodeID) +} + // hasPeerPubKey reports whether a peer's Ed25519 public key is already // cached (no registry fetch). Thin shim over // keyexchange.Manager.HasPeerPubKey. diff --git a/pkg/daemon/zz_attack_replay_punch_reflection_test.go b/pkg/daemon/zz_attack_replay_punch_reflection_test.go new file mode 100644 index 00000000..57da2d05 --- /dev/null +++ b/pkg/daemon/zz_attack_replay_punch_reflection_test.go @@ -0,0 +1,254 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package daemon + +import ( + "encoding/binary" + "net" + "testing" + "time" + + "github.com/pilot-protocol/common/protocol" +) + +// Adversarial replay for the beacon-punch reflection finding (PPA-005). +// +// The attack: a daemon that acts on any BeaconMsgPunchCommand it receives +// is an open UDP reflector — the command carries an attacker-chosen +// target IP:port and the daemon obligingly sends punch packets there. The +// fix binds punch commands (and relay deliveries) to the configured +// beacon endpoint. +// +// These tests drive tm.handleBeaconMessage — the exact function the L2 +// readLoop dispatches into — with attacker bytes from attacker-chosen +// source addresses, and assert the reflection target receives nothing. +// Everything is local: sockets bind 127.0.0.1:0. + +// punchCommandFor builds the on-wire BeaconMsgPunchCommand that names an +// attacker-chosen reflection target. +func punchCommandFor(target *net.UDPAddr) []byte { + ip4 := target.IP.To4() + data := make([]byte, 1+1+len(ip4)+2) + data[0] = protocol.BeaconMsgPunchCommand + data[1] = byte(len(ip4)) + copy(data[2:2+len(ip4)], ip4) + binary.BigEndian.PutUint16(data[2+len(ip4):], uint16(target.Port)) + return data +} + +// drainCount reports how many datagrams landed on conn within window. +func drainCount(t *testing.T, conn *net.UDPConn, window time.Duration) int { + t.Helper() + deadline := time.Now().Add(window) + buf := make([]byte, 64) + n := 0 + for { + remaining := time.Until(deadline) + if remaining <= 0 { + return n + } + conn.SetReadDeadline(time.Now().Add(remaining)) + if _, _, err := conn.ReadFromUDP(buf); err != nil { + return n + } + n++ + } +} + +// newReflectionRig returns a listening tunnel manager with a configured +// beacon address, plus a socket standing in for the reflection victim. +func newReflectionRig(t *testing.T) (*TunnelManager, *net.UDPAddr, *net.UDPConn) { + t.Helper() + tm := NewTunnelManager() + if err := tm.Listen("127.0.0.1:0"); err != nil { + t.Fatalf("Listen: %v", err) + } + t.Cleanup(func() { tm.Close() }) + + // A real local socket standing in for the beacon, so the configured + // beacon address is one nothing else can accidentally occupy. + beaconSock := mustListenUDP(t) + t.Cleanup(func() { beaconSock.Close() }) + beaconAddr := beaconSock.LocalAddr().(*net.UDPAddr) + tm.SetBeaconAddr(beaconAddr.String()) + + target := mustListenUDP(t) + t.Cleanup(func() { target.Close() }) + + return tm, beaconAddr, target +} + +// TestAttackReplay_PunchReflectionFromForgedSources replays the forged +// punch command from every source an off-path attacker can actually +// choose: an unrelated host, the beacon's IP on a different port (the +// classic "close enough" bypass), and a different IP on the beacon's +// port. None may produce a packet at the reflection target. +func TestAttackReplay_PunchReflectionFromForgedSources(t *testing.T) { + t.Parallel() + tm, beaconAddr, target := newReflectionRig(t) + targetAddr := target.LocalAddr().(*net.UDPAddr) + + sources := map[string]*net.UDPAddr{ + "unrelated_host": {IP: net.ParseIP("127.0.0.1"), Port: 6666}, + "beacon_ip_wrong_port": {IP: beaconAddr.IP, Port: beaconAddr.Port + 1}, + "beacon_port_other_ip": {IP: net.ParseIP("127.0.0.2"), Port: beaconAddr.Port}, + "port_zero": {IP: beaconAddr.IP, Port: 0}, + } + + for name, src := range sources { + tm.handleBeaconMessage(punchCommandFor(targetAddr), src) + if got := drainCount(t, target, 200*time.Millisecond); got != 0 { + t.Fatalf("ATTACK SUCCEEDED (%s): daemon reflected %d punch packets at an attacker-chosen target", name, got) + } + } +} + +// TestAttackReplay_PunchReflectionEmitsWhenSourceIsTheBeacon is the +// control that proves the assertions above are meaningful: with the +// source set to the configured beacon endpoint, the daemon does emit +// punch packets. If this ever stops firing, the negative tests are +// vacuous. +func TestAttackReplay_PunchReflectionEmitsWhenSourceIsTheBeacon(t *testing.T) { + t.Parallel() + tm, beaconAddr, target := newReflectionRig(t) + targetAddr := target.LocalAddr().(*net.UDPAddr) + + tm.handleBeaconMessage(punchCommandFor(targetAddr), beaconAddr) + + if got := drainCount(t, target, 500*time.Millisecond); got == 0 { + t.Fatal("control failed: no punch emitted even from the configured beacon — the negative tests above prove nothing") + } +} + +// TestAttackReplay_PunchReflectionAmplificationSpray is the volumetric +// form: a single forged source sprays punch commands to drive the +// daemon into flooding a third party. The reflector must stay silent no +// matter how many commands arrive. +func TestAttackReplay_PunchReflectionAmplificationSpray(t *testing.T) { + t.Parallel() + tm, _, target := newReflectionRig(t) + targetAddr := target.LocalAddr().(*net.UDPAddr) + + data := punchCommandFor(targetAddr) + spoofed := &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 7777} + for i := 0; i < 256; i++ { + tm.handleBeaconMessage(data, spoofed) + } + + if got := drainCount(t, target, 500*time.Millisecond); got != 0 { + t.Fatalf("ATTACK SUCCEEDED: 256 forged punch commands produced %d reflected packets", got) + } +} + +// TestAttackReplay_PunchReflectionWithNoBeaconConfigured covers the +// fail-closed case: a daemon that has not yet learned a beacon address +// must reject punch commands outright rather than treating "no beacon +// configured" as "anything goes". +func TestAttackReplay_PunchReflectionWithNoBeaconConfigured(t *testing.T) { + t.Parallel() + tm := NewTunnelManager() + if err := tm.Listen("127.0.0.1:0"); err != nil { + t.Fatalf("Listen: %v", err) + } + defer tm.Close() + + target := mustListenUDP(t) + defer target.Close() + targetAddr := target.LocalAddr().(*net.UDPAddr) + + spoofed := &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 8888} + tm.handleBeaconMessage(punchCommandFor(targetAddr), spoofed) + tm.handleBeaconMessage(punchCommandFor(targetAddr), nil) + + if got := drainCount(t, target, 200*time.Millisecond); got != 0 { + t.Fatalf("ATTACK SUCCEEDED: daemon with no beacon configured reflected %d punch packets", got) + } +} + +// TestAttackReplay_RelayDeliverInjectionFromForgedSource is the +// injection sibling: a forged BeaconMsgRelayDeliver naming an arbitrary +// srcNodeID must not reach the frame dispatcher and must not create peer +// or relay-peer state for a node ID the attacker invented. +func TestAttackReplay_RelayDeliverInjectionFromForgedSource(t *testing.T) { + t.Parallel() + tm, _, _ := newReflectionRig(t) + + const spoofedSrc = uint32(123456) + data := append([]byte{protocol.BeaconMsgRelayDeliver}, plaintextRelayPayload(t, spoofedSrc, "injected")...) + spoofed := &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 9999} + tm.handleBeaconMessage(data, spoofed) + + tm.mu.RLock() + _, peerExists := tm.peers[spoofedSrc] + tm.mu.RUnlock() + if peerExists { + t.Fatalf("ATTACK SUCCEEDED: forged relay-deliver created peer state for node %d", spoofedSrc) + } + if tm.routing.IsRelayPeer(spoofedSrc) { + t.Fatalf("ATTACK SUCCEEDED: forged relay-deliver marked node %d as a relay peer", spoofedSrc) + } +} + +// TestAttackReplay_MalformedPunchCommandFromBeacon checks the parser +// under the source gate: even from the legitimate beacon, a truncated or +// nonsense punch command must be dropped without panicking and without +// emitting anything. +func TestAttackReplay_MalformedPunchCommandFromBeacon(t *testing.T) { + t.Parallel() + tm, beaconAddr, target := newReflectionRig(t) + + defer func() { + if r := recover(); r != nil { + t.Fatalf("ATTACK SUCCEEDED: daemon panicked on a malformed punch command: %v", r) + } + }() + + payloads := [][]byte{ + {protocol.BeaconMsgPunchCommand}, + {protocol.BeaconMsgPunchCommand, 4}, + {protocol.BeaconMsgPunchCommand, 4, 1, 2}, + {protocol.BeaconMsgPunchCommand, 16, 1, 2, 3, 4}, + {protocol.BeaconMsgPunchCommand, 255, 1, 2, 3, 4, 5, 6}, + {protocol.BeaconMsgPunchCommand, 0}, + {protocol.BeaconMsgPunchCommand, 5, 1, 2, 3, 4, 5, 0, 80}, + } + for _, p := range payloads { + tm.handleBeaconMessage(p, beaconAddr) + } + + if got := drainCount(t, target, 200*time.Millisecond); got != 0 { + t.Fatalf("malformed punch commands produced %d packets at an unrelated socket", got) + } +} + +// TestAttackReplay_CompatModeDisablesPunchSourceCheck is a +// characterization test, not a pass/fail on an attack: handleBeaconMessage +// computes fromBeacon as `ForceRelay() || IsFromBeacon(from)`, so in +// compat mode (ConnectCompat sets ForceRelay) the source check is +// disabled entirely. +// +// This is not remotely reachable today — compat mode replaces the UDP +// socket with a WSS pipe that terminates at the beacon, so the only +// frames reaching this function already came from the beacon. It is +// pinned here so that any future change re-enabling a UDP read path +// alongside ForceRelay trips this test and gets re-reviewed. +func TestAttackReplay_CompatModeDisablesPunchSourceCheck(t *testing.T) { + t.Parallel() + tm, _, target := newReflectionRig(t) + targetAddr := target.LocalAddr().(*net.UDPAddr) + + tm.routing.SetForceRelay(true) + + spoofed := &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 4444} + tm.handleBeaconMessage(punchCommandFor(targetAddr), spoofed) + + got := drainCount(t, target, 500*time.Millisecond) + if got == 0 { + t.Log("ForceRelay no longer bypasses the punch-command source check — the gate was tightened; " + + "update this characterization test") + return + } + t.Logf("characterized: with ForceRelay set (compat mode) the punch-command source check is bypassed "+ + "and %d punch packets were emitted to a source-chosen target; reachable only over the "+ + "beacon-terminated WSS pipe today", got) +} diff --git a/pkg/daemon/zz_fuzz_tunnel_dispatch_test.go b/pkg/daemon/zz_fuzz_tunnel_dispatch_test.go new file mode 100644 index 00000000..e793181d --- /dev/null +++ b/pkg/daemon/zz_fuzz_tunnel_dispatch_test.go @@ -0,0 +1,268 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package daemon + +import ( + "crypto/ecdh" + "crypto/ed25519" + "crypto/rand" + "encoding/binary" + "net" + "testing" + + "github.com/pilot-protocol/common/crypto" + "github.com/pilot-protocol/common/protocol" +) + +// These targets cover the daemon's inbound wire surface: everything the +// readLoop reaches after udpio.Recv hands it a datagram. Each handler has +// its own recoverLayer boundary in production, which means a real panic +// would be swallowed and show up only as silently-dropped traffic. The +// targets therefore compare RecoveredPanicCount across the call so a caught +// panic still fails the test. + +// fuzzTunnelManager builds a TunnelManager with encryption enabled, an +// identity installed, a bound socket, and a beacon address configured, so +// the relay-deliver and key-exchange branches are all reachable. Built once +// at *testing.F scope — per-iteration key generation would dominate. +func fuzzTunnelManager(tb testing.TB) *TunnelManager { + tb.Helper() + tm := NewTunnelManager() + if err := tm.EnableEncryption(); err != nil { + tb.Fatalf("EnableEncryption: %v", err) + } + id, err := crypto.GenerateIdentity() + if err != nil { + tb.Fatalf("identity: %v", err) + } + tm.SetIdentity(id) + tm.SetNodeID(7) + if err := tm.Listen("127.0.0.1:0"); err != nil { + tb.Fatalf("Listen: %v", err) + } + if err := tm.SetBeaconAddr("127.0.0.1:1"); err != nil { + tb.Fatalf("SetBeaconAddr: %v", err) + } + // Resolving node 1 to a real key exercises the registry-resolved branch + // of handleAuthKeyExchange (signature verify against a known key); + // every other node id takes the unresolved branch. Node 2 resolves to a + // deliberately wrong-length key — the shape that panics an unguarded + // ed25519.Verify. + tm.SetPeerVerifyFunc(func(nodeID uint32) (ed25519.PublicKey, error) { + switch nodeID { + case 1: + return id.PublicKey, nil + case 2: + return ed25519.PublicKey(make([]byte, 7)), nil + } + return nil, nil + }) + tb.Cleanup(func() { tm.Close() }) + return tm +} + +// drainRecv keeps the unbuffered recvCh from blocking the fuzz goroutine +// when an input decodes into a well-formed plaintext packet. +func drainRecv(tm *TunnelManager) func() { + stop := make(chan struct{}) + go func() { + for { + select { + case <-tm.RecvCh(): + case <-stop: + return + } + } + }() + return func() { close(stop) } +} + +func beaconFrame(msgType byte, body []byte) []byte { + out := make([]byte, 1+len(body)) + out[0] = msgType + copy(out[1:], body) + return out +} + +// FuzzTunnelBeaconMessage targets handleBeaconMessage — the branch the +// readLoop takes for any first byte < 0x10. Both the from-beacon and +// not-from-beacon source checks are exercised, since the beacon-source +// gate is the only thing standing between an off-path sender and +// handleRelayDeliver. +func FuzzTunnelBeaconMessage(f *testing.F) { + f.Add([]byte{}) + f.Add([]byte{protocol.BeaconMsgDiscoverReply}) + f.Add([]byte{protocol.BeaconMsgPunchCommand}) + f.Add([]byte{protocol.BeaconMsgRelayDeliver}) + // Punch command with each IP-length encoding, plus a truncated one. + f.Add(beaconFrame(protocol.BeaconMsgPunchCommand, []byte{4, 127, 0, 0, 1, 0x1F, 0x90})) + f.Add(beaconFrame(protocol.BeaconMsgPunchCommand, []byte{16})) + f.Add(beaconFrame(protocol.BeaconMsgPunchCommand, []byte{0xFF, 1, 2, 3})) + // Relay-deliver carrying each inner tunnel magic. + for _, magic := range [][4]byte{ + protocol.TunnelMagicAuthEx, + protocol.TunnelMagicKeyEx, + protocol.TunnelMagicSecure, + protocol.TunnelMagic, + protocol.TunnelMagicPunch, + } { + body := make([]byte, 4+4) + binary.BigEndian.PutUint32(body[0:4], 1) + copy(body[4:8], magic[:]) + f.Add(beaconFrame(protocol.BeaconMsgRelayDeliver, body)) + } + for i := byte(0); i < 0x10; i++ { + f.Add([]byte{i}) + f.Add([]byte{i, 0xFF, 0xFF, 0xFF, 0xFF}) + } + + tm := fuzzTunnelManager(f) + stop := drainRecv(tm) + f.Cleanup(stop) + + beaconAddr := tm.routing.BeaconAddr() + offPath := &net.UDPAddr{IP: net.IPv4(203, 0, 113, 9), Port: 3333} + + f.Fuzz(func(t *testing.T, data []byte) { + if len(data) > 65535 { + data = data[:65535] + } + before := RecoveredPanicCount() + tm.handleBeaconMessage(data, beaconAddr) + tm.handleBeaconMessage(data, offPath) + if after := RecoveredPanicCount(); after != before { + t.Fatalf("handleBeaconMessage recovered a panic on input %x", data) + } + }) +} + +// FuzzTunnelRelayDeliver targets handleRelayDeliver's inner-frame parser +// directly. This is the highest-value surface in the daemon: the beacon is +// explicitly not trusted, srcNodeID is attacker-chosen, and the inner frame +// is re-dispatched into the crypto handlers. +func FuzzTunnelRelayDeliver(f *testing.F) { + f.Add([]byte{}) + f.Add(make([]byte, 4)) + f.Add(make([]byte, 5)) + f.Add(make([]byte, 8)) + for _, magic := range [][4]byte{ + protocol.TunnelMagicAuthEx, + protocol.TunnelMagicKeyEx, + protocol.TunnelMagicSecure, + protocol.TunnelMagic, + } { + // srcNodeID + magic, with nothing after it. + b := make([]byte, 4+4) + binary.BigEndian.PutUint32(b[0:4], 1) + copy(b[4:8], magic[:]) + f.Add(b) + // srcNodeID + magic + a partial body. + b2 := make([]byte, 4+4+16) + binary.BigEndian.PutUint32(b2[0:4], 1) + copy(b2[4:8], magic[:]) + f.Add(b2) + } + // A structurally complete PILA payload (unsigned, so it must be rejected). + { + b := make([]byte, 4+4+4+32+32+64) + binary.BigEndian.PutUint32(b[0:4], 1) + copy(b[4:8], protocol.TunnelMagicAuthEx[:]) + binary.BigEndian.PutUint32(b[8:12], 1) + f.Add(b) + } + // A structurally complete PILK payload with a real X25519 point. + { + if k, err := ecdh.X25519().GenerateKey(rand.Reader); err == nil { + b := make([]byte, 4+4+4+32) + binary.BigEndian.PutUint32(b[0:4], 2) + copy(b[4:8], protocol.TunnelMagicKeyEx[:]) + binary.BigEndian.PutUint32(b[8:12], 2) + copy(b[12:44], k.PublicKey().Bytes()) + f.Add(b) + } + } + + tm := fuzzTunnelManager(f) + stop := drainRecv(tm) + f.Cleanup(stop) + + f.Fuzz(func(t *testing.T, data []byte) { + if len(data) > 65535 { + data = data[:65535] + } + before := RecoveredPanicCount() + tm.handleRelayDeliver(data) + if after := RecoveredPanicCount(); after != before { + t.Fatalf("handleRelayDeliver recovered a panic on input %x", data) + } + }) +} + +// FuzzTunnelKeyExchange targets both key-exchange entry points as the +// readLoop calls them (magic already stripped), on the direct and relayed +// paths. The PILA path runs attacker bytes through an Ed25519 verify, which +// is exactly where the wrong-length-public-key panic class lives. +func FuzzTunnelKeyExchange(f *testing.F) { + f.Add([]byte{}) + f.Add(make([]byte, 3)) + f.Add(make([]byte, 4)) + f.Add(make([]byte, 36)) // exact PILK size + f.Add(make([]byte, 35)) // one short + f.Add(make([]byte, 4+32+32+64)) // exact PILA size + f.Add(make([]byte, 4+32+32+63)) // one short + { + b := make([]byte, 4+32+32+64) + binary.BigEndian.PutUint32(b[0:4], 1) + f.Add(b) + } + + tm := fuzzTunnelManager(f) + stop := drainRecv(tm) + f.Cleanup(stop) + from := &net.UDPAddr{IP: net.IPv4(198, 51, 100, 3), Port: 9999} + + f.Fuzz(func(t *testing.T, data []byte) { + if len(data) > 4096 { + data = data[:4096] + } + before := RecoveredPanicCount() + tm.handleAuthKeyExchange(data, from, false) + tm.handleAuthKeyExchange(data, from, true) + tm.handleKeyExchange(data, from, false) + tm.handleKeyExchange(data, from, true) + if after := RecoveredPanicCount(); after != before { + t.Fatalf("key exchange handler recovered a panic on input %x", data) + } + }) +} + +// FuzzTunnelEncrypted targets handleEncrypted with the PILS magic stripped, +// mirroring the readLoop call. Covers the no-key, replay, outside-window, +// and AEAD-failure branches plus the post-decrypt Unmarshal. +func FuzzTunnelEncrypted(f *testing.F) { + f.Add([]byte{}) + f.Add(make([]byte, 4+12+15)) + f.Add(make([]byte, 4+12+16)) + f.Add(make([]byte, 256)) + { + b := make([]byte, 4+12+16) + binary.BigEndian.PutUint32(b[0:4], 1) + f.Add(b) + } + + tm := fuzzTunnelManager(f) + stop := drainRecv(tm) + f.Cleanup(stop) + from := &net.UDPAddr{IP: net.IPv4(198, 51, 100, 4), Port: 8888} + + f.Fuzz(func(t *testing.T, data []byte) { + if len(data) > 65535 { + data = data[:65535] + } + before := RecoveredPanicCount() + tm.handleEncrypted(data, from) + if after := RecoveredPanicCount(); after != before { + t.Fatalf("handleEncrypted recovered a panic on input %x", data) + } + }) +} diff --git a/pkg/daemon/zz_trust_key_binding_test.go b/pkg/daemon/zz_trust_key_binding_test.go new file mode 100644 index 00000000..3cbc2392 --- /dev/null +++ b/pkg/daemon/zz_trust_key_binding_test.go @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package daemon + +import ( + "crypto/ed25519" + "encoding/base64" + "testing" + "time" +) + +// keyBoundFakeHandshakeService implements HandshakeService plus the +// optional IsTrustedWithKey half, recording what the daemon passed in. +type keyBoundFakeHandshakeService struct { + trusted map[uint32]string // node ID → bound key ("" = unbound) + gotKeyFor map[uint32]string + withKeyHits int +} + +func newKeyBoundFake() *keyBoundFakeHandshakeService { + return &keyBoundFakeHandshakeService{ + trusted: map[uint32]string{}, + gotKeyFor: map[uint32]string{}, + } +} + +func (f *keyBoundFakeHandshakeService) IsTrusted(nodeID uint32) bool { + _, ok := f.trusted[nodeID] + return ok +} + +func (f *keyBoundFakeHandshakeService) IsTrustedWithKey(nodeID uint32, pubKeyB64 string) bool { + f.withKeyHits++ + f.gotKeyFor[nodeID] = pubKeyB64 + bound, ok := f.trusted[nodeID] + if !ok { + return false + } + if bound == "" || pubKeyB64 == "" { + return true + } + return bound == pubKeyB64 +} + +func (f *keyBoundFakeHandshakeService) TrustedPeers() []HandshakeTrustRecord { return nil } +func (f *keyBoundFakeHandshakeService) PendingRequests() []HandshakePendingRecord { return nil } +func (f *keyBoundFakeHandshakeService) PendingCount() int { return 0 } +func (f *keyBoundFakeHandshakeService) SendRequest(uint32, string) error { return nil } +func (f *keyBoundFakeHandshakeService) ApproveHandshake(uint32) error { return nil } +func (f *keyBoundFakeHandshakeService) RejectHandshake(uint32, string) error { return nil } +func (f *keyBoundFakeHandshakeService) RevokeTrust(uint32) error { return nil } +func (f *keyBoundFakeHandshakeService) WaitForTrust(uint32, time.Duration) bool { return false } +func (f *keyBoundFakeHandshakeService) ProcessRelayedRequest(uint32, string) {} +func (f *keyBoundFakeHandshakeService) ProcessRelayedApproval(uint32) {} +func (f *keyBoundFakeHandshakeService) ProcessRelayedRejection(uint32) {} +func (f *keyBoundFakeHandshakeService) Stop() {} + +// nodeIDOnlyFakeHandshakeService embeds the interface, so its method +// set is exactly HandshakeService — no IsTrustedWithKey. That is what +// an older plugin build looks like to the daemon's type assertion. +type nodeIDOnlyFakeHandshakeService struct{ HandshakeService } + +func testPubKey(t *testing.T, seed byte) (ed25519.PublicKey, string) { + t.Helper() + raw := make([]byte, ed25519.SeedSize) + for i := range raw { + raw[i] = seed + } + pk := ed25519.NewKeyFromSeed(raw).Public().(ed25519.PublicKey) + return pk, base64.StdEncoding.EncodeToString(pk) +} + +// The daemon hands the cached tunnel key to the trust store, so a node +// ID whose key changed does not keep the previous holder's trust. +func TestHandshakeTrustsPassesCachedPeerKey(t *testing.T) { + d := New(Config{}) + pkA, pkAB64 := testPubKey(t, 1) + pkB, _ := testPubKey(t, 2) + + svc := newKeyBoundFake() + svc.trusted[42] = pkAB64 + d.RegisterHandshakeService(svc) + + d.tunnels.kx.SetPeerPubKey(42, pkA) + if !d.handshakeTrusts(42) { + t.Fatal("peer whose cached key matches the binding should be trusted") + } + if got := svc.gotKeyFor[42]; got != pkAB64 { + t.Fatalf("daemon passed %q, want the cached key %q", got, pkAB64) + } + + // Same node ID, different key behind it. + d.tunnels.kx.SetPeerPubKey(42, pkB) + if d.handshakeTrusts(42) { + t.Fatal("peer presenting a different key must not be trusted") + } +} + +// With no key cached for the peer the daemon passes "", and the store +// falls back to its node-ID-only answer. +func TestHandshakeTrustsNoCachedKeyPassesEmpty(t *testing.T) { + d := New(Config{}) + + svc := newKeyBoundFake() + svc.trusted[42] = "some-bound-key" + d.RegisterHandshakeService(svc) + + if !d.handshakeTrusts(42) { + t.Fatal("trust must not be denied just because no key is cached") + } + if got, ok := svc.gotKeyFor[42]; !ok || got != "" { + t.Fatalf("daemon passed %q, want empty string when no key is cached", got) + } +} + +// A handshake service without the key-bound check keeps working through +// the node-ID-only path. +func TestHandshakeTrustsFallsBackForServiceWithoutKeyCheck(t *testing.T) { + d := New(Config{}) + + inner := newKeyBoundFake() + inner.trusted[42] = "" + d.RegisterHandshakeService(&nodeIDOnlyFakeHandshakeService{HandshakeService: inner}) + + pk, _ := testPubKey(t, 3) + d.tunnels.kx.SetPeerPubKey(42, pk) + + if !d.handshakeTrusts(42) { + t.Fatal("node-ID-only service should still report trust") + } + if inner.withKeyHits != 0 { + t.Fatal("key-bound check should not have been reached") + } + if d.handshakeTrusts(43) { + t.Fatal("unknown node should not be trusted") + } +} + +// A nil handshake service is not trusted and must not panic. +func TestHandshakeTrustsNilServiceIsFalse(t *testing.T) { + d := New(Config{}) + if d.handshakeTrusts(42) { + t.Fatal("no handshake service means no trust") + } +} + +// cachedPeerKeyB64 never triggers a registry lookup, so it stays safe on +// the packet path. +func TestCachedPeerKeyB64IsCacheOnly(t *testing.T) { + d := New(Config{}) + + if got := d.cachedPeerKeyB64(42); got != "" { + t.Fatalf("uncached peer should yield empty string, got %q", got) + } + + pk, b64 := testPubKey(t, 4) + d.tunnels.kx.SetPeerPubKey(42, pk) + if got := d.cachedPeerKeyB64(42); got != b64 { + t.Fatalf("cachedPeerKeyB64 = %q, want %q", got, b64) + } +}