package checker import ( "encoding/binary" "errors" "net" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // mkXorMappedV4 builds a synthetic STUN binding success response carrying an // XOR-MAPPED-ADDRESS attribute for an IPv4 endpoint. Used by several test // cases to keep byte-construction DRY. func mkXorMappedV4(t *testing.T, ip net.IP, port uint16, txID [12]byte) []byte { t.Helper() ip4 := ip.To4() require.NotNil(t, ip4, "ip must be IPv4") // Attribute value: 1B reserved + 1B family + 2B xPort + 4B xAddr = 8 bytes. attrVal := make([]byte, 8) attrVal[0] = 0 attrVal[1] = stunAddressFamilyIPv4 binary.BigEndian.PutUint16(attrVal[2:4], port^uint16(stunMagicCookie>>16)) xAddr := binary.BigEndian.Uint32(ip4) ^ stunMagicCookie binary.BigEndian.PutUint32(attrVal[4:8], xAddr) // Attribute header (4B) + value (8B) = 12 bytes total, no padding needed. attr := make([]byte, 4+len(attrVal)) binary.BigEndian.PutUint16(attr[0:2], stunAttrXORMappedAddress) binary.BigEndian.PutUint16(attr[2:4], uint16(len(attrVal))) copy(attr[4:], attrVal) // 20B header + attrs. resp := make([]byte, 20+len(attr)) binary.BigEndian.PutUint16(resp[0:2], stunBindingSuccessResponse) binary.BigEndian.PutUint16(resp[2:4], uint16(len(attr))) binary.BigEndian.PutUint32(resp[4:8], stunMagicCookie) copy(resp[8:20], txID[:]) copy(resp[20:], attr) return resp } // mkXorMappedV6 builds a synthetic response with an IPv6 XOR-MAPPED-ADDRESS. func mkXorMappedV6(t *testing.T, ip net.IP, port uint16, txID [12]byte) []byte { t.Helper() ip6 := ip.To16() require.NotNil(t, ip6, "ip must be IPv6") require.Equal(t, net.IPv6len, len(ip6)) attrVal := make([]byte, 4+16) attrVal[0] = 0 attrVal[1] = stunAddressFamilyIPv6 binary.BigEndian.PutUint16(attrVal[2:4], port^uint16(stunMagicCookie>>16)) var key [16]byte binary.BigEndian.PutUint32(key[0:4], stunMagicCookie) copy(key[4:16], txID[:]) for i := 0; i < 16; i++ { attrVal[4+i] = ip6[i] ^ key[i] } attr := make([]byte, 4+len(attrVal)) binary.BigEndian.PutUint16(attr[0:2], stunAttrXORMappedAddress) binary.BigEndian.PutUint16(attr[2:4], uint16(len(attrVal))) copy(attr[4:], attrVal) resp := make([]byte, 20+len(attr)) binary.BigEndian.PutUint16(resp[0:2], stunBindingSuccessResponse) binary.BigEndian.PutUint16(resp[2:4], uint16(len(attr))) binary.BigEndian.PutUint32(resp[4:8], stunMagicCookie) copy(resp[8:20], txID[:]) copy(resp[20:], attr) return resp } func TestNewTransactionID(t *testing.T) { a, err := NewTransactionID() require.NoError(t, err) b, err := NewTransactionID() require.NoError(t, err) assert.NotEqual(t, a, b, "two consecutive transaction IDs should differ (cryptographic randomness)") assert.Len(t, a[:], 12) } func TestEncodeBindingRequest(t *testing.T) { txID := [12]byte{0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08} got := EncodeBindingRequest(txID) require.Len(t, got, 20) assert.Equal(t, byte(0x00), got[0]) assert.Equal(t, byte(0x01), got[1], "type LSB = 0x01 (binding request)") assert.Equal(t, byte(0x00), got[2]) assert.Equal(t, byte(0x00), got[3], "attribute length = 0 (empty body)") assert.Equal(t, []byte{0x21, 0x12, 0xA4, 0x42}, got[4:8], "magic cookie") assert.Equal(t, txID[:], got[8:20], "transaction id") } func TestParseBindingResponse_HappyV4(t *testing.T) { txID := [12]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12} ip := net.IPv4(198, 51, 100, 1).To4() const port uint16 = 42 resp := mkXorMappedV4(t, ip, port, txID) // Sanity-check the bytes match the worked example in the task description. // xPort = 42 ^ 0x2112 = 0x2138 assert.Equal(t, byte(0x21), resp[20+4+2]) assert.Equal(t, byte(0x38), resp[20+4+3]) // xIP = 0xC6336401 ^ 0x2112A442 = 0xE721C043 assert.Equal(t, []byte{0xE7, 0x21, 0xC0, 0x43}, resp[20+4+4:20+4+8]) gotIP, gotPort, err := ParseBindingResponse(resp, txID) require.NoError(t, err) assert.True(t, gotIP.Equal(ip), "got %s want %s", gotIP, ip) assert.Equal(t, port, gotPort) assert.Len(t, gotIP, 4, "IPv4 result should be 4-byte slice") } func TestParseBindingResponse_HappyV6(t *testing.T) { txID := [12]byte{0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC} ip := net.ParseIP("2001:db8::1") require.NotNil(t, ip) const port uint16 = 0x1234 resp := mkXorMappedV6(t, ip, port, txID) gotIP, gotPort, err := ParseBindingResponse(resp, txID) require.NoError(t, err) assert.True(t, gotIP.Equal(ip), "got %s want %s", gotIP, ip) assert.Equal(t, port, gotPort) assert.Len(t, gotIP, 16, "IPv6 result should be 16-byte slice") } func TestParseBindingResponse_MultipleUnknownAttributesThenMapped(t *testing.T) { txID := [12]byte{9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9} ip := net.IPv4(8, 8, 8, 8).To4() const port uint16 = 53 // Build header with three attributes: // 1. unknown type 0x8022 (SOFTWARE), value="abc" -> 3 bytes value + 1 byte pad // 2. unknown type 0x8023, value="hi" -> 2 bytes + 2 bytes pad // 3. real XOR-MAPPED-ADDRESS var attrs []byte addAttr := func(t uint16, val []byte) { hdr := make([]byte, 4) binary.BigEndian.PutUint16(hdr[0:2], t) binary.BigEndian.PutUint16(hdr[2:4], uint16(len(val))) attrs = append(attrs, hdr...) attrs = append(attrs, val...) // pad to 4-byte boundary for len(attrs)%4 != 0 { attrs = append(attrs, 0) } } addAttr(0x8022, []byte("abc")) addAttr(0x8023, []byte("hi")) // XOR-MAPPED-ADDRESS attribute xmAttrVal := make([]byte, 8) xmAttrVal[1] = stunAddressFamilyIPv4 binary.BigEndian.PutUint16(xmAttrVal[2:4], port^uint16(stunMagicCookie>>16)) xAddr := binary.BigEndian.Uint32(ip) ^ stunMagicCookie binary.BigEndian.PutUint32(xmAttrVal[4:8], xAddr) addAttr(stunAttrXORMappedAddress, xmAttrVal) resp := make([]byte, 20+len(attrs)) binary.BigEndian.PutUint16(resp[0:2], stunBindingSuccessResponse) binary.BigEndian.PutUint16(resp[2:4], uint16(len(attrs))) binary.BigEndian.PutUint32(resp[4:8], stunMagicCookie) copy(resp[8:20], txID[:]) copy(resp[20:], attrs) gotIP, gotPort, err := ParseBindingResponse(resp, txID) require.NoError(t, err) assert.True(t, gotIP.Equal(ip)) assert.Equal(t, port, gotPort) } func TestParseBindingResponse_Errors(t *testing.T) { txID := [12]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12} otherTxID := [12]byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} t.Run("truncated", func(t *testing.T) { buf := make([]byte, 10) _, _, err := ParseBindingResponse(buf, txID) assert.ErrorIs(t, err, ErrSTUNTooShort) }) t.Run("bad_magic_cookie", func(t *testing.T) { resp := mkXorMappedV4(t, net.IPv4(1, 2, 3, 4), 80, txID) copy(resp[4:8], []byte{0xAA, 0xBB, 0xCC, 0xDD}) _, _, err := ParseBindingResponse(resp, txID) assert.ErrorIs(t, err, ErrSTUNBadMagicCookie) }) t.Run("not_success_request_type", func(t *testing.T) { resp := mkXorMappedV4(t, net.IPv4(1, 2, 3, 4), 80, txID) binary.BigEndian.PutUint16(resp[0:2], stunBindingRequest) // 0x0001 _, _, err := ParseBindingResponse(resp, txID) assert.ErrorIs(t, err, ErrSTUNNotSuccess) }) t.Run("not_success_error_response_type", func(t *testing.T) { resp := mkXorMappedV4(t, net.IPv4(1, 2, 3, 4), 80, txID) binary.BigEndian.PutUint16(resp[0:2], 0x0111) // binding error response _, _, err := ParseBindingResponse(resp, txID) assert.ErrorIs(t, err, ErrSTUNNotSuccess) }) t.Run("no_xor_mapped_address", func(t *testing.T) { // 20-byte header + zero attributes resp := make([]byte, 20) binary.BigEndian.PutUint16(resp[0:2], stunBindingSuccessResponse) binary.BigEndian.PutUint16(resp[2:4], 0) binary.BigEndian.PutUint32(resp[4:8], stunMagicCookie) copy(resp[8:20], txID[:]) _, _, err := ParseBindingResponse(resp, txID) assert.ErrorIs(t, err, ErrSTUNNoMappedAddress) }) t.Run("unsupported_family", func(t *testing.T) { resp := mkXorMappedV4(t, net.IPv4(1, 2, 3, 4), 80, txID) // Flip family byte (offset 20 + 4 + 1 = 25) to 0x03. resp[25] = 0x03 _, _, err := ParseBindingResponse(resp, txID) assert.ErrorIs(t, err, ErrSTUNUnsupportedFamily) }) t.Run("attribute_length_overflow", func(t *testing.T) { // Build a header claiming 24 bytes of attrs, but only put one bogus // attribute of declared length 100 inside. resp := make([]byte, 20+24) binary.BigEndian.PutUint16(resp[0:2], stunBindingSuccessResponse) binary.BigEndian.PutUint16(resp[2:4], 24) binary.BigEndian.PutUint32(resp[4:8], stunMagicCookie) copy(resp[8:20], txID[:]) // attribute: type=0x0020, length=100 (lies — only 20 bytes of value follow) binary.BigEndian.PutUint16(resp[20:22], stunAttrXORMappedAddress) binary.BigEndian.PutUint16(resp[22:24], 100) _, _, err := ParseBindingResponse(resp, txID) require.Error(t, err) assert.Contains(t, err.Error(), "claims length 100") }) t.Run("attribute_section_length_overflow", func(t *testing.T) { // Header says attrLen=200 but buffer only has 20 bytes after header. resp := make([]byte, 20+20) binary.BigEndian.PutUint16(resp[0:2], stunBindingSuccessResponse) binary.BigEndian.PutUint16(resp[2:4], 200) binary.BigEndian.PutUint32(resp[4:8], stunMagicCookie) copy(resp[8:20], txID[:]) _, _, err := ParseBindingResponse(resp, txID) require.Error(t, err) assert.Contains(t, err.Error(), "exceeds buffer") }) t.Run("tx_id_mismatch", func(t *testing.T) { resp := mkXorMappedV4(t, net.IPv4(1, 2, 3, 4), 80, txID) _, _, err := ParseBindingResponse(resp, otherTxID) assert.ErrorIs(t, err, ErrSTUNTxIDMismatch) }) } // TestRoundTripLocalhost stands up a tiny STUN server on loopback that // handles exactly one binding request and replies with the client's own // address as XOR-MAPPED-ADDRESS. Verifies the encode/parse pair end-to-end // against a real UDP socket. func TestRoundTripLocalhost(t *testing.T) { server, err := net.ListenPacket("udp", "127.0.0.1:0") require.NoError(t, err, "net.ListenPacket must succeed for round-trip test (real-network requirement)") t.Cleanup(func() { _ = server.Close() }) // Server goroutine: read one request, parse minimally, reply. serverDone := make(chan struct{}) go func() { defer close(serverDone) buf := make([]byte, 1500) _ = server.SetReadDeadline(time.Now().Add(3 * time.Second)) n, from, rerr := server.ReadFrom(buf) if rerr != nil { return } if n < 20 { return } // Verify it's a binding request with right magic. if binary.BigEndian.Uint16(buf[0:2]) != stunBindingRequest { return } if binary.BigEndian.Uint32(buf[4:8]) != stunMagicCookie { return } var txID [12]byte copy(txID[:], buf[8:20]) udpFrom, ok := from.(*net.UDPAddr) if !ok { return } reply := mkXorMappedV4(t, udpFrom.IP.To4(), uint16(udpFrom.Port), txID) _, _ = server.WriteTo(reply, from) }() // Client side. serverAddr := server.LocalAddr().(*net.UDPAddr) conn, err := net.DialUDP("udp", nil, serverAddr) require.NoError(t, err) t.Cleanup(func() { _ = conn.Close() }) txID, err := NewTransactionID() require.NoError(t, err) start := time.Now() _, err = conn.Write(EncodeBindingRequest(txID)) require.NoError(t, err) require.NoError(t, conn.SetReadDeadline(time.Now().Add(time.Second))) respBuf := make([]byte, 1500) n, err := conn.Read(respBuf) require.NoError(t, err) rtt := time.Since(start) gotIP, gotPort, err := ParseBindingResponse(respBuf[:n], txID) require.NoError(t, err) clientLocal := conn.LocalAddr().(*net.UDPAddr) assert.True(t, gotIP.Equal(net.IPv4(127, 0, 0, 1)), "got %s want 127.0.0.1", gotIP) assert.Equal(t, uint16(clientLocal.Port), gotPort, "port should match client local port") assert.Less(t, rtt, 200*time.Millisecond, "loopback RTT should be under 200ms (got %s)", rtt) // Make sure the server goroutine exits cleanly. select { case <-serverDone: case <-time.After(2 * time.Second): t.Fatal("server goroutine did not exit") } } // Sanity: errors.Is chain works for wrapped sentinels. func TestSentinelsAreUnique(t *testing.T) { all := []error{ ErrSTUNTooShort, ErrSTUNBadMagicCookie, ErrSTUNNotSuccess, ErrSTUNTxIDMismatch, ErrSTUNNoMappedAddress, ErrSTUNUnsupportedFamily, } for i, a := range all { for j, b := range all { if i == j { continue } assert.False(t, errors.Is(a, b), "sentinel %d should not match sentinel %d", i, j) } } }