From 729557d00d3f7b16e4d1d876adca23404eff738c Mon Sep 17 00:00:00 2001 From: samosvalishe Date: Mon, 18 May 2026 20:56:24 +0300 Subject: [PATCH] =?UTF-8?q?refactor(wrap):=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B9=D1=82=D0=B8=20=D0=BD=D0=B0=20=D0=BC=D0=B8=D0=BC=D0=B8?= =?UTF-8?q?=D0=BA=D1=80=D0=B8=D1=8E=20=D0=BF=D0=BE=D0=B4=20SRTP=20=D0=B2?= =?UTF-8?q?=20=D0=BE=D0=B1=D1=85=D0=BE=D0=B4=20content-=D1=84=D0=B8=D0=BB?= =?UTF-8?q?=D1=8C=D1=82=D1=80=D0=B0=20VK=20TURN?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- client/main.go | 2 +- client/wrap.go | 107 ++++++++++++++++++++++++-------------------- client/wrap_test.go | 75 ++++++++++++++++++++++--------- server/wrap.go | 77 +++++++++++++++++-------------- 4 files changed, 157 insertions(+), 104 deletions(-) diff --git a/client/main.go b/client/main.go index c327359..912006a 100644 --- a/client/main.go +++ b/client/main.go @@ -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 diff --git a/client/wrap.go b/client/wrap.go index 26379e5..d8efe80 100644 --- a/client/wrap.go +++ b/client/wrap.go @@ -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) } diff --git a/client/wrap_test.go b/client/wrap_test.go index a2ad9d5..a7a561b 100644 --- a/client/wrap_test.go +++ b/client/wrap_test.go @@ -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") } } diff --git a/server/wrap.go b/server/wrap.go index 81b43be..8bf60c1 100644 --- a/server/wrap.go +++ b/server/wrap.go @@ -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