diff --git a/client/main.go b/client/main.go index 510f6ac..c315a76 100644 --- a/client/main.go +++ b/client/main.go @@ -34,6 +34,7 @@ import ( "github.com/cacggghp/vk-turn-proxy/internal/bond" "github.com/cacggghp/vk-turn-proxy/internal/netadapt" "github.com/cacggghp/vk-turn-proxy/internal/stats" + "github.com/cacggghp/vk-turn-proxy/internal/wrap" "github.com/cacggghp/vk-turn-proxy/tcputil" "github.com/cbeuw/connutil" "github.com/google/uuid" @@ -929,10 +930,10 @@ func oneTurnConnection(ctx context.Context, turnParams *turnParams, peer *net.UD // Do not set conn2 deadline (conn2 can sometimes be listenConn if direct mode is used) }) var internalPipeAddr atomic.Value - var wc *wrapConn - if len(turnParams.wrapKey) == wrapKeyLen { + var wc *wrap.Conn + if len(turnParams.wrapKey) == wrap.KeyLen { var wcErr error - wc, wcErr = newWrapConn(turnParams.wrapKey, false) + wc, wcErr = wrap.NewConn(turnParams.wrapKey, false) if wcErr != nil { log.Printf("[STREAM %d] WRAP init failed: %v", streamID, wcErr) turncancel() @@ -947,7 +948,7 @@ func oneTurnConnection(ctx context.Context, turnParams *turnParams, peer *net.UD // stream so the hot-path TX loop performs zero allocations. var wireBuf []byte if wc != nil { - wireBuf = make([]byte, wrapMaxWire(len(buf))) + wireBuf = make([]byte, wrap.MaxWire(len(buf))) } for { if turnctx.Err() != nil { @@ -965,7 +966,7 @@ func oneTurnConnection(ctx context.Context, turnParams *turnParams, peer *net.UD out := buf[:n] if wc != nil { - written, wrapErr := wc.wrapInto(wireBuf, out) + written, wrapErr := wc.WrapInto(wireBuf, out) if wrapErr != nil { log.Printf("[STREAM %d] WRAP failed: %v", streamID, wrapErr) return @@ -985,7 +986,7 @@ func oneTurnConnection(ctx context.Context, turnParams *turnParams, peer *net.UD defer turncancel() readBufLen := 1600 if wc != nil { - readBufLen = wrapMaxWire(1600) + readBufLen = wrap.MaxWire(1600) } buf := make([]byte, readBufLen) plain := make([]byte, 1600) @@ -1002,7 +1003,7 @@ func oneTurnConnection(ctx context.Context, turnParams *turnParams, peer *net.UD if addr, ok := addr1.(net.Addr); ok { payload := buf[:n] if wc != nil { - m, wrapErr := wc.unwrapPacket(payload, plain) + m, wrapErr := wc.Unwrap(payload, plain) if wrapErr != nil { log.Printf("[STREAM %d] UNWRAP failed: %v (n=%d)", streamID, wrapErr, n) continue @@ -1145,7 +1146,7 @@ func main() { appDialer = dnsdial.AppDialer(*dnsFlag) dnsdial.InstallGlobalResolver(*dnsFlag) if *genWrapKey { - key, err := genWrapKeyHex() + key, err := wrap.GenKeyHex() if err != nil { log.Panicf("%v", err) } @@ -1165,7 +1166,7 @@ func main() { if *wrapMode && *direct { log.Panicf("-wrap requires DTLS; remove -no-dtls") } - wrapKey, err := decodeWrapKey(*wrapMode, *wrapKeyHex) + wrapKey, err := wrap.DecodeKey(*wrapMode, *wrapKeyHex) if err != nil { log.Panicf("%v", err) } @@ -1859,15 +1860,15 @@ func createSmuxSession(ctx context.Context, tp *turnParams, peer *net.UDPAddr, i cleanup() return nil, nil, fmt.Errorf("generate cert: %w", err) } - var relayWC *wrapConn - if len(tp.wrapKey) == wrapKeyLen { - relayWC, err = newWrapConn(tp.wrapKey, false) + var relayWC *wrap.Conn + if len(tp.wrapKey) == wrap.KeyLen { + relayWC, err = wrap.NewConn(tp.wrapKey, false) if err != nil { cleanup() return nil, nil, fmt.Errorf("wrap init: %w", err) } } - dtlsPC := &relayPacketConn{relay: relayConn, peer: peer, wc: relayWC} + dtlsPC := &wrap.RelayPacketConn{Relay: relayConn, Peer: peer, Conn: relayWC} dtlsConn, err := dtls.ClientWithOptions(dtlsPC, peer, dtls.WithCertificates(certificate), dtls.WithInsecureSkipVerify(true), @@ -1915,51 +1916,6 @@ func createSmuxSession(ctx context.Context, tp *turnParams, peer *net.UDPAddr, i return smuxSess, cleanup, nil } -// relayPacketConn wraps a TURN relay PacketConn to direct all writes to the peer. -// When wc != nil, packets are wrapped/unwrapped with SRTP-mimicry AEAD. -type relayPacketConn struct { - relay net.PacketConn - peer net.Addr - wc *wrapConn -} - -func (r *relayPacketConn) ReadFrom(b []byte) (int, net.Addr, error) { - if r.wc == nil { - return r.relay.ReadFrom(b) - } - buf := make([]byte, wrapMaxWire(len(b))) - n, addr, err := r.relay.ReadFrom(buf) - if err != nil { - return 0, addr, err - } - m, err := r.wc.unwrapPacket(buf[:n], b) - if err != nil { - return 0, addr, err - } - return m, addr, nil -} - -func (r *relayPacketConn) WriteTo(b []byte, _ net.Addr) (int, error) { - if r.wc == nil { - return r.relay.WriteTo(b, r.peer) - } - out := make([]byte, wrapMaxWire(len(b))) - n, err := r.wc.wrapInto(out, b) - if err != nil { - return 0, err - } - if _, err = r.relay.WriteTo(out[:n], r.peer); err != nil { - return 0, err - } - return len(b), nil -} - -func (r *relayPacketConn) Close() error { return r.relay.Close() } -func (r *relayPacketConn) LocalAddr() net.Addr { return r.relay.LocalAddr() } -func (r *relayPacketConn) SetDeadline(t time.Time) error { return r.relay.SetDeadline(t) } -func (r *relayPacketConn) SetReadDeadline(t time.Time) error { return r.relay.SetReadDeadline(t) } -func (r *relayPacketConn) SetWriteDeadline(t time.Time) error { return r.relay.SetWriteDeadline(t) } - // pipe copies data bidirectionally between two connections. // It returns bytes copied as c1<-c2 and c2<-c1. func pipe(ctx context.Context, c1, c2 net.Conn) (int64, int64) { diff --git a/client/wrap.go b/client/wrap.go deleted file mode 100644 index d8efe80..0000000 --- a/client/wrap.go +++ /dev/null @@ -1,171 +0,0 @@ -// SPDX-License-Identifier: MIT - -package main - -import ( - "crypto/cipher" - "crypto/rand" - "encoding/binary" - "encoding/hex" - "errors" - "fmt" - "sync/atomic" - - "golang.org/x/crypto/chacha20poly1305" -) - -// Wire format — SRTP-like mimicry: -// -// [12B RTP header | 12B explicit nonce | AEAD ciphertext | 16B tag] -// -// RTP header (RFC 3550): -// -// byte 0: 0x80 V=2, P=0, X=0, CC=0 -// byte 1: 0x6F M=0, PT=111 (opus, typical voice PT) -// byte 2-3: seq16 BE monotonic, init random -// byte 4-7: ts32 BE monotonic, init random, increments by 960 (20ms @ 48kHz) -// byte 8-11: SSRC random per conn, MSB encodes direction -// -// 12B explicit nonce = 4B sessionID || 8B counter (BE). sessionID MSB -// matches SSRC MSB (direction bit). counter starts at a random uint64. -// AAD = first 24 bytes (RTP header || nonce). -// -// VK TURN appears to forward SRTP-shaped ChannelData on a fast path and -// drop anomalous payloads. AEAD ciphertext + 16B tag is plausible as -// AES-GCM SRTP per RFC 7714. - -const ( - wrapKeyLen = 32 - wrapRTPHdrLen = 12 - wrapNonceLen = 12 - wrapTagLen = 16 - wrapHeaderLen = wrapRTPHdrLen + wrapNonceLen // 24 - wrapOverhead = wrapHeaderLen + wrapTagLen // 40 - wrapRTPVersion = 0x80 // V=2, P=0, X=0, CC=0 - wrapRTPPT = 0x6F // M=0, PT=111 (opus) - wrapTSStep = 960 // 20ms @ 48kHz -) - -type wrapConn struct { - aead cipher.AEAD - sessionID [4]byte // 4B prefix for nonce; MSB encodes direction - ssrc [4]byte // SSRC for RTP header; MSB encodes direction - counter atomic.Uint64 - seq atomic.Uint32 // RTP sequence (used as uint16) - timestamp atomic.Uint32 // RTP timestamp -} - -func newWrapConn(key []byte, isServer bool) (*wrapConn, error) { - if len(key) != wrapKeyLen { - return nil, fmt.Errorf("wrap: key must be %d bytes (got %d)", wrapKeyLen, len(key)) - } - aead, err := chacha20poly1305.New(key) - if err != nil { - return nil, fmt.Errorf("wrap: aead init: %w", err) - } - w := &wrapConn{aead: aead} - - var rnd [16]byte - if _, err := rand.Read(rnd[:]); err != nil { - return nil, fmt.Errorf("wrap: rand init: %w", err) - } - copy(w.sessionID[:], rnd[0:4]) - copy(w.ssrc[:], rnd[4:8]) - if isServer { - w.sessionID[0] |= 0x80 - w.ssrc[0] |= 0x80 - } else { - w.sessionID[0] &^= 0x80 - w.ssrc[0] &^= 0x80 - } - w.seq.Store(uint32(binary.BigEndian.Uint16(rnd[8:10]))) - w.timestamp.Store(binary.BigEndian.Uint32(rnd[10:14])) - - var cb [8]byte - if _, err := rand.Read(cb[:]); err != nil { - return nil, fmt.Errorf("wrap: counter rand: %w", err) - } - w.counter.Store(binary.BigEndian.Uint64(cb[:])) - return w, nil -} - -// wrapMaxWire returns max wire bytes for a given payload size. -func wrapMaxWire(payloadLen int) int { - return wrapOverhead + payloadLen -} - -func (w *wrapConn) wrapInto(dst, payload []byte) (int, error) { - wireLen := wrapOverhead + len(payload) - if len(dst) < wireLen { - return 0, errors.New("wrap: dst buffer too small") - } - - // RTP header. - dst[0] = wrapRTPVersion - dst[1] = wrapRTPPT - seq := uint16(w.seq.Add(1) - 1) - binary.BigEndian.PutUint16(dst[2:4], seq) - ts := w.timestamp.Add(wrapTSStep) - wrapTSStep - binary.BigEndian.PutUint32(dst[4:8], ts) - copy(dst[8:12], w.ssrc[:]) - - // Explicit nonce. - noncePos := wrapRTPHdrLen - copy(dst[noncePos:noncePos+4], w.sessionID[:]) - ctr := w.counter.Add(1) - 1 - binary.BigEndian.PutUint64(dst[noncePos+4:noncePos+wrapNonceLen], ctr) - - nonce := dst[noncePos : noncePos+wrapNonceLen] - aad := dst[:wrapHeaderLen] - ctPos := wrapHeaderLen - copy(dst[ctPos:], payload) - w.aead.Seal(dst[ctPos:ctPos], nonce, dst[ctPos:ctPos+len(payload)], aad) - - return wireLen, nil -} - -func (w *wrapConn) unwrapPacket(wire, dst []byte) (int, error) { - if len(wire) < wrapOverhead { - return 0, errors.New("wrap: packet too short") - } - nonce := wire[wrapRTPHdrLen : wrapRTPHdrLen+wrapNonceLen] - aad := wire[:wrapHeaderLen] - ct := wire[wrapHeaderLen:] - - plain, err := w.aead.Open(ct[:0], nonce, ct, aad) - if err != nil { - return 0, fmt.Errorf("wrap: AEAD open: %w", err) - } - if len(plain) > len(dst) { - return 0, errors.New("wrap: dst buffer too small") - } - copy(dst[:len(plain)], plain) - return len(plain), nil -} - -// --- Helpers --- - -func genWrapKeyHex() (string, error) { - key := make([]byte, wrapKeyLen) - if _, err := rand.Read(key); err != nil { - return "", fmt.Errorf("wrap: key gen: %w", err) - } - return hex.EncodeToString(key), nil -} - -func decodeWrapKey(enabled bool, raw string) ([]byte, error) { - if !enabled { - return nil, nil - } - if raw == "" { - return nil, errors.New("-wrap requires -wrap-key") - } - key, err := hex.DecodeString(raw) - if err != nil { - return nil, fmt.Errorf("-wrap-key invalid hex: %w", err) - } - if len(key) != wrapKeyLen { - return nil, fmt.Errorf("-wrap-key must decode to %d bytes (got %d)", wrapKeyLen, len(key)) - } - return key, nil -} diff --git a/client/wrap_test.go b/client/wrap_test.go deleted file mode 100644 index 60151ab..0000000 --- a/client/wrap_test.go +++ /dev/null @@ -1,204 +0,0 @@ -package main - -import ( - "bytes" - "encoding/binary" - "strings" - "testing" -) - -func TestWrapConnRoundTrip(t *testing.T) { - key := bytes.Repeat([]byte{0x42}, wrapKeyLen) - payload := []byte("dtls record bytes") - - client, err := newWrapConn(key, false) - if err != nil { - t.Fatalf("newWrapConn(client): %v", err) - } - server, err := newWrapConn(key, true) - if err != nil { - t.Fatalf("newWrapConn(server): %v", err) - } - - wire := make([]byte, wrapMaxWire(len(payload))) - n, err := client.wrapInto(wire, payload) - if err != nil { - t.Fatalf("wrapInto: %v", err) - } - wire = wire[:n] - - if wire[0] != wrapRTPVersion { - t.Fatalf("RTP byte0 = 0x%02X, want 0x%02X", wire[0], wrapRTPVersion) - } - if wire[1] != wrapRTPPT { - t.Fatalf("RTP byte1 (PT) = 0x%02X, want 0x%02X", wire[1], wrapRTPPT) - } - if bytes.Contains(wire, payload) { - t.Fatalf("wrapped packet contains plaintext payload") - } - - dst := make([]byte, 1600) - m, err := server.unwrapPacket(wire, dst) - if err != nil { - t.Fatalf("unwrapPacket: %v", err) - } - if m != len(payload) { - t.Fatalf("unwrapped len = %d, want %d", m, len(payload)) - } - if !bytes.Equal(dst[:m], payload) { - t.Fatalf("round trip mismatch: got %q want %q", dst[:m], payload) - } - - // Server → Client - wire2 := make([]byte, wrapMaxWire(len(payload))) - n2, err := server.wrapInto(wire2, payload) - if err != nil { - t.Fatalf("server wrapInto: %v", err) - } - m2, err := client.unwrapPacket(wire2[:n2], dst) - if err != nil { - t.Fatalf("client unwrapPacket: %v", err) - } - if !bytes.Equal(dst[:m2], payload) { - t.Fatalf("server→client round trip mismatch") - } -} - -func TestWrapRTPHeaderProgression(t *testing.T) { - key := bytes.Repeat([]byte{0x42}, wrapKeyLen) - wc, err := newWrapConn(key, false) - if err != nil { - t.Fatalf("newWrapConn: %v", err) - } - payload := []byte("x") - - wire1 := make([]byte, wrapMaxWire(len(payload))) - n1, err := wc.wrapInto(wire1, payload) - if err != nil { - t.Fatalf("wrapInto 1: %v", err) - } - wire2 := make([]byte, wrapMaxWire(len(payload))) - n2, err := wc.wrapInto(wire2, payload) - if err != nil { - t.Fatalf("wrapInto 2: %v", err) - } - if n1 != n2 { - t.Fatalf("wire size variance: %d vs %d", n1, n2) - } - - seq1 := binary.BigEndian.Uint16(wire1[2:4]) - seq2 := binary.BigEndian.Uint16(wire2[2:4]) - if seq2 != seq1+1 { - t.Fatalf("seq did not increment: %d → %d", seq1, seq2) - } - - ts1 := binary.BigEndian.Uint32(wire1[4:8]) - ts2 := binary.BigEndian.Uint32(wire2[4:8]) - if ts2-ts1 != wrapTSStep { - t.Fatalf("timestamp step = %d, want %d", ts2-ts1, wrapTSStep) - } - - // SSRC stable across packets. - if !bytes.Equal(wire1[8:12], wire2[8:12]) { - t.Fatalf("SSRC changed between packets") - } -} - -func TestWrapDirectionBit(t *testing.T) { - key := bytes.Repeat([]byte{0x42}, wrapKeyLen) - client, err := newWrapConn(key, false) - if err != nil { - t.Fatalf("newWrapConn(client): %v", err) - } - server, err := newWrapConn(key, true) - if err != nil { - t.Fatalf("newWrapConn(server): %v", err) - } - - if client.sessionID[0]&0x80 != 0 { - t.Fatalf("client sessionID MSB should be 0, got 0x%02X", client.sessionID[0]) - } - if server.sessionID[0]&0x80 == 0 { - t.Fatalf("server sessionID MSB should be 1, got 0x%02X", server.sessionID[0]) - } - if client.ssrc[0]&0x80 != 0 { - t.Fatalf("client SSRC MSB should be 0, got 0x%02X", client.ssrc[0]) - } - if server.ssrc[0]&0x80 == 0 { - t.Fatalf("server SSRC MSB should be 1, got 0x%02X", server.ssrc[0]) - } -} - -func TestDecodeWrapKeyRequiresValidKeyWhenEnabled(t *testing.T) { - if key, err := decodeWrapKey(false, ""); err != nil || key != nil { - t.Fatalf("disabled decodeWrapKey = (%v, %v), want (nil, nil)", key, err) - } - - if _, err := decodeWrapKey(true, ""); err == nil { - t.Fatalf("decodeWrapKey accepted empty key") - } - - shortHex := strings.Repeat("ab", wrapKeyLen-1) - if _, err := decodeWrapKey(true, shortHex); err == nil { - t.Fatalf("decodeWrapKey accepted short key") - } - - fullHex := strings.Repeat("ab", wrapKeyLen) - key, err := decodeWrapKey(true, fullHex) - if err != nil { - t.Fatalf("decodeWrapKey returned error: %v", err) - } - if len(key) != wrapKeyLen { - t.Fatalf("decoded key len = %d, want %d", len(key), wrapKeyLen) - } -} - -func TestUnwrapRejectsShortPacket(t *testing.T) { - key := bytes.Repeat([]byte{0x42}, wrapKeyLen) - wc, err := newWrapConn(key, false) - if err != nil { - t.Fatalf("newWrapConn: %v", err) - } - if _, err := wc.unwrapPacket([]byte("short"), make([]byte, 16)); err == nil { - t.Fatalf("unwrapPacket accepted short packet") - } -} - -func TestUnwrapRejectsTamperedPacket(t *testing.T) { - key := bytes.Repeat([]byte{0x42}, wrapKeyLen) - client, err := newWrapConn(key, false) - if err != nil { - t.Fatalf("newWrapConn(client): %v", err) - } - server, err := newWrapConn(key, true) - if err != nil { - t.Fatalf("newWrapConn(server): %v", err) - } - - payload := []byte("integrity test") - wire := make([]byte, wrapMaxWire(len(payload))) - n, err := client.wrapInto(wire, payload) - if err != nil { - t.Fatalf("wrapInto: %v", err) - } - wire = wire[:n] - - // Flip a bit in the ciphertext. - wire[wrapHeaderLen+1] ^= 0xFF - - dst := make([]byte, 1600) - if _, uerr := server.unwrapPacket(wire, dst); uerr == nil { - t.Fatalf("unwrapPacket accepted tampered ciphertext") - } - - // Re-wrap and tamper RTP header (AAD). - n2, err := client.wrapInto(wire, payload) - if err != nil { - t.Fatalf("wrapInto: %v", err) - } - wire = wire[:n2] - wire[8] ^= 0x01 // flip a bit in SSRC - if _, uerr := server.unwrapPacket(wire, dst); uerr == nil { - t.Fatalf("unwrapPacket accepted tampered AAD") - } -} diff --git a/internal/wrap/listen.go b/internal/wrap/listen.go new file mode 100644 index 0000000..676d4a1 --- /dev/null +++ b/internal/wrap/listen.go @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: MIT + +package wrap + +import ( + "errors" + "fmt" + "net" + "sync" + "time" + + dtlsnet "github.com/pion/dtls/v3/pkg/net" + pionudp "github.com/pion/transport/v4/udp" +) + +// bufPool eliminates per-packet heap allocation on the hot read/write paths +// of the server-side wrapped PacketConn. +var bufPool = sync.Pool{ + New: func() any { + b := make([]byte, 1600+Overhead) + return &b + }, +} + +// Listen returns a dtls PacketListener that AEAD-wraps every accepted PacketConn. +func Listen(addr *net.UDPAddr, key []byte) (dtlsnet.PacketListener, error) { + state, err := NewState(key) + if err != nil { + return nil, err + } + inner, err := pionudp.Listen("udp", addr) + if err != nil { + return nil, fmt.Errorf("wrap: udp listen: %w", err) + } + return &packetListener{ + inner: dtlsnet.PacketListenerFromListener(inner), + state: state, + }, nil +} + +type packetListener struct { + inner dtlsnet.PacketListener + state *State +} + +func (l *packetListener) Accept() (net.PacketConn, net.Addr, error) { + pc, addr, err := l.inner.Accept() + if err != nil { + return pc, addr, err + } + conn, err := NewConnFromState(l.state, true) + if err != nil { + return nil, addr, err + } + return &packetConn{inner: pc, conn: conn}, addr, nil +} + +func (l *packetListener) Close() error { return l.inner.Close() } +func (l *packetListener) Addr() net.Addr { return l.inner.Addr() } + +// packetConn is a per-peer net.PacketConn that AEAD-wraps reads and writes +// using bufPool to keep the hot path allocation-free. +type packetConn struct { + inner net.PacketConn + conn *Conn +} + +func (c *packetConn) ReadFrom(p []byte) (int, net.Addr, error) { + bp := bufPool.Get().(*[]byte) //nolint:errcheck // pool New always returns *[]byte + buf := *bp + need := len(p) + Overhead + if cap(buf) < need { + buf = make([]byte, need) + *bp = buf + } + defer bufPool.Put(bp) + + n, addr, err := c.inner.ReadFrom(buf[:cap(buf)]) + if err != nil { + return 0, addr, err + } + wire := buf[:n] + if len(wire) < Overhead { + return 0, addr, errors.New("wrap: packet too short") + } + m, err := c.conn.Unwrap(wire, p) + if err != nil { + return 0, addr, err + } + return m, addr, nil +} + +func (c *packetConn) WriteTo(p []byte, addr net.Addr) (int, error) { + wireLen := Overhead + len(p) + + bp := bufPool.Get().(*[]byte) //nolint:errcheck // pool New always returns *[]byte + out := *bp + if cap(out) < wireLen { + out = make([]byte, wireLen) + *bp = out + } + out = out[:wireLen] + defer bufPool.Put(bp) + + n, err := c.conn.WrapInto(out, p) + if err != nil { + return 0, err + } + if _, err := c.inner.WriteTo(out[:n], addr); err != nil { + return 0, err + } + return len(p), nil +} + +func (c *packetConn) Close() error { return c.inner.Close() } +func (c *packetConn) LocalAddr() net.Addr { return c.inner.LocalAddr() } +func (c *packetConn) SetDeadline(t time.Time) error { return c.inner.SetDeadline(t) } +func (c *packetConn) SetReadDeadline(t time.Time) error { return c.inner.SetReadDeadline(t) } +func (c *packetConn) SetWriteDeadline(t time.Time) error { return c.inner.SetWriteDeadline(t) } diff --git a/internal/wrap/relay.go b/internal/wrap/relay.go new file mode 100644 index 0000000..aeeabe4 --- /dev/null +++ b/internal/wrap/relay.go @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT + +package wrap + +import ( + "net" + "time" +) + +// RelayPacketConn wraps a TURN relay PacketConn to direct all writes to a fixed peer. +// When Conn is non-nil, packets are wrapped/unwrapped with SRTP-mimicry AEAD. +type RelayPacketConn struct { + Relay net.PacketConn + Peer net.Addr + Conn *Conn +} + +func (r *RelayPacketConn) ReadFrom(b []byte) (int, net.Addr, error) { + if r.Conn == nil { + return r.Relay.ReadFrom(b) + } + buf := make([]byte, MaxWire(len(b))) + n, addr, err := r.Relay.ReadFrom(buf) + if err != nil { + return 0, addr, err + } + m, err := r.Conn.Unwrap(buf[:n], b) + if err != nil { + return 0, addr, err + } + return m, addr, nil +} + +func (r *RelayPacketConn) WriteTo(b []byte, _ net.Addr) (int, error) { + if r.Conn == nil { + return r.Relay.WriteTo(b, r.Peer) + } + out := make([]byte, MaxWire(len(b))) + n, err := r.Conn.WrapInto(out, b) + if err != nil { + return 0, err + } + if _, err = r.Relay.WriteTo(out[:n], r.Peer); err != nil { + return 0, err + } + return len(b), nil +} + +func (r *RelayPacketConn) Close() error { return r.Relay.Close() } +func (r *RelayPacketConn) LocalAddr() net.Addr { return r.Relay.LocalAddr() } +func (r *RelayPacketConn) SetDeadline(t time.Time) error { return r.Relay.SetDeadline(t) } +func (r *RelayPacketConn) SetReadDeadline(t time.Time) error { return r.Relay.SetReadDeadline(t) } +func (r *RelayPacketConn) SetWriteDeadline(t time.Time) error { return r.Relay.SetWriteDeadline(t) } diff --git a/internal/wrap/wrap.go b/internal/wrap/wrap.go new file mode 100644 index 0000000..15c56c1 --- /dev/null +++ b/internal/wrap/wrap.go @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: MIT + +// Package wrap implements an SRTP-mimicry AEAD framing used to bypass +// the VK TURN content filter. +// +// Wire format: +// +// [12B RTP header | 12B explicit nonce | AEAD ciphertext | 16B tag] +// +// RTP header (RFC 3550): +// +// byte 0: 0x80 V=2, P=0, X=0, CC=0 +// byte 1: 0x6F M=0, PT=111 (opus, typical voice PT) +// byte 2-3: seq16 BE monotonic, init random +// byte 4-7: ts32 BE monotonic, init random, increments by 960 (20ms @ 48kHz) +// byte 8-11: SSRC random per conn, MSB encodes direction +// +// 12B explicit nonce = 4B sessionID || 8B counter (BE). sessionID MSB +// matches SSRC MSB (direction bit). counter starts at a random uint64. +// AAD = first 24 bytes (RTP header || nonce). +// +// Wire format is frozen — bit-exact compatibility with deployed peers is required. +package wrap + +import ( + "crypto/cipher" + "crypto/rand" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "sync/atomic" + + "golang.org/x/crypto/chacha20poly1305" +) + +const ( + KeyLen = 32 + rtpHdrLen = 12 + nonceLen = 12 + tagLen = 16 + headerLen = rtpHdrLen + nonceLen // 24 + Overhead = headerLen + tagLen // 40 + rtpVersion = 0x80 // V=2, P=0, X=0, CC=0 + rtpPT = 0x6F // M=0, PT=111 (opus) + tsStep = 960 // 20ms @ 48kHz +) + +// MaxWire returns max wire bytes for a given payload size. +func MaxWire(payloadLen int) int { return Overhead + payloadLen } + +// State holds an AEAD instance derived from the shared key. +// One State can be shared across many Conns (e.g. server-side listener). +type State struct { + aead cipher.AEAD +} + +func NewState(key []byte) (*State, error) { + if len(key) != KeyLen { + return nil, fmt.Errorf("wrap: key must be %d bytes (got %d)", KeyLen, len(key)) + } + aead, err := chacha20poly1305.New(key) + if err != nil { + return nil, fmt.Errorf("wrap: aead init: %w", err) + } + return &State{aead: aead}, nil +} + +// Conn carries per-stream RTP state (seq/timestamp/SSRC/counter) plus a +// reference to the shared AEAD State. +type Conn struct { + state *State + sessionID [4]byte // 4B prefix for nonce; MSB encodes direction + ssrc [4]byte // SSRC for RTP header; MSB encodes direction + counter atomic.Uint64 + seq atomic.Uint32 // RTP sequence (used as uint16) + timestamp atomic.Uint32 // RTP timestamp +} + +// NewConn allocates a fresh State from key and a Conn bound to it. +func NewConn(key []byte, isServer bool) (*Conn, error) { + s, err := NewState(key) + if err != nil { + return nil, err + } + return NewConnFromState(s, isServer) +} + +// NewConnFromState builds a Conn with random per-stream RTP fields, +// reusing the supplied State. +func NewConnFromState(state *State, isServer bool) (*Conn, error) { + if state == nil { + return nil, errors.New("wrap: nil state") + } + c := &Conn{state: state} + + var rnd [16]byte + if _, err := rand.Read(rnd[:]); err != nil { + return nil, fmt.Errorf("wrap: rand init: %w", err) + } + copy(c.sessionID[:], rnd[0:4]) + copy(c.ssrc[:], rnd[4:8]) + if isServer { + c.sessionID[0] |= 0x80 + c.ssrc[0] |= 0x80 + } else { + c.sessionID[0] &^= 0x80 + c.ssrc[0] &^= 0x80 + } + c.seq.Store(uint32(binary.BigEndian.Uint16(rnd[8:10]))) + c.timestamp.Store(binary.BigEndian.Uint32(rnd[10:14])) + + var cb [8]byte + if _, err := rand.Read(cb[:]); err != nil { + return nil, fmt.Errorf("wrap: counter rand: %w", err) + } + c.counter.Store(binary.BigEndian.Uint64(cb[:])) + return c, nil +} + +// WrapInto encodes payload into dst (must be at least MaxWire(len(payload)) bytes) +// and returns the number of wire bytes written. +func (c *Conn) WrapInto(dst, payload []byte) (int, error) { + wireLen := Overhead + len(payload) + if len(dst) < wireLen { + return 0, errors.New("wrap: dst buffer too small") + } + + // RTP header. + dst[0] = rtpVersion + dst[1] = rtpPT + seq := uint16(c.seq.Add(1) - 1) + binary.BigEndian.PutUint16(dst[2:4], seq) + ts := c.timestamp.Add(tsStep) - tsStep + binary.BigEndian.PutUint32(dst[4:8], ts) + copy(dst[8:12], c.ssrc[:]) + + // Explicit nonce. + noncePos := rtpHdrLen + copy(dst[noncePos:noncePos+4], c.sessionID[:]) + ctr := c.counter.Add(1) - 1 + binary.BigEndian.PutUint64(dst[noncePos+4:noncePos+nonceLen], ctr) + + nonce := dst[noncePos : noncePos+nonceLen] + aad := dst[:headerLen] + ctPos := headerLen + copy(dst[ctPos:], payload) + c.state.aead.Seal(dst[ctPos:ctPos], nonce, dst[ctPos:ctPos+len(payload)], aad) + + return wireLen, nil +} + +// Unwrap decodes a wire packet into dst and returns the plaintext length. +func (c *Conn) Unwrap(wire, dst []byte) (int, error) { + if len(wire) < Overhead { + return 0, errors.New("wrap: packet too short") + } + nonce := wire[rtpHdrLen : rtpHdrLen+nonceLen] + aad := wire[:headerLen] + ct := wire[headerLen:] + + plain, err := c.state.aead.Open(ct[:0], nonce, ct, aad) + if err != nil { + return 0, fmt.Errorf("wrap: AEAD open: %w", err) + } + if len(plain) > len(dst) { + return 0, errors.New("wrap: dst buffer too small") + } + copy(dst[:len(plain)], plain) + return len(plain), nil +} + +// --- Key helpers --- + +// GenKeyHex returns a fresh hex-encoded random key. +func GenKeyHex() (string, error) { + key := make([]byte, KeyLen) + if _, err := rand.Read(key); err != nil { + return "", fmt.Errorf("wrap: key gen: %w", err) + } + return hex.EncodeToString(key), nil +} + +// DecodeKey decodes a hex key and validates length when enabled. +// When enabled is false, returns (nil, nil). +func DecodeKey(enabled bool, raw string) ([]byte, error) { + if !enabled { + return nil, nil + } + if raw == "" { + return nil, errors.New("-wrap requires -wrap-key") + } + key, err := hex.DecodeString(raw) + if err != nil { + return nil, fmt.Errorf("-wrap-key invalid hex: %w", err) + } + if len(key) != KeyLen { + return nil, fmt.Errorf("-wrap-key must decode to %d bytes (got %d)", KeyLen, len(key)) + } + return key, nil +} diff --git a/internal/wrap/wrap_test.go b/internal/wrap/wrap_test.go new file mode 100644 index 0000000..8e4c67c --- /dev/null +++ b/internal/wrap/wrap_test.go @@ -0,0 +1,200 @@ +package wrap + +import ( + "bytes" + "encoding/binary" + "strings" + "testing" +) + +func TestConnRoundTrip(t *testing.T) { + key := bytes.Repeat([]byte{0x42}, KeyLen) + payload := []byte("dtls record bytes") + + client, err := NewConn(key, false) + if err != nil { + t.Fatalf("NewConn(client): %v", err) + } + server, err := NewConn(key, true) + if err != nil { + t.Fatalf("NewConn(server): %v", err) + } + + wire := make([]byte, MaxWire(len(payload))) + n, err := client.WrapInto(wire, payload) + if err != nil { + t.Fatalf("WrapInto: %v", err) + } + wire = wire[:n] + + if wire[0] != rtpVersion { + t.Fatalf("RTP byte0 = 0x%02X, want 0x%02X", wire[0], rtpVersion) + } + if wire[1] != rtpPT { + t.Fatalf("RTP byte1 (PT) = 0x%02X, want 0x%02X", wire[1], rtpPT) + } + if bytes.Contains(wire, payload) { + t.Fatalf("wrapped packet contains plaintext payload") + } + + dst := make([]byte, 1600) + m, err := server.Unwrap(wire, dst) + if err != nil { + t.Fatalf("Unwrap: %v", err) + } + if m != len(payload) { + t.Fatalf("unwrapped len = %d, want %d", m, len(payload)) + } + if !bytes.Equal(dst[:m], payload) { + t.Fatalf("round trip mismatch: got %q want %q", dst[:m], payload) + } + + wire2 := make([]byte, MaxWire(len(payload))) + n2, err := server.WrapInto(wire2, payload) + if err != nil { + t.Fatalf("server WrapInto: %v", err) + } + m2, err := client.Unwrap(wire2[:n2], dst) + if err != nil { + t.Fatalf("client Unwrap: %v", err) + } + if !bytes.Equal(dst[:m2], payload) { + t.Fatalf("server→client round trip mismatch") + } +} + +func TestRTPHeaderProgression(t *testing.T) { + key := bytes.Repeat([]byte{0x42}, KeyLen) + c, err := NewConn(key, false) + if err != nil { + t.Fatalf("NewConn: %v", err) + } + payload := []byte("x") + + wire1 := make([]byte, MaxWire(len(payload))) + n1, err := c.WrapInto(wire1, payload) + if err != nil { + t.Fatalf("WrapInto 1: %v", err) + } + wire2 := make([]byte, MaxWire(len(payload))) + n2, err := c.WrapInto(wire2, payload) + if err != nil { + t.Fatalf("WrapInto 2: %v", err) + } + if n1 != n2 { + t.Fatalf("wire size variance: %d vs %d", n1, n2) + } + + seq1 := binary.BigEndian.Uint16(wire1[2:4]) + seq2 := binary.BigEndian.Uint16(wire2[2:4]) + if seq2 != seq1+1 { + t.Fatalf("seq did not increment: %d → %d", seq1, seq2) + } + + ts1 := binary.BigEndian.Uint32(wire1[4:8]) + ts2 := binary.BigEndian.Uint32(wire2[4:8]) + if ts2-ts1 != tsStep { + t.Fatalf("timestamp step = %d, want %d", ts2-ts1, tsStep) + } + + if !bytes.Equal(wire1[8:12], wire2[8:12]) { + t.Fatalf("SSRC changed between packets") + } +} + +func TestDirectionBit(t *testing.T) { + key := bytes.Repeat([]byte{0x42}, KeyLen) + client, err := NewConn(key, false) + if err != nil { + t.Fatalf("NewConn(client): %v", err) + } + server, err := NewConn(key, true) + if err != nil { + t.Fatalf("NewConn(server): %v", err) + } + + if client.sessionID[0]&0x80 != 0 { + t.Fatalf("client sessionID MSB should be 0, got 0x%02X", client.sessionID[0]) + } + if server.sessionID[0]&0x80 == 0 { + t.Fatalf("server sessionID MSB should be 1, got 0x%02X", server.sessionID[0]) + } + if client.ssrc[0]&0x80 != 0 { + t.Fatalf("client SSRC MSB should be 0, got 0x%02X", client.ssrc[0]) + } + if server.ssrc[0]&0x80 == 0 { + t.Fatalf("server SSRC MSB should be 1, got 0x%02X", server.ssrc[0]) + } +} + +func TestDecodeKeyRequiresValidKeyWhenEnabled(t *testing.T) { + if key, err := DecodeKey(false, ""); err != nil || key != nil { + t.Fatalf("disabled DecodeKey = (%v, %v), want (nil, nil)", key, err) + } + + if _, err := DecodeKey(true, ""); err == nil { + t.Fatalf("DecodeKey accepted empty key") + } + + shortHex := strings.Repeat("ab", KeyLen-1) + if _, err := DecodeKey(true, shortHex); err == nil { + t.Fatalf("DecodeKey accepted short key") + } + + fullHex := strings.Repeat("ab", KeyLen) + key, err := DecodeKey(true, fullHex) + if err != nil { + t.Fatalf("DecodeKey returned error: %v", err) + } + if len(key) != KeyLen { + t.Fatalf("decoded key len = %d, want %d", len(key), KeyLen) + } +} + +func TestUnwrapRejectsShortPacket(t *testing.T) { + key := bytes.Repeat([]byte{0x42}, KeyLen) + c, err := NewConn(key, false) + if err != nil { + t.Fatalf("NewConn: %v", err) + } + if _, err := c.Unwrap([]byte("short"), make([]byte, 16)); err == nil { + t.Fatalf("Unwrap accepted short packet") + } +} + +func TestUnwrapRejectsTamperedPacket(t *testing.T) { + key := bytes.Repeat([]byte{0x42}, KeyLen) + client, err := NewConn(key, false) + if err != nil { + t.Fatalf("NewConn(client): %v", err) + } + server, err := NewConn(key, true) + if err != nil { + t.Fatalf("NewConn(server): %v", err) + } + + payload := []byte("integrity test") + wire := make([]byte, MaxWire(len(payload))) + n, err := client.WrapInto(wire, payload) + if err != nil { + t.Fatalf("WrapInto: %v", err) + } + wire = wire[:n] + + wire[headerLen+1] ^= 0xFF + + dst := make([]byte, 1600) + if _, uerr := server.Unwrap(wire, dst); uerr == nil { + t.Fatalf("Unwrap accepted tampered ciphertext") + } + + n2, err := client.WrapInto(wire, payload) + if err != nil { + t.Fatalf("WrapInto: %v", err) + } + wire = wire[:n2] + wire[8] ^= 0x01 + if _, uerr := server.Unwrap(wire, dst); uerr == nil { + t.Fatalf("Unwrap accepted tampered AAD") + } +} diff --git a/server/main.go b/server/main.go index 9e34e0e..ef4c102 100644 --- a/server/main.go +++ b/server/main.go @@ -17,6 +17,7 @@ import ( "github.com/cacggghp/vk-turn-proxy/internal/bond" "github.com/cacggghp/vk-turn-proxy/internal/stats" + "github.com/cacggghp/vk-turn-proxy/internal/wrap" "github.com/cacggghp/vk-turn-proxy/tcputil" "github.com/pion/dtls/v3" "github.com/pion/dtls/v3/pkg/crypto/selfsign" @@ -44,7 +45,7 @@ func main() { isDebug = *debugFlag if *genWrapKey { - key := make([]byte, wrapKeyLen) + key := make([]byte, wrap.KeyLen) if _, err := rand.Read(key); err != nil { log.Panicf("gen-wrap-key: rand.Read: %v", err) } @@ -80,8 +81,8 @@ func main() { if err != nil { log.Panicf("-wrap-key invalid hex: %v", err) } - if len(wrapKey) != wrapKeyLen { - log.Panicf("-wrap-key must decode to %d bytes (got %d)", wrapKeyLen, len(wrapKey)) + if len(wrapKey) != wrap.KeyLen { + log.Panicf("-wrap-key must decode to %d bytes (got %d)", wrap.KeyLen, len(wrapKey)) } } log.Printf("Starting server listen=%s connect=%s vless=%t vless-bond=%t wrap=%t bond-autodetect=true", *listen, *connect, *vlessMode, *vlessBond, *wrapMode) @@ -104,7 +105,7 @@ func main() { var listener net.Listener if *wrapMode { log.Printf("WRAP mode enabled: listener only accepts clients with matching -wrap-key") - wrapListener, werr := listenWrapped(addr, wrapKey) + wrapListener, werr := wrap.Listen(addr, wrapKey) if werr != nil { panic(werr) } diff --git a/server/wrap.go b/server/wrap.go deleted file mode 100644 index 8bf60c1..0000000 --- a/server/wrap.go +++ /dev/null @@ -1,198 +0,0 @@ -// SPDX-License-Identifier: MIT - -package main - -import ( - "crypto/cipher" - "crypto/rand" - "encoding/binary" - "errors" - "fmt" - "net" - "sync" - "sync/atomic" - "time" - - dtlsnet "github.com/pion/dtls/v3/pkg/net" - pionudp "github.com/pion/transport/v4/udp" - "golang.org/x/crypto/chacha20poly1305" -) - -// Wire format is identical to client — see client/wrap.go. Server sets the -// MSB of sessionID/SSRC; client clears it. RTP header fields are per-conn. - -const ( - wrapKeyLen = 32 - wrapRTPHdrLen = 12 - wrapNonceLen = 12 - wrapTagLen = 16 - wrapHeaderLen = wrapRTPHdrLen + wrapNonceLen - wrapOverhead = wrapHeaderLen + wrapTagLen - wrapRTPVersion = 0x80 - wrapRTPPT = 0x6F - wrapTSStep = 960 -) - -// bufPool eliminates per-packet heap allocation on the hot read/write paths. -var bufPool = sync.Pool{ - New: func() any { - b := make([]byte, 1600+wrapOverhead) - return &b - }, -} - -// wrapState holds the AEAD instance shared by all connections under one key. -type wrapState struct { - aead cipher.AEAD -} - -func newWrapState(key []byte) (*wrapState, error) { - if len(key) != wrapKeyLen { - return nil, fmt.Errorf("wrap: key must be %d bytes (got %d)", wrapKeyLen, len(key)) - } - aead, err := chacha20poly1305.New(key) - if err != nil { - return nil, fmt.Errorf("wrap: aead init: %w", err) - } - return &wrapState{aead: aead}, nil -} - -// --- Listener --- - -func listenWrapped(addr *net.UDPAddr, key []byte) (dtlsnet.PacketListener, error) { - ws, err := newWrapState(key) - if err != nil { - return nil, err - } - inner, err := pionudp.Listen("udp", addr) - if err != nil { - return nil, fmt.Errorf("wrap: udp listen: %w", err) - } - return &wrapPacketListener{ - inner: dtlsnet.PacketListenerFromListener(inner), - ws: ws, - }, nil -} - -type wrapPacketListener struct { - inner dtlsnet.PacketListener - ws *wrapState -} - -func (l *wrapPacketListener) Accept() (net.PacketConn, net.Addr, error) { - pc, addr, err := l.inner.Accept() - if err != nil { - return pc, addr, err - } - c := &wrapPacketConn{inner: pc, ws: l.ws} - - var rnd [16]byte - if _, err := rand.Read(rnd[:]); err != nil { - return nil, addr, fmt.Errorf("wrap: rand init: %w", err) - } - copy(c.sessionID[:], rnd[0:4]) - copy(c.ssrc[:], rnd[4:8]) - c.sessionID[0] |= 0x80 - c.ssrc[0] |= 0x80 - c.seq.Store(uint32(binary.BigEndian.Uint16(rnd[8:10]))) - c.timestamp.Store(binary.BigEndian.Uint32(rnd[10:14])) - - var cb [8]byte - if _, err := rand.Read(cb[:]); err != nil { - return nil, addr, fmt.Errorf("wrap: counter rand: %w", err) - } - c.counter.Store(binary.BigEndian.Uint64(cb[:])) - return c, addr, nil -} - -func (l *wrapPacketListener) Close() error { return l.inner.Close() } -func (l *wrapPacketListener) Addr() net.Addr { return l.inner.Addr() } - -// --- Per-peer PacketConn --- - -type wrapPacketConn struct { - inner net.PacketConn - ws *wrapState - sessionID [4]byte - ssrc [4]byte - counter atomic.Uint64 - seq atomic.Uint32 - timestamp atomic.Uint32 -} - -func (c *wrapPacketConn) ReadFrom(p []byte) (int, net.Addr, error) { - bp := bufPool.Get().(*[]byte) //nolint:errcheck // pool New always returns *[]byte - buf := *bp - need := len(p) + wrapOverhead - if cap(buf) < need { - buf = make([]byte, need) - *bp = buf - } - defer bufPool.Put(bp) - - n, addr, err := c.inner.ReadFrom(buf[:cap(buf)]) - if err != nil { - return 0, addr, err - } - wire := buf[:n] - if len(wire) < wrapOverhead { - return 0, addr, errors.New("wrap: packet too short") - } - nonce := wire[wrapRTPHdrLen : wrapRTPHdrLen+wrapNonceLen] - aad := wire[:wrapHeaderLen] - ct := wire[wrapHeaderLen:] - - plain, err := c.ws.aead.Open(ct[:0], nonce, ct, aad) - if err != nil { - return 0, addr, fmt.Errorf("wrap: AEAD open: %w", err) - } - if len(plain) > len(p) { - return 0, addr, errors.New("wrap: dst buffer too small") - } - copy(p[:len(plain)], plain) - return len(plain), addr, nil -} - -func (c *wrapPacketConn) WriteTo(p []byte, addr net.Addr) (int, error) { - wireLen := wrapOverhead + len(p) - - bp := bufPool.Get().(*[]byte) //nolint:errcheck // pool New always returns *[]byte - out := *bp - if cap(out) < wireLen { - out = make([]byte, wireLen) - *bp = out - } - out = out[:wireLen] - defer bufPool.Put(bp) - - // RTP header. - out[0] = wrapRTPVersion - out[1] = wrapRTPPT - seq := uint16(c.seq.Add(1) - 1) - binary.BigEndian.PutUint16(out[2:4], seq) - ts := c.timestamp.Add(wrapTSStep) - wrapTSStep - binary.BigEndian.PutUint32(out[4:8], ts) - copy(out[8:12], c.ssrc[:]) - - noncePos := wrapRTPHdrLen - copy(out[noncePos:noncePos+4], c.sessionID[:]) - ctr := c.counter.Add(1) - 1 - binary.BigEndian.PutUint64(out[noncePos+4:noncePos+wrapNonceLen], ctr) - - nonce := out[noncePos : noncePos+wrapNonceLen] - aad := out[:wrapHeaderLen] - ctPos := wrapHeaderLen - copy(out[ctPos:], p) - c.ws.aead.Seal(out[ctPos:ctPos], nonce, out[ctPos:ctPos+len(p)], aad) - - if _, err := c.inner.WriteTo(out, addr); err != nil { - return 0, err - } - return len(p), nil -} - -func (c *wrapPacketConn) Close() error { return c.inner.Close() } -func (c *wrapPacketConn) LocalAddr() net.Addr { return c.inner.LocalAddr() } -func (c *wrapPacketConn) SetDeadline(t time.Time) error { return c.inner.SetDeadline(t) } -func (c *wrapPacketConn) SetReadDeadline(t time.Time) error { return c.inner.SetReadDeadline(t) } -func (c *wrapPacketConn) SetWriteDeadline(t time.Time) error { return c.inner.SetWriteDeadline(t) }