Rod Hynes 6 лет назад
Родитель
Сommit
92989ce2e9

+ 0 - 6
psiphon/common/logger.go

@@ -50,9 +50,3 @@ type MetricsSource interface {
 	// metrics from the MetricsSource
 	GetMetrics() LogFields
 }
-
-func SetIrregularTunnelErrorLogField(
-	logFields LogFields, tunnelError error) {
-
-	logFields["tunnel_error"] = tunnelError
-}

+ 10 - 4
psiphon/common/obfuscator/history.go

@@ -107,13 +107,18 @@ func NewSeedHistory(config *SeedHistoryConfig) *SeedHistory {
 }
 
 // AddNew adds a new seed value to the history. If the seed value is already
-// in the history, and an expected case such as a meek retry is ruled out,
-// AddNew returns false.
+// in the history, and an expected case such as a meek retry is ruled out (or
+// strictMode is on), AddNew returns false.
 //
 // When a duplicate seed is found, a common.LogFields instance is returned,
 // populated with event data. Log fields may be returned in either the false
 // or true case.
-func (h *SeedHistory) AddNew(clientIP string, seed []byte) (bool, *common.LogFields) {
+func (h *SeedHistory) AddNew(
+	strictMode bool,
+	clientIP string,
+	seedType string,
+	seed []byte) (bool, *common.LogFields) {
+
 	key := string(seed)
 
 	// Limitation: go-cache-lru does not currently support atomically setting if
@@ -135,6 +140,7 @@ func (h *SeedHistory) AddNew(clientIP string, seed []byte) (bool, *common.LogFie
 
 	logFields := common.LogFields{
 		"duplicate_seed":            hex.EncodeToString(seed),
+		"duplicate_seed_type":       seedType,
 		"duplicate_elapsed_time_ms": int64(time.Since(previousTime.(time.Time)) / time.Millisecond),
 	}
 
@@ -142,7 +148,7 @@ func (h *SeedHistory) AddNew(clientIP string, seed []byte) (bool, *common.LogFie
 	if ok {
 		if clientIP == previousClientIP.(string) {
 			logFields["duplicate_client_ip"] = "equal"
-			return true, &logFields
+			return !strictMode, &logFields
 		} else {
 			logFields["duplicate_client_ip"] = "unequal"
 			return false, &logFields

+ 8 - 2
psiphon/common/obfuscator/obfuscatedSshConn.go

@@ -127,7 +127,10 @@ func NewObfuscatedSSHConn(
 	obfuscationPaddingPRNGSeed *prng.Seed,
 	minPadding, maxPadding *int,
 	seedHistory *SeedHistory,
-	irregularLogger func(clientIP string, logFields common.LogFields)) (*ObfuscatedSSHConn, error) {
+	irregularLogger func(
+		clientIP string,
+		err error,
+		logFields common.LogFields)) (*ObfuscatedSSHConn, error) {
 
 	var err error
 	var obfuscator *Obfuscator
@@ -219,7 +222,10 @@ func NewServerObfuscatedSSHConn(
 	conn net.Conn,
 	obfuscationKeyword string,
 	seedHistory *SeedHistory,
-	irregularLogger func(clientIP string, logFields common.LogFields)) (*ObfuscatedSSHConn, error) {
+	irregularLogger func(
+		clientIP string,
+		err error,
+		logFields common.LogFields)) (*ObfuscatedSSHConn, error) {
 
 	return NewObfuscatedSSHConn(
 		OBFUSCATION_CONN_MODE_SERVER,

+ 13 - 10
psiphon/common/obfuscator/obfuscator.go

@@ -69,8 +69,9 @@ type ObfuscatorConfig struct {
 	// SeedHistory and IrregularLogger are optional parameters used only by
 	// server obfuscators.
 
-	SeedHistory     *SeedHistory
-	IrregularLogger func(clientIP string, logFields common.LogFields)
+	SeedHistory       *SeedHistory
+	StrictHistoryMode bool
+	IrregularLogger   func(clientIP string, err error, logFields common.LogFields)
 }
 
 // NewClientObfuscator creates a new Obfuscator, staging a seed message to be
@@ -300,13 +301,15 @@ func readSeedMessage(
 	errBackTrace := "obfuscator.NewServerObfuscator"
 
 	if config.SeedHistory != nil {
-		ok, duplicateLogFields := config.SeedHistory.AddNew(clientIP, seed)
+		ok, duplicateLogFields := config.SeedHistory.AddNew(
+			config.StrictHistoryMode, clientIP, "obfuscator-seed", seed)
 		errStr := "duplicate obfuscation seed"
 		if duplicateLogFields != nil {
 			if config.IrregularLogger != nil {
-				common.SetIrregularTunnelErrorLogField(
-					*duplicateLogFields, errors.BackTraceNew(errBackTrace, errStr))
-				config.IrregularLogger(clientIP, *duplicateLogFields)
+				config.IrregularLogger(
+					clientIP,
+					errors.BackTraceNew(errBackTrace, errStr),
+					*duplicateLogFields)
 			}
 		}
 		if !ok {
@@ -356,10 +359,10 @@ func readSeedMessage(
 
 	if errStr != "" {
 		if config.IrregularLogger != nil {
-			errLogFields := make(common.LogFields)
-			common.SetIrregularTunnelErrorLogField(
-				errLogFields, errors.BackTraceNew(errBackTrace, errStr))
-			config.IrregularLogger(clientIP, errLogFields)
+			config.IrregularLogger(
+				clientIP,
+				errors.BackTraceNew(errBackTrace, errStr),
+				nil)
 		}
 		return nil, nil, nil, errors.TraceNew(errStr)
 	}

+ 7 - 3
psiphon/common/obfuscator/obfuscator_test.go

@@ -51,7 +51,11 @@ func TestObfuscator(t *testing.T) {
 		MaxPadding:      &maxPadding,
 		PaddingPRNGSeed: paddingPRNGSeed,
 		SeedHistory:     NewSeedHistory(&SeedHistoryConfig{ClientIPTTL: 500 * time.Millisecond}),
-		IrregularLogger: func(_ string, logFields common.LogFields) {
+		IrregularLogger: func(_ string, err error, logFields common.LogFields) {
+			if logFields == nil {
+				logFields = make(common.LogFields)
+			}
+			logFields["tunnel_error"] = err.Error()
 			irregularLogFields = logFields
 			t.Logf("IrregularLogger: %+v", logFields)
 		},
@@ -185,8 +189,8 @@ func TestObfuscatedSSHConn(t *testing.T) {
 				conn,
 				keyword,
 				NewSeedHistory(nil),
-				func(_ string, logFields common.LogFields) {
-					t.Logf("IrregularLogger: %+v", logFields)
+				func(_ string, err error, logFields common.LogFields) {
+					t.Logf("IrregularLogger: %s %+v", err, logFields)
 				})
 		}
 

+ 97 - 0
psiphon/common/obfuscator/passthrough.go

@@ -0,0 +1,97 @@
+/*
+ * Copyright (c) 2020, 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 obfuscator
+
+import (
+	"crypto/hmac"
+	"crypto/rand"
+	"crypto/sha256"
+	"crypto/subtle"
+	"io"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
+	"golang.org/x/crypto/hkdf"
+)
+
+const (
+	TLS_PASSTHROUGH_NONCE_SIZE   = 16
+	TLS_PASSTHROUGH_KEY_SIZE     = 32
+	TLS_PASSTHROUGH_MESSAGE_SIZE = 32
+)
+
+// DeriveTLSPassthroughKey derives a TLS passthrough key from a master
+// obfuscated key. The resulting key can be cached and passed to
+// VerifyTLSPassthroughMessage.
+func DeriveTLSPassthroughKey(obfuscatedKey string) ([]byte, error) {
+
+	secret := []byte(obfuscatedKey)
+
+	salt := []byte("passthrough-obfuscation-key")
+
+	key := make([]byte, TLS_PASSTHROUGH_KEY_SIZE)
+
+	_, err := io.ReadFull(hkdf.New(sha256.New, secret, salt, nil), key)
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+
+	return key, nil
+}
+
+// MakeTLSPassthroughMessage generates a unique TLS passthrough message
+// using the passthrough key derived from a master obfuscated key.
+//
+// The passthrough message demonstrates knowledge of the obfuscated key.
+func MakeTLSPassthroughMessage(obfuscatedKey string) ([]byte, error) {
+
+	passthroughKey, err := DeriveTLSPassthroughKey(obfuscatedKey)
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+
+	message := make([]byte, TLS_PASSTHROUGH_MESSAGE_SIZE)
+
+	_, err = rand.Read(message[0:TLS_PASSTHROUGH_NONCE_SIZE])
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+
+	h := hmac.New(sha256.New, passthroughKey)
+	h.Write(message[0:TLS_PASSTHROUGH_NONCE_SIZE])
+	copy(message[TLS_PASSTHROUGH_NONCE_SIZE:], h.Sum(nil))
+
+	return message, nil
+}
+
+// VerifyTLSPassthroughMessage checks that the specified passthrough message
+// was generated using the passthrough key.
+func VerifyTLSPassthroughMessage(passthroughKey, message []byte) bool {
+
+	if len(message) != TLS_PASSTHROUGH_MESSAGE_SIZE {
+		return false
+	}
+
+	h := hmac.New(sha256.New, passthroughKey)
+	h.Write(message[0:TLS_PASSTHROUGH_NONCE_SIZE])
+
+	return 1 == subtle.ConstantTimeCompare(
+		message[TLS_PASSTHROUGH_NONCE_SIZE:],
+		h.Sum(nil)[0:TLS_PASSTHROUGH_MESSAGE_SIZE-TLS_PASSTHROUGH_NONCE_SIZE])
+}

+ 63 - 0
psiphon/common/obfuscator/passthrough_test.go

@@ -0,0 +1,63 @@
+/*
+ * Copyright (c) 2020, 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 obfuscator
+
+import (
+	"bytes"
+	"testing"
+)
+
+func TestTLSPassthrough(t *testing.T) {
+
+	correctMasterKey := "correct-master-key"
+	incorrectMasterKey := "incorrect-master-key"
+
+	passthroughKey, err := DeriveTLSPassthroughKey(correctMasterKey)
+	if err != nil {
+		t.Fatalf("DeriveTLSPassthroughKey failed: %s", err)
+	}
+
+	validMessage, err := MakeTLSPassthroughMessage(correctMasterKey)
+	if err != nil {
+		t.Fatalf("MakeTLSPassthroughMessage failed: %s", err)
+	}
+
+	if !VerifyTLSPassthroughMessage(passthroughKey, validMessage) {
+		t.Fatalf("unexpected invalid passthrough messages")
+	}
+
+	anotherValidMessage, err := MakeTLSPassthroughMessage(correctMasterKey)
+	if err != nil {
+		t.Fatalf("MakeTLSPassthroughMessage failed: %s", err)
+	}
+
+	if bytes.Equal(validMessage, anotherValidMessage) {
+		t.Fatalf("unexpected identical passthrough messages")
+	}
+
+	invalidMessage, err := MakeTLSPassthroughMessage(incorrectMasterKey)
+	if err != nil {
+		t.Fatalf("MakeTLSPassthroughMessage failed: %s", err)
+	}
+
+	if VerifyTLSPassthroughMessage(passthroughKey, invalidMessage) {
+		t.Fatalf("unexpected valid passthrough messages")
+	}
+}

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

@@ -211,6 +211,11 @@ func TunnelProtocolIsCompatibleWithFragmentor(protocol string) bool {
 		protocol == TUNNEL_PROTOCOL_FRONTED_MEEK_HTTP
 }
 
+func TunnelProtocolSupportsPassthrough(protocol string) bool {
+	return protocol == TUNNEL_PROTOCOL_UNFRONTED_MEEK_HTTPS ||
+		protocol == TUNNEL_PROTOCOL_UNFRONTED_MEEK_SESSION_TICKET
+}
+
 func UseClientTunnelProtocol(
 	clientProtocol string,
 	serverProtocols TunnelProtocols) bool {

+ 1 - 4
psiphon/common/utils.go

@@ -104,13 +104,10 @@ func GetStringSlice(value interface{}) ([]string, bool) {
 // crypto/rand.Read.
 func MakeSecureRandomBytes(length int) ([]byte, error) {
 	randomBytes := make([]byte, length)
-	n, err := rand.Read(randomBytes)
+	_, err := rand.Read(randomBytes)
 	if err != nil {
 		return nil, errors.Trace(err)
 	}
-	if n != length {
-		return nil, errors.TraceNew("insufficient random bytes")
-	}
 	return randomBytes, nil
 }
 

+ 12 - 0
psiphon/meekConn.go

@@ -355,6 +355,18 @@ func DialMeek(
 			tlsConfig.ObfuscatedSessionTicketKey = meekConfig.MeekObfuscatedKey
 		}
 
+		// As the passthrough message is unique and indistinguisbale from a normal
+		// TLS client random value, we set it unconditionally and not just for
+		// protocols which may support passthrough (even for those protocols,
+		// clients don't know which servers are configured to use it).
+
+		passthroughMessage, err := obfuscator.MakeTLSPassthroughMessage(
+			meekConfig.MeekObfuscatedKey)
+		if err != nil {
+			return nil, errors.Trace(err)
+		}
+		tlsConfig.PassthroughMessage = passthroughMessage
+
 		tlsDialer := NewCustomTLSDialer(tlsConfig)
 
 		// Pre-dial one TLS connection in order to inspect the negotiated

+ 24 - 2
psiphon/server/config.go

@@ -136,13 +136,22 @@ type Config struct {
 	// protocols include:
 	// "SSH", "OSSH", "UNFRONTED-MEEK-OSSH", "UNFRONTED-MEEK-HTTPS-OSSH",
 	// "UNFRONTED-MEEK-SESSION-TICKET-OSSH", "FRONTED-MEEK-OSSH",
-	// "FRONTED-MEEK-HTTP-OSSH", "QUIC-OSSH", "MARIONETTE-OSSH", and
-	// "TAPDANCE-OSSH".
+	// ""FRONTED-MEEK-QUIC-OSSH" FRONTED-MEEK-HTTP-OSSH", "QUIC-OSSH",
+	// ""MARIONETTE-OSSH", and TAPDANCE-OSSH".
 	//
 	// In the case of "MARIONETTE-OSSH" the port value is ignored and must be
 	// set to 0. The port value specified in the Marionette format is used.
 	TunnelProtocolPorts map[string]int
 
+	// TunnelProtocolPassthroughAddresses specifies passthrough addresses to be
+	// used for tunnel protocols configured in  TunnelProtocolPorts. Passthrough
+	// is a probing defense which relays all network traffic between a client and
+	// the passthrough target when the client fails anti-probing tests.
+	//
+	// TunnelProtocolPassthroughAddresses is supported for:
+	// "UNFRONTED-MEEK-HTTPS-OSSH", "UNFRONTED-MEEK-SESSION-TICKET-OSSH".
+	TunnelProtocolPassthroughAddresses map[string]string
+
 	// SSHPrivateKey is the SSH host key. The same key is used for
 	// all protocols, run by this server instance, which use SSH.
 	SSHPrivateKey string
@@ -480,6 +489,19 @@ func LoadConfig(configJSON []byte) (*Config, error) {
 		}
 	}
 
+	for tunnelProtocol, address := range config.TunnelProtocolPassthroughAddresses {
+		if !protocol.TunnelProtocolSupportsPassthrough(tunnelProtocol) {
+			return nil, errors.Tracef("Passthrough unsupported tunnel protocol: %s", tunnelProtocol)
+		}
+		if _, _, err := net.SplitHostPort(address); err != nil {
+			if err != nil {
+				return nil, errors.Tracef(
+					"Tunnel protocol %s passthrough address %s invalid: %s",
+					tunnelProtocol, address, err)
+			}
+		}
+	}
+
 	config.sshBeginHandshakeTimeout = SSH_BEGIN_HANDSHAKE_TIMEOUT
 	if config.SSHBeginHandshakeTimeoutMilliseconds != nil {
 		config.sshBeginHandshakeTimeout = time.Duration(*config.SSHBeginHandshakeTimeoutMilliseconds) * time.Millisecond

+ 161 - 89
psiphon/server/meek.go

@@ -101,6 +101,7 @@ type MeekServer struct {
 	listener               net.Listener
 	listenerTunnelProtocol string
 	listenerPort           int
+	passthroughAddress     string
 	tlsConfig              *tris.Config
 	obfuscatorSeedHistory  *obfuscator.SeedHistory
 	clientHandler          func(clientTunnelProtocol string, clientConn net.Conn)
@@ -140,11 +141,14 @@ func NewMeekServer(
 
 	bufferPool := NewCachedResponseBufferPool(bufferLength, bufferCount)
 
+	passthroughAddress := support.Config.TunnelProtocolPassthroughAddresses[listenerTunnelProtocol]
+
 	meekServer := &MeekServer{
 		support:                support,
 		listener:               listener,
 		listenerTunnelProtocol: listenerTunnelProtocol,
 		listenerPort:           listenerPort,
+		passthroughAddress:     passthroughAddress,
 		obfuscatorSeedHistory:  obfuscator.NewSeedHistory(nil),
 		clientHandler:          clientHandler,
 		openConns:              common.NewConns(),
@@ -157,8 +161,8 @@ func NewMeekServer(
 	}
 
 	if useTLS {
-		tlsConfig, err := makeMeekTLSConfig(
-			support, isFronted, useObfuscatedSessionTickets)
+		tlsConfig, err := meekServer.makeMeekTLSConfig(
+			isFronted, useObfuscatedSessionTickets)
 		if err != nil {
 			return nil, errors.Trace(err)
 		}
@@ -885,12 +889,13 @@ func (server *MeekServer) getMeekCookiePayload(
 		&obfuscator.ObfuscatorConfig{
 			Keyword:     server.support.Config.MeekObfuscatedKey,
 			SeedHistory: server.obfuscatorSeedHistory,
-			IrregularLogger: func(clientIP string, logFields common.LogFields) {
+			IrregularLogger: func(clientIP string, err error, logFields common.LogFields) {
 				logIrregularTunnel(
 					server.support,
 					server.listenerTunnelProtocol,
 					server.listenerPort,
 					clientIP,
+					errors.Trace(err),
 					LogFields(logFields))
 			},
 		},
@@ -931,94 +936,12 @@ func (server *MeekServer) getMeekCookiePayload(
 	return payload, nil
 }
 
-type meekSession struct {
-	// Note: 64-bit ints used with atomic operations are placed
-	// at the start of struct to ensure 64-bit alignment.
-	// (https://golang.org/pkg/sync/atomic/#pkg-note-BUG)
-	lastActivity                     int64
-	requestCount                     int64
-	metricClientRetries              int64
-	metricPeakResponseSize           int64
-	metricPeakCachedResponseSize     int64
-	metricPeakCachedResponseHitSize  int64
-	metricCachedResponseMissPosition int64
-	lock                             sync.Mutex
-	deleted                          bool
-	clientConn                       *meekConn
-	meekProtocolVersion              int
-	sessionIDSent                    bool
-	cachedResponse                   *CachedResponse
-}
-
-func (session *meekSession) touch() {
-	atomic.StoreInt64(&session.lastActivity, int64(monotime.Now()))
-}
-
-func (session *meekSession) expired() bool {
-	lastActivity := monotime.Time(atomic.LoadInt64(&session.lastActivity))
-	return monotime.Since(lastActivity) > MEEK_MAX_SESSION_STALENESS
-}
-
-// delete releases all resources allocated by a session.
-func (session *meekSession) delete(haveLock bool) {
-
-	// TODO: close the persistent HTTP client connection, if one exists?
-
-	// This final call session.cachedResponse.Reset releases shared resources.
-	//
-	// This call requires exclusive access. session.lock is be obtained before
-	// calling session.cachedResponse.Reset. Once the lock is obtained, no
-	// request for this session is being processed concurrently, and pending
-	// requests will block at session.lock.
-	//
-	// This logic assumes that no further session.cachedResponse access occurs,
-	// or else resources may deplete (buffers won't be returned to the pool).
-	// These requirements are achieved by obtaining the lock, setting
-	// session.deleted, and any subsequent request handlers checking
-	// session.deleted immediately after obtaining the lock.
-	//
-	// session.lock.Lock may block for up to MEEK_HTTP_CLIENT_IO_TIMEOUT,
-	// the timeout for any active request handler processing a session
-	// request.
-	//
-	// When the lock must be acquired, clientConn.Close is called first, to
-	// interrupt any existing request handler blocking on pumpReads or pumpWrites.
-
-	session.clientConn.Close()
-
-	if !haveLock {
-		session.lock.Lock()
-	}
-
-	// Release all extended buffers back to the pool.
-	// session.cachedResponse.Reset is not safe for concurrent calls.
-	session.cachedResponse.Reset()
-
-	session.deleted = true
-
-	if !haveLock {
-		session.lock.Unlock()
-	}
-}
-
-// GetMetrics implements the common.MetricsSource interface.
-func (session *meekSession) GetMetrics() common.LogFields {
-	logFields := make(common.LogFields)
-	logFields["meek_client_retries"] = atomic.LoadInt64(&session.metricClientRetries)
-	logFields["meek_peak_response_size"] = atomic.LoadInt64(&session.metricPeakResponseSize)
-	logFields["meek_peak_cached_response_size"] = atomic.LoadInt64(&session.metricPeakCachedResponseSize)
-	logFields["meek_peak_cached_response_hit_size"] = atomic.LoadInt64(&session.metricPeakCachedResponseHitSize)
-	logFields["meek_cached_response_miss_position"] = atomic.LoadInt64(&session.metricCachedResponseMissPosition)
-	return logFields
-}
-
 // makeMeekTLSConfig creates a TLS config for a meek HTTPS listener.
 // Currently, this config is optimized for fronted meek where the nature
 // of the connection is non-circumvention; it's optimized for performance
 // assuming the peer is an uncensored CDN.
-func makeMeekTLSConfig(
-	support *SupportServices,
-	isFronted, useObfuscatedSessionTickets bool) (*tris.Config, error) {
+func (server *MeekServer) makeMeekTLSConfig(
+	isFronted bool, useObfuscatedSessionTickets bool) (*tris.Config, error) {
 
 	certificate, privateKey, err := common.GenerateWebServerCertificate(values.GetHostName())
 	if err != nil {
@@ -1077,8 +1000,10 @@ func makeMeekTLSConfig(
 		// See obfuscated session ticket overview
 		// in NewObfuscatedClientSessionCache.
 
+		config.UseObfuscatedSessionTickets = true
+
 		var obfuscatedSessionTicketKey [32]byte
-		key, err := hex.DecodeString(support.Config.MeekObfuscatedKey)
+		key, err := hex.DecodeString(server.support.Config.MeekObfuscatedKey)
 		if err == nil && len(key) != 32 {
 			err = std_errors.New("invalid obfuscated session key length")
 		}
@@ -1102,9 +1027,152 @@ func makeMeekTLSConfig(
 			obfuscatedSessionTicketKey})
 	}
 
+	// When configured, initialize passthrough mode, an anti-probing defense.
+	// Clients must prove knowledge of the obfuscated key via a message sent in
+	// the TLS ClientHello random field.
+	//
+	// When clients fail to provide a valid message, the client connection is
+	// relayed to the designated passthrough address, typically another web site.
+	// The entire flow is relayed, including the original ClientHello, so the
+	// client will perform a TLS handshake with the passthrough target.
+	//
+	// Irregular events are logged for invalid client activity.
+
+	if server.passthroughAddress != "" {
+
+		config.PassthroughAddress = server.passthroughAddress
+
+		passthroughKey, err := obfuscator.DeriveTLSPassthroughKey(
+			server.support.Config.MeekObfuscatedKey)
+		if err != nil {
+			return nil, errors.Trace(err)
+		}
+
+		config.PassthroughKey = passthroughKey
+
+		config.PassthroughLogInvalidMessage = func(
+			clientIP string) {
+
+			logIrregularTunnel(
+				server.support,
+				server.listenerTunnelProtocol,
+				server.listenerPort,
+				clientIP,
+				errors.TraceNew("invalid passthrough message"),
+				nil)
+		}
+
+		config.PassthroughHistoryAddNew = func(
+			clientIP string,
+			clientRandom []byte) bool {
+
+			// strictMode is true as, unlike with meek cookies, legitimate meek clients
+			// never retry TLS connections using a previous random value.
+
+			ok, logFields := server.obfuscatorSeedHistory.AddNew(
+				true,
+				clientIP,
+				"client-random",
+				clientRandom)
+
+			if logFields != nil {
+				logIrregularTunnel(
+					server.support,
+					server.listenerTunnelProtocol,
+					server.listenerPort,
+					clientIP,
+					errors.TraceNew("duplicate passthrough message"),
+					LogFields(*logFields))
+			}
+
+			return ok
+		}
+	}
+
 	return config, nil
 }
 
+type meekSession struct {
+	// Note: 64-bit ints used with atomic operations are placed
+	// at the start of struct to ensure 64-bit alignment.
+	// (https://golang.org/pkg/sync/atomic/#pkg-note-BUG)
+	lastActivity                     int64
+	requestCount                     int64
+	metricClientRetries              int64
+	metricPeakResponseSize           int64
+	metricPeakCachedResponseSize     int64
+	metricPeakCachedResponseHitSize  int64
+	metricCachedResponseMissPosition int64
+	lock                             sync.Mutex
+	deleted                          bool
+	clientConn                       *meekConn
+	meekProtocolVersion              int
+	sessionIDSent                    bool
+	cachedResponse                   *CachedResponse
+}
+
+func (session *meekSession) touch() {
+	atomic.StoreInt64(&session.lastActivity, int64(monotime.Now()))
+}
+
+func (session *meekSession) expired() bool {
+	lastActivity := monotime.Time(atomic.LoadInt64(&session.lastActivity))
+	return monotime.Since(lastActivity) > MEEK_MAX_SESSION_STALENESS
+}
+
+// delete releases all resources allocated by a session.
+func (session *meekSession) delete(haveLock bool) {
+
+	// TODO: close the persistent HTTP client connection, if one exists?
+
+	// This final call session.cachedResponse.Reset releases shared resources.
+	//
+	// This call requires exclusive access. session.lock is be obtained before
+	// calling session.cachedResponse.Reset. Once the lock is obtained, no
+	// request for this session is being processed concurrently, and pending
+	// requests will block at session.lock.
+	//
+	// This logic assumes that no further session.cachedResponse access occurs,
+	// or else resources may deplete (buffers won't be returned to the pool).
+	// These requirements are achieved by obtaining the lock, setting
+	// session.deleted, and any subsequent request handlers checking
+	// session.deleted immediately after obtaining the lock.
+	//
+	// session.lock.Lock may block for up to MEEK_HTTP_CLIENT_IO_TIMEOUT,
+	// the timeout for any active request handler processing a session
+	// request.
+	//
+	// When the lock must be acquired, clientConn.Close is called first, to
+	// interrupt any existing request handler blocking on pumpReads or pumpWrites.
+
+	session.clientConn.Close()
+
+	if !haveLock {
+		session.lock.Lock()
+	}
+
+	// Release all extended buffers back to the pool.
+	// session.cachedResponse.Reset is not safe for concurrent calls.
+	session.cachedResponse.Reset()
+
+	session.deleted = true
+
+	if !haveLock {
+		session.lock.Unlock()
+	}
+}
+
+// GetMetrics implements the common.MetricsSource interface.
+func (session *meekSession) GetMetrics() common.LogFields {
+	logFields := make(common.LogFields)
+	logFields["meek_client_retries"] = atomic.LoadInt64(&session.metricClientRetries)
+	logFields["meek_peak_response_size"] = atomic.LoadInt64(&session.metricPeakResponseSize)
+	logFields["meek_peak_cached_response_size"] = atomic.LoadInt64(&session.metricPeakCachedResponseSize)
+	logFields["meek_peak_cached_response_hit_size"] = atomic.LoadInt64(&session.metricPeakCachedResponseHitSize)
+	logFields["meek_cached_response_miss_position"] = atomic.LoadInt64(&session.metricCachedResponseMissPosition)
+	return logFields
+}
+
 // makeMeekSessionID creates a new session ID. The variable size is intended to
 // frustrate traffic analysis of both plaintext and TLS meek traffic.
 func makeMeekSessionID() (string, error) {
@@ -1427,5 +1495,9 @@ func (conn *meekConn) SetWriteDeadline(t time.Time) error {
 // MetricsSource.GetMetrics, has a pointer only to this conn, so it calls
 // through to the session.
 func (conn *meekConn) GetMetrics() common.LogFields {
-	return conn.meekSession.GetMetrics()
+	logFields := conn.meekSession.GetMetrics()
+	if conn.meekServer.passthroughAddress != "" {
+		logFields["passthrough_address"] = conn.meekServer.passthroughAddress
+	}
+	return logFields
 }

+ 236 - 0
psiphon/server/passthrough_test.go

@@ -0,0 +1,236 @@
+/*
+ * Copyright (c) 2020, 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 server
+
+import (
+	"bytes"
+	"context"
+	"crypto/tls"
+	"crypto/x509"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io/ioutil"
+	"net"
+	"net/http"
+	"os"
+	"path/filepath"
+	"sync"
+	"sync/atomic"
+	"testing"
+	"time"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
+)
+
+func TestPassthrough(t *testing.T) {
+
+	psiphon.SetEmitDiagnosticNotices(true, true)
+
+	// Run passthrough web server
+
+	webServerCertificate, webServerPrivateKey, err := common.GenerateWebServerCertificate("example.org")
+	if err != nil {
+		t.Fatalf("common.GenerateWebServerCertificate failed: %s", err)
+	}
+
+	webListener, err := net.Listen("tcp", "127.0.0.1:0")
+	if err != nil {
+		t.Fatalf("net.Listen failed: %s", err)
+	}
+	defer webListener.Close()
+
+	webCertificate, err := tls.X509KeyPair(
+		[]byte(webServerCertificate),
+		[]byte(webServerPrivateKey))
+	if err != nil {
+		t.Fatalf("tls.X509KeyPair failed: %s", err)
+	}
+
+	webListener = tls.NewListener(webListener, &tls.Config{
+		Certificates: []tls.Certificate{webCertificate},
+	})
+
+	webServerAddress := webListener.Addr().String()
+
+	webResponseBody := []byte(prng.HexString(32))
+
+	webServer := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+		w.Write(webResponseBody)
+	})
+
+	go func() {
+		http.Serve(webListener, webServer)
+	}()
+
+	// Run Psiphon server
+
+	tunnelProtocol := protocol.TUNNEL_PROTOCOL_UNFRONTED_MEEK_SESSION_TICKET
+
+	generateConfigParams := &GenerateConfigParams{
+		ServerIPAddress:      "127.0.0.1",
+		EnableSSHAPIRequests: true,
+		WebServerPort:        8000,
+		TunnelProtocolPorts:  map[string]int{tunnelProtocol: 4000},
+	}
+
+	serverConfigJSON, _, _, _, encodedServerEntry, err := GenerateConfig(generateConfigParams)
+	if err != nil {
+		t.Fatalf("error generating server config: %s", err)
+	}
+
+	var serverConfig map[string]interface{}
+	json.Unmarshal(serverConfigJSON, &serverConfig)
+
+	serverConfig["LogFilename"] = filepath.Join(testDataDirName, "psiphond.log")
+	serverConfig["LogLevel"] = "debug"
+	serverConfig["TunnelProtocolPassthroughAddresses"] = map[string]string{tunnelProtocol: webServerAddress}
+
+	serverConfigJSON, _ = json.Marshal(serverConfig)
+
+	serverWaitGroup := new(sync.WaitGroup)
+	serverWaitGroup.Add(1)
+	go func() {
+		defer serverWaitGroup.Done()
+		err := RunServices(serverConfigJSON)
+		if err != nil {
+			t.Errorf("error running server: %s", err)
+		}
+	}()
+
+	defer func() {
+		p, _ := os.FindProcess(os.Getpid())
+		p.Signal(os.Interrupt)
+		serverWaitGroup.Wait()
+	}()
+
+	// TODO: monitor logs for more robust wait-until-loaded.
+	time.Sleep(1 * time.Second)
+
+	// Test: normal client connects successfully
+
+	clientConfigJSON := fmt.Sprintf(`
+		    {
+		    	"DataRootDirectory" : "%s",
+		        "ClientPlatform" : "Windows",
+		        "ClientVersion" : "0",
+		        "SponsorId" : "0",
+		        "PropagationChannelId" : "0",
+		        "TargetServerEntry" : "%s"
+		    }`, testDataDirName, string(encodedServerEntry))
+
+	clientConfig, err := psiphon.LoadConfig([]byte(clientConfigJSON))
+	if err != nil {
+		t.Fatalf("error processing configuration file: %s", err)
+	}
+
+	err = clientConfig.Commit(false)
+	if err != nil {
+		t.Fatalf("error committing configuration file: %s", err)
+	}
+
+	err = psiphon.OpenDataStore(clientConfig)
+	if err != nil {
+		t.Fatalf("error initializing client datastore: %s", err)
+	}
+	defer psiphon.CloseDataStore()
+
+	controller, err := psiphon.NewController(clientConfig)
+	if err != nil {
+		t.Fatalf("error creating client controller: %s", err)
+	}
+
+	tunnelEstablished := make(chan struct{}, 1)
+
+	psiphon.SetNoticeWriter(psiphon.NewNoticeReceiver(
+		func(notice []byte) {
+			noticeType, payload, err := psiphon.GetNotice(notice)
+			if err != nil {
+				return
+			}
+			if noticeType == "Tunnels" {
+				count := int(payload["count"].(float64))
+				if count >= 1 {
+					tunnelEstablished <- struct{}{}
+				}
+			}
+		}))
+
+	ctx, cancelFunc := context.WithCancel(context.Background())
+	controllerWaitGroup := new(sync.WaitGroup)
+	controllerWaitGroup.Add(1)
+	go func() {
+		defer controllerWaitGroup.Done()
+		controller.Run(ctx)
+	}()
+	<-tunnelEstablished
+	cancelFunc()
+	controllerWaitGroup.Wait()
+
+	// Test: passthrough
+
+	// Non-psiphon HTTPS request routed to passthrough web server
+
+	verifiedCertificate := int32(0)
+
+	httpClient := &http.Client{
+		Transport: &http.Transport{
+			TLSClientConfig: &tls.Config{
+				InsecureSkipVerify: true,
+				VerifyPeerCertificate: func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
+					if len(rawCerts) < 1 {
+						return errors.New("no certificate to verify")
+					}
+					if !bytes.Equal(rawCerts[0], []byte(webCertificate.Certificate[0])) {
+						return errors.New("unexpected certificate")
+					}
+					atomic.StoreInt32(&verifiedCertificate, 1)
+					return nil
+				},
+			},
+		},
+	}
+
+	response, err := httpClient.Get("https://" + webServerAddress)
+	if err != nil {
+		t.Fatalf("http.Get failed: %s", err)
+	}
+	defer response.Body.Close()
+
+	if atomic.LoadInt32(&verifiedCertificate) != 1 {
+		t.Fatalf("certificate not verified")
+	}
+
+	if response.StatusCode != http.StatusOK {
+		t.Fatalf("unexpected response.StatusCode: %d", response.StatusCode)
+	}
+
+	responseBody, err := ioutil.ReadAll(response.Body)
+	if err != nil {
+		t.Fatalf("ioutil.ReadAll failed: %s", err)
+	}
+
+	if !bytes.Equal(responseBody, webResponseBody) {
+		t.Fatalf("unexpected responseBody: %s", string(responseBody))
+	}
+}

+ 6 - 0
psiphon/server/services.go

@@ -365,12 +365,18 @@ func logIrregularTunnel(
 	listenerTunnelProtocol string,
 	listenerPort int,
 	clientIP string,
+	tunnelError error,
 	logFields LogFields) {
 
+	if logFields == nil {
+		logFields = make(LogFields)
+	}
+
 	logFields["event_name"] = "irregular_tunnel"
 	logFields["listener_protocol"] = listenerTunnelProtocol
 	logFields["listener_port_number"] = listenerPort
 	support.GeoIPService.Lookup(clientIP).SetLogFields(logFields)
+	logFields["tunnel_error"] = tunnelError.Error()
 	log.LogRawFieldsWithTimestamp(logFields)
 }
 

+ 4 - 5
psiphon/server/tunnelServer.go

@@ -1033,15 +1033,13 @@ func (sshServer *sshServer) handleClient(
 
 		if tunnelErr != nil {
 
-			logFields := make(common.LogFields)
-			common.SetIrregularTunnelErrorLogField(
-				logFields, errors.Trace(tunnelErr))
 			logIrregularTunnel(
 				sshServer.support,
 				sshListener.tunnelProtocol,
 				sshListener.port,
 				common.IPAddressFromAddr(clientAddr),
-				LogFields(logFields))
+				errors.Trace(tunnelErr),
+				nil)
 
 			var afterFunc *time.Timer
 			if sshServer.support.Config.sshHandshakeTimeout > 0 {
@@ -1377,12 +1375,13 @@ func (sshClient *sshClient) run(
 				conn,
 				sshClient.sshServer.support.Config.ObfuscatedSSHKey,
 				sshClient.sshServer.obfuscatorSeedHistory,
-				func(clientIP string, logFields common.LogFields) {
+				func(clientIP string, err error, logFields common.LogFields) {
 					logIrregularTunnel(
 						sshClient.sshServer.support,
 						sshClient.sshListener.tunnelProtocol,
 						sshClient.sshListener.port,
 						clientIP,
+						errors.Trace(err),
 						LogFields(logFields))
 				})
 

+ 9 - 4
psiphon/sessionTicket_test.go

@@ -77,12 +77,17 @@ func runObfuscatedSessionTicket(t *testing.T, tlsProfile string) {
 	}
 
 	serverConfig := &tris.Config{
-		Certificates:     []tris.Certificate{*certificate},
-		NextProtos:       []string{"http/1.1"},
-		MinVersion:       utls.VersionTLS12,
-		SessionTicketKey: obfuscatedSessionTicketSharedSecret,
+		Certificates:                []tris.Certificate{*certificate},
+		NextProtos:                  []string{"http/1.1"},
+		MinVersion:                  utls.VersionTLS12,
+		UseExtendedMasterSecret:     true,
+		UseObfuscatedSessionTickets: true,
 	}
 
+	// Note: SessionTicketKey needs to be set, or else, it appears,
+	// tris.Config.serverInit() will clobber the value set by
+	// SetSessionTicketKeys.
+	serverConfig.SessionTicketKey = obfuscatedSessionTicketSharedSecret
 	serverConfig.SetSessionTicketKeys([][32]byte{
 		standardSessionTicketKey, obfuscatedSessionTicketSharedSecret})
 

+ 19 - 0
psiphon/tlsDialer.go

@@ -142,6 +142,11 @@ type CustomTLSConfig struct {
 	// using the specified key.
 	ObfuscatedSessionTicketKey string
 
+	// PassthroughMessage, when specified, is a 32 byte value that is sent in the
+	// ClientHello random value field. The value should be generated using
+	// obfuscator.MakeTLSPassthroughMessage.
+	PassthroughMessage []byte
+
 	clientSessionCache utls.ClientSessionCache
 }
 
@@ -484,6 +489,11 @@ func CustomTLSDial(
 
 	conn.SetSessionCache(clientSessionCache)
 
+	// TODO: can conn.SetClientRandom be made to take effect if called here? In
+	// testing, the random value appears to be overwritten. As is, the overhead
+	// of needRemarshal is now always required to handle
+	// config.PassthroughMessage.
+
 	// Build handshake state in advance to obtain the TLS version, which is used
 	// to determine whether the following customizations may be applied. Don't use
 	// getClientHelloVersion, since that may incur additional overhead.
@@ -638,6 +648,15 @@ func CustomTLSDial(
 
 	}
 
+	if config.PassthroughMessage != nil {
+		err := conn.SetClientRandom(config.PassthroughMessage)
+		if err != nil {
+			return nil, errors.Trace(err)
+		}
+
+		needRemarshal = true
+	}
+
 	if needRemarshal {
 		// Apply changes to utls
 		err = conn.MarshalClientHello()

+ 44 - 1
vendor/github.com/Psiphon-Labs/tls-tris/common.go

@@ -650,8 +650,51 @@ type Config struct {
 	UseExtendedMasterSecret bool
 
 	// [Psiphon]
-	// Seeded PRNG allows for optional replay of same randomized Client Hello.
+	// ClientHelloPRNGSeed is a seeded PRNG which allows for optional replay of
+	// same randomized Client Hello.
 	ClientHelloPRNGSeed *prng.Seed
+
+	// [Psiphon]
+	// UseObfuscatedSessionTickets should be set when using obfuscated session
+	// tickets. This setting ensures that checkForResumption operates in a way
+	// that is compatible with the obfuscated session ticket scheme.
+	//
+	// This flag doesn't fully configure obfuscated session tickets.
+	// SessionTicketKey and SetSessionTicketKeys must also be intialized. See the
+	// setup in psiphon/server.MeekServer.makeMeekTLSConfig.
+	//
+	// See the comment for NewObfuscatedClientSessionState for more details on
+	// obfuscated session tickets.
+	UseObfuscatedSessionTickets bool
+
+	// [Psiphon]
+	// PassthroughAddress, when not blank, enables passthrough mode. It is a
+	// network address, host and port, to which client traffic is relayed when
+	// the client fails anti-probing tests.
+	//
+	// The PassthroughAddress is expected to be a TCP endpoint. Passthrough is
+	// triggered when a ClientHello random field doesn't have a valid value, as
+	// determined by PassthroughKey.
+	PassthroughAddress string
+
+	// [Psiphon]
+	// PassthroughKey must be set, to a value generated by
+	// obfuscator.DerivePassthroughKey, when passthrough mode is enabled.
+	PassthroughKey []byte
+
+	// [Psiphon]
+	// PassthroughHistoryAddNew must be set when passthough mode is enabled. The
+	// function should check that a ClientHello random value has not been
+	// previously observed, returning true only for a newly observed value. Any
+	// logging is the callback's responsibility.
+	PassthroughHistoryAddNew func(
+		clientIP string,
+		clientRandom []byte) bool
+
+	// [Psiphon]
+	// PassthroughLogInvalidMessage must be set when passthough mode is enabled.
+	// The function should log an invalid ClientHello random value event.
+	PassthroughLogInvalidMessage func(clientIP string)
 }
 
 // ticketKeyNameLen is the number of bytes of identifier that is prepended to

+ 14 - 0
vendor/github.com/Psiphon-Labs/tls-tris/conn.go

@@ -900,6 +900,20 @@ func peekAlert(b *block) error {
 // sendAlert sends a TLS alert message.
 // c.out.Mutex <= L.
 func (c *Conn) sendAlertLocked(err alert) error {
+
+	// [Psiphon]
+	// Do not send TLS alerts before the passthrough state is determined.
+	// Otherwise, an invalid client would receive non-passthrough traffic.
+	//
+	// Limitation: ClientHello-related alerts to legitimate clients are not sent.
+	// This changes the nature of errors that such clients may report when their
+	// TLS handshake fails. This change in behavior is only visible to legitimate
+	// clients.
+	if c.config.PassthroughAddress != "" &&
+		c.conn.(*recorderConn).IsRecording() {
+		return nil
+	}
+
 	switch err {
 	case alertNoRenegotiation, alertCloseNotify:
 		c.tmp[0] = alertLevelWarning

+ 232 - 3
vendor/github.com/Psiphon-Labs/tls-tris/handshake_server.go

@@ -5,6 +5,7 @@
 package tls
 
 import (
+	"bytes"
 	"crypto"
 	"crypto/ecdsa"
 	"crypto/rsa"
@@ -13,7 +14,11 @@ import (
 	"errors"
 	"fmt"
 	"io"
+	"net"
 	"sync/atomic"
+	"time"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/obfuscator"
 )
 
 // serverHandshakeState contains details of a server handshake in progress.
@@ -62,6 +67,132 @@ func (c *Conn) serverHandshake() error {
 	c.in.traceErr = hs.traceErr
 	c.out.traceErr = hs.traceErr
 	isResume, err := hs.readClientHello()
+
+	// [Psiphon]
+	// The ClientHello with the passthrough message is now available. Route the
+	// client to passthrough based on message inspection. This code assumes the
+	// client TCP conn has been wrapped with recordingConn, which has recorded
+	// all bytes sent by the client, which will be replayed, byte-for-byte, to
+	// the passthrough; as a result, passthrough clients will perform their TLS
+	// handshake with the passthrough target, receive its certificate, and in the
+	// case of HTTPS, receive the passthrough target's HTTP responses.
+	//
+	// Passthrough is also triggered if readClientHello fails. E.g., on other
+	// invalid input cases including "tls: handshake message of length..." or if
+	// the ClientHello is otherwise invalid. This ensures that clients sending
+	// random data will be relayed to the passthrough and not receive a
+	// distinguishing error response.
+	//
+	// The `tls` API performs handshakes on demand. E.g., the first call to
+	// tls.Conn.Read will perform a handshake if it's not yet been performed.
+	// Consumers such as `http` may call Read and then Close. To minimize code
+	// changes, in the passthrough case the ownership of Conn.conn, the client
+	// TCP conn, is transferred to the passthrough relay and a closedConn is
+	// substituted for Conn.conn. This allows the remaining `tls` code paths to
+	// continue reference a net.Conn, albiet one that is closed, so Reads and
+	// Writes will fail.
+
+	if c.config.PassthroughAddress != "" {
+
+		doPassthrough := false
+
+		if err != nil {
+			doPassthrough = true
+			err = fmt.Errorf("passthrough: %s", err)
+		}
+
+		if !doPassthrough {
+			if !obfuscator.VerifyTLSPassthroughMessage(
+				c.config.PassthroughKey, hs.clientHello.random) {
+
+				// Legitimate, older clients that don't use passthrough messages will hit
+				// this case. Reduce false positive event logs with this heuristic: if
+				// isResume, the client sent a valid session ticket, so either the client
+				// sent a valid obfuscated session ticket proving knowledge of the
+				// obfuscation key, or the client previously connected and obtained a
+				// server-issued session ticket (this latter case shouldn't happen as the
+				// passthough message is now required for all connections; but isResume
+				// doesn't strictly mean the session ticket was _obfuscated_).
+				c.config.PassthroughLogInvalidMessage(
+					c.conn.RemoteAddr().String())
+
+				doPassthrough = true
+				err = errors.New("passthrough: invalid client random")
+			}
+		}
+
+		if !doPassthrough {
+			if !c.config.PassthroughHistoryAddNew(
+				c.conn.RemoteAddr().String(), hs.clientHello.random) {
+
+				doPassthrough = true
+				err = errors.New("passthrough: duplicate client random")
+			}
+		}
+
+		if doPassthrough {
+
+			// When performing passthrough, we must exit at the "return err" below.
+			// This is a failsafe to ensure err is always set.
+			if err == nil {
+				err = errors.New("passthrough: missing error")
+			}
+
+			passthroughReadBuffer := c.conn.(*recorderConn).GetReadBuffer().Bytes()
+
+			// Modifying c.conn directly is safe only because Conn.Handshake, which
+			// calls Conn.serverHandshake, is holding c.handshakeMutex and c.in locks,
+			// and because of the serial nature of c.conn access during the handshake
+			// sequence.
+			conn := c.conn
+			c.conn = newClosedConn(conn)
+
+			go func() {
+
+				// Perform the passthrough relay.
+				//
+				// Limitations:
+				//
+				// - The local TCP stack may differ from passthrough target in a
+				//   detectable way.
+				//
+				// - There may be detectable timing characteristics due to the network hop
+				//   to the passthrough target.
+				//
+				// - Application-level socket operations may produce detectable
+				//   differences (e.g., CloseWrite/FIN).
+				//
+				// - The dial to the passthrough, or other upstream network operations,
+				//   may fail. These errors are not logged.
+				//
+				// - There's no timeout on the passthrough dial and no time limit on the
+				//   passthrough relay so that the invalid client can't detect a timeout
+				//   shorter than the passthrough target; this may cause additional load.
+
+				defer conn.Close()
+
+				passthroughConn, err := net.Dial("tcp", c.config.PassthroughAddress)
+				if err != nil {
+					return
+				}
+				_, err = passthroughConn.Write(passthroughReadBuffer)
+				if err != nil {
+					return
+				}
+
+				// Allow garbage collection.
+				passthroughReadBuffer = nil
+
+				go func() {
+					_, _ = io.Copy(passthroughConn, conn)
+					passthroughConn.Close()
+				}()
+				_, _ = io.Copy(conn, passthroughConn)
+			}()
+
+		}
+	}
+
 	if err != nil {
 		return err
 	}
@@ -153,6 +284,96 @@ func (c *Conn) serverHandshake() error {
 	return nil
 }
 
+// [Psiphon]
+// recorderConn is a net.Conn which records all bytes read from the wrapped
+// conn until GetReadBuffer is called, which returns the buffered bytes and
+// stops recording. This is used to replay, byte-for-byte, the bytes sent by a
+// client when switching to passthrough.
+//
+// recorderConn operations are not safe for concurrent use and intended only
+// to be used in the initial phase of the TLS handshake, where the order of
+// operations is deterministic.
+type recorderConn struct {
+	net.Conn
+	readBuffer *bytes.Buffer
+}
+
+func newRecorderConn(conn net.Conn) *recorderConn {
+	return &recorderConn{
+		Conn:       conn,
+		readBuffer: new(bytes.Buffer),
+	}
+}
+
+func (c *recorderConn) Read(p []byte) (n int, err error) {
+	n, err = c.Conn.Read(p)
+	if n > 0 && c.readBuffer != nil {
+		_, _ = c.readBuffer.Write(p[:n])
+	}
+	return n, err
+}
+
+func (c *recorderConn) GetReadBuffer() *bytes.Buffer {
+	b := c.readBuffer
+	c.readBuffer = nil
+	return b
+}
+
+func (c *recorderConn) IsRecording() bool {
+	return c.readBuffer != nil
+}
+
+// [Psiphon]
+// closedConn is a net.Conn which behaves as if it were closed: all reads and
+// writes fail. This is used when switching to passthrough mode: ownership of
+// the invalid client conn is taken by the passthrough relay and a closedConn
+// replaces the network conn used by the local TLS server code path.
+type closedConn struct {
+	localAddr  net.Addr
+	remoteAddr net.Addr
+}
+
+var closedClosedError = errors.New("closed")
+
+func newClosedConn(conn net.Conn) *closedConn {
+	return &closedConn{
+		localAddr:  conn.LocalAddr(),
+		remoteAddr: conn.RemoteAddr(),
+	}
+}
+
+func (c *closedConn) Read(_ []byte) (int, error) {
+	return 0, closedClosedError
+}
+
+func (c *closedConn) Write(_ []byte) (int, error) {
+	return 0, closedClosedError
+}
+
+func (c *closedConn) Close() error {
+	return nil
+}
+
+func (c *closedConn) LocalAddr() net.Addr {
+	return c.localAddr
+}
+
+func (c *closedConn) RemoteAddr() net.Addr {
+	return c.remoteAddr
+}
+
+func (c *closedConn) SetDeadline(_ time.Time) error {
+	return closedClosedError
+}
+
+func (c *closedConn) SetReadDeadline(_ time.Time) error {
+	return closedClosedError
+}
+
+func (c *closedConn) SetWriteDeadline(_ time.Time) error {
+	return closedClosedError
+}
+
 // readClientHello reads a ClientHello message from the client and decides
 // whether we will perform session resumption.
 func (hs *serverHandshakeState) readClientHello() (isResume bool, err error) {
@@ -427,9 +648,17 @@ func (hs *serverHandshakeState) checkForResumption() bool {
 		return false
 	}
 
-	// Do not resume connections where client support for EMS has changed
-	if (hs.clientHello.extendedMSSupported && c.config.UseExtendedMasterSecret) != hs.sessionState.usedEMS {
-		return false
+	// [Psiphon]
+	// When using obfuscated session tickets, the client-generated session ticket
+	// state never uses EMS. ClientHellos vary in EMS support. So, in this mode,
+	// skip this check to ensure the obfuscated session tickets are not
+	// rejected.
+	if !c.config.UseObfuscatedSessionTickets {
+
+		// Do not resume connections where client support for EMS has changed
+		if (hs.clientHello.extendedMSSupported && c.config.UseExtendedMasterSecret) != hs.sessionState.usedEMS {
+			return false
+		}
 	}
 
 	cipherSuiteOk := false

+ 8 - 0
vendor/github.com/Psiphon-Labs/tls-tris/tls.go

@@ -29,6 +29,14 @@ import (
 // The configuration config must be non-nil and must include
 // at least one certificate or else set GetCertificate.
 func Server(conn net.Conn, config *Config) *Conn {
+
+	// [Psiphon]
+	// Initialize traffic recording to facilitate playback in the case of
+	// passthrough.
+	if config.PassthroughAddress != "" {
+		conn = newRecorderConn(conn)
+	}
+
 	return &Conn{conn: conn, config: config}
 }
 

+ 3 - 3
vendor/vendor.json

@@ -147,10 +147,10 @@
 			"revisionTime": "2020-01-16T02:28:06Z"
 		},
 		{
-			"checksumSHA1": "+lsQUKG+zIU9IxrQf5pJBSRE79Q=",
+			"checksumSHA1": "tP8/SZKnStfvqhHMeB5EpgtoGSQ=",
 			"path": "github.com/Psiphon-Labs/tls-tris",
-			"revision": "78f52e6d62437fc594a15aafbd8c583d5eb48d46",
-			"revisionTime": "2019-12-06T19:09:01Z"
+			"revision": "7ff412878bba4c627909aed23258d42b1f2b14f5",
+			"revisionTime": "2020-03-26T18:33:34Z"
 		},
 		{
 			"checksumSHA1": "30PBqj9BW03KCVqASvLg3bR+xYc=",