refactor: вынести wrap в internal/wrap

Объединяет client/wrap.go + server/wrap.go в internal/wrap с экспортируемым
API: State (общий AEAD), Conn (per-stream RTP-состояние), WrapInto/Unwrap,
Listen (серверный PacketListener), RelayPacketConn (клиентская обёртка
TURN-relay), хелперы GenKeyHex/DecodeKey. Wire-формат сохранён побитово.

relayPacketConn переезжает из client/main.go в пакет wrap как RelayPacketConn
(его единственный осмысленный дом — он владеет *wrap.Conn).

-573 строки (client/wrap.go, server/wrap.go, client/wrap_test.go удалены).
This commit is contained in:
samosvalishe
2026-05-19 09:28:24 +03:00
parent ada0c746aa
commit 322e8db4fa
9 changed files with 592 additions and 635 deletions
+14 -58
View File
@@ -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) {
-171
View File
@@ -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
}
-204
View File
@@ -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")
}
}
+119
View File
@@ -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) }
+53
View File
@@ -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) }
+201
View File
@@ -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
}
+200
View File
@@ -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")
}
}
+5 -4
View File
@@ -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)
}
-198
View File
@@ -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) }