feat: gecko obfs (experimental) (#1584)

This commit is contained in:
Toby 2026-05-22 20:35:41 -07:00 committed by GitHub
parent 3b64f66995
commit c3a806b5cb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1376 additions and 92 deletions

View file

@ -113,9 +113,16 @@ type clientConfigObfsSalamander struct {
Password string `mapstructure:"password"`
}
type clientConfigObfsGecko struct {
Password string `mapstructure:"password"`
MinPacketSize int `mapstructure:"minPacketSize"`
MaxPacketSize int `mapstructure:"maxPacketSize"`
}
type clientConfigObfs struct {
Type string `mapstructure:"type"`
Salamander clientConfigObfsSalamander `mapstructure:"salamander"`
Gecko clientConfigObfsGecko `mapstructure:"gecko"`
}
type clientConfigTLS struct {
@ -233,42 +240,46 @@ func (c *clientConfig) fillServerAddr(hyConfig *client.Config) error {
return nil
}
// fillConnFactory must be called after fillServerAddr, as we have different logic
// for ConnFactory depending on whether we have a port hopping address.
// fillConnFactory must be called after fillServerAddr, since the right kind
// of inner conn (plain vs port-hopping) depends on the resolved server addr.
func (c *clientConfig) fillConnFactory(hyConfig *client.Config) error {
so, err := c.socketOptions()
if err != nil {
return err
}
// Inner PacketConn
var newFunc func(addr net.Addr) (net.PacketConn, error)
hopInterval, err := c.Transport.UDP.hopIntervalConfig()
if err != nil {
return configError{Field: "transport.udp", Err: err}
}
var openInner func() (net.PacketConn, error)
switch strings.ToLower(c.Transport.Type) {
case "", "udp":
if hyConfig.ServerAddr.Network() == "udphop" {
hopAddr := hyConfig.ServerAddr.(*udphop.UDPHopAddr)
newFunc = func(addr net.Addr) (net.PacketConn, error) {
openInner = func() (net.PacketConn, error) {
return udphop.NewUDPHopPacketConn(hopAddr, hopInterval, so.ListenUDP)
}
} else {
newFunc = func(addr net.Addr) (net.PacketConn, error) {
openInner = func() (net.PacketConn, error) {
return so.ListenUDP()
}
}
default:
return configError{Field: "transport.type", Err: errors.New("unsupported transport type")}
}
// Obfuscation
ob, err := c.obfuscator()
if err != nil {
return err
}
hyConfig.ConnFactory = &adaptiveConnFactory{
NewFunc: newFunc,
Obfuscator: ob,
hyConfig.ConnFactory = &singleUseConnFactory{
Open: func() (net.PacketConn, error) {
conn, err := openInner()
if err != nil {
return nil, err
}
wrapped, err := c.wrapObfs(conn)
if err != nil {
_ = conn.Close()
return nil, err
}
return wrapped, nil
},
}
return nil
}
@ -292,16 +303,26 @@ func (c *clientConfig) socketOptions() (*sockopts.SocketOptions, error) {
return so, nil
}
func (c *clientConfig) obfuscator() (obfs.Obfuscator, error) {
func (c *clientConfig) wrapObfs(conn net.PacketConn) (net.PacketConn, error) {
switch strings.ToLower(c.Obfs.Type) {
case "", "plain":
return nil, nil
return conn, nil
case "salamander":
ob, err := obfs.NewSalamanderObfuscator([]byte(c.Obfs.Salamander.Password))
wrapped, err := obfs.WrapPacketConnSalamander(conn, []byte(c.Obfs.Salamander.Password))
if err != nil {
return nil, configError{Field: "obfs.salamander.password", Err: err}
}
return ob, nil
return wrapped, nil
case "gecko":
wrapped, err := obfs.WrapPacketConnGecko(conn, obfs.GeckoOptions{
Password: []byte(c.Obfs.Gecko.Password),
MinPacketSize: c.Obfs.Gecko.MinPacketSize,
MaxPacketSize: c.Obfs.Gecko.MaxPacketSize,
})
if err != nil {
return nil, configError{Field: "obfs.gecko", Err: err}
}
return wrapped, nil
default:
return nil, configError{Field: "obfs.type", Err: errors.New("unsupported obfuscation type")}
}
@ -456,6 +477,9 @@ func (c *clientConfig) URI() string {
case "salamander":
q.Set("obfs", "salamander")
q.Set("obfs-password", c.Obfs.Salamander.Password)
case "gecko":
q.Set("obfs", "gecko")
q.Set("obfs-password", c.Obfs.Gecko.Password)
}
if c.TLS.SNI != "" {
q.Set("sni", c.TLS.SNI)
@ -513,6 +537,8 @@ func (c *clientConfig) parseURI() bool {
switch strings.ToLower(obfsType) {
case "salamander":
c.Obfs.Salamander.Password = q.Get("obfs-password")
case "gecko":
c.Obfs.Gecko.Password = q.Get("obfs-password")
}
}
if sni := q.Get("sni"); sni != "" {
@ -680,15 +706,13 @@ func (c *clientConfig) realmConfig(addr *realm.Addr) (*client.Config, error) {
zap.String("realm", addr.RealmID),
zap.String("peer", result.PeerAddr.String()))
var finalConn net.PacketConn = baseConn
ob, err := c.obfuscator()
finalConn, err := c.wrapObfs(baseConn)
if err != nil {
return nil, err
}
if ob != nil {
finalConn = obfs.WrapPacketConn(baseConn, ob)
hyConfig.ConnFactory = &singleUseConnFactory{
Open: func() (net.PacketConn, error) { return finalConn, nil },
}
hyConfig.ConnFactory = &singleUseConnFactory{Conn: finalConn}
success = true
return hyConfig, nil
}
@ -1135,25 +1159,14 @@ func normalizeCertHash(hash string) string {
return r
}
type adaptiveConnFactory struct {
NewFunc func(addr net.Addr) (net.PacketConn, error)
Obfuscator obfs.Obfuscator // nil if no obfuscation
}
func (f *adaptiveConnFactory) New(addr net.Addr) (net.PacketConn, error) {
if f.Obfuscator == nil {
return f.NewFunc(addr)
} else {
conn, err := f.NewFunc(addr)
if err != nil {
return nil, err
}
return obfs.WrapPacketConn(conn, f.Obfuscator), nil
}
}
// singleUseConnFactory invokes Open exactly once to produce the underlying
// conn. It defers any allocation to the moment core/client.connect() actually
// asks for the conn, so config-validation failures inside NewClient (e.g.
// verifyAndFill rejecting a value) don't leak a socket. For the realm path
// the conn is already open by the time we reach the factory, so Open just
// returns it; for the regular path Open opens the UDP socket fresh.
type singleUseConnFactory struct {
Conn net.PacketConn
Open func() (net.PacketConn, error)
mu sync.Mutex
used bool
@ -1162,11 +1175,11 @@ type singleUseConnFactory struct {
func (f *singleUseConnFactory) New(net.Addr) (net.PacketConn, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.used || f.Conn == nil {
return nil, errors.New("realm connection already used")
if f.used {
return nil, errors.New("connection factory already used")
}
f.used = true
return f.Conn, nil
return f.Open()
}
func addrPortStrings(addrs []netip.AddrPort) []string {

View file

@ -177,6 +177,20 @@ func TestClientConfigURI(t *testing.T) {
},
},
},
{
uri: "hysteria2://pw@geckotown.com:8443/?obfs=gecko&obfs-password=hidden",
uriOK: true,
config: &clientConfig{
Server: "geckotown.com:8443",
Auth: "pw",
Obfs: clientConfigObfs{
Type: "gecko",
Gecko: clientConfigObfsGecko{
Password: "hidden",
},
},
},
},
{
uri: "invalid.bs",
uriOK: false,
@ -238,7 +252,7 @@ func TestSingleUseConnFactory(t *testing.T) {
assert.NoError(t, err)
defer conn.Close()
f := &singleUseConnFactory{Conn: conn}
f := &singleUseConnFactory{Open: func() (net.PacketConn, error) { return conn, nil }}
got, err := f.New(&net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 443})
assert.NoError(t, err)
assert.Equal(t, conn, got)

View file

@ -97,9 +97,16 @@ type serverConfigObfsSalamander struct {
Password string `mapstructure:"password"`
}
type serverConfigObfsGecko struct {
Password string `mapstructure:"password"`
MinPacketSize int `mapstructure:"minPacketSize"`
MaxPacketSize int `mapstructure:"maxPacketSize"`
}
type serverConfigObfs struct {
Type string `mapstructure:"type"`
Salamander serverConfigObfsSalamander `mapstructure:"salamander"`
Gecko serverConfigObfsGecko `mapstructure:"gecko"`
}
type serverConfigTLS struct {
@ -312,30 +319,17 @@ func (c *serverConfig) fillConn(hyConfig *server.Config) error {
return configError{Field: "listen", Err: err}
}
}
switch strings.ToLower(c.Obfs.Type) {
case "", "plain":
hyConfig.Conn = packetConn
hyConfig.Cleanup = cleanup
return nil
case "salamander":
ob, err := obfs.NewSalamanderObfuscator([]byte(c.Obfs.Salamander.Password))
if err != nil {
_ = conn.Close()
if cleanup != nil {
_ = cleanup.Close()
}
return configError{Field: "obfs.salamander.password", Err: err}
}
hyConfig.Conn = obfs.WrapPacketConn(packetConn, ob)
hyConfig.Cleanup = cleanup
return nil
default:
wrapped, err := c.wrapObfs(packetConn)
if err != nil {
_ = conn.Close()
if cleanup != nil {
_ = cleanup.Close()
}
return configError{Field: "obfs.type", Err: errors.New("unsupported obfuscation type")}
return err
}
hyConfig.Conn = wrapped
hyConfig.Cleanup = cleanup
return nil
}
func parseServerRealmAddr(listen string) (*realm.Addr, bool, error) {
@ -371,8 +365,9 @@ func (c *serverConfig) fillRealmConn(hyConfig *server.Config, addr *realm.Addr)
_ = conn.Close()
return configError{Field: "realm", Err: err}
}
packetConn, err := c.wrapServerPacketConn(punchConn, conn)
packetConn, err := c.wrapObfs(punchConn)
if err != nil {
_ = conn.Close()
return err
}
@ -388,19 +383,27 @@ func (c *serverConfig) fillRealmConn(hyConfig *server.Config, addr *realm.Addr)
return nil
}
func (c *serverConfig) wrapServerPacketConn(packetConn net.PacketConn, udpConn *net.UDPConn) (net.PacketConn, error) {
func (c *serverConfig) wrapObfs(conn net.PacketConn) (net.PacketConn, error) {
switch strings.ToLower(c.Obfs.Type) {
case "", "plain":
return packetConn, nil
return conn, nil
case "salamander":
ob, err := obfs.NewSalamanderObfuscator([]byte(c.Obfs.Salamander.Password))
wrapped, err := obfs.WrapPacketConnSalamander(conn, []byte(c.Obfs.Salamander.Password))
if err != nil {
_ = udpConn.Close()
return nil, configError{Field: "obfs.salamander.password", Err: err}
}
return obfs.WrapPacketConn(packetConn, ob), nil
return wrapped, nil
case "gecko":
wrapped, err := obfs.WrapPacketConnGecko(conn, obfs.GeckoOptions{
Password: []byte(c.Obfs.Gecko.Password),
MinPacketSize: c.Obfs.Gecko.MinPacketSize,
MaxPacketSize: c.Obfs.Gecko.MaxPacketSize,
})
if err != nil {
return nil, configError{Field: "obfs.gecko", Err: err}
}
return wrapped, nil
default:
_ = udpConn.Close()
return nil, configError{Field: "obfs.type", Err: errors.New("unsupported obfuscation type")}
}
}

View file

@ -9,10 +9,10 @@ import (
const udpBufferSize = 2048 // QUIC packets are at most 1500 bytes long, so 2k should be more than enough
// Obfuscator is the interface that wraps the Obfuscate and Deobfuscate methods.
// Both methods return the number of bytes written to out.
// obfuscator wraps a per-packet, length-preserving cipher.
// Obfuscate / Deobfuscate return the number of bytes written to out.
// If a packet is not valid, the methods should return 0.
type Obfuscator interface {
type obfuscator interface {
Obfuscate(in, out []byte) int
Deobfuscate(in, out []byte) int
}
@ -21,7 +21,7 @@ var _ net.PacketConn = (*obfsPacketConn)(nil)
type obfsPacketConn struct {
Conn net.PacketConn
Obfs Obfuscator
Obfs obfuscator
readBuf []byte
readMutex sync.Mutex
@ -49,14 +49,14 @@ type obfsPacketConnUDP struct {
UDPConn udpLikePacketConn
}
// WrapPacketConn enables obfuscation on a net.PacketConn.
// wrapPacketConn enables per-packet obfuscation on a net.PacketConn.
// The obfuscation is transparent to the caller - the n bytes returned by
// ReadFrom and WriteTo are the number of original bytes, not after
// obfuscation/deobfuscation.
func WrapPacketConn(conn net.PacketConn, obfs Obfuscator) net.PacketConn {
func wrapPacketConn(conn net.PacketConn, ob obfuscator) net.PacketConn {
opc := &obfsPacketConn{
Conn: conn,
Obfs: obfs,
Obfs: ob,
readBuf: make([]byte, udpBufferSize),
writeBuf: make([]byte, udpBufferSize),
}

View file

@ -46,19 +46,19 @@ func TestWrapPacketConnUsesUDPVariantForUDPConn(t *testing.T) {
require.NoError(t, err)
defer udp.Close()
wrapped := WrapPacketConn(udp, noopObfs{})
wrapped := wrapPacketConn(udp, noopObfs{})
_, ok := wrapped.(*obfsPacketConnUDP)
assert.True(t, ok, "wrapping a *net.UDPConn should return *obfsPacketConnUDP")
}
func TestWrapPacketConnUsesUDPVariantForUDPLikeWrapper(t *testing.T) {
wrapped := WrapPacketConn(fakeUDPLikeConn{}, noopObfs{})
wrapped := wrapPacketConn(fakeUDPLikeConn{}, noopObfs{})
_, ok := wrapped.(*obfsPacketConnUDP)
assert.True(t, ok, "wrapping a udpLikePacketConn should return *obfsPacketConnUDP")
}
func TestWrapPacketConnFallsBackForPlainPacketConn(t *testing.T) {
wrapped := WrapPacketConn(fakePlainConn{}, noopObfs{})
wrapped := wrapPacketConn(fakePlainConn{}, noopObfs{})
_, isUDP := wrapped.(*obfsPacketConnUDP)
assert.False(t, isUDP, "wrapping a plain net.PacketConn should not return *obfsPacketConnUDP")
}

341
extras/obfs/gecko.go Normal file
View file

@ -0,0 +1,341 @@
package obfs
import (
"crypto/rand"
"encoding/binary"
"errors"
"net"
"sync"
"sync/atomic"
"syscall"
"time"
)
// Gecko adds shape obfuscation on top of Salamander: QUIC long-header
// (handshake) packets are fragmented into randomly-sized, randomly-padded
// chunks; short-header packets pass through untouched.
const (
geckoReassemblyTTL = 8 * time.Second
geckoMaxReassembly = 4096
geckoMaxPerSource = 8
geckoBufferSize = 2048
geckoDefaultMinPacket = 512
geckoDefaultMaxPacket = 1200
)
type GeckoOptions struct {
Password []byte
MinPacketSize int
MaxPacketSize int
}
func WrapPacketConnGecko(conn net.PacketConn, opts GeckoOptions) (net.PacketConn, error) {
if len(opts.Password) == 0 {
return nil, errors.New("gecko: password is required")
}
minPkt, maxPkt := opts.MinPacketSize, opts.MaxPacketSize
if minPkt == 0 {
minPkt = geckoDefaultMinPacket
}
if maxPkt == 0 {
maxPkt = geckoDefaultMaxPacket
}
if minPkt <= 0 || minPkt > maxPkt || maxPkt > geckoBufferSize {
return nil, errors.New("gecko: invalid min/max packet size")
}
inner, err := WrapPacketConnSalamander(conn, opts.Password)
if err != nil {
return nil, err
}
return newGeckoPacketConn(inner, minPkt, maxPkt), nil
}
type reassemblyKey struct {
addr string
msgID uint8
}
type reassemblyEntry struct {
chunks [][]byte
received int
total uint8
deadline time.Time
}
type geckoPacketConn struct {
inner net.PacketConn
minPkt, maxPkt int
msgID atomic.Uint32
readMu sync.Mutex
readBuf []byte
mu sync.Mutex
reassembly map[reassemblyKey]*reassemblyEntry
perSource map[string]int
closeCh chan struct{}
closeOnce sync.Once
}
func newGeckoPacketConn(inner net.PacketConn, minPkt, maxPkt int) *geckoPacketConn {
g := &geckoPacketConn{
inner: inner,
minPkt: minPkt,
maxPkt: maxPkt,
readBuf: make([]byte, geckoBufferSize),
reassembly: make(map[reassemblyKey]*reassemblyEntry),
perSource: make(map[string]int),
closeCh: make(chan struct{}),
}
go g.gcLoop()
return g
}
// --- Send path ---
func (g *geckoPacketConn) WriteTo(p []byte, addr net.Addr) (int, error) {
if len(p) == 0 {
return 0, nil
}
if p[0]&0x80 != 0 {
// QUIC long header, do fragmentation.
return g.writeFragmented(p, addr)
}
// QUIC short header (data), pass through.
return g.inner.WriteTo(p, addr)
}
func (g *geckoPacketConn) writeFragmented(p []byte, addr net.Addr) (int, error) {
chunks := randomFragmentChunks()
chunkSize := len(p) / chunks
msgID := uint8(g.msgID.Add(1))
for i := range chunks {
start := i * chunkSize
end := len(p)
if i < chunks-1 {
end = start + chunkSize
}
chunk := p[start:end]
padLen := g.randomPadLen(len(chunk))
buf := make([]byte, geckoHeaderSize+int(padLen)+len(chunk))
n, err := encodeFrame(frameHeader{
padLen: padLen,
msgID: msgID,
chunkIdx: uint8(i),
totalChunks: uint8(chunks),
}, chunk, buf)
if err != nil {
return 0, err
}
if _, err := g.inner.WriteTo(buf[:n], addr); err != nil {
return 0, err
}
}
return len(p), nil
}
// randomPadLen picks padding so the final UDP datagram (Salamander salt +
// header + padding + chunk) falls within [minPkt, maxPkt]. If the chunk alone
// already exceeds maxPkt, no padding is added.
func (g *geckoPacketConn) randomPadLen(chunkLen int) uint16 {
base := smSaltLen + geckoHeaderSize + chunkLen
lo := max(g.minPkt, base)
if lo > g.maxPkt {
return 0
}
return uint16(lo - base + randIntn(g.maxPkt-lo+1))
}
func randomFragmentChunks() int {
return geckoMinFragmentChunks + randIntn(geckoMaxFragmentChunks-geckoMinFragmentChunks+1)
}
// randIntn returns a uniform random int in [0, n).
func randIntn(n int) int {
if n <= 1 {
return 0
}
var b [4]byte
_, _ = rand.Read(b[:])
return int(binary.BigEndian.Uint32(b[:]) % uint32(n))
}
// --- Receive path ---
func (g *geckoPacketConn) ReadFrom(p []byte) (int, net.Addr, error) {
g.readMu.Lock()
defer g.readMu.Unlock()
buf := g.readBuf
for {
n, addr, err := g.inner.ReadFrom(buf)
if err != nil {
return 0, addr, err
}
if n <= 0 {
continue
}
// Top bit set → Gecko fragment frame; clear → short-header packet
// or garbage, passed through for QUIC to handle.
if buf[0]&0x80 == 0 {
return copy(p, buf[:n]), addr, nil
}
h, payload, decErr := decodeFrame(buf[:n])
if decErr != nil {
// Malformed frame; drop silently.
continue
}
out, ready := g.acceptChunk(addr, h, payload)
if !ready {
continue
}
return copy(p, out), addr, nil
}
}
func (g *geckoPacketConn) acceptChunk(addr net.Addr, h frameHeader, payload []byte) ([]byte, bool) {
key := reassemblyKey{addr: addr.String(), msgID: h.msgID}
g.mu.Lock()
defer g.mu.Unlock()
e, exists := g.reassembly[key]
if !exists {
// Per-source cap.
if g.perSource[key.addr] >= geckoMaxPerSource {
return nil, false
}
// Global cap with eviction.
if len(g.reassembly) >= geckoMaxReassembly {
g.evictOldestLocked()
}
e = &reassemblyEntry{
chunks: make([][]byte, h.totalChunks),
total: h.totalChunks,
deadline: time.Now().Add(geckoReassemblyTTL),
}
g.reassembly[key] = e
g.perSource[key.addr]++
} else if e.total != h.totalChunks {
// Inconsistent chunk count; drop.
return nil, false
}
if int(h.chunkIdx) >= len(e.chunks) || e.chunks[h.chunkIdx] != nil {
// Bad index or duplicate; drop.
return nil, false
}
cp := make([]byte, len(payload))
copy(cp, payload)
e.chunks[h.chunkIdx] = cp
e.received++
if e.received < int(e.total) {
return nil, false
}
total := 0
for _, c := range e.chunks {
total += len(c)
}
out := make([]byte, total)
off := 0
for _, c := range e.chunks {
off += copy(out[off:], c)
}
g.dropEntryLocked(key)
return out, true
}
// --- Maintenance ---
func (g *geckoPacketConn) gcLoop() {
t := time.NewTicker(geckoReassemblyTTL / 2)
defer t.Stop()
for {
select {
case <-g.closeCh:
return
case now := <-t.C:
g.gcExpired(now)
}
}
}
func (g *geckoPacketConn) gcExpired(now time.Time) {
g.mu.Lock()
defer g.mu.Unlock()
for k, e := range g.reassembly {
if now.After(e.deadline) {
g.dropEntryLocked(k)
}
}
}
// dropEntryLocked must be called with mu held.
func (g *geckoPacketConn) dropEntryLocked(k reassemblyKey) {
if _, ok := g.reassembly[k]; !ok {
return
}
delete(g.reassembly, k)
g.perSource[k.addr]--
if g.perSource[k.addr] <= 0 {
delete(g.perSource, k.addr)
}
}
// evictOldestLocked must be called with mu held. O(n) over the map; n is
// bounded by geckoMaxReassembly.
func (g *geckoPacketConn) evictOldestLocked() {
var oldestKey reassemblyKey
var oldestDeadline time.Time
first := true
for k, e := range g.reassembly {
if first || e.deadline.Before(oldestDeadline) {
oldestKey = k
oldestDeadline = e.deadline
first = false
}
}
if !first {
g.dropEntryLocked(oldestKey)
}
}
// --- net.PacketConn boilerplate ---
func (g *geckoPacketConn) Close() error {
g.closeOnce.Do(func() { close(g.closeCh) })
return g.inner.Close()
}
func (g *geckoPacketConn) LocalAddr() net.Addr { return g.inner.LocalAddr() }
func (g *geckoPacketConn) SetDeadline(t time.Time) error { return g.inner.SetDeadline(t) }
func (g *geckoPacketConn) SetReadDeadline(t time.Time) error { return g.inner.SetReadDeadline(t) }
func (g *geckoPacketConn) SetWriteDeadline(t time.Time) error {
return g.inner.SetWriteDeadline(t)
}
// --- UDP-flavor passthrough ---
func (g *geckoPacketConn) SyscallConn() (syscall.RawConn, error) {
if u, ok := g.inner.(udpLikePacketConn); ok {
return u.SyscallConn()
}
return nil, errors.ErrUnsupported
}
func (g *geckoPacketConn) SetReadBuffer(bytes int) error {
if u, ok := g.inner.(udpLikePacketConn); ok {
return u.SetReadBuffer(bytes)
}
return errors.ErrUnsupported
}
func (g *geckoPacketConn) SetWriteBuffer(bytes int) error {
if u, ok := g.inner.(udpLikePacketConn); ok {
return u.SetWriteBuffer(bytes)
}
return errors.ErrUnsupported
}

View file

@ -0,0 +1,86 @@
package obfs
import (
"crypto/rand"
"encoding/binary"
"errors"
)
const (
geckoFlagFragment = 0x80
geckoHeaderSize = 5
geckoMinFragmentChunks = 2
geckoMaxFragmentChunks = 8
)
var (
errFrameTruncated = errors.New("gecko frame truncated")
errFrameInvalid = errors.New("gecko frame invalid")
)
// frameHeader is a Gecko fragment frame header.
// Wire layout (after Salamander decryption):
//
// byte 0: 0x80 (fragment marker; low 7 bits reserved)
// byte 1: msgID
// byte 2: chunkIdx:4 | totalChunks:4
// byte 3-4: padLen (uint16, big-endian)
// then padLen random padding bytes, then the chunk payload
type frameHeader struct {
padLen uint16
msgID uint8
chunkIdx uint8 // < totalChunks
totalChunks uint8 // [2, 8]
}
// encodeFrame writes a frame into out, filling the padding region with random
// bytes. out must be at least geckoHeaderSize + h.padLen + len(payload) long.
func encodeFrame(h frameHeader, payload, out []byte) (int, error) {
if h.totalChunks < geckoMinFragmentChunks || h.totalChunks > geckoMaxFragmentChunks {
return 0, errFrameInvalid
}
if h.chunkIdx >= h.totalChunks {
return 0, errFrameInvalid
}
needed := geckoHeaderSize + int(h.padLen) + len(payload)
if len(out) < needed {
return 0, errFrameTruncated
}
out[0] = geckoFlagFragment
out[1] = h.msgID
out[2] = h.chunkIdx<<4 | h.totalChunks&0x0f
binary.BigEndian.PutUint16(out[3:5], h.padLen)
if _, err := rand.Read(out[geckoHeaderSize : geckoHeaderSize+int(h.padLen)]); err != nil {
return 0, err
}
copy(out[geckoHeaderSize+int(h.padLen):], payload)
return needed, nil
}
// decodeFrame parses a frame from in. The returned payload is a sub-slice of
// in (zero-copy) covering the bytes after the header and padding.
func decodeFrame(in []byte) (frameHeader, []byte, error) {
if len(in) < geckoHeaderSize {
return frameHeader{}, nil, errFrameTruncated
}
if in[0]&geckoFlagFragment == 0 {
return frameHeader{}, nil, errFrameInvalid
}
h := frameHeader{
msgID: in[1],
chunkIdx: in[2] >> 4,
totalChunks: in[2] & 0x0f,
padLen: binary.BigEndian.Uint16(in[3:5]),
}
if h.totalChunks < geckoMinFragmentChunks || h.totalChunks > geckoMaxFragmentChunks {
return frameHeader{}, nil, errFrameInvalid
}
if h.chunkIdx >= h.totalChunks {
return frameHeader{}, nil, errFrameInvalid
}
if geckoHeaderSize+int(h.padLen) > len(in) {
return frameHeader{}, nil, errFrameTruncated
}
return h, in[geckoHeaderSize+int(h.padLen):], nil
}

View file

@ -0,0 +1,96 @@
package obfs
import (
"bytes"
"errors"
"testing"
)
func TestEncodeDecodeFrame(t *testing.T) {
payload := []byte{0xa1, 0xb2, 0xc3, 0xd4}
for total := geckoMinFragmentChunks; total <= geckoMaxFragmentChunks; total++ {
for idx := 0; idx < total; idx++ {
for _, padLen := range []int{0, 1, 64, 127, 512, 1100} {
h := frameHeader{
padLen: uint16(padLen),
msgID: 0xa5,
chunkIdx: uint8(idx),
totalChunks: uint8(total),
}
out := make([]byte, geckoHeaderSize+padLen+len(payload))
n, err := encodeFrame(h, payload, out)
if err != nil {
t.Fatalf("encode total=%d idx=%d padLen=%d: %v", total, idx, padLen, err)
}
if n != len(out) {
t.Fatalf("encode wrote %d, want %d", n, len(out))
}
got, body, err := decodeFrame(out)
if err != nil {
t.Fatalf("decode total=%d idx=%d padLen=%d: %v", total, idx, padLen, err)
}
if got != h {
t.Fatalf("header mismatch: got %+v want %+v", got, h)
}
if !bytes.Equal(body, payload) {
t.Fatalf("payload mismatch total=%d idx=%d padLen=%d", total, idx, padLen)
}
}
}
}
}
func TestEncodeFrameRejectsInvalid(t *testing.T) {
payload := []byte{0xff}
cases := []struct {
name string
h frameHeader
err error
}{
{"totalChunks zero", frameHeader{totalChunks: 0}, errFrameInvalid},
{"totalChunks one", frameHeader{totalChunks: 1}, errFrameInvalid},
{"totalChunks nine", frameHeader{totalChunks: 9}, errFrameInvalid},
{"chunkIdx out of range", frameHeader{totalChunks: 4, chunkIdx: 4}, errFrameInvalid},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
out := make([]byte, 1024)
if _, err := encodeFrame(tc.h, payload, out); !errors.Is(err, tc.err) {
t.Fatalf("got %v, want %v", err, tc.err)
}
})
}
}
func TestEncodeFrameRejectsShortBuffer(t *testing.T) {
payload := []byte{0x01, 0x02, 0x03}
h := frameHeader{padLen: 8, totalChunks: 2, chunkIdx: 0}
out := make([]byte, 5) // need 5 + 8 + 3 = 16
if _, err := encodeFrame(h, payload, out); !errors.Is(err, errFrameTruncated) {
t.Fatalf("got %v, want %v", err, errFrameTruncated)
}
}
func TestDecodeFrameRejectsInvalid(t *testing.T) {
cases := []struct {
name string
in []byte
err error
}{
{"empty", []byte{}, errFrameTruncated},
{"header truncated", []byte{0x80, 0x55, 0x22, 0x00}, errFrameTruncated},
{"not a fragment", []byte{0x00, 0x00, 0x22, 0x00, 0x00}, errFrameInvalid},
{"totalChunks zero", []byte{0x80, 0x00, 0x00, 0x00, 0x00}, errFrameInvalid},
{"totalChunks one", []byte{0x80, 0x00, 0x01, 0x00, 0x00}, errFrameInvalid},
{"totalChunks nine", []byte{0x80, 0x00, 0x09, 0x00, 0x00}, errFrameInvalid},
{"chunkIdx == totalChunks", []byte{0x80, 0x00, 0x44, 0x00, 0x00}, errFrameInvalid},
{"padLen overrun", []byte{0x80, 0x00, 0x02, 0x00, 0x12, 0x01, 0x02}, errFrameTruncated},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if _, _, err := decodeFrame(tc.in); !errors.Is(err, tc.err) {
t.Fatalf("got %v, want %v", err, tc.err)
}
})
}
}

719
extras/obfs/gecko_test.go Normal file
View file

@ -0,0 +1,719 @@
package obfs
import (
"bytes"
"errors"
"fmt"
"math/rand"
"net"
"sync"
"sync/atomic"
"syscall"
"testing"
"time"
)
// --- in-memory packet pipe ---
// memEnd is one side of a bidirectional, lossy in-memory packet pipe.
type memEnd struct {
addr net.Addr
other *memEnd
mu sync.Mutex
closed bool
done chan struct{}
inbox chan memPacket
writeCount atomic.Int64
dropFn func(idx int) bool // optional: return true to drop the n-th outgoing packet
}
type memPacket struct {
src net.Addr
data []byte
}
func newMemPipe(aAddr, bAddr net.Addr) (*memEnd, *memEnd) {
a := &memEnd{addr: aAddr, inbox: make(chan memPacket, 1024), done: make(chan struct{})}
b := &memEnd{addr: bAddr, inbox: make(chan memPacket, 1024), done: make(chan struct{})}
a.other = b
b.other = a
return a, b
}
func (e *memEnd) WriteTo(p []byte, _ net.Addr) (int, error) {
idx := int(e.writeCount.Add(1) - 1)
if e.dropFn != nil && e.dropFn(idx) {
return len(p), nil
}
cp := make([]byte, len(p))
copy(cp, p)
select {
case e.other.inbox <- memPacket{src: e.addr, data: cp}:
case <-e.other.done:
}
return len(p), nil
}
func (e *memEnd) ReadFrom(p []byte) (int, net.Addr, error) {
select {
case pkt := <-e.inbox:
return copy(p, pkt.data), pkt.src, nil
case <-e.done:
return 0, nil, net.ErrClosed
}
}
func (e *memEnd) Close() error {
e.mu.Lock()
defer e.mu.Unlock()
if e.closed {
return nil
}
e.closed = true
close(e.done)
return nil
}
func (e *memEnd) LocalAddr() net.Addr { return e.addr }
func (e *memEnd) SetDeadline(time.Time) error { return nil }
func (e *memEnd) SetReadDeadline(time.Time) error { return nil }
func (e *memEnd) SetWriteDeadline(time.Time) error { return nil }
// pendingInbox returns the count of buffered, undelivered packets on this end.
func (e *memEnd) pendingInbox() int { return len(e.inbox) }
// --- helpers ---
func mustWrapGecko(t *testing.T, conn net.PacketConn, password string) net.PacketConn {
t.Helper()
g, err := WrapPacketConnGecko(conn, GeckoOptions{Password: []byte(password)})
if err != nil {
t.Fatalf("WrapPacketConnGecko: %v", err)
}
return g
}
func makeAddrs() (a, b net.Addr) {
return &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 1111},
&net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 2222}
}
// quicLong / quicShort produce realistic-looking QUIC payloads of n bytes
// with the appropriate top-bit on byte 0 to trigger / avoid fragmentation.
func quicLong(n int) []byte {
p := make([]byte, n)
rand.New(rand.NewSource(1)).Read(p)
p[0] = 0xc0
return p
}
func quicShort(n int) []byte {
p := make([]byte, n)
rand.New(rand.NewSource(2)).Read(p)
p[0] = 0x40
return p
}
// --- tests ---
func TestGeckoRoundTripShortHeader(t *testing.T) {
aAddr, bAddr := makeAddrs()
a, b := newMemPipe(aAddr, bAddr)
defer a.Close()
defer b.Close()
ga := mustWrapGecko(t, a, "test")
gb := mustWrapGecko(t, b, "test")
defer ga.Close()
defer gb.Close()
payload := quicShort(400)
if _, err := ga.WriteTo(payload, bAddr); err != nil {
t.Fatalf("WriteTo: %v", err)
}
// Short-header packets should produce exactly one wire datagram.
if got := a.writeCount.Load(); got != 1 {
t.Fatalf("inner writes = %d, want 1", got)
}
buf := make([]byte, 4096)
n, src, err := gb.ReadFrom(buf)
if err != nil {
t.Fatalf("ReadFrom: %v", err)
}
if !bytes.Equal(buf[:n], payload) {
t.Fatalf("payload mismatch")
}
if src.String() != aAddr.String() {
t.Fatalf("src = %v, want %v", src, aAddr)
}
}
func TestGeckoRoundTripLongHeader(t *testing.T) {
aAddr, bAddr := makeAddrs()
a, b := newMemPipe(aAddr, bAddr)
defer a.Close()
defer b.Close()
ga := mustWrapGecko(t, a, "test")
gb := mustWrapGecko(t, b, "test")
defer ga.Close()
defer gb.Close()
payload := quicLong(1200)
if _, err := ga.WriteTo(payload, bAddr); err != nil {
t.Fatalf("WriteTo: %v", err)
}
chunks := a.writeCount.Load()
if chunks < geckoMinFragmentChunks || chunks > geckoMaxFragmentChunks {
t.Fatalf("inner writes = %d, want in [%d,%d]", chunks, geckoMinFragmentChunks, geckoMaxFragmentChunks)
}
buf := make([]byte, 4096)
n, _, err := gb.ReadFrom(buf)
if err != nil {
t.Fatalf("ReadFrom: %v", err)
}
if !bytes.Equal(buf[:n], payload) {
t.Fatalf("payload mismatch")
}
}
func TestGeckoRoundTripSmallLongHeader(t *testing.T) {
for _, size := range []int{1, 2, 5, 10, 15, 20, 25, 27, 30, 40, 64, 128} {
t.Run(fmt.Sprintf("size=%d", size), func(t *testing.T) {
aAddr, bAddr := makeAddrs()
a, b := newMemPipe(aAddr, bAddr)
defer a.Close()
defer b.Close()
ga := mustWrapGecko(t, a, "test")
gb := mustWrapGecko(t, b, "test")
defer ga.Close()
defer gb.Close()
payload := quicLong(size)
if _, err := ga.WriteTo(payload, bAddr); err != nil {
t.Fatalf("WriteTo: %v", err)
}
buf := make([]byte, 4096)
n, _, err := gb.ReadFrom(buf)
if err != nil {
t.Fatalf("ReadFrom: %v", err)
}
if !bytes.Equal(buf[:n], payload) {
t.Fatalf("payload mismatch: got %d bytes, want %d", n, size)
}
})
}
}
func TestGeckoWriteFragmentedNeverPanics(t *testing.T) {
aAddr, bAddr := makeAddrs()
a, b := newMemPipe(aAddr, bAddr)
defer a.Close()
defer b.Close()
stop := make(chan struct{})
defer close(stop)
drainInbox(b, stop)
g := mustWrapGecko(t, a, "test").(*geckoPacketConn)
defer g.Close()
for size := 1; size <= 64; size++ {
payload := quicLong(size)
if _, err := g.WriteTo(payload, bAddr); err != nil {
t.Fatalf("size=%d: WriteTo: %v", size, err)
}
}
}
func TestGeckoReassemblesOutOfOrder(t *testing.T) {
aAddr, bAddr := makeAddrs()
a, b := newMemPipe(aAddr, bAddr)
defer a.Close()
defer b.Close()
// We need exclusive access to b's inbox to reorder. Wrap a side directly,
// receive raw datagrams from b, shuffle them into a private pipe that
// feeds the receiver gecko.
ga := mustWrapGecko(t, a, "test")
defer ga.Close()
payload := quicLong(900)
if _, err := ga.WriteTo(payload, bAddr); err != nil {
t.Fatalf("WriteTo: %v", err)
}
// Drain b's inbox.
var pkts []memPacket
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) && a.writeCount.Load() > int64(len(pkts)) {
select {
case pkt := <-b.inbox:
pkts = append(pkts, pkt)
case <-time.After(50 * time.Millisecond):
}
}
if int64(len(pkts)) != a.writeCount.Load() {
t.Fatalf("captured %d/%d", len(pkts), a.writeCount.Load())
}
// Build a private pipe and feed packets in reverse order.
c, d := newMemPipe(aAddr, bAddr)
gd := mustWrapGecko(t, d, "test")
defer gd.Close()
defer c.Close()
go func() {
for i := len(pkts) - 1; i >= 0; i-- {
d.inbox <- pkts[i]
}
}()
buf := make([]byte, 4096)
n, _, err := gd.ReadFrom(buf)
if err != nil {
t.Fatalf("ReadFrom: %v", err)
}
if !bytes.Equal(buf[:n], payload) {
t.Fatalf("payload mismatch after reorder")
}
}
func TestGeckoExpiresIncompleteFragment(t *testing.T) {
aAddr, bAddr := makeAddrs()
a, b := newMemPipe(aAddr, bAddr)
defer a.Close()
defer b.Close()
// Drop the first chunk so reassembly can never complete.
a.dropFn = func(idx int) bool { return idx == 0 }
ga := mustWrapGecko(t, a, "test")
gb := mustWrapGecko(t, b, "test").(*geckoPacketConn)
defer ga.Close()
defer gb.Close()
payload := quicLong(900)
if _, err := ga.WriteTo(payload, bAddr); err != nil {
t.Fatalf("WriteTo: %v", err)
}
// Read all incoming packets in a goroutine; ReadFrom should never return
// because no message is complete.
done := make(chan struct{})
go func() {
buf := make([]byte, 4096)
gb.ReadFrom(buf)
close(done)
}()
// Wait until the reassembly entry exists, then fast-forward time.
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
gb.mu.Lock()
n := len(gb.reassembly)
gb.mu.Unlock()
if n > 0 {
break
}
time.Sleep(5 * time.Millisecond)
}
gb.mu.Lock()
if len(gb.reassembly) == 0 {
gb.mu.Unlock()
t.Fatal("expected at least one reassembly entry")
}
gb.mu.Unlock()
gb.gcExpired(time.Now().Add(geckoReassemblyTTL + time.Second))
gb.mu.Lock()
if len(gb.reassembly) != 0 {
gb.mu.Unlock()
t.Fatalf("reassembly map not empty after gc")
}
if len(gb.perSource) != 0 {
gb.mu.Unlock()
t.Fatalf("perSource map not empty after gc")
}
gb.mu.Unlock()
// Cleanup: closing gb unblocks the reader goroutine.
gb.Close()
<-done
}
func TestGeckoEnforcesPerSourceCap(t *testing.T) {
aAddr, bAddr := makeAddrs()
a, b := newMemPipe(aAddr, bAddr)
defer a.Close()
defer b.Close()
// Drop every second chunk onward so reassembly never completes; the
// receiver accumulates partial entries.
a.dropFn = func(idx int) bool { return idx > 0 && idx%2 == 0 }
ga := mustWrapGecko(t, a, "test")
gb := mustWrapGecko(t, b, "test").(*geckoPacketConn)
defer ga.Close()
defer gb.Close()
go func() {
buf := make([]byte, 4096)
for {
if _, _, err := gb.ReadFrom(buf); err != nil {
return
}
}
}()
for i := 0; i < geckoMaxPerSource+5; i++ {
if _, err := ga.WriteTo(quicLong(1200), bAddr); err != nil {
t.Fatalf("WriteTo %d: %v", i, err)
}
}
// Wait for the receiver to settle.
time.Sleep(100 * time.Millisecond)
gb.mu.Lock()
defer gb.mu.Unlock()
count := gb.perSource[aAddr.String()]
if count > geckoMaxPerSource {
t.Fatalf("perSource = %d, want <= %d", count, geckoMaxPerSource)
}
}
func TestGeckoEvictsOldestOnGlobalCap(t *testing.T) {
g := newGeckoPacketConn(nil, geckoDefaultMinPacket, geckoDefaultMaxPacket) // not actually used for I/O
defer close(g.closeCh)
// Manually fill the reassembly map past the cap with entries from
// distinct sources (so the per-source cap doesn't trigger first).
now := time.Now()
for i := 0; i < geckoMaxReassembly; i++ {
key := reassemblyKey{addr: fmt.Sprintf("src-%d", i), msgID: 1}
g.reassembly[key] = &reassemblyEntry{
chunks: make([][]byte, 4),
total: 4,
deadline: now.Add(time.Duration(i) * time.Millisecond),
}
g.perSource[key.addr]++
}
// Trigger eviction.
g.mu.Lock()
g.evictOldestLocked()
g.mu.Unlock()
if len(g.reassembly) != geckoMaxReassembly-1 {
t.Fatalf("after evict len = %d, want %d", len(g.reassembly), geckoMaxReassembly-1)
}
// The "oldest" was the one with the smallest deadline → src-0.
if _, ok := g.reassembly[reassemblyKey{addr: "src-0", msgID: 1}]; ok {
t.Fatal("oldest entry not evicted")
}
if g.perSource["src-0"] != 0 {
t.Fatalf("perSource[src-0] = %d after eviction, want 0", g.perSource["src-0"])
}
}
func TestGeckoBoundedUnderGarbageFlood(t *testing.T) {
aAddr, bAddr := makeAddrs()
a, b := newMemPipe(aAddr, bAddr)
defer a.Close()
defer b.Close()
gb := mustWrapGecko(t, b, "test").(*geckoPacketConn)
defer gb.Close()
go func() {
buf := make([]byte, 4096)
for {
if _, _, err := gb.ReadFrom(buf); err != nil {
return
}
}
}()
// Inject 50k random datagrams from a moderate number of distinct sources.
rng := rand.New(rand.NewSource(42))
for i := 0; i < 50_000; i++ {
size := 16 + rng.Intn(200)
junk := make([]byte, size)
rng.Read(junk)
src := &net.UDPAddr{IP: net.IPv4(10, 0, 0, byte(i%256)), Port: 1024 + i%4096}
select {
case b.inbox <- memPacket{src: src, data: junk}:
case <-time.After(time.Second):
t.Fatal("inbox blocked")
}
}
// Let the receiver drain.
for i := 0; i < 50; i++ {
if a.pendingInbox() == 0 && b.pendingInbox() == 0 {
break
}
time.Sleep(20 * time.Millisecond)
}
gb.mu.Lock()
defer gb.mu.Unlock()
if len(gb.reassembly) > geckoMaxReassembly {
t.Fatalf("reassembly map size = %d, exceeds cap %d", len(gb.reassembly), geckoMaxReassembly)
}
}
func TestGeckoUDPPassthrough(t *testing.T) {
udp, err := net.ListenUDP("udp", &net.UDPAddr{})
if err != nil {
t.Fatal(err)
}
defer udp.Close()
g, err := WrapPacketConnGecko(udp, GeckoOptions{Password: []byte("test")})
if err != nil {
t.Fatal(err)
}
defer g.Close()
type udpLike interface {
SyscallConn() (syscall.RawConn, error)
SetReadBuffer(int) error
SetWriteBuffer(int) error
}
u, ok := g.(udpLike)
if !ok {
t.Fatal("gecko conn does not expose UDP-flavor methods")
}
if rc, err := u.SyscallConn(); err != nil || rc == nil {
t.Fatalf("SyscallConn: %v %v", rc, err)
}
if err := u.SetReadBuffer(1 << 20); err != nil {
t.Fatalf("SetReadBuffer: %v", err)
}
if err := u.SetWriteBuffer(1 << 20); err != nil {
t.Fatalf("SetWriteBuffer: %v", err)
}
}
func TestGeckoRequiresPassword(t *testing.T) {
if _, err := WrapPacketConnGecko(nil, GeckoOptions{}); err == nil {
t.Fatal("expected error for missing password")
}
}
func TestGeckoRejectsInvalidPacketSize(t *testing.T) {
cases := []GeckoOptions{
{Password: []byte("x"), MinPacketSize: 1000, MaxPacketSize: 500}, // min > max
{Password: []byte("x"), MinPacketSize: -1}, // min <= 0
{Password: []byte("x"), MaxPacketSize: geckoBufferSize + 1}, // max too large
}
for i, opt := range cases {
if _, err := WrapPacketConnGecko(nil, opt); err == nil {
t.Fatalf("case %d: expected error", i)
}
}
}
// TestGeckoPaddingWithinBounds verifies every fragmented wire datagram is
// padded into the configured [min, max] size band.
func TestGeckoPaddingWithinBounds(t *testing.T) {
const minSize, maxSize = 400, 900
aAddr, bAddr := makeAddrs()
a, b := newMemPipe(aAddr, bAddr)
defer a.Close()
defer b.Close()
ga, err := WrapPacketConnGecko(a, GeckoOptions{
Password: []byte("test"),
MinPacketSize: minSize,
MaxPacketSize: maxSize,
})
if err != nil {
t.Fatalf("WrapPacketConnGecko: %v", err)
}
defer ga.Close()
for _, size := range []int{1, 50, 200, 600, 1200} {
if _, err := ga.WriteTo(quicLong(size), bAddr); err != nil {
t.Fatalf("WriteTo size=%d: %v", size, err)
}
}
for {
select {
case pkt := <-b.inbox:
if len(pkt.data) < minSize || len(pkt.data) > maxSize {
t.Fatalf("datagram size %d outside [%d, %d]", len(pkt.data), minSize, maxSize)
}
default:
return
}
}
}
// Compile-time interface assertions.
var (
_ net.PacketConn = (*geckoPacketConn)(nil)
_ udpLikePacketConn = (*geckoPacketConn)(nil)
)
// Sanity: errors.ErrUnsupported is what we return when inner isn't UDP-like.
func TestGeckoNonUDPInnerReturnsUnsupported(t *testing.T) {
aAddr, bAddr := makeAddrs()
a, _ := newMemPipe(aAddr, bAddr)
defer a.Close()
g := mustWrapGecko(t, a, "test").(*geckoPacketConn)
defer g.Close()
if _, err := g.SyscallConn(); !errors.Is(err, errors.ErrUnsupported) {
t.Fatalf("SyscallConn err = %v, want ErrUnsupported", err)
}
if err := g.SetReadBuffer(1 << 20); !errors.Is(err, errors.ErrUnsupported) {
t.Fatalf("SetReadBuffer err = %v, want ErrUnsupported", err)
}
if err := g.SetWriteBuffer(1 << 20); !errors.Is(err, errors.ErrUnsupported) {
t.Fatalf("SetWriteBuffer err = %v, want ErrUnsupported", err)
}
}
// --- benchmarks ---
// drainInbox empties an inbox in a goroutine until done is closed; used in
// benchmarks so a full memEnd.inbox never blocks WriteTo.
func drainInbox(end *memEnd, done <-chan struct{}) {
go func() {
for {
select {
case <-done:
return
case <-end.inbox:
}
}
}()
}
func BenchmarkGeckoWriteShortHeader(b *testing.B) {
aAddr, bAddr := makeAddrs()
a, bEnd := newMemPipe(aAddr, bAddr)
defer a.Close()
defer bEnd.Close()
stop := make(chan struct{})
defer close(stop)
drainInbox(bEnd, stop)
g, _ := WrapPacketConnGecko(a, GeckoOptions{Password: []byte("bench")})
defer g.Close()
payload := quicShort(400)
b.SetBytes(int64(len(payload)))
b.ResetTimer()
for i := 0; i < b.N; i++ {
if _, err := g.WriteTo(payload, bAddr); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkGeckoWriteLongHeader(b *testing.B) {
aAddr, bAddr := makeAddrs()
a, bEnd := newMemPipe(aAddr, bAddr)
defer a.Close()
defer bEnd.Close()
stop := make(chan struct{})
defer close(stop)
drainInbox(bEnd, stop)
g, _ := WrapPacketConnGecko(a, GeckoOptions{Password: []byte("bench")})
defer g.Close()
payload := quicLong(1200)
b.SetBytes(int64(len(payload)))
b.ResetTimer()
for i := 0; i < b.N; i++ {
if _, err := g.WriteTo(payload, bAddr); err != nil {
b.Fatal(err)
}
}
}
// captureGeckoWire fragments payload through a sender Gecko conn and returns
// the resulting wire datagrams, for use as fixed input to read benchmarks.
func captureGeckoWire(b *testing.B, payload []byte) []memPacket {
b.Helper()
aAddr, bAddr := makeAddrs()
a, bEnd := newMemPipe(aAddr, bAddr)
defer a.Close()
defer bEnd.Close()
ga, err := WrapPacketConnGecko(a, GeckoOptions{Password: []byte("bench")})
if err != nil {
b.Fatal(err)
}
defer ga.Close()
if _, err := ga.WriteTo(payload, bAddr); err != nil {
b.Fatal(err)
}
var wire []memPacket
for {
select {
case pkt := <-bEnd.inbox:
wire = append(wire, pkt)
default:
return wire
}
}
}
func BenchmarkGeckoReadShortHeader(b *testing.B) {
wire := captureGeckoWire(b, quicShort(400))
_, recv := newMemPipe(makeAddrs())
gb, err := WrapPacketConnGecko(recv, GeckoOptions{Password: []byte("bench")})
if err != nil {
b.Fatal(err)
}
defer gb.Close()
buf := make([]byte, 4096)
b.SetBytes(400)
b.ResetTimer()
for i := 0; i < b.N; i++ {
for _, pkt := range wire {
recv.inbox <- pkt
}
if _, _, err := gb.ReadFrom(buf); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkGeckoReadLongHeader(b *testing.B) {
wire := captureGeckoWire(b, quicLong(1200))
_, recv := newMemPipe(makeAddrs())
gb, err := WrapPacketConnGecko(recv, GeckoOptions{Password: []byte("bench")})
if err != nil {
b.Fatal(err)
}
defer gb.Close()
buf := make([]byte, 4096)
b.SetBytes(1200)
b.ResetTimer()
for i := 0; i < b.N; i++ {
for _, pkt := range wire {
recv.inbox <- pkt
}
if _, _, err := gb.ReadFrom(buf); err != nil {
b.Fatal(err)
}
}
}

View file

@ -3,6 +3,7 @@ package obfs
import (
"fmt"
"math/rand"
"net"
"sync"
"time"
@ -15,14 +16,14 @@ const (
smKeyLen = blake2b.Size256
)
var _ Obfuscator = (*SalamanderObfuscator)(nil)
var _ obfuscator = (*salamanderObfuscator)(nil)
var ErrPSKTooShort = fmt.Errorf("PSK must be at least %d bytes", smPSKMinLen)
// SalamanderObfuscator is an obfuscator that obfuscates each packet with
// salamanderObfuscator is an obfuscator that obfuscates each packet with
// the BLAKE2b-256 hash of a pre-shared key combined with a random salt.
// Packet format: [8-byte salt][payload]
type SalamanderObfuscator struct {
type salamanderObfuscator struct {
PSK []byte
RandSrc *rand.Rand
@ -30,21 +31,32 @@ type SalamanderObfuscator struct {
keyInput []byte
}
func NewSalamanderObfuscator(psk []byte) (*SalamanderObfuscator, error) {
func newSalamanderObfuscator(psk []byte) (*salamanderObfuscator, error) {
if len(psk) < smPSKMinLen {
return nil, ErrPSKTooShort
}
pskCopy := append([]byte(nil), psk...)
keyInput := make([]byte, len(pskCopy)+smSaltLen)
copy(keyInput, pskCopy)
return &SalamanderObfuscator{
return &salamanderObfuscator{
PSK: pskCopy,
RandSrc: rand.New(rand.NewSource(time.Now().UnixNano())),
keyInput: keyInput,
}, nil
}
func (o *SalamanderObfuscator) Obfuscate(in, out []byte) int {
// WrapPacketConnSalamander wraps conn with Salamander obfuscation: each
// outbound packet is XOR'd with BLAKE2b-256(PSK || random salt) and the
// 8-byte salt is prepended on the wire.
func WrapPacketConnSalamander(conn net.PacketConn, psk []byte) (net.PacketConn, error) {
ob, err := newSalamanderObfuscator(psk)
if err != nil {
return nil, err
}
return wrapPacketConn(conn, ob), nil
}
func (o *salamanderObfuscator) Obfuscate(in, out []byte) int {
outLen := len(in) + smSaltLen
if len(out) < outLen {
return 0
@ -59,7 +71,7 @@ func (o *SalamanderObfuscator) Obfuscate(in, out []byte) int {
return outLen
}
func (o *SalamanderObfuscator) Deobfuscate(in, out []byte) int {
func (o *salamanderObfuscator) Deobfuscate(in, out []byte) int {
outLen := len(in) - smSaltLen
if outLen <= 0 || len(out) < outLen {
return 0
@ -73,7 +85,7 @@ func (o *SalamanderObfuscator) Deobfuscate(in, out []byte) int {
return outLen
}
func (o *SalamanderObfuscator) keyLocked(salt []byte) [smKeyLen]byte {
func (o *salamanderObfuscator) keyLocked(salt []byte) [smKeyLen]byte {
copy(o.keyInput[len(o.PSK):], salt[:smSaltLen])
return blake2b.Sum256(o.keyInput)
}

View file

@ -8,7 +8,7 @@ import (
)
func BenchmarkSalamanderObfuscator_Obfuscate(b *testing.B) {
o, _ := NewSalamanderObfuscator([]byte("average_password"))
o, _ := newSalamanderObfuscator([]byte("average_password"))
in := make([]byte, 1200)
_, _ = rand.Read(in)
out := make([]byte, 2048)
@ -19,7 +19,7 @@ func BenchmarkSalamanderObfuscator_Obfuscate(b *testing.B) {
}
func BenchmarkSalamanderObfuscator_Deobfuscate(b *testing.B) {
o, _ := NewSalamanderObfuscator([]byte("average_password"))
o, _ := newSalamanderObfuscator([]byte("average_password"))
in := make([]byte, 1200)
_, _ = rand.Read(in)
out := make([]byte, 2048)
@ -30,7 +30,7 @@ func BenchmarkSalamanderObfuscator_Deobfuscate(b *testing.B) {
}
func TestSalamanderObfuscator(t *testing.T) {
o, _ := NewSalamanderObfuscator([]byte("average_password"))
o, _ := newSalamanderObfuscator([]byte("average_password"))
in := make([]byte, 1200)
oOut := make([]byte, 2048)
dOut := make([]byte, 2048)