Initial v1.1.8 Commits
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
// 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/rand"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/crypto/chacha20poly1305"
|
||||
)
|
||||
|
||||
// ─── 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.
|
||||
type ObfsState struct {
|
||||
mu sync.Mutex
|
||||
seq uint16
|
||||
ts uint32
|
||||
}
|
||||
|
||||
// NewObfsState creates a state with random initial seq/ts.
|
||||
func NewObfsState() *ObfsState {
|
||||
var buf [6]byte
|
||||
rand.Read(buf[:])
|
||||
return &ObfsState{
|
||||
seq: binary.BigEndian.Uint16(buf[0:2]),
|
||||
ts: binary.BigEndian.Uint32(buf[2:6]),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 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()
|
||||
seq := state.seq
|
||||
ts := state.ts
|
||||
state.seq++
|
||||
state.ts += 960 // 20ms frame @ 48kHz (OPUS standard)
|
||||
state.mu.Unlock()
|
||||
|
||||
// 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 := chacha20poly1305.New(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 := chacha20poly1305.New(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
|
||||
}
|
||||
Reference in New Issue
Block a user