// 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) } }