36e788402a
Hand-rolled RFC 5389 binding-request encoder + binding-success-response parser. Just enough to extract XOR-MAPPED-ADDRESS from a server's reply after socks5UDPAssociate returns a relay endpoint. Avoids pulling in pion/stun for ~80 LOC of encoding/binary work. Provides NewTransactionID, EncodeBindingRequest, ParseBindingResponse and six sentinel errors (ErrSTUN*) so HintFor (T11) can match specific failure modes. Full TLV attribute walking with bounds checks; supports both IPv4 and IPv6 XOR-MAPPED-ADDRESS values. Tests cover encoder layout, IPv4/IPv6 happy paths, attribute walking past unknown attributes, all error paths, sentinel uniqueness, and a real loopback round-trip via net.ListenPacket. 90.0% combined coverage (socks5+stun); stun.go funcs all >= 87%. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
185 lines
6.4 KiB
Go
185 lines
6.4 KiB
Go
// Package checker — STUN binding-request codec.
|
|
//
|
|
// Hand-rolled RFC 5389 binding request encoder + binding success response
|
|
// parser. Just enough to extract XOR-MAPPED-ADDRESS — no message integrity,
|
|
// no fingerprint, no ALTERNATE-SERVER, no STUN-USE-CANDIDATE. Used after
|
|
// socks5UDPAssociate succeeds to verify the relay actually forwards UDP
|
|
// to the public Internet.
|
|
//
|
|
// We deliberately avoid pulling in pion/stun: ~80 LOC of encoding/binary,
|
|
// one attribute type. Adding ~50 KB of compiled code + a transitive
|
|
// dependency for one round trip is overkill.
|
|
package checker
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
)
|
|
|
|
// Magic cookie defined by RFC 5389 §6.
|
|
const stunMagicCookie = 0x2112A442
|
|
|
|
// STUN message types and attribute types we care about.
|
|
const (
|
|
stunBindingRequest = 0x0001
|
|
stunBindingSuccessResponse = 0x0101
|
|
stunAttrXORMappedAddress = 0x0020
|
|
stunAddressFamilyIPv4 = 0x01
|
|
stunAddressFamilyIPv6 = 0x02
|
|
)
|
|
|
|
// Sentinel errors so HintFor (and tests) can match specific failure modes.
|
|
var (
|
|
ErrSTUNTooShort = errors.New("stun: response shorter than 20-byte header")
|
|
ErrSTUNBadMagicCookie = errors.New("stun: magic cookie mismatch")
|
|
ErrSTUNNotSuccess = errors.New("stun: response is not a Binding Success Response")
|
|
ErrSTUNTxIDMismatch = errors.New("stun: transaction ID mismatch")
|
|
ErrSTUNNoMappedAddress = errors.New("stun: response has no XOR-MAPPED-ADDRESS attribute")
|
|
ErrSTUNUnsupportedFamily = errors.New("stun: unsupported XOR-MAPPED-ADDRESS family")
|
|
)
|
|
|
|
// NewTransactionID returns 12 cryptographically-random bytes suitable for
|
|
// use as a STUN transaction ID. Errors only if rand.Reader fails — caller
|
|
// should propagate (the runtime is in trouble at that point anyway).
|
|
func NewTransactionID() ([12]byte, error) {
|
|
var id [12]byte
|
|
if _, err := rand.Read(id[:]); err != nil {
|
|
return id, fmt.Errorf("stun: read random transaction id: %w", err)
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
// EncodeBindingRequest builds a 20-byte STUN Binding Request with the given
|
|
// transaction ID. RFC 5389 allows empty request bodies.
|
|
func EncodeBindingRequest(txID [12]byte) []byte {
|
|
buf := make([]byte, 20)
|
|
binary.BigEndian.PutUint16(buf[0:2], stunBindingRequest)
|
|
binary.BigEndian.PutUint16(buf[2:4], 0) // attribute length
|
|
binary.BigEndian.PutUint32(buf[4:8], stunMagicCookie)
|
|
copy(buf[8:20], txID[:])
|
|
return buf
|
|
}
|
|
|
|
// ParseBindingResponse decodes a STUN Binding Success Response and returns
|
|
// the public IP+port advertised in XOR-MAPPED-ADDRESS.
|
|
//
|
|
// Validates header (length, magic, message type, transaction ID), then
|
|
// walks the TLV attribute section and extracts the first XOR-MAPPED-ADDRESS
|
|
// attribute. Other attributes are skipped (per RFC 5389 §15 the
|
|
// "comprehension-optional" range is everything ≥ 0x8000; we skip every
|
|
// non-XOR-MAPPED-ADDRESS attribute regardless, since this is the only one
|
|
// we care about).
|
|
func ParseBindingResponse(buf []byte, expectedTxID [12]byte) (net.IP, uint16, error) {
|
|
if len(buf) < 20 {
|
|
return nil, 0, ErrSTUNTooShort
|
|
}
|
|
|
|
msgType := binary.BigEndian.Uint16(buf[0:2])
|
|
attrLen := binary.BigEndian.Uint16(buf[2:4])
|
|
cookie := binary.BigEndian.Uint32(buf[4:8])
|
|
if cookie != stunMagicCookie {
|
|
return nil, 0, ErrSTUNBadMagicCookie
|
|
}
|
|
if msgType != stunBindingSuccessResponse {
|
|
return nil, 0, fmt.Errorf("%w: type=0x%04x", ErrSTUNNotSuccess, msgType)
|
|
}
|
|
|
|
var txID [12]byte
|
|
copy(txID[:], buf[8:20])
|
|
if txID != expectedTxID {
|
|
return nil, 0, ErrSTUNTxIDMismatch
|
|
}
|
|
|
|
// Sanity check: attrLen must not exceed buffer.
|
|
if int(attrLen) > len(buf)-20 {
|
|
return nil, 0, fmt.Errorf("stun: attribute section length %d exceeds buffer (%d bytes after header)", attrLen, len(buf)-20)
|
|
}
|
|
|
|
attrs := buf[20 : 20+int(attrLen)]
|
|
off := 0
|
|
for off < len(attrs) {
|
|
// Each attribute header is 4 bytes (type + length).
|
|
if len(attrs)-off < 4 {
|
|
return nil, 0, fmt.Errorf("stun: truncated attribute header at offset %d", off)
|
|
}
|
|
aType := binary.BigEndian.Uint16(attrs[off : off+2])
|
|
aLen := binary.BigEndian.Uint16(attrs[off+2 : off+4])
|
|
valStart := off + 4
|
|
valEnd := valStart + int(aLen)
|
|
if valEnd > len(attrs) {
|
|
return nil, 0, fmt.Errorf("stun: attribute at offset %d claims length %d but only %d bytes remain", off, aLen, len(attrs)-valStart)
|
|
}
|
|
|
|
if aType == stunAttrXORMappedAddress {
|
|
ip, port, err := parseXORMappedAddress(attrs[valStart:valEnd], txID)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
return ip, port, nil
|
|
}
|
|
|
|
// Skip attribute including padding to next 4-byte boundary.
|
|
paddedLen := (int(aLen) + 3) &^ 3
|
|
next := valStart + paddedLen
|
|
if next > len(attrs) {
|
|
// Padding runs past end — treat as truncation.
|
|
return nil, 0, fmt.Errorf("stun: attribute padding at offset %d runs past end", off)
|
|
}
|
|
off = next
|
|
}
|
|
|
|
return nil, 0, ErrSTUNNoMappedAddress
|
|
}
|
|
|
|
// parseXORMappedAddress decodes the value of an XOR-MAPPED-ADDRESS attribute.
|
|
//
|
|
// Layout (RFC 5389 §15.2):
|
|
//
|
|
// 0 1 2 3
|
|
// 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
|
|
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
|
// |0 0 0 0 0 0 0 0| Family | X-Port |
|
|
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
|
// | X-Address (Variable)
|
|
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
|
func parseXORMappedAddress(val []byte, txID [12]byte) (net.IP, uint16, error) {
|
|
if len(val) < 4 {
|
|
return nil, 0, fmt.Errorf("stun: XOR-MAPPED-ADDRESS truncated (got %d bytes, need ≥4)", len(val))
|
|
}
|
|
family := val[1]
|
|
xPort := binary.BigEndian.Uint16(val[2:4])
|
|
port := xPort ^ uint16(stunMagicCookie>>16)
|
|
|
|
switch family {
|
|
case stunAddressFamilyIPv4:
|
|
if len(val) < 8 {
|
|
return nil, 0, fmt.Errorf("stun: XOR-MAPPED-ADDRESS IPv4 truncated (got %d bytes, need 8)", len(val))
|
|
}
|
|
xAddr := binary.BigEndian.Uint32(val[4:8])
|
|
addr := xAddr ^ stunMagicCookie
|
|
ip := make(net.IP, 4)
|
|
binary.BigEndian.PutUint32(ip, addr)
|
|
return ip, port, nil
|
|
|
|
case stunAddressFamilyIPv6:
|
|
if len(val) < 20 {
|
|
return nil, 0, fmt.Errorf("stun: XOR-MAPPED-ADDRESS IPv6 truncated (got %d bytes, need 20)", len(val))
|
|
}
|
|
// XOR with magic_cookie || transaction_id (16 bytes total).
|
|
var key [16]byte
|
|
binary.BigEndian.PutUint32(key[0:4], stunMagicCookie)
|
|
copy(key[4:16], txID[:])
|
|
ip := make(net.IP, 16)
|
|
for i := 0; i < 16; i++ {
|
|
ip[i] = val[4+i] ^ key[i]
|
|
}
|
|
return ip, port, nil
|
|
|
|
default:
|
|
return nil, 0, fmt.Errorf("%w: family=0x%02x", ErrSTUNUnsupportedFamily, family)
|
|
}
|
|
}
|