Forráskód Böngészése

Add obfuscated QUIC

Rod Hynes 7 éve
szülő
commit
ccd8186ea9

+ 5 - 3
psiphon/common/protocol/protocol.go

@@ -230,15 +230,17 @@ func (profiles TLSProfiles) PruneInvalid() TLSProfiles {
 }
 
 const (
-	QUIC_VERSION_GQUIC39 = "gQUICv39"
-	QUIC_VERSION_GQUIC43 = "gQUICv43"
-	QUIC_VERSION_GQUIC44 = "gQUICv44"
+	QUIC_VERSION_GQUIC39    = "gQUICv39"
+	QUIC_VERSION_GQUIC43    = "gQUICv43"
+	QUIC_VERSION_GQUIC44    = "gQUICv44"
+	QUIC_VERSION_OBFUSCATED = "OBFUSCATED"
 )
 
 var SupportedQUICVersions = QUICVersions{
 	QUIC_VERSION_GQUIC39,
 	QUIC_VERSION_GQUIC43,
 	QUIC_VERSION_GQUIC44,
+	QUIC_VERSION_OBFUSCATED,
 }
 
 type QUICVersions []string

+ 329 - 0
psiphon/common/quic/obfuscator.go

@@ -0,0 +1,329 @@
+/*
+ * Copyright (c) 2018, Psiphon Inc.
+ * All rights reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+package quic
+
+import (
+	"bytes"
+	"crypto/rand"
+	"crypto/sha256"
+	"fmt"
+	"io"
+	"net"
+	"sync"
+	"sync/atomic"
+	"time"
+
+	"github.com/Psiphon-Labs/goarista/monotime"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/crypto/hkdf"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/crypto/salsa20"
+)
+
+const (
+	MAX_QUIC_IPV4_PACKET_SIZE            = 1252
+	MAX_QUIC_IPV6_PACKET_SIZE            = 1232
+	MAX_OBFUSCATED_QUIC_IPV4_PACKET_SIZE = 1372
+	MAX_OBFUSCATED_QUIC_IPV6_PACKET_SIZE = 1352
+	MAX_PADDING_SIZE                     = 64
+)
+
+type ObfuscatedPacketConn struct {
+	net.PacketConn
+	isServer       bool
+	isClosed       int32
+	runWaitGroup   *sync.WaitGroup
+	stopBroadcast  chan struct{}
+	obfuscationKey [32]byte
+	peerModesMutex sync.Mutex
+	peerModes      map[string]*peerMode
+}
+
+type peerMode struct {
+	isObfuscated   bool
+	lastPacketTime monotime.Time
+}
+
+func (p *peerMode) isStale() bool {
+	return monotime.Since(p.lastPacketTime) >= SERVER_IDLE_TIMEOUT
+}
+
+func NewObfuscatedPacketConnPacketConn(
+	conn net.PacketConn,
+	isServer bool,
+	obfuscationKey string) (*ObfuscatedPacketConn, error) {
+
+	packetConn := &ObfuscatedPacketConn{
+		PacketConn: conn,
+		isServer:   isServer,
+		peerModes:  make(map[string]*peerMode),
+	}
+
+	secret := []byte(obfuscationKey)
+	salt := []byte("quic-obfuscation-key")
+	_, err := io.ReadFull(
+		hkdf.New(sha256.New, secret, salt, nil), packetConn.obfuscationKey[:])
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	if isServer {
+
+		packetConn.runWaitGroup = new(sync.WaitGroup)
+		packetConn.stopBroadcast = make(chan struct{}, 1)
+
+		// Reap stale peer mode information to reclaim memory.
+
+		packetConn.runWaitGroup.Add(1)
+		go func() {
+			defer packetConn.runWaitGroup.Done()
+
+			ticker := time.NewTicker(SERVER_IDLE_TIMEOUT / 2)
+			defer ticker.Stop()
+			for {
+				select {
+				case <-ticker.C:
+					packetConn.peerModesMutex.Lock()
+					for address, mode := range packetConn.peerModes {
+						if mode.isStale() {
+							delete(packetConn.peerModes, address)
+						}
+					}
+					packetConn.peerModesMutex.Unlock()
+				case <-packetConn.stopBroadcast:
+					return
+				}
+			}
+		}()
+	}
+
+	return packetConn, nil
+}
+
+func (conn *ObfuscatedPacketConn) Close() error {
+
+	// Ensure close channel only called once.
+	if !atomic.CompareAndSwapInt32(&conn.isClosed, 0, 1) {
+		return nil
+	}
+
+	if conn.isServer {
+		close(conn.stopBroadcast)
+		conn.runWaitGroup.Wait()
+	}
+
+	return conn.PacketConn.Close()
+}
+
+func (conn *ObfuscatedPacketConn) ReadFrom(p []byte) (int, net.Addr, error) {
+
+	n, addr, err := conn.PacketConn.ReadFrom(p)
+
+	if n > 0 {
+
+		isObfuscated := true
+
+		if conn.isServer {
+
+			// The server handles both plain and obfuscated QUIC packets.
+			// isQUIC performs DPI to determine whether the packet appears to
+			// be QUIC, in which case deobfuscation is not performed. Not all
+			// plain QUIC packets will pass the DPI test, but the initial
+			// packet(s) in a flow are expected to match; so the server
+			// records a peer "mode", referenced by peer address to know when
+			// to skip deobfuscation for later packets.
+			//
+			// It's possible for clients to redial QUIC connections,
+			// transitioning from obfuscated to plain, using the same source
+			// address (IP and port). This is more likely when many clients
+			// are behind NAT. If a packet appears to be QUIC, this will reset
+			// any existing peer "mode" to plain. The obfuscator checks that
+			// its obfuscated packets don't pass the QUIC DPI test.
+			//
+			// TODO: delete peerMode when a packet is a client connection
+			// termination QUIC packet? Will reclaim peerMode memory faster
+			// than relying on reaper.
+
+			isQUIC := isQUIC(p[:n])
+
+			conn.peerModesMutex.Lock()
+			address := addr.String()
+			mode, ok := conn.peerModes[address]
+			if !ok {
+				mode = &peerMode{isObfuscated: !isQUIC}
+				conn.peerModes[address] = mode
+			} else if mode.isStale() {
+				mode.isObfuscated = !isQUIC
+			} else if mode.isObfuscated && isQUIC {
+				mode.isObfuscated = false
+			}
+			isObfuscated = mode.isObfuscated
+			mode.lastPacketTime = monotime.Now()
+			conn.peerModesMutex.Unlock()
+
+		}
+
+		if isObfuscated {
+
+			// We can use p as a scratch buffer for deobfuscation, and this
+			// avoids allocting a buffer.
+
+			if n < 9 {
+				return n, addr, common.ContextError(
+					fmt.Errorf("unexpected obfuscated QUIC packet length: %d", n))
+			}
+
+			salsa20.XORKeyStream(p[8:], p[8:], p[0:8], &conn.obfuscationKey)
+
+			paddingLen := int(p[8])
+			if paddingLen > MAX_PADDING_SIZE || paddingLen > n-9 {
+				return n, addr, common.ContextError(
+					fmt.Errorf("unexpected padding length: %d, %d", paddingLen, n))
+			}
+
+			n -= 9 + paddingLen
+			copy(p[0:n], p[9+paddingLen:n+9+paddingLen])
+		}
+	}
+
+	return n, addr, err
+}
+
+type obfuscatorBuffer struct {
+	buffer [MAX_OBFUSCATED_QUIC_IPV4_PACKET_SIZE]byte
+}
+
+var obfuscatorBufferPool = &sync.Pool{
+	New: func() interface{} {
+		return new(obfuscatorBuffer)
+	},
+}
+
+func getMaxPacketSizes(addr net.Addr) (int, int) {
+	if udpAddr, ok := addr.(*net.UDPAddr); ok && udpAddr.IP.To4() == nil {
+		return MAX_QUIC_IPV6_PACKET_SIZE, MAX_OBFUSCATED_QUIC_IPV6_PACKET_SIZE
+	}
+	return MAX_QUIC_IPV4_PACKET_SIZE, MAX_OBFUSCATED_QUIC_IPV4_PACKET_SIZE
+}
+
+func (conn *ObfuscatedPacketConn) WriteTo(p []byte, addr net.Addr) (int, error) {
+
+	n := len(p)
+
+	isObfuscated := true
+
+	if conn.isServer {
+
+		conn.peerModesMutex.Lock()
+		address := addr.String()
+		mode, ok := conn.peerModes[address]
+		isObfuscated = ok && mode.isObfuscated
+		conn.peerModesMutex.Unlock()
+
+	}
+
+	if isObfuscated {
+
+		maxQUICPacketSize, maxObfuscatedPacketSize := getMaxPacketSizes(addr)
+
+		if n > maxQUICPacketSize {
+			return 0, common.ContextError(
+				fmt.Errorf("unexpected QUIC packet length: %d", n))
+		}
+
+		// Note: escape analysis showed a local array escaping to the heap,
+		// so use a buffer pool instead to avoid heap allocation per packet.
+
+		b := obfuscatorBufferPool.Get().(*obfuscatorBuffer)
+		buffer := b.buffer[:]
+		defer obfuscatorBufferPool.Put(b)
+
+		for {
+			_, err := rand.Read(buffer[0:8])
+			if err != nil {
+				return 0, common.ContextError(err)
+			}
+
+			// Don't use a random nonce that looks like QUIC, or the
+			// peer will not treat this packet as obfuscated.
+			if !isQUIC(buffer[:]) {
+				break
+			}
+		}
+
+		// Obfuscated QUIC padding results in packets that exceed the
+		// QUIC max packet size of 1280.
+
+		maxPaddingSize := maxObfuscatedPacketSize - (n + 9)
+		if maxPaddingSize < 0 {
+			maxPaddingSize = 0
+		}
+		if maxPaddingSize > MAX_PADDING_SIZE {
+			maxPaddingSize = MAX_PADDING_SIZE
+		}
+
+		paddingLen, err := common.MakeSecureRandomRange(0, maxPaddingSize)
+		if err != nil {
+			return 0, common.ContextError(err)
+		}
+
+		buffer[8] = uint8(paddingLen)
+		_, err = rand.Read(buffer[9 : 9+paddingLen])
+		if err != nil {
+			return 0, common.ContextError(err)
+		}
+
+		copy(buffer[9+paddingLen:], p)
+		dataLen := 9 + paddingLen + n
+
+		salsa20.XORKeyStream(
+			buffer[8:dataLen], buffer[8:dataLen], buffer[0:8], &conn.obfuscationKey)
+
+		p = buffer[:dataLen]
+	}
+
+	_, err := conn.PacketConn.WriteTo(p, addr)
+
+	return n, err
+}
+
+var fingerprints = [][]byte{
+	[]byte("CHLO"),
+	[]byte("SNI"),
+}
+
+func isQUIC(buffer []byte) bool {
+
+	// Note: we tested and rejected QUIC nDPI's detection:
+	// https://github.com/ntop/nDPI/blob/01bf295a19c19dc4f521ee40f0c478c794e1b5e4/src/lib/protocols/quic.c#L63
+	// nDPI assumes that, in the first client packet, the version is always present and starts with "Q"; and
+	// that certain Public Flags are set/unset. However v44 appears to send an initial packet with no version.
+	//
+	// In all currently supported versions, the first client packet contains the "CHLO" and "SNI" tags.
+	//
+	// TODO: compute and look for tags in exact offsets
+
+	for _, fingerprint := range fingerprints {
+		if -1 == bytes.Index(buffer, fingerprint) {
+			return false
+		}
+	}
+
+	return true
+}

+ 36 - 7
psiphon/common/quic/quic.go

@@ -71,7 +71,9 @@ type Listener struct {
 }
 
 // Listen creates a new Listener.
-func Listen(addr string) (*Listener, error) {
+func Listen(
+	address string,
+	obfuscationKey string) (*Listener, error) {
 
 	certificate, privateKey, err := common.GenerateWebServerCertificate(
 		common.GenerateHostName())
@@ -97,8 +99,24 @@ func Listen(addr string) (*Listener, error) {
 		KeepAlive:             true,
 	}
 
-	quicListener, err := quic_go.ListenAddr(
-		addr, tlsConfig, quicConfig)
+	addr, err := net.ResolveUDPAddr("udp", address)
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	udpConn, err := net.ListenUDP("udp", addr)
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	packetConn, err := NewObfuscatedPacketConnPacketConn(
+		udpConn, true, obfuscationKey)
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	quicListener, err := quic_go.Listen(
+		packetConn, tlsConfig, quicConfig)
 	if err != nil {
 		return nil, common.ContextError(err)
 	}
@@ -126,9 +144,10 @@ func (listener *Listener) Accept() (net.Conn, error) {
 }
 
 var supportedVersionNumbers = map[string]quic_go.VersionNumber{
-	protocol.QUIC_VERSION_GQUIC39: quic_go.VersionGQUIC39,
-	protocol.QUIC_VERSION_GQUIC43: quic_go.VersionGQUIC43,
-	protocol.QUIC_VERSION_GQUIC44: quic_go.VersionGQUIC44,
+	protocol.QUIC_VERSION_GQUIC39:    quic_go.VersionGQUIC39,
+	protocol.QUIC_VERSION_GQUIC43:    quic_go.VersionGQUIC43,
+	protocol.QUIC_VERSION_GQUIC44:    quic_go.VersionGQUIC44,
+	protocol.QUIC_VERSION_OBFUSCATED: quic_go.VersionGQUIC43,
 }
 
 // Dial establishes a new QUIC session and stream to the server specified by
@@ -145,7 +164,8 @@ func Dial(
 	packetConn net.PacketConn,
 	remoteAddr *net.UDPAddr,
 	quicSNIAddress string,
-	negotiateQUICVersion string) (net.Conn, error) {
+	negotiateQUICVersion string,
+	obfuscationKey string) (net.Conn, error) {
 
 	var versions []quic_go.VersionNumber
 
@@ -169,6 +189,15 @@ func Dial(
 		quicConfig.HandshakeTimeout = deadline.Sub(time.Now())
 	}
 
+	if negotiateQUICVersion == protocol.QUIC_VERSION_OBFUSCATED {
+		var err error
+		packetConn, err = NewObfuscatedPacketConnPacketConn(
+			packetConn, false, obfuscationKey)
+		if err != nil {
+			return nil, common.ContextError(err)
+		}
+	}
+
 	session, err := quic_go.DialContext(
 		ctx,
 		packetConn,

+ 8 - 2
psiphon/common/quic/quic_test.go

@@ -57,7 +57,12 @@ func runQUIC(t *testing.T, negotiateQUICVersion string) {
 	// connection termination packets.
 	serverIdleTimeout = 1 * time.Second
 
-	listener, err := Listen("127.0.0.1:0")
+	obfuscationKey, err := common.MakeSecureRandomStringHex(32)
+	if err != nil {
+		t.Fatalf("MakeSecureRandomStringHex failed: %s", err)
+	}
+
+	listener, err := Listen("127.0.0.1:0", obfuscationKey)
 	if err != nil {
 		t.Fatalf("Listen failed: %s", err)
 	}
@@ -126,7 +131,8 @@ func runQUIC(t *testing.T, negotiateQUICVersion string) {
 				packetConn,
 				remoteAddr,
 				serverAddress,
-				negotiateQUICVersion)
+				negotiateQUICVersion,
+				obfuscationKey)
 			if err != nil {
 				return common.ContextError(err)
 			}

+ 3 - 1
psiphon/server/tunnelServer.go

@@ -144,7 +144,9 @@ func (server *TunnelServer) Run() error {
 
 		if protocol.TunnelProtocolUsesQUIC(tunnelProtocol) {
 
-			listener, err = quic.Listen(localAddress)
+			listener, err = quic.Listen(
+				localAddress,
+				support.Config.ObfuscatedSSHKey)
 
 		} else if protocol.TunnelProtocolUsesMarionette(tunnelProtocol) {
 

+ 2 - 1
psiphon/tunnel.go

@@ -903,7 +903,8 @@ func dialSsh(
 			packetConn,
 			remoteAddr,
 			quicDialSNIAddress,
-			selectQUICVersion(config.clientParameters))
+			selectQUICVersion(config.clientParameters),
+			serverEntry.SshObfuscatedKey)
 		if err != nil {
 			return nil, common.ContextError(err)
 		}