From 1152b251495d9ac02e2629ee5cb62497fc874705 Mon Sep 17 00:00:00 2001 From: riccardom Date: Mon, 19 Oct 2026 09:48:18 +0200 Subject: [PATCH] [client] pqkem: run the data-path transport over a net.PacketConn Preparation for running the ML-KEM exchange on the js/wasm client. There, and in other netstack-mode clients, the overlay IP exists only inside the userspace stack, so the KEM socket has to be opened on the netstack. The netstack returns a *gonet.UDPConn rather than a *net.UDPConn, so the transport can no longer hold the host socket's concrete type. Hold the socket as a net.PacketConn, which both implement, and convert endpoints at the edges. Sources are unmapped explicitly, since a udp4 socket's net.UDPAddr may carry the 16-byte form. No behaviour change: the transport still binds on the host, and the next commit opens it on the netstack. Adds a host round-trip test that also covers the fallback to an ephemeral port. --- client/internal/pqkem_transport.go | 32 +++++++++---- client/internal/pqkem_transport_test.go | 62 +++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 8 deletions(-) create mode 100644 client/internal/pqkem_transport_test.go diff --git a/client/internal/pqkem_transport.go b/client/internal/pqkem_transport.go index 7096c9e9d..b0901afb7 100644 --- a/client/internal/pqkem_transport.go +++ b/client/internal/pqkem_transport.go @@ -19,7 +19,7 @@ const DefaultPort = 51833 // overlay IP. It implements pqkem.Transport — the manager owns the remoteID<->endpoint // routing and drives this socket's lifecycle (Run / Close). type pqTransport struct { - conn *net.UDPConn + conn net.PacketConn port int wg sync.WaitGroup } @@ -36,21 +36,32 @@ func newPQTransport(overlayIP netip.Addr) (*pqTransport, error) { // so the transport binds over IPv4. Unmap first so AsSlice() yields 4 bytes for an // IPv4-mapped IPv6 address (a hardcoded "udp4" would otherwise fail on its 16 bytes). overlayIP = overlayIP.Unmap() - ip := net.IP(overlayIP.AsSlice()) - conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: ip, Port: DefaultPort}) + listen := func(port uint16) (net.PacketConn, error) { + conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IP(overlayIP.AsSlice()), Port: int(port)}) + if err != nil { + return nil, err + } + return conn, nil + } + conn, err := listen(DefaultPort) if err != nil { log.Debugf("pqkem: default port %d unavailable on %s (%v), using an ephemeral port", DefaultPort, overlayIP, err) - conn, err = net.ListenUDP("udp4", &net.UDPAddr{IP: ip, Port: 0}) + conn, err = listen(0) if err != nil { return nil, fmt.Errorf("bind pqkem udp on overlay %s: %w", overlayIP, err) } } - return &pqTransport{conn: conn, port: conn.LocalAddr().(*net.UDPAddr).Port}, nil + local, ok := conn.LocalAddr().(*net.UDPAddr) + if !ok { + _ = conn.Close() + return nil, fmt.Errorf("pqkem transport bound to a non-UDP address %s", conn.LocalAddr()) + } + return &pqTransport{conn: conn, port: local.Port}, nil } // Send implements pqkem.Transport. func (t *pqTransport) Send(endpoint netip.AddrPort, msg []byte) error { - _, err := t.conn.WriteToUDPAddrPort(msg, endpoint) + _, err := t.conn.WriteTo(msg, net.UDPAddrFromAddrPort(endpoint)) return err } @@ -65,13 +76,18 @@ func (t *pqTransport) Run(onInbound func(src netip.AddrPort, msg []byte)) { defer t.wg.Done() buf := make([]byte, 2048) for { - n, src, err := t.conn.ReadFromUDPAddrPort(buf) + n, addr, err := t.conn.ReadFrom(buf) if err != nil { return } + src, ok := addr.(*net.UDPAddr) + if !ok { + continue + } + ap := src.AddrPort() msg := make([]byte, n) copy(msg, buf[:n]) - onInbound(src, msg) + onInbound(netip.AddrPortFrom(ap.Addr().Unmap(), ap.Port()), msg) } }() } diff --git a/client/internal/pqkem_transport_test.go b/client/internal/pqkem_transport_test.go new file mode 100644 index 000000000..a60063d65 --- /dev/null +++ b/client/internal/pqkem_transport_test.go @@ -0,0 +1,62 @@ +package internal + +import ( + "net/netip" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type pqInbound struct { + src netip.AddrPort + msg []byte +} + +func runPQTransport(t *testing.T, tr *pqTransport) <-chan pqInbound { + t.Helper() + got := make(chan pqInbound, 1) + tr.Run(func(src netip.AddrPort, msg []byte) { + select { + case got <- pqInbound{src: src, msg: msg}: + default: + } + }) + t.Cleanup(func() { _ = tr.Close() }) + return got +} + +func receivePQ(t *testing.T, got <-chan pqInbound) pqInbound { + t.Helper() + select { + case in := <-got: + return in + case <-time.After(5 * time.Second): + t.Fatal("no datagram received") + return pqInbound{} + } +} + +// TestPQTransport_HostRoundTrip sends a datagram between two host-bound transports. The +// second bind collides with the first on DefaultPort, so it also covers the fallback to +// an ephemeral port. The source must come back as a plain (unmapped) IPv4 endpoint, +// since the manager matches it against registered peer endpoints. +func TestPQTransport_HostRoundTrip(t *testing.T) { + loopback := netip.MustParseAddr("127.0.0.1") + + a, err := newPQTransport(loopback) + require.NoError(t, err) + b, err := newPQTransport(loopback) + require.NoError(t, err) + require.NotEqual(t, a.LocalPort(), b.LocalPort(), "two transports on one IP must get distinct ports") + + runPQTransport(t, a) + gotB := runPQTransport(t, b) + + require.NoError(t, a.Send(netip.AddrPortFrom(loopback, uint16(b.LocalPort())), []byte("offer"))) + + in := receivePQ(t, gotB) + assert.Equal(t, []byte("offer"), in.msg, "payload must arrive intact") + assert.Equal(t, netip.AddrPortFrom(loopback, uint16(a.LocalPort())), in.src, "source must be the sender's unmapped IPv4 endpoint") +}