231 lines
6.3 KiB
Go
231 lines
6.3 KiB
Go
// SPDX-License-Identifier: MIT
|
|
// obfs.go — WebRTC SRTP-like obfuscation for DTLS traffic
|
|
// Each UDP packet is wrapped in an RTP header making it indistinguishable
|
|
// from a real WebRTC OPUS audio stream to DPI systems.
|
|
//
|
|
// Packet format:
|
|
// [RTP Header 12 bytes][ChaCha20-Poly1305 payload+tag][Padding 0-N bytes][PadLen 1 byte]
|
|
//
|
|
// The RTP header fields (SSRC + SeqNum + Timestamp) form the 12-byte AEAD nonce,
|
|
// so no separate nonce prefix is needed.
|
|
|
|
package main
|
|
|
|
import (
|
|
"crypto/cipher"
|
|
"crypto/rand"
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"sync"
|
|
|
|
"golang.org/x/crypto/chacha20poly1305"
|
|
)
|
|
|
|
var aeadCache sync.Map
|
|
|
|
func getAEAD(key []byte) (cipher.AEAD, error) {
|
|
if len(key) != wrapKeyLen {
|
|
return nil, fmt.Errorf("obfs: key must be %d bytes", wrapKeyLen)
|
|
}
|
|
keyStr := string(key)
|
|
if val, ok := aeadCache.Load(keyStr); ok {
|
|
return val.(cipher.AEAD), nil
|
|
}
|
|
aead, err := chacha20poly1305.New(key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
aeadCache.Store(keyStr, aead)
|
|
return aead, nil
|
|
}
|
|
|
|
// ─── Configuration ───
|
|
|
|
// ObfsConfig holds per-session obfuscation parameters.
|
|
type ObfsConfig struct {
|
|
SSRC uint32 // Synchronization Source — random per session
|
|
PayloadType uint8 // RTP payload type (111 = OPUS dynamic)
|
|
PaddingMax int // Max random padding bytes appended
|
|
}
|
|
|
|
// NewObfsConfig creates a config with random SSRC and sane defaults.
|
|
func NewObfsConfig() *ObfsConfig {
|
|
var buf [4]byte
|
|
rand.Read(buf[:])
|
|
return &ObfsConfig{
|
|
SSRC: binary.BigEndian.Uint32(buf[:]),
|
|
PayloadType: 111, // dynamic PT for OPUS
|
|
PaddingMax: 24,
|
|
}
|
|
}
|
|
|
|
// ─── Per-direction state (sequence + timestamp counters) ───
|
|
|
|
// ObfsState tracks monotonically increasing RTP sequence number and timestamp using a 48-bit packet counter.
|
|
type ObfsState struct {
|
|
mu sync.Mutex
|
|
initSeq uint16
|
|
initTs uint32
|
|
count uint64
|
|
}
|
|
|
|
// NewObfsState creates a state with random initial seq/ts and count=0.
|
|
func NewObfsState() *ObfsState {
|
|
var buf [6]byte
|
|
rand.Read(buf[:])
|
|
return &ObfsState{
|
|
initSeq: binary.BigEndian.Uint16(buf[0:2]),
|
|
initTs: binary.BigEndian.Uint32(buf[2:6]),
|
|
count: 0,
|
|
}
|
|
}
|
|
|
|
// ─── Nonce derivation ───
|
|
|
|
// obfsBuildNonce deterministically builds a 12-byte AEAD nonce from RTP fields.
|
|
//
|
|
// [SSRC 4B][SeqNum 2B][0x00 0x00][Timestamp 4B]
|
|
func obfsBuildNonce(ssrc uint32, seq uint16, ts uint32) []byte {
|
|
n := make([]byte, 12)
|
|
binary.BigEndian.PutUint32(n[0:4], ssrc)
|
|
binary.BigEndian.PutUint16(n[4:6], seq)
|
|
// n[6], n[7] = 0x00 — zero padding for unique nonce space
|
|
binary.BigEndian.PutUint32(n[8:12], ts)
|
|
return n
|
|
}
|
|
|
|
// ─── Wrap (encrypt + add RTP header) ───
|
|
|
|
// obfsWrapPacket wraps a plaintext payload into an RTP-like packet with authenticated encryption.
|
|
// The output looks like:
|
|
//
|
|
// [V=2,P=1,X=0,CC=0 | PT | SeqNum | Timestamp | SSRC | encrypted_payload | padding | padLen]
|
|
func obfsWrapPacket(key, payload []byte, cfg *ObfsConfig, state *ObfsState) ([]byte, error) {
|
|
if len(key) != wrapKeyLen {
|
|
return nil, fmt.Errorf("obfs: key must be %d bytes (got %d)", wrapKeyLen, len(key))
|
|
}
|
|
if len(payload) == 0 {
|
|
return nil, errors.New("obfs: empty payload")
|
|
}
|
|
|
|
state.mu.Lock()
|
|
c := state.count
|
|
state.count++
|
|
state.mu.Unlock()
|
|
|
|
seq := state.initSeq + uint16(c)
|
|
ts := state.initTs + uint32(c)*960 + uint32(c>>16)
|
|
|
|
// Build nonce from RTP fields
|
|
nonce := obfsBuildNonce(cfg.SSRC, seq, ts)
|
|
|
|
// Determine padding
|
|
padRand := 0
|
|
if cfg.PaddingMax > 0 {
|
|
var rndBuf [1]byte
|
|
rand.Read(rndBuf[:])
|
|
padRand = int(rndBuf[0]) % cfg.PaddingMax
|
|
}
|
|
padTotal := padRand + 1 // +1 for the length byte itself
|
|
|
|
// Allocate output: 12 (header) + payload + AEAD tag + padTotal
|
|
outLen := 12 + len(payload) + chacha20poly1305.Overhead + padTotal
|
|
out := make([]byte, outLen)
|
|
|
|
// RTP Header (12 bytes)
|
|
out[0] = 0x80 | 0x20 // V=2, P=1 (padding present)
|
|
out[1] = cfg.PayloadType & 0x7F
|
|
binary.BigEndian.PutUint16(out[2:4], seq)
|
|
binary.BigEndian.PutUint32(out[4:8], ts)
|
|
binary.BigEndian.PutUint32(out[8:12], cfg.SSRC)
|
|
|
|
aead, err := getAEAD(key)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("obfs: cipher init: %w", err)
|
|
}
|
|
sealed := aead.Seal(out[12:12], nonce, payload, out[:12])
|
|
|
|
// Random padding bytes
|
|
padStart := 12 + len(sealed)
|
|
if padRand > 0 {
|
|
rand.Read(out[padStart : padStart+padRand])
|
|
}
|
|
|
|
// Last byte = total padding count (RFC 3550 §5.1)
|
|
out[outLen-1] = byte(padTotal)
|
|
|
|
return out, nil
|
|
}
|
|
|
|
// ─── Unwrap (strip RTP header + decrypt) ───
|
|
|
|
// obfsUnwrapPacket strips the RTP header, removes padding, and decrypts the payload.
|
|
// Returns number of plaintext bytes written to dst.
|
|
func obfsUnwrapPacket(key, wire, dst []byte) (int, error) {
|
|
if len(key) != wrapKeyLen {
|
|
return 0, fmt.Errorf("obfs: key must be %d bytes (got %d)", wrapKeyLen, len(key))
|
|
}
|
|
if len(wire) < 13 { // 12 header + at least 1 byte
|
|
return 0, errors.New("obfs: packet too short")
|
|
}
|
|
|
|
// Validate RTP version
|
|
if (wire[0] >> 6) != 2 {
|
|
return 0, errors.New("obfs: not RTP v2")
|
|
}
|
|
|
|
// Extract RTP fields for nonce
|
|
seq := binary.BigEndian.Uint16(wire[2:4])
|
|
ts := binary.BigEndian.Uint32(wire[4:8])
|
|
ssrc := binary.BigEndian.Uint32(wire[8:12])
|
|
|
|
// Handle padding (P bit)
|
|
payloadEnd := len(wire)
|
|
if wire[0]&0x20 != 0 {
|
|
padLen := int(wire[len(wire)-1])
|
|
if padLen == 0 || padLen > payloadEnd-12 {
|
|
return 0, fmt.Errorf("obfs: invalid padding length %d", padLen)
|
|
}
|
|
payloadEnd -= padLen
|
|
}
|
|
|
|
ciphertextLen := payloadEnd - 12
|
|
if ciphertextLen <= chacha20poly1305.Overhead {
|
|
return 0, errors.New("obfs: no payload after stripping header/padding")
|
|
}
|
|
if ciphertextLen-chacha20poly1305.Overhead > len(dst) {
|
|
return 0, errors.New("obfs: dst buffer too small")
|
|
}
|
|
|
|
// Build nonce and decrypt
|
|
nonce := obfsBuildNonce(ssrc, seq, ts)
|
|
aead, err := getAEAD(key)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("obfs: cipher init: %w", err)
|
|
}
|
|
plain, err := aead.Open(dst[:0], nonce, wire[12:payloadEnd], wire[:12])
|
|
if err != nil {
|
|
return 0, fmt.Errorf("obfs: auth: %w", err)
|
|
}
|
|
|
|
return len(plain), nil
|
|
}
|
|
|
|
// ─── Detection ───
|
|
|
|
// obfsIsRTPPacket checks if a raw UDP packet looks like our obfuscated RTP.
|
|
// Used by the server and client to reject non-obfuscated packets.
|
|
func obfsIsRTPPacket(wire []byte) bool {
|
|
if len(wire) < 13 {
|
|
return false
|
|
}
|
|
// RTP version must be 2
|
|
if (wire[0] >> 6) != 2 {
|
|
return false
|
|
}
|
|
// Our payload type = 111
|
|
pt := wire[1] & 0x7F
|
|
return pt == 111
|
|
}
|