refactor(wrap): перейти на мимикрию под SRTP в обход content-фильтра VK TURN

Wire-формат noise-AEAD (1b7cb4c) был реализован верно, но перестал доставлять
трафик через VK TURN после auth: ChannelData payload, не похожий по форме на
SRTP/DTLS, похоже, дропается VK TURN после недавнего изменения на стороне
сервера.

Новый wire-формат мимикрирует под SRTP (RFC 3550 / RFC 7714 AEAD-профиль):

  [12B RTP header | 12B nonce | AEAD ciphertext | 16B tag]

RTP header:
  byte 0:   0x80          V=2, P=0, X=0, CC=0
  byte 1:   0x6F          M=0, PT=111 (opus, частый VK voice PT)
  byte 2-3: seq16 BE      монотонный, init random
  byte 4-7: ts32 BE       монотонный +960 на пакет (20ms @ 48kHz)
  byte 8-11: SSRC         случайный на conn, MSB кодирует направление

Nonce = 4B sessionID (MSB=направление) || 8B counter (init random).
AAD = первые 24 байта, так что любое искажение RTP-заголовка ломает Open.

Overhead 40 B/пакет против 36 у noise-AEAD и 12 у исходного XOR. Если VK TURN
форвардит SRTP-образный трафик по быстрому пути, повышенный overhead окупается
отсутствием медленного shaping.

Тесты: round-trip, прогрессия RTP-полей, бит направления, искажение AAD.
This commit is contained in:
samosvalishe
2026-05-18 20:56:24 +03:00
parent 1506dafe3c
commit 729557d00d
4 changed files with 157 additions and 104 deletions
+1 -1
View File
@@ -2222,7 +2222,7 @@ func createSmuxSession(ctx context.Context, tp *turnParams, peer *net.UDPAddr, i
}
// relayPacketConn wraps a TURN relay PacketConn to direct all writes to the peer.
// When wc != nil, packets are wrapped/unwrapped with AEAD noise obfuscation.
// When wc != nil, packets are wrapped/unwrapped with SRTP-mimicry AEAD.
type relayPacketConn struct {
relay net.PacketConn
peer net.Addr
+59 -48
View File
@@ -14,35 +14,45 @@ import (
"golang.org/x/crypto/chacha20poly1305"
)
// Wire format — noise-only AEAD (no protocol mimicry):
// Wire format — SRTP-like mimicry:
//
// [1B header | (prefix_len-1)B random | 12B nonce | AEAD ciphertext | 16B tag]
// [12B RTP header | 12B explicit nonce | AEAD ciphertext | 16B tag]
//
// prefix_len = 1 + (header & 0x07), so byte 0 is uniformly random across all
// 256 values while encoding 1..8 bytes of leading random padding. There is
// no fixed byte, no version, no length field — everything past the header
// byte is indistinguishable from random to a passive observer.
// RTP header (RFC 3550):
//
// Nonce (12B) = 4B sessionID || 8B counter (big-endian). sessionID has its
// MSB set on server side, cleared on client side, preventing nonce reuse
// between directions under the same key. counter starts at a random uint64
// to avoid reuse across process restarts.
// 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
//
// AAD = nonce. The random prefix is not authenticated (it carries no
// information).
// 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
wrapNonceLen = 12
wrapTagLen = 16
wrapPrefixMin = 1
wrapPrefixMax = 8
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
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) {
@@ -54,14 +64,23 @@ func newWrapConn(key []byte, isServer bool) (*wrapConn, error) {
return nil, fmt.Errorf("wrap: aead init: %w", err)
}
w := &wrapConn{aead: aead}
if _, err := rand.Read(w.sessionID[:]); err != nil {
return nil, fmt.Errorf("wrap: sessionID rand: %w", err)
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)
@@ -72,56 +91,48 @@ func newWrapConn(key []byte, isServer bool) (*wrapConn, error) {
// wrapMaxWire returns max wire bytes for a given payload size.
func wrapMaxWire(payloadLen int) int {
return wrapPrefixMax + wrapNonceLen + payloadLen + wrapTagLen
return wrapOverhead + payloadLen
}
func (w *wrapConn) wrapInto(dst, payload []byte) (int, error) {
// Header + prefix length.
var hdr [1]byte
if _, err := rand.Read(hdr[:]); err != nil {
return 0, fmt.Errorf("wrap: prefix rand: %w", err)
}
prefixLen := wrapPrefixMin + int(hdr[0]&0x07) // 1..8
wireLen := prefixLen + wrapNonceLen + len(payload) + wrapTagLen
wireLen := wrapOverhead + len(payload)
if len(dst) < wireLen {
return 0, errors.New("wrap: dst buffer too small")
}
dst[0] = hdr[0]
if prefixLen > 1 {
if _, err := rand.Read(dst[1:prefixLen]); err != nil {
return 0, fmt.Errorf("wrap: prefix rand: %w", err)
}
}
// 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[:])
// Nonce: 4B sessionID || 8B counter.
noncePos := prefixLen
// 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]
ctPos := noncePos + wrapNonceLen
// In-place: write plaintext at ctPos, then Seal over it.
aad := dst[:wrapHeaderLen]
ctPos := wrapHeaderLen
copy(dst[ctPos:], payload)
w.aead.Seal(dst[ctPos:ctPos], nonce, dst[ctPos:ctPos+len(payload)], nonce)
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) < wrapPrefixMin+wrapNonceLen+wrapTagLen {
if len(wire) < wrapOverhead {
return 0, errors.New("wrap: packet too short")
}
prefixLen := wrapPrefixMin + int(wire[0]&0x07)
if len(wire) < prefixLen+wrapNonceLen+wrapTagLen {
return 0, errors.New("wrap: packet too short for prefix")
}
nonce := wire[prefixLen : prefixLen+wrapNonceLen]
ct := wire[prefixLen+wrapNonceLen:]
nonce := wire[wrapRTPHdrLen : wrapRTPHdrLen+wrapNonceLen]
aad := wire[:wrapHeaderLen]
ct := wire[wrapHeaderLen:]
plain, err := w.aead.Open(ct[:0], nonce, ct, nonce)
plain, err := w.aead.Open(ct[:0], nonce, ct, aad)
if err != nil {
return 0, fmt.Errorf("wrap: AEAD open: %w", err)
}
+54 -21
View File
@@ -2,6 +2,7 @@ package main
import (
"bytes"
"encoding/binary"
"strings"
"testing"
)
@@ -19,7 +20,6 @@ func TestWrapConnRoundTrip(t *testing.T) {
t.Fatalf("newWrapConn(server): %v", err)
}
// Client → Server
wire := make([]byte, wrapMaxWire(len(payload)))
n, err := client.wrapInto(wire, payload)
if err != nil {
@@ -27,6 +27,12 @@ func TestWrapConnRoundTrip(t *testing.T) {
}
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")
}
@@ -58,7 +64,7 @@ func TestWrapConnRoundTrip(t *testing.T) {
}
}
func TestWrapPrefixLenInRange(t *testing.T) {
func TestWrapRTPHeaderProgression(t *testing.T) {
key := bytes.Repeat([]byte{0x42}, wrapKeyLen)
wc, err := newWrapConn(key, false)
if err != nil {
@@ -66,21 +72,35 @@ func TestWrapPrefixLenInRange(t *testing.T) {
}
payload := []byte("x")
// Run many trials; every prefix_len must fall in [1..8].
for range 200 {
wire := make([]byte, wrapMaxWire(len(payload)))
n, err := wc.wrapInto(wire, payload)
if err != nil {
t.Fatalf("wrapInto: %v", err)
}
prefixLen := wrapPrefixMin + int(wire[0]&0x07)
if prefixLen < 1 || prefixLen > 8 {
t.Fatalf("prefix_len out of range: %d", prefixLen)
}
expected := prefixLen + wrapNonceLen + len(payload) + wrapTagLen
if n != expected {
t.Fatalf("wire size mismatch: got %d want %d (prefix_len=%d)", n, expected, prefixLen)
}
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")
}
}
@@ -101,6 +121,12 @@ func TestWrapDirectionBit(t *testing.T) {
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) {
@@ -157,12 +183,19 @@ func TestUnwrapRejectsTamperedPacket(t *testing.T) {
}
wire = wire[:n]
// Flip a bit in the ciphertext (past prefix+nonce).
prefixLen := wrapPrefixMin + int(wire[0]&0x07)
wire[prefixLen+wrapNonceLen+1] ^= 0xFF
// Flip a bit in the ciphertext.
wire[wrapHeaderLen+1] ^= 0xFF
dst := make([]byte, 1600)
if _, err := server.unwrapPacket(wire, dst); err == nil {
t.Fatalf("unwrapPacket accepted tampered packet")
t.Fatalf("unwrapPacket accepted tampered ciphertext")
}
// Re-wrap and tamper RTP header (AAD).
n2, _ := client.wrapInto(wire, payload)
wire = wire[:n2]
wire[8] ^= 0x01 // flip a bit in SSRC
if _, err := server.unwrapPacket(wire, dst); err == nil {
t.Fatalf("unwrapPacket accepted tampered AAD")
}
}
+43 -34
View File
@@ -19,20 +19,24 @@ import (
)
// Wire format is identical to client — see client/wrap.go. Server sets the
// MSB of sessionID; client clears it. Counter init is random per process.
// MSB of sessionID/SSRC; client clears it. RTP header fields are per-conn.
const (
wrapKeyLen = 32
wrapNonceLen = 12
wrapTagLen = 16
wrapPrefixMin = 1
wrapPrefixMax = 8
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+wrapPrefixMax+wrapNonceLen+wrapTagLen)
b := make([]byte, 1600+wrapOverhead)
return &b
},
}
@@ -81,11 +85,18 @@ func (l *wrapPacketListener) Accept() (net.PacketConn, net.Addr, error) {
return pc, addr, err
}
c := &wrapPacketConn{inner: pc, ws: l.ws}
// Per-connection sessionID (server MSB=1) + random counter init.
if _, err := rand.Read(c.sessionID[:]); err != nil {
return nil, addr, fmt.Errorf("wrap: sessionID rand: %w", err)
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)
@@ -103,13 +114,16 @@ 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) + wrapPrefixMax + wrapNonceLen + wrapTagLen
need := len(p) + wrapOverhead
if cap(buf) < need {
buf = make([]byte, need)
*bp = buf
@@ -121,17 +135,14 @@ func (c *wrapPacketConn) ReadFrom(p []byte) (int, net.Addr, error) {
return 0, addr, err
}
wire := buf[:n]
if len(wire) < wrapPrefixMin+wrapNonceLen+wrapTagLen {
if len(wire) < wrapOverhead {
return 0, addr, errors.New("wrap: packet too short")
}
prefixLen := wrapPrefixMin + int(wire[0]&0x07)
if len(wire) < prefixLen+wrapNonceLen+wrapTagLen {
return 0, addr, errors.New("wrap: packet too short for prefix")
}
nonce := wire[prefixLen : prefixLen+wrapNonceLen]
ct := wire[prefixLen+wrapNonceLen:]
nonce := wire[wrapRTPHdrLen : wrapRTPHdrLen+wrapNonceLen]
aad := wire[:wrapHeaderLen]
ct := wire[wrapHeaderLen:]
plain, err := c.ws.aead.Open(ct[:0], nonce, ct, nonce)
plain, err := c.ws.aead.Open(ct[:0], nonce, ct, aad)
if err != nil {
return 0, addr, fmt.Errorf("wrap: AEAD open: %w", err)
}
@@ -143,12 +154,7 @@ func (c *wrapPacketConn) ReadFrom(p []byte) (int, net.Addr, error) {
}
func (c *wrapPacketConn) WriteTo(p []byte, addr net.Addr) (int, error) {
var hdr [1]byte
if _, err := rand.Read(hdr[:]); err != nil {
return 0, fmt.Errorf("wrap: prefix rand: %w", err)
}
prefixLen := wrapPrefixMin + int(hdr[0]&0x07)
wireLen := prefixLen + wrapNonceLen + len(p) + wrapTagLen
wireLen := wrapOverhead + len(p)
bp := bufPool.Get().(*[]byte) //nolint:errcheck // pool New always returns *[]byte
out := *bp
@@ -159,22 +165,25 @@ func (c *wrapPacketConn) WriteTo(p []byte, addr net.Addr) (int, error) {
out = out[:wireLen]
defer bufPool.Put(bp)
out[0] = hdr[0]
if prefixLen > 1 {
if _, err := rand.Read(out[1:prefixLen]); err != nil {
return 0, fmt.Errorf("wrap: prefix rand: %w", err)
}
}
// 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 := prefixLen
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]
ctPos := noncePos + wrapNonceLen
aad := out[:wrapHeaderLen]
ctPos := wrapHeaderLen
copy(out[ctPos:], p)
c.ws.aead.Seal(out[ctPos:ctPos], nonce, out[ctPos:ctPos+len(p)], nonce)
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