diff --git a/client/internal/engine.go b/client/internal/engine.go index cd43a44cc..88a3db0e7 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -598,7 +598,7 @@ func (e *Engine) startPQKEMManager(publicKey wgtypes.Key) error { if !pqkem.Enabled() { return nil } - tr, err := newPQTransport(e.config.WgAddr.IP) + tr, err := newPQTransport(e.config.WgAddr.IP, e.wgInterface.GetNet()) if err != nil { if pqkem.Strict() { return fmt.Errorf("pqkem: strict mode enabled but transport bind failed: %w", err) diff --git a/client/internal/pqkem_transport.go b/client/internal/pqkem_transport.go index b0901afb7..5dfd088bd 100644 --- a/client/internal/pqkem_transport.go +++ b/client/internal/pqkem_transport.go @@ -7,6 +7,7 @@ import ( "sync" log "github.com/sirupsen/logrus" + "golang.zx2c4.com/wireguard/tun/netstack" ) // DefaultPort is the preferred UDP port for the ML-KEM data-path service, bound on @@ -25,10 +26,15 @@ type pqTransport struct { } // newPQTransport binds a UDP socket on the WG overlay IP, preferring DefaultPort and -// falling back to an OS-assigned ephemeral port if it is in use. Call it after the WG -// interface is up so the overlay IP is assigned; when the bound port is not -// DefaultPort it must be announced to peers via Body.mlkemPort. -func newPQTransport(overlayIP netip.Addr) (*pqTransport, error) { +// falling back to an ephemeral port if it is in use. Call it after the WG interface is +// up so the overlay IP is assigned; when the bound port is not DefaultPort it must be +// announced to peers via Body.mlkemPort. +// +// ns is the interface's netstack when it runs in netstack mode (js/wasm, embedded +// clients): the overlay IP then exists only inside that userspace stack, so the socket +// is opened there and its datagrams ride the tunnel like any other overlay traffic. A +// nil ns binds on the host. +func newPQTransport(overlayIP netip.Addr, ns *netstack.Net) (*pqTransport, error) { if !overlayIP.IsValid() { return nil, fmt.Errorf("invalid overlay IP for pqkem transport") } @@ -37,6 +43,13 @@ func newPQTransport(overlayIP netip.Addr) (*pqTransport, error) { // IPv4-mapped IPv6 address (a hardcoded "udp4" would otherwise fail on its 16 bytes). overlayIP = overlayIP.Unmap() listen := func(port uint16) (net.PacketConn, error) { + if ns != nil { + conn, err := ns.ListenUDPAddrPort(netip.AddrPortFrom(overlayIP, port)) + if err != nil { + return nil, err + } + return conn, nil + } conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IP(overlayIP.AsSlice()), Port: int(port)}) if err != nil { return nil, err diff --git a/client/internal/pqkem_transport_test.go b/client/internal/pqkem_transport_test.go index a60063d65..ffaec7014 100644 --- a/client/internal/pqkem_transport_test.go +++ b/client/internal/pqkem_transport_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.zx2c4.com/wireguard/tun/netstack" ) type pqInbound struct { @@ -45,9 +46,9 @@ func receivePQ(t *testing.T, got <-chan pqInbound) pqInbound { func TestPQTransport_HostRoundTrip(t *testing.T) { loopback := netip.MustParseAddr("127.0.0.1") - a, err := newPQTransport(loopback) + a, err := newPQTransport(loopback, nil) require.NoError(t, err) - b, err := newPQTransport(loopback) + b, err := newPQTransport(loopback, nil) require.NoError(t, err) require.NotEqual(t, a.LocalPort(), b.LocalPort(), "two transports on one IP must get distinct ports") @@ -60,3 +61,32 @@ func TestPQTransport_HostRoundTrip(t *testing.T) { 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") } + +// TestPQTransport_NetstackRoundTrip covers netstack mode (js/wasm and embedded clients), +// where the overlay IP exists only inside the userspace stack. Binding it on the host +// fails, which disabled the exchange (or, in strict mode, stopped the engine from +// starting); the transport must open its socket on the netstack instead. +func TestPQTransport_NetstackRoundTrip(t *testing.T) { + overlay := netip.MustParseAddr("192.0.2.1") + dev, ns, err := netstack.CreateNetTUN([]netip.Addr{overlay}, nil, 1280) + require.NoError(t, err) + t.Cleanup(func() { _ = dev.Close() }) + + _, err = newPQTransport(overlay, nil) + require.Error(t, err, "the netstack-only overlay IP must not be bindable on the host") + + a, err := newPQTransport(overlay, ns) + require.NoError(t, err) + assert.Equal(t, DefaultPort, a.LocalPort(), "the default port must be free on a fresh netstack") + b, err := newPQTransport(overlay, ns) + require.NoError(t, err) + + runPQTransport(t, a) + gotB := runPQTransport(t, b) + + require.NoError(t, a.Send(netip.AddrPortFrom(overlay, 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(overlay, uint16(a.LocalPort())), in.src, "source must be the sender's overlay endpoint") +}