diff --git a/client/main.go b/client/main.go index 7872789..c327359 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 DTLS-mimicry obfuscation. +// When wc != nil, packets are wrapped/unwrapped with AEAD noise obfuscation. type relayPacketConn struct { relay net.PacketConn peer net.Addr diff --git a/client/wrap.go b/client/wrap.go index 37f2f99..26379e5 100644 --- a/client/wrap.go +++ b/client/wrap.go @@ -5,7 +5,6 @@ package main import ( "crypto/cipher" "crypto/rand" - "crypto/sha256" "encoding/binary" "encoding/hex" "errors" @@ -15,41 +14,37 @@ import ( "golang.org/x/crypto/chacha20poly1305" ) -// Wire format — DTLS 1.2 ApplicationData mimicry: +// Wire format — noise-only AEAD (no protocol mimicry): // -// [1B content_type=0x17 | 2B version=0xFEFD | 2B epoch | 6B seq_num | 2B length | -// AEAD_ciphertext(padded plaintext) | 16B Poly1305 tag] +// [1B header | (prefix_len-1)B random | 12B nonce | AEAD ciphertext | 16B tag] // -// Plaintext before encryption: [2B payload_length (BE) | payload | random padding] -// Padded to the nearest bucket in padBuckets to defeat size fingerprinting. +// 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. // -// Nonce (12B) = XOR(SHA-256(key)[0:12], [00 00 | epoch(2B) | seq(6B) | 00 00]) -// AAD = the 13-byte DTLS record header. +// 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. // -// Epoch MSB encodes direction: 0 = client, 1 = server. -// This guarantees no nonce reuse between client and server under the same key. +// AAD = nonce. The random prefix is not authenticated (it carries no +// information). const ( wrapKeyLen = 32 - wrapHeaderLen = 13 // DTLS record header - wrapTagLen = 16 // Poly1305 tag - wrapLenPrefix = 2 // uint16 BE real payload length - wrapOverhead = wrapHeaderLen + wrapTagLen + wrapLenPrefix // 31 + wrapNonceLen = 12 + wrapTagLen = 16 + wrapPrefixMin = 1 + wrapPrefixMax = 8 ) -// padBuckets defines plaintext size buckets. The padded plaintext -// ([2B length | payload | padding]) is rounded up to the nearest bucket. -var padBuckets = [...]int{128, 256, 512, 768, 1024, 1300} - -// wrapConn holds per-connection state for DTLS-mimicry obfuscation. type wrapConn struct { aead cipher.AEAD - nonceMask [12]byte // SHA-256(key)[0:12] - epoch uint16 - seq atomic.Uint64 + sessionID [4]byte + counter atomic.Uint64 } -// newWrapConn creates wrap state. isServer must be true on the server side. func newWrapConn(key []byte, isServer bool) (*wrapConn, error) { if len(key) != wrapKeyLen { return nil, fmt.Errorf("wrap: key must be %d bytes (got %d)", wrapKeyLen, len(key)) @@ -58,133 +53,86 @@ func newWrapConn(key []byte, isServer bool) (*wrapConn, error) { if err != nil { return nil, fmt.Errorf("wrap: aead init: %w", err) } - h := sha256.Sum256(key) - var mask [12]byte - copy(mask[:], h[:12]) - - var eb [2]byte - if _, err := rand.Read(eb[:]); err != nil { - return nil, fmt.Errorf("wrap: epoch rand: %w", err) + w := &wrapConn{aead: aead} + if _, err := rand.Read(w.sessionID[:]); err != nil { + return nil, fmt.Errorf("wrap: sessionID rand: %w", err) } - epoch := binary.BigEndian.Uint16(eb[:]) if isServer { - epoch |= 0x8000 + w.sessionID[0] |= 0x80 } else { - epoch &^= 0x8000 + w.sessionID[0] &^= 0x80 } - return &wrapConn{aead: aead, nonceMask: mask, epoch: epoch}, nil + var cb [8]byte + if _, err := rand.Read(cb[:]); err != nil { + return nil, fmt.Errorf("wrap: counter rand: %w", err) + } + w.counter.Store(binary.BigEndian.Uint64(cb[:])) + return w, nil } -// deriveNonce builds the 12-byte AEAD nonce from header fields. -func (w *wrapConn) deriveNonce(epoch uint16, seqNum uint64) [12]byte { - var explicit [12]byte - binary.BigEndian.PutUint16(explicit[2:4], epoch) - binary.BigEndian.PutUint16(explicit[4:6], uint16(seqNum>>32)) - binary.BigEndian.PutUint32(explicit[6:10], uint32(seqNum)) - var nonce [12]byte - for i := range 12 { - nonce[i] = w.nonceMask[i] ^ explicit[i] - } - return nonce -} - -// padPlaintextSize returns the padded plaintext size for a given payload. -func padPlaintextSize(payloadLen int) int { - needed := wrapLenPrefix + payloadLen - for _, b := range padBuckets { - if b >= needed { - return b - } - } - return needed -} - -// WrapMaxWire returns the maximum wire length for a given payload size. +// wrapMaxWire returns max wire bytes for a given payload size. func wrapMaxWire(payloadLen int) int { - return wrapHeaderLen + padPlaintextSize(payloadLen) + wrapTagLen + return wrapPrefixMax + wrapNonceLen + payloadLen + wrapTagLen } -// wrapInto encrypts payload into dst. Returns total wire bytes written. -// dst must have len >= wrapMaxWire(len(payload)). func (w *wrapConn) wrapInto(dst, payload []byte) (int, error) { - paddedLen := padPlaintextSize(len(payload)) - wireLen := wrapHeaderLen + paddedLen + wrapTagLen + // 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 if len(dst) < wireLen { return 0, errors.New("wrap: dst buffer too small") } - seq := w.seq.Add(1) - 1 - - // --- DTLS record header (13 bytes) --- - dst[0] = 0x17 // ApplicationData - dst[1] = 0xFE // DTLS 1.2 - dst[2] = 0xFD - binary.BigEndian.PutUint16(dst[3:5], w.epoch) - binary.BigEndian.PutUint16(dst[5:7], uint16(seq>>32)) - binary.BigEndian.PutUint32(dst[7:11], uint32(seq)) - binary.BigEndian.PutUint16(dst[11:13], uint16(paddedLen+wrapTagLen)) - - // --- Build plaintext: [2B len | payload | padding] --- - plain := dst[wrapHeaderLen : wrapHeaderLen+paddedLen] - binary.BigEndian.PutUint16(plain[0:2], uint16(len(payload))) - copy(plain[wrapLenPrefix:], payload) - // Random padding for the tail - padStart := wrapLenPrefix + len(payload) - if padStart < paddedLen { - _, _ = rand.Read(plain[padStart:paddedLen]) + 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) + } } - // --- AEAD encrypt in-place --- - nonce := w.deriveNonce(w.epoch, seq) - aad := dst[:wrapHeaderLen] - w.aead.Seal(dst[wrapHeaderLen:wrapHeaderLen], nonce[:], plain, aad) + // Nonce: 4B sessionID || 8B counter. + noncePos := prefixLen + 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. + copy(dst[ctPos:], payload) + w.aead.Seal(dst[ctPos:ctPos], nonce, dst[ctPos:ctPos+len(payload)], nonce) return wireLen, nil } -// unwrapPacket decrypts a wire-format packet into dst. Returns payload bytes. func (w *wrapConn) unwrapPacket(wire, dst []byte) (int, error) { - if len(wire) < wrapHeaderLen+wrapTagLen+wrapLenPrefix { + if len(wire) < wrapPrefixMin+wrapNonceLen+wrapTagLen { return 0, errors.New("wrap: packet too short") } - if wire[0] != 0x17 { - return 0, fmt.Errorf("wrap: unexpected content type 0x%02X", wire[0]) + 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:] - epoch := binary.BigEndian.Uint16(wire[3:5]) - seqHigh := uint64(binary.BigEndian.Uint16(wire[5:7])) - seqLow := uint64(binary.BigEndian.Uint32(wire[7:11])) - seqNum := (seqHigh << 32) | seqLow - bodyLen := int(binary.BigEndian.Uint16(wire[11:13])) - - if wrapHeaderLen+bodyLen > len(wire) { - return 0, errors.New("wrap: truncated packet") - } - - nonce := w.deriveNonce(epoch, seqNum) - aad := wire[:wrapHeaderLen] - ciphertext := wire[wrapHeaderLen : wrapHeaderLen+bodyLen] - - plain, err := w.aead.Open(ciphertext[:0], nonce[:], ciphertext, aad) + plain, err := w.aead.Open(ct[:0], nonce, ct, nonce) if err != nil { return 0, fmt.Errorf("wrap: AEAD open: %w", err) } - - if len(plain) < wrapLenPrefix { - return 0, errors.New("wrap: plaintext too short for length prefix") - } - realLen := int(binary.BigEndian.Uint16(plain[0:2])) - if realLen > len(plain)-wrapLenPrefix { - return 0, fmt.Errorf("wrap: payload length %d exceeds plaintext %d", realLen, len(plain)-wrapLenPrefix) - } - if realLen > len(dst) { + if len(plain) > len(dst) { return 0, errors.New("wrap: dst buffer too small") } - copy(dst[:realLen], plain[wrapLenPrefix:wrapLenPrefix+realLen]) - return realLen, nil + copy(dst[:len(plain)], plain) + return len(plain), nil } -// --- Helper functions (unchanged API for flag parsing) --- +// --- Helpers --- func genWrapKeyHex() (string, error) { key := make([]byte, wrapKeyLen) diff --git a/client/wrap_test.go b/client/wrap_test.go index 78beb8a..0cf9cbe 100644 --- a/client/wrap_test.go +++ b/client/wrap_test.go @@ -27,13 +27,6 @@ func TestWrapConnRoundTrip(t *testing.T) { } wire = wire[:n] - // Check DTLS header mimicry - if wire[0] != 0x17 { - t.Fatalf("content_type = 0x%02X, want 0x17", wire[0]) - } - if wire[1] != 0xFE || wire[2] != 0xFD { - t.Fatalf("version = 0x%02X%02X, want 0xFEFD", wire[1], wire[2]) - } if bytes.Contains(wire, payload) { t.Fatalf("wrapped packet contains plaintext payload") } @@ -65,35 +58,39 @@ func TestWrapConnRoundTrip(t *testing.T) { } } -func TestWrapPaddingHidesSize(t *testing.T) { +func TestWrapPrefixLenInRange(t *testing.T) { key := bytes.Repeat([]byte{0x42}, wrapKeyLen) wc, _ := newWrapConn(key, false) + payload := []byte("x") - // Two payloads of different size should produce same wire size if in same bucket - p1 := make([]byte, 10) - p2 := make([]byte, 100) - - w1 := make([]byte, wrapMaxWire(len(p1))) - w2 := make([]byte, wrapMaxWire(len(p2))) - - n1, _ := wc.wrapInto(w1, p1) - n2, _ := wc.wrapInto(w2, p2) - - if n1 != n2 { - t.Fatalf("different wire sizes for payloads in same bucket: %d vs %d", n1, n2) + // 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) + } } } -func TestWrapEpochDirection(t *testing.T) { +func TestWrapDirectionBit(t *testing.T) { key := bytes.Repeat([]byte{0x42}, wrapKeyLen) client, _ := newWrapConn(key, false) server, _ := newWrapConn(key, true) - if client.epoch&0x8000 != 0 { - t.Fatalf("client epoch MSB should be 0, got 0x%04X", client.epoch) + if client.sessionID[0]&0x80 != 0 { + t.Fatalf("client sessionID MSB should be 0, got 0x%02X", client.sessionID[0]) } - if server.epoch&0x8000 == 0 { - t.Fatalf("server epoch MSB should be 1, got 0x%04X", server.epoch) + if server.sessionID[0]&0x80 == 0 { + t.Fatalf("server sessionID MSB should be 1, got 0x%02X", server.sessionID[0]) } } @@ -139,8 +136,9 @@ func TestUnwrapRejectsTamperedPacket(t *testing.T) { n, _ := client.wrapInto(wire, payload) wire = wire[:n] - // Flip a bit in the ciphertext - wire[wrapHeaderLen+5] ^= 0xFF + // Flip a bit in the ciphertext (past prefix+nonce). + prefixLen := wrapPrefixMin + int(wire[0]&0x07) + wire[prefixLen+wrapNonceLen+1] ^= 0xFF dst := make([]byte, 1600) if _, err := server.unwrapPacket(wire, dst); err == nil { diff --git a/server/wrap.go b/server/wrap.go index b6647a8..8f7738c 100644 --- a/server/wrap.go +++ b/server/wrap.go @@ -5,7 +5,6 @@ package main import ( "crypto/cipher" "crypto/rand" - "crypto/sha256" "encoding/binary" "errors" "fmt" @@ -19,45 +18,28 @@ import ( "golang.org/x/crypto/chacha20poly1305" ) -// Wire format is identical to the client — see client/wrap.go for the -// full specification. Server uses epoch MSB=1, client uses MSB=0. +// 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. const ( wrapKeyLen = 32 - wrapHeaderLen = 13 // DTLS 1.2 record header - wrapTagLen = 16 // Poly1305 tag - wrapLenPrefix = 2 // uint16 BE real payload length + wrapNonceLen = 12 + wrapTagLen = 16 + wrapPrefixMin = 1 + wrapPrefixMax = 8 ) -var padBuckets = [...]int{128, 256, 512, 768, 1024, 1300} - // bufPool eliminates per-packet heap allocation on the hot read/write paths. var bufPool = sync.Pool{ New: func() any { - b := make([]byte, 1600) + b := make([]byte, 1600+wrapPrefixMax+wrapNonceLen+wrapTagLen) return &b }, } -func padPlaintextSize(payloadLen int) int { - needed := wrapLenPrefix + payloadLen - for _, b := range padBuckets { - if b >= needed { - return b - } - } - return needed -} - -func wrapMaxWire(payloadLen int) int { - return wrapHeaderLen + padPlaintextSize(payloadLen) + wrapTagLen -} - -// wrapState holds the AEAD instance and nonce mask shared by all connections -// under one key, plus per-connection epoch/seq state. +// wrapState holds the AEAD instance shared by all connections under one key. type wrapState struct { - aead cipher.AEAD - nonceMask [12]byte + aead cipher.AEAD } func newWrapState(key []byte) (*wrapState, error) { @@ -68,22 +50,7 @@ func newWrapState(key []byte) (*wrapState, error) { if err != nil { return nil, fmt.Errorf("wrap: aead init: %w", err) } - h := sha256.Sum256(key) - var mask [12]byte - copy(mask[:], h[:12]) - return &wrapState{aead: aead, nonceMask: mask}, nil -} - -func (s *wrapState) deriveNonce(epoch uint16, seqNum uint64) [12]byte { - var explicit [12]byte - binary.BigEndian.PutUint16(explicit[2:4], epoch) - binary.BigEndian.PutUint16(explicit[4:6], uint16(seqNum>>32)) - binary.BigEndian.PutUint32(explicit[6:10], uint32(seqNum)) - var nonce [12]byte - for i := range 12 { - nonce[i] = s.nonceMask[i] ^ explicit[i] - } - return nonce + return &wrapState{aead: aead}, nil } // --- Listener --- @@ -113,12 +80,18 @@ func (l *wrapPacketListener) Accept() (net.PacketConn, net.Addr, error) { if err != nil { return pc, addr, err } - // Server epoch: random with MSB=1 - var eb [2]byte - _, _ = rand.Read(eb[:]) - epoch := binary.BigEndian.Uint16(eb[:]) | 0x8000 - - return &wrapPacketConn{inner: pc, ws: l.ws, epoch: epoch}, addr, nil + 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) + } + c.sessionID[0] |= 0x80 + var cb [8]byte + if _, err := rand.Read(cb[:]); err != nil { + return nil, addr, fmt.Errorf("wrap: counter rand: %w", err) + } + c.counter.Store(binary.BigEndian.Uint64(cb[:])) + return c, addr, nil } func (l *wrapPacketListener) Close() error { return l.inner.Close() } @@ -127,17 +100,18 @@ func (l *wrapPacketListener) Addr() net.Addr { return l.inner.Addr() } // --- Per-peer PacketConn --- type wrapPacketConn struct { - inner net.PacketConn - ws *wrapState - epoch uint16 - seq atomic.Uint64 + inner net.PacketConn + ws *wrapState + sessionID [4]byte + counter atomic.Uint64 } func (c *wrapPacketConn) ReadFrom(p []byte) (int, net.Addr, error) { bp := bufPool.Get().(*[]byte) buf := *bp - if cap(buf) < len(p)+wrapHeaderLen+wrapTagLen { - buf = make([]byte, len(p)+wrapHeaderLen+wrapTagLen) + need := len(p) + wrapPrefixMax + wrapNonceLen + wrapTagLen + if cap(buf) < need { + buf = make([]byte, need) *bp = buf } defer bufPool.Put(bp) @@ -147,45 +121,34 @@ func (c *wrapPacketConn) ReadFrom(p []byte) (int, net.Addr, error) { return 0, addr, err } wire := buf[:n] - if len(wire) < wrapHeaderLen+wrapTagLen+wrapLenPrefix { + if len(wire) < wrapPrefixMin+wrapNonceLen+wrapTagLen { return 0, addr, errors.New("wrap: packet too short") } - if wire[0] != 0x17 { - return 0, addr, fmt.Errorf("wrap: unexpected content type 0x%02X", wire[0]) + 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:] - epoch := binary.BigEndian.Uint16(wire[3:5]) - seqHigh := uint64(binary.BigEndian.Uint16(wire[5:7])) - seqLow := uint64(binary.BigEndian.Uint32(wire[7:11])) - seqNum := (seqHigh << 32) | seqLow - bodyLen := int(binary.BigEndian.Uint16(wire[11:13])) - - if wrapHeaderLen+bodyLen > n { - return 0, addr, errors.New("wrap: truncated packet") - } - - nonce := c.ws.deriveNonce(epoch, seqNum) - aad := wire[:wrapHeaderLen] - ciphertext := wire[wrapHeaderLen : wrapHeaderLen+bodyLen] - - plain, err := c.ws.aead.Open(ciphertext[:0], nonce[:], ciphertext, aad) + plain, err := c.ws.aead.Open(ct[:0], nonce, ct, nonce) if err != nil { return 0, addr, fmt.Errorf("wrap: AEAD open: %w", err) } - if len(plain) < wrapLenPrefix { - return 0, addr, errors.New("wrap: plaintext too short") + if len(plain) > len(p) { + return 0, addr, errors.New("wrap: dst buffer too small") } - realLen := int(binary.BigEndian.Uint16(plain[0:2])) - if realLen > len(plain)-wrapLenPrefix || realLen > len(p) { - return 0, addr, errors.New("wrap: payload length mismatch") - } - copy(p[:realLen], plain[wrapLenPrefix:wrapLenPrefix+realLen]) - return realLen, addr, nil + copy(p[:len(plain)], plain) + return len(plain), addr, nil } func (c *wrapPacketConn) WriteTo(p []byte, addr net.Addr) (int, error) { - paddedLen := padPlaintextSize(len(p)) - wireLen := wrapHeaderLen + paddedLen + wrapTagLen + 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 bp := bufPool.Get().(*[]byte) out := *bp @@ -196,29 +159,22 @@ func (c *wrapPacketConn) WriteTo(p []byte, addr net.Addr) (int, error) { out = out[:wireLen] defer bufPool.Put(bp) - seq := c.seq.Add(1) - 1 - - // DTLS record header - out[0] = 0x17 - out[1] = 0xFE - out[2] = 0xFD - binary.BigEndian.PutUint16(out[3:5], c.epoch) - binary.BigEndian.PutUint16(out[5:7], uint16(seq>>32)) - binary.BigEndian.PutUint32(out[7:11], uint32(seq)) - binary.BigEndian.PutUint16(out[11:13], uint16(paddedLen+wrapTagLen)) - - // Plaintext: [2B len | payload | random padding] - plain := out[wrapHeaderLen : wrapHeaderLen+paddedLen] - binary.BigEndian.PutUint16(plain[0:2], uint16(len(p))) - copy(plain[wrapLenPrefix:], p) - padStart := wrapLenPrefix + len(p) - if padStart < paddedLen { - _, _ = rand.Read(plain[padStart:paddedLen]) + 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) + } } - nonce := c.ws.deriveNonce(c.epoch, seq) - aad := out[:wrapHeaderLen] - c.ws.aead.Seal(out[wrapHeaderLen:wrapHeaderLen], nonce[:], plain, aad) + noncePos := prefixLen + 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 + copy(out[ctPos:], p) + c.ws.aead.Seal(out[ctPos:ctPos], nonce, out[ctPos:ctPos+len(p)], nonce) if _, err := c.inner.WriteTo(out, addr); err != nil { return 0, err