Files
Zern-BlackOut/go_client/obfs.go
T
2026-05-26 22:48:52 +03:00

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
}