Browse Source

feat: add TURN ChannelData obfuscation

Port the WRAP layer. Obfuscate DTLS packets before they enter TURN ChannelData with per-packet ChaCha20-XOR, and fail fast on invalid WRAP keys.

Co-Authored-By: Anton Monakhov <[email protected]>
pull/181/head
Moroka8 2 months ago
parent
commit
0f244412ec
  1. 2
      Dockerfile
  2. 97
      client/main.go
  3. 78
      client/wrap.go
  4. 66
      client/wrap_test.go
  5. 11
      docker-entrypoint.sh
  6. 47
      server/main.go
  7. 99
      server/wrap.go

2
Dockerfile

@ -13,7 +13,7 @@ WORKDIR /app
COPY docker-entrypoint.sh .
COPY --from=builder /build/vk-turn-proxy .
RUN chmod +x docker-entrypoint.sh
RUN sed -i 's/\r$//' docker-entrypoint.sh && chmod +x docker-entrypoint.sh
EXPOSE 56000/tcp
EXPOSE 56000/udp

97
client/main.go

@ -801,9 +801,10 @@ const (
cacheSafetyMargin = 60 * time.Second
maxCacheErrors = 3
errorWindow = 10 * time.Second
streamsPerCache = 10
)
var streamsPerCache = 10
func getCacheID(streamID int) int {
return streamID / streamsPerCache
}
@ -1742,6 +1743,7 @@ type turnParams struct {
port string
link string
udp bool
wrapKey []byte
getCreds getCredsFunc
}
@ -1875,6 +1877,7 @@ 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
useWrap := len(turnParams.wrapKey) == wrapKeyLen
go func() {
defer turncancel()
@ -1893,7 +1896,17 @@ func oneTurnConnection(ctx context.Context, turnParams *turnParams, peer *net.UD
internalPipeAddr.Store(addr1)
written, err1 := relayConn.WriteTo(buf[:n], peer)
out := buf[:n]
if useWrap {
wrapped, wrapErr := wrapPacket(turnParams.wrapKey, out)
if wrapErr != nil {
log.Printf("[STREAM %d] WRAP failed: %v", streamID, wrapErr)
return
}
out = wrapped
}
written, err1 := relayConn.WriteTo(out, peer)
stats.addTx(written)
if err1 != nil {
return
@ -1904,7 +1917,12 @@ func oneTurnConnection(ctx context.Context, turnParams *turnParams, peer *net.UD
go func() {
defer wg.Done()
defer turncancel()
buf := make([]byte, 1600)
readBufLen := 1600
if useWrap {
readBufLen += wrapNonceLen
}
buf := make([]byte, readBufLen)
plain := make([]byte, 1600)
for {
n, _, err1 := relayConn.ReadFrom(buf)
if err1 != nil {
@ -1916,8 +1934,17 @@ func oneTurnConnection(ctx context.Context, turnParams *turnParams, peer *net.UD
}
if addr, ok := addr1.(net.Addr); ok {
stats.addRx(n)
if _, err := conn2.WriteTo(buf[:n], addr); err != nil {
payload := buf[:n]
if useWrap {
m, wrapErr := unwrapPacket(turnParams.wrapKey, payload, plain)
if wrapErr != nil {
log.Printf("[STREAM %d] UNWRAP failed: %v (n=%d)", streamID, wrapErr, n)
continue
}
payload = plain[:m]
}
stats.addRx(len(payload))
if _, err := conn2.WriteTo(payload, addr); err != nil {
return
}
}
@ -2054,10 +2081,22 @@ func main() {
direct := flag.Bool("no-dtls", false, "connect without obfuscation. DO NOT USE")
vlessMode := flag.Bool("vless", false, "VLESS mode: forward TCP connections (for VLESS) instead of UDP packets")
vlessBond := flag.Bool("vless-bond", false, "bond one VLESS TCP connection across all active smux sessions")
wrapMode := flag.Bool("wrap", false, "WRAP mode: ChaCha20-XOR obfuscate DTLS packets before they reach TURN ChannelData")
wrapKeyHex := flag.String("wrap-key", "", "32-byte hex-encoded shared key for -wrap (64 hex chars)")
genWrapKey := flag.Bool("gen-wrap-key", false, "print a fresh 64-character hex key for -wrap-key and exit")
streamsPerCredFlag := flag.Int("streams-per-cred", streamsPerCache, "number of TURN streams sharing one VK credential cache")
debugFlag := flag.Bool("debug", false, "enable debug logging")
manualCaptchaFlag := flag.Bool("manual-captcha", false, "skip auto captcha solving, use manual mode immediately")
captchaSolverFlag := flag.String("captcha-solver", "v2", "auto captcha solver implementation: v1|v2")
flag.Parse()
if *genWrapKey {
key, err := genWrapKeyHex()
if err != nil {
log.Panicf("%v", err)
}
fmt.Println(key)
return
}
if *peerAddr == "" {
log.Panicf("Need peer address!")
}
@ -2068,6 +2107,20 @@ func main() {
if (*vklink == "") == (*yalink == "") {
log.Panicf("Need either vk-link or yandex-link!")
}
if *wrapMode && *direct {
log.Panicf("-wrap requires DTLS; remove -no-dtls")
}
wrapKey, err := decodeWrapKey(*wrapMode, *wrapKeyHex)
if err != nil {
log.Panicf("%v", err)
}
if *wrapMode {
log.Printf("WRAP mode enabled: peer server must use matching -wrap-key")
}
if *streamsPerCredFlag <= 0 {
log.Panicf("-streams-per-cred must be positive")
}
streamsPerCache = *streamsPerCredFlag
isDebug = *debugFlag
manualCaptcha = *manualCaptchaFlag
@ -2114,6 +2167,7 @@ func main() {
port: *port,
link: link,
udp: *udp,
wrapKey: wrapKey,
getCreds: getCreds,
}
@ -2856,7 +2910,7 @@ func createSmuxSession(ctx context.Context, tp *turnParams, peer *net.UDPAddr, i
cleanup()
return nil, nil, fmt.Errorf("generate cert: %w", err)
}
dtlsPC := &relayPacketConn{relay: relayConn, peer: peer}
dtlsPC := &relayPacketConn{relay: relayConn, peer: peer, wrapKey: tp.wrapKey}
dtlsConn, err := dtls.ClientWithOptions(dtlsPC, peer,
dtls.WithCertificates(certificate),
dtls.WithInsecureSkipVerify(true),
@ -2906,16 +2960,39 @@ func createSmuxSession(ctx context.Context, tp *turnParams, peer *net.UDPAddr, i
// relayPacketConn wraps a TURN relay PacketConn to direct all writes to the peer.
type relayPacketConn struct {
relay net.PacketConn
peer net.Addr
relay net.PacketConn
peer net.Addr
wrapKey []byte
}
func (r *relayPacketConn) ReadFrom(b []byte) (int, net.Addr, error) {
return r.relay.ReadFrom(b)
if len(r.wrapKey) != wrapKeyLen {
return r.relay.ReadFrom(b)
}
buf := make([]byte, len(b)+wrapNonceLen)
n, addr, err := r.relay.ReadFrom(buf)
if err != nil {
return 0, addr, err
}
m, err := unwrapPacket(r.wrapKey, buf[:n], b)
if err != nil {
return 0, addr, err
}
return m, addr, nil
}
func (r *relayPacketConn) WriteTo(b []byte, _ net.Addr) (int, error) {
return r.relay.WriteTo(b, r.peer)
if len(r.wrapKey) != wrapKeyLen {
return r.relay.WriteTo(b, r.peer)
}
out, err := wrapPacket(r.wrapKey, b)
if err != nil {
return 0, err
}
if _, err = r.relay.WriteTo(out, r.peer); err != nil {
return 0, err
}
return len(b), nil
}
func (r *relayPacketConn) Close() error { return r.relay.Close() }

78
client/wrap.go

@ -0,0 +1,78 @@
// SPDX-License-Identifier: MIT
package main
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"golang.org/x/crypto/chacha20"
)
const (
wrapNonceLen = 12
wrapKeyLen = 32
)
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
}
func wrapPacket(key, payload []byte) ([]byte, error) {
if len(key) != wrapKeyLen {
return nil, fmt.Errorf("wrap: key must be %d bytes (got %d)", wrapKeyLen, len(key))
}
out := make([]byte, wrapNonceLen+len(payload))
if _, err := rand.Read(out[:wrapNonceLen]); err != nil {
return nil, fmt.Errorf("wrap: nonce gen: %w", err)
}
cipher, err := chacha20.NewUnauthenticatedCipher(key, out[:wrapNonceLen])
if err != nil {
return nil, fmt.Errorf("wrap: cipher init: %w", err)
}
cipher.XORKeyStream(out[wrapNonceLen:], payload)
return out, nil
}
func unwrapPacket(key, wire, dst []byte) (int, error) {
if len(key) != wrapKeyLen {
return 0, fmt.Errorf("wrap: key must be %d bytes (got %d)", wrapKeyLen, len(key))
}
if len(wire) < wrapNonceLen {
return 0, errors.New("wrap: short packet (no nonce)")
}
nonce := wire[:wrapNonceLen]
ciphertext := wire[wrapNonceLen:]
if len(ciphertext) > len(dst) {
return 0, errors.New("wrap: dst buffer too small")
}
cipher, err := chacha20.NewUnauthenticatedCipher(key, nonce)
if err != nil {
return 0, fmt.Errorf("wrap: cipher init: %w", err)
}
cipher.XORKeyStream(dst[:len(ciphertext)], ciphertext)
return len(ciphertext), nil
}

66
client/wrap_test.go

@ -0,0 +1,66 @@
package main
import (
"bytes"
"strings"
"testing"
)
func TestWrapPacketRoundTrip(t *testing.T) {
key := bytes.Repeat([]byte{0x42}, wrapKeyLen)
payload := []byte("dtls record bytes")
wire, err := wrapPacket(key, payload)
if err != nil {
t.Fatalf("wrapPacket returned error: %v", err)
}
if len(wire) != len(payload)+wrapNonceLen {
t.Fatalf("wire len = %d, want %d", len(wire), len(payload)+wrapNonceLen)
}
if bytes.Contains(wire, payload) {
t.Fatalf("wrapped packet contains plaintext payload")
}
dst := make([]byte, len(payload))
n, err := unwrapPacket(key, wire, dst)
if err != nil {
t.Fatalf("unwrapPacket returned error: %v", err)
}
if n != len(payload) {
t.Fatalf("unwrapped len = %d, want %d", n, len(payload))
}
if !bytes.Equal(dst[:n], payload) {
t.Fatalf("round trip mismatch: got %q want %q", dst[:n], payload)
}
}
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 TestUnwrapPacketRejectsShortPacket(t *testing.T) {
key := bytes.Repeat([]byte{0x42}, wrapKeyLen)
if _, err := unwrapPacket(key, []byte("short"), make([]byte, 16)); err == nil {
t.Fatalf("unwrapPacket accepted short packet")
}
}

11
docker-entrypoint.sh

@ -2,6 +2,7 @@
set -e
CONNECT="${CONNECT_ADDR:?CONNECT_ADDR is required}"
LISTEN="${LISTEN_ADDR:-0.0.0.0:56000}"
VLESS_FLAG=""
if [ "${VLESS_MODE}" = "true" ]; then
@ -13,4 +14,12 @@ if [ "${VLESS_BOND}" = "true" ]; then
BOND_FLAG="-vless-bond"
fi
exec ./vk-turn-proxy -listen 0.0.0.0:56000 -connect "$CONNECT" $VLESS_FLAG $BOND_FLAG
WRAP_FLAG=""
WRAP_KEY_FLAG=""
if [ "${WRAP_MODE}" = "true" ]; then
WRAP="${WRAP_KEY:?WRAP_KEY is required when WRAP_MODE=true}"
WRAP_FLAG="-wrap"
WRAP_KEY_FLAG="-wrap-key $WRAP"
fi
exec ./vk-turn-proxy -listen "$LISTEN" -connect "$CONNECT" $VLESS_FLAG $BOND_FLAG $WRAP_FLAG $WRAP_KEY_FLAG

47
server/main.go

@ -2,7 +2,9 @@ package main
import (
"context"
"crypto/rand"
"encoding/binary"
"encoding/hex"
"flag"
"fmt"
"io"
@ -26,8 +28,20 @@ func main() {
connect := flag.String("connect", "", "connect to ip:port")
vlessMode := flag.Bool("vless", false, "VLESS mode: forward TCP connections (for VLESS) instead of UDP packets")
vlessBond := flag.Bool("vless-bond", false, "bond one VLESS TCP connection across all active smux sessions")
wrapMode := flag.Bool("wrap", false, "WRAP mode: ChaCha20-XOR obfuscate DTLS packets before they reach TURN ChannelData")
wrapKeyHex := flag.String("wrap-key", "", "32-byte hex-encoded shared key for -wrap (64 hex chars)")
genWrapKey := flag.Bool("gen-wrap-key", false, "print a fresh 64-character hex key for -wrap-key and exit")
flag.Parse()
if *genWrapKey {
key := make([]byte, wrapKeyLen)
if _, err := rand.Read(key); err != nil {
log.Panicf("gen-wrap-key: rand.Read: %v", err)
}
fmt.Println(hex.EncodeToString(key))
return
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
signalChan := make(chan os.Signal, 1)
@ -47,7 +61,20 @@ func main() {
if len(*connect) == 0 {
log.Panicf("server address is required")
}
log.Printf("Starting server listen=%s connect=%s vless=%t vless-bond=%t bond-autodetect=true", *listen, *connect, *vlessMode, *vlessBond)
var wrapKey []byte
if *wrapMode {
if *wrapKeyHex == "" {
log.Panicf("-wrap requires -wrap-key")
}
wrapKey, err = hex.DecodeString(*wrapKeyHex)
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))
}
}
log.Printf("Starting server listen=%s connect=%s vless=%t vless-bond=%t wrap=%t bond-autodetect=true", *listen, *connect, *vlessMode, *vlessBond, *wrapMode)
// Generate a certificate and private key to secure the connection
certificate, genErr := selfsign.GenerateSelfSigned()
if genErr != nil {
@ -58,15 +85,23 @@ func main() {
// Everything below is the pion-DTLS API! Thanks for using it ❤️.
//
// Connect to a DTLS server
listener, err := dtls.ListenWithOptions(
"udp",
addr,
dtlsOpts := []dtls.ServerOption{
dtls.WithCertificates(certificate),
dtls.WithExtendedMasterSecret(dtls.RequireExtendedMasterSecret),
dtls.WithCipherSuites(dtls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256),
dtls.WithConnectionIDGenerator(dtls.RandomCIDGenerator(8)),
)
}
var listener net.Listener
if *wrapMode {
log.Printf("WRAP mode enabled: listener only accepts clients with matching -wrap-key")
wrapListener, werr := listenWrapped(addr, wrapKey)
if werr != nil {
panic(werr)
}
listener, err = dtls.NewListenerWithOptions(wrapListener, dtlsOpts...)
} else {
listener, err = dtls.ListenWithOptions("udp", addr, dtlsOpts...)
}
if err != nil {
panic(err)
}

99
server/wrap.go

@ -0,0 +1,99 @@
// SPDX-License-Identifier: MIT
package main
import (
"crypto/rand"
"errors"
"fmt"
"net"
"time"
dtlsnet "github.com/pion/dtls/v3/pkg/net"
pionudp "github.com/pion/transport/v4/udp"
"golang.org/x/crypto/chacha20"
)
const (
wrapNonceLen = 12
wrapKeyLen = 32
)
func listenWrapped(addr *net.UDPAddr, key []byte) (dtlsnet.PacketListener, error) {
if len(key) != wrapKeyLen {
return nil, fmt.Errorf("wrap: key must be %d bytes (got %d)", wrapKeyLen, len(key))
}
inner, err := pionudp.Listen("udp", addr)
if err != nil {
return nil, fmt.Errorf("wrap: udp listen: %w", err)
}
return &wrapPacketListener{
inner: dtlsnet.PacketListenerFromListener(inner),
key: key,
}, nil
}
type wrapPacketListener struct {
inner dtlsnet.PacketListener
key []byte
}
func (l *wrapPacketListener) Accept() (net.PacketConn, net.Addr, error) {
pc, addr, err := l.inner.Accept()
if err != nil {
return pc, addr, err
}
return &wrapPacketConn{inner: pc, key: l.key}, addr, nil
}
func (l *wrapPacketListener) Close() error { return l.inner.Close() }
func (l *wrapPacketListener) Addr() net.Addr { return l.inner.Addr() }
type wrapPacketConn struct {
inner net.PacketConn
key []byte
}
func (c *wrapPacketConn) ReadFrom(p []byte) (int, net.Addr, error) {
buf := make([]byte, len(p)+wrapNonceLen)
n, addr, err := c.inner.ReadFrom(buf)
if err != nil {
return 0, addr, err
}
if n < wrapNonceLen {
return 0, addr, errors.New("wrap: short packet (no nonce)")
}
nonce := buf[:wrapNonceLen]
ciphertext := buf[wrapNonceLen:n]
if len(ciphertext) > len(p) {
return 0, addr, errors.New("wrap: read buffer too small")
}
cipher, err := chacha20.NewUnauthenticatedCipher(c.key, nonce)
if err != nil {
return 0, addr, fmt.Errorf("wrap: cipher init: %w", err)
}
cipher.XORKeyStream(p[:len(ciphertext)], ciphertext)
return len(ciphertext), addr, nil
}
func (c *wrapPacketConn) WriteTo(p []byte, addr net.Addr) (int, error) {
out := make([]byte, wrapNonceLen+len(p))
if _, err := rand.Read(out[:wrapNonceLen]); err != nil {
return 0, fmt.Errorf("wrap: nonce gen: %w", err)
}
cipher, err := chacha20.NewUnauthenticatedCipher(c.key, out[:wrapNonceLen])
if err != nil {
return 0, fmt.Errorf("wrap: cipher init: %w", err)
}
cipher.XORKeyStream(out[wrapNonceLen:], p)
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) }
Loading…
Cancel
Save