فهرست منبع

Add TLS-OSSH obfuscation protocol

mirokuratczyk 2 سال پیش
والد
کامیت
50cdedb6c3

+ 13 - 2
psiphon/common/parameters/parameters.go

@@ -228,7 +228,6 @@ const (
 	ReplayObfuscatorPadding                          = "ReplayObfuscatorPadding"
 	ReplayFragmentor                                 = "ReplayFragmentor"
 	ReplayTLSProfile                                 = "ReplayTLSProfile"
-	ReplayRandomizedTLSProfile                       = "ReplayRandomizedTLSProfile"
 	ReplayFronting                                   = "ReplayFronting"
 	ReplayHostname                                   = "ReplayHostname"
 	ReplayQUICVersion                                = "ReplayQUICVersion"
@@ -345,6 +344,13 @@ const (
 	OSSHPrefixSplitMaxDelay                          = "OSSHPrefixSplitMaxDelay"
 	OSSHPrefixEnableFragmentor                       = "OSSHPrefixEnableFragmentor"
 	ServerOSSHPrefixSpecs                            = "ServerOSSHPrefixSpecs"
+	TLSTunnelTrafficShapingProbability               = "TLSTunnelTrafficShapingProbability"
+	TLSTunnelMinTLSPadding                           = "TLSTunnelMinTLSPadding"
+	TLSTunnelMaxTLSPadding                           = "TLSTunnelMaxTLSPadding"
+
+	// Retired parameters
+
+	ReplayRandomizedTLSProfile = "ReplayRandomizedTLSProfile"
 )
 
 const (
@@ -588,7 +594,6 @@ var defaultParameters = map[string]struct {
 	ReplayObfuscatorPadding:                {value: true},
 	ReplayFragmentor:                       {value: true},
 	ReplayTLSProfile:                       {value: true},
-	ReplayRandomizedTLSProfile:             {value: true},
 	ReplayFronting:                         {value: true},
 	ReplayHostname:                         {value: true},
 	ReplayQUICVersion:                      {value: true},
@@ -730,6 +735,12 @@ var defaultParameters = map[string]struct {
 	OSSHPrefixSplitMaxDelay:    {value: time.Duration(0), minimum: time.Duration(0)},
 	OSSHPrefixEnableFragmentor: {value: false},
 	ServerOSSHPrefixSpecs:      {value: transforms.Specs{}, flags: serverSideOnly},
+
+	// TLSTunnelMinTLSPadding/TLSTunnelMaxTLSPadding are subject to TLS server limitations.
+
+	TLSTunnelTrafficShapingProbability: {value: 1.0, minimum: 0.0},
+	TLSTunnelMinTLSPadding:             {value: 0, minimum: 0},
+	TLSTunnelMaxTLSPadding:             {value: 0, minimum: 0},
 }
 
 // IsServerSideOnly indicates if the parameter specified by name is used

+ 17 - 1
psiphon/common/protocol/protocol.go

@@ -32,6 +32,7 @@ import (
 const (
 	TUNNEL_PROTOCOL_SSH                              = "SSH"
 	TUNNEL_PROTOCOL_OBFUSCATED_SSH                   = "OSSH"
+	TUNNEL_PROTOCOL_TLS_OBFUSCATED_SSH               = "TLS-OSSH"
 	TUNNEL_PROTOCOL_UNFRONTED_MEEK                   = "UNFRONTED-MEEK-OSSH"
 	TUNNEL_PROTOCOL_UNFRONTED_MEEK_HTTPS             = "UNFRONTED-MEEK-HTTPS-OSSH"
 	TUNNEL_PROTOCOL_UNFRONTED_MEEK_SESSION_TICKET    = "UNFRONTED-MEEK-SESSION-TICKET-OSSH"
@@ -147,6 +148,7 @@ func (labeledProtocols LabeledTunnelProtocols) PruneInvalid() LabeledTunnelProto
 var SupportedTunnelProtocols = TunnelProtocols{
 	TUNNEL_PROTOCOL_SSH,
 	TUNNEL_PROTOCOL_OBFUSCATED_SSH,
+	TUNNEL_PROTOCOL_TLS_OBFUSCATED_SSH,
 	TUNNEL_PROTOCOL_UNFRONTED_MEEK,
 	TUNNEL_PROTOCOL_UNFRONTED_MEEK_HTTPS,
 	TUNNEL_PROTOCOL_UNFRONTED_MEEK_SESSION_TICKET,
@@ -177,6 +179,13 @@ func TunnelProtocolUsesObfuscatedSSH(protocol string) bool {
 	return protocol != TUNNEL_PROTOCOL_SSH
 }
 
+// NOTE: breaks the naming convention of dropping the OSSH suffix because
+// UsesTLS is ambiguous by itself as there are other protocols which use
+// a TLS layer, e.g. UNFRONTED-MEEK-HTTPS-OSSH.
+func TunnelProtocolUsesTLSOSSH(protocol string) bool {
+	return protocol == TUNNEL_PROTOCOL_TLS_OBFUSCATED_SSH
+}
+
 func TunnelProtocolUsesMeek(protocol string) bool {
 	return TunnelProtocolUsesMeekHTTP(protocol) ||
 		TunnelProtocolUsesMeekHTTPS(protocol) ||
@@ -239,6 +248,7 @@ func TunnelProtocolIsResourceIntensive(protocol string) bool {
 func TunnelProtocolIsCompatibleWithFragmentor(protocol string) bool {
 	return protocol == TUNNEL_PROTOCOL_SSH ||
 		protocol == TUNNEL_PROTOCOL_OBFUSCATED_SSH ||
+		protocol == TUNNEL_PROTOCOL_TLS_OBFUSCATED_SSH ||
 		protocol == TUNNEL_PROTOCOL_UNFRONTED_MEEK ||
 		protocol == TUNNEL_PROTOCOL_UNFRONTED_MEEK_HTTPS ||
 		protocol == TUNNEL_PROTOCOL_UNFRONTED_MEEK_SESSION_TICKET ||
@@ -251,10 +261,15 @@ func TunnelProtocolRequiresTLS12SessionTickets(protocol string) bool {
 	return protocol == TUNNEL_PROTOCOL_UNFRONTED_MEEK_SESSION_TICKET
 }
 
+func TunnelProtocolRequiresTLS13Support(protocol string) bool {
+	return protocol == TUNNEL_PROTOCOL_TLS_OBFUSCATED_SSH
+}
+
 func TunnelProtocolSupportsPassthrough(protocol string) bool {
 	return protocol == TUNNEL_PROTOCOL_UNFRONTED_MEEK_HTTPS ||
 		protocol == TUNNEL_PROTOCOL_UNFRONTED_MEEK_SESSION_TICKET ||
-		protocol == TUNNEL_PROTOCOL_UNFRONTED_MEEK
+		protocol == TUNNEL_PROTOCOL_UNFRONTED_MEEK ||
+		protocol == TUNNEL_PROTOCOL_TLS_OBFUSCATED_SSH
 }
 
 func TunnelProtocolSupportsUpstreamProxy(protocol string) bool {
@@ -264,6 +279,7 @@ func TunnelProtocolSupportsUpstreamProxy(protocol string) bool {
 func TunnelProtocolMayUseServerPacketManipulation(protocol string) bool {
 	return protocol == TUNNEL_PROTOCOL_SSH ||
 		protocol == TUNNEL_PROTOCOL_OBFUSCATED_SSH ||
+		protocol == TUNNEL_PROTOCOL_TLS_OBFUSCATED_SSH ||
 		protocol == TUNNEL_PROTOCOL_UNFRONTED_MEEK ||
 		protocol == TUNNEL_PROTOCOL_UNFRONTED_MEEK_HTTPS ||
 		protocol == TUNNEL_PROTOCOL_UNFRONTED_MEEK_SESSION_TICKET

+ 36 - 0
psiphon/common/protocol/serverEntry.go

@@ -62,6 +62,7 @@ type ServerEntry struct {
 	Capabilities                    []string `json:"capabilities"`
 	Region                          string   `json:"region"`
 	FrontingProviderID              string   `json:"frontingProviderID"`
+	TlsOSSHPort                     int      `json:"tlsOSSHPort"`
 	MeekServerPort                  int      `json:"meekServerPort"`
 	MeekCookieEncryptionPublicKey   string   `json:"meekCookieEncryptionPublicKey"`
 	MeekObfuscatedKey               string   `json:"meekObfuscatedKey"`
@@ -466,6 +467,8 @@ func GetTacticsCapability(protocol string) string {
 func (serverEntry *ServerEntry) hasCapability(requiredCapability string) bool {
 	for _, capability := range serverEntry.Capabilities {
 
+		originalCapability := capability
+
 		capability = strings.ReplaceAll(capability, "-PASSTHROUGH-v2", "")
 		capability = strings.ReplaceAll(capability, "-PASSTHROUGH", "")
 
@@ -477,6 +480,30 @@ func (serverEntry *ServerEntry) hasCapability(requiredCapability string) bool {
 		if capability == requiredCapability {
 			return true
 		}
+
+		// Special case: some capabilities may additionally support TLS-OSSH.
+		if requiredCapability == GetCapability(TUNNEL_PROTOCOL_TLS_OBFUSCATED_SSH) && capabilitySupportsTLSOSSH(originalCapability) {
+			return true
+		}
+	}
+	return false
+}
+
+// capabilitySupportsTLSOSSH returns true if and only if the given capability
+// supports TLS-OSSH in addition to its primary protocol.
+func capabilitySupportsTLSOSSH(capability string) bool {
+
+	tlsCapabilities := []string{
+		GetCapability(TUNNEL_PROTOCOL_UNFRONTED_MEEK_HTTPS),
+		GetCapability(TUNNEL_PROTOCOL_UNFRONTED_MEEK_SESSION_TICKET),
+	}
+
+	for _, tlsCapability := range tlsCapabilities {
+		// The TLS capability is additionally supported by UNFRONTED-MEEK-HTTPS
+		// and UNFRONTED-MEEK-SESSION-TICKET capabilities with passthrough.
+		if capability == tlsCapability+"-PASSTHROUGH-v2" {
+			return true
+		}
 	}
 	return false
 }
@@ -617,6 +644,15 @@ func (serverEntry *ServerEntry) GetDialPortNumber(tunnelProtocol string) (int, e
 
 	switch tunnelProtocol {
 
+	case TUNNEL_PROTOCOL_TLS_OBFUSCATED_SSH:
+		if serverEntry.TlsOSSHPort == 0 {
+			// Special case: a server which supports UNFRONTED-MEEK-HTTPS-OSSH
+			// or UNFRONTED-MEEK-SESSION-TICKET-OSSH also supports TLS-OSSH
+			// over the same port.
+			return serverEntry.MeekServerPort, nil
+		}
+		return serverEntry.TlsOSSHPort, nil
+
 	case TUNNEL_PROTOCOL_SSH:
 		return serverEntry.SshPort, nil
 

+ 33 - 1
psiphon/config.go

@@ -173,7 +173,7 @@ type Config struct {
 	NetworkLatencyMultiplier float64
 
 	// LimitTunnelProtocols indicates which protocols to use. Valid values
-	// include: "SSH", "OSSH", "UNFRONTED-MEEK-OSSH",
+	// include: "SSH", "OSSH", "TLS-OSSH", "UNFRONTED-MEEK-OSSH",
 	// "UNFRONTED-MEEK-HTTPS-OSSH", "UNFRONTED-MEEK-SESSION-TICKET-OSSH",
 	// "FRONTED-MEEK-OSSH", "FRONTED-MEEK-HTTP-OSSH", "QUIC-OSSH",
 	// "FRONTED-MEEK-QUIC-OSSH", "TAPDANCE-OSSH", and "CONJURE-OSSH".
@@ -848,6 +848,11 @@ type Config struct {
 	OSSHPrefixSplitMaxDelayMilliseconds *int
 	OSSHPrefixEnableFragmentor          *bool
 
+	// TLSTunnelTrafficShapingProbability and associated fields are for testing.
+	TLSTunnelTrafficShapingProbability *float64
+	TLSTunnelMinTLSPadding             *int
+	TLSTunnelMaxTLSPadding             *int
+
 	// params is the active parameters.Parameters with defaults, config values,
 	// and, optionally, tactics applied.
 	//
@@ -2006,6 +2011,18 @@ func (config *Config) makeConfigParameters() map[string]interface{} {
 		applyParameters[parameters.OSSHPrefixEnableFragmentor] = *config.OSSHPrefixEnableFragmentor
 	}
 
+	if config.TLSTunnelTrafficShapingProbability != nil {
+		applyParameters[parameters.TLSTunnelTrafficShapingProbability] = *config.TLSTunnelTrafficShapingProbability
+	}
+
+	if config.TLSTunnelMinTLSPadding != nil {
+		applyParameters[parameters.TLSTunnelMinTLSPadding] = *config.TLSTunnelMinTLSPadding
+	}
+
+	if config.TLSTunnelMaxTLSPadding != nil {
+		applyParameters[parameters.TLSTunnelMaxTLSPadding] = *config.TLSTunnelMaxTLSPadding
+	}
+
 	// When adding new config dial parameters that may override tactics, also
 	// update setDialParametersHash.
 
@@ -2522,6 +2539,21 @@ func (config *Config) setDialParametersHash() {
 		binary.Write(hash, binary.LittleEndian, *config.OSSHPrefixEnableFragmentor)
 	}
 
+	if config.TLSTunnelTrafficShapingProbability != nil {
+		hash.Write([]byte("TLSTunnelTrafficShapingProbability"))
+		binary.Write(hash, binary.LittleEndian, int64(*config.TLSTunnelTrafficShapingProbability))
+	}
+
+	if config.TLSTunnelMinTLSPadding != nil {
+		hash.Write([]byte("TLSTunnelMinTLSPadding"))
+		binary.Write(hash, binary.LittleEndian, int64(*config.TLSTunnelMinTLSPadding))
+	}
+
+	if config.TLSTunnelMaxTLSPadding != nil {
+		hash.Write([]byte("TLSTunnelMaxTLSPadding"))
+		binary.Write(hash, binary.LittleEndian, int64(*config.TLSTunnelMaxTLSPadding))
+	}
+
 	config.dialParametersHash = hash.Sum(nil)
 }
 

+ 20 - 0
psiphon/controller_test.go

@@ -201,6 +201,26 @@ func TestObfuscatedSSH(t *testing.T) {
 		})
 }
 
+func TestTLS(t *testing.T) {
+
+	t.Skipf("temporarily disabled")
+
+	controllerRun(t,
+		&controllerRunConfig{
+			expectNoServerEntries:    false,
+			protocol:                 protocol.TUNNEL_PROTOCOL_TLS_OBFUSCATED_SSH,
+			clientIsLatestVersion:    false,
+			disableUntunneledUpgrade: true,
+			disableEstablishing:      false,
+			disableApi:               false,
+			tunnelPoolSize:           1,
+			useUpstreamProxy:         false,
+			disruptNetwork:           false,
+			transformHostNames:       false,
+			useFragmentor:            false,
+		})
+}
+
 func TestUnfrontedMeek(t *testing.T) {
 	controllerRun(t,
 		&controllerRunConfig{

+ 54 - 37
psiphon/dialParameters.go

@@ -42,7 +42,6 @@ import (
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/resolver"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/transforms"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/values"
-	utls "github.com/refraction-networking/utls"
 	"golang.org/x/net/bpf"
 )
 
@@ -111,6 +110,10 @@ type DialParameters struct {
 	MeekTLSPaddingSize        int
 	MeekResolvedIPAddress     atomic.Value `json:"-"`
 
+	TLSOSSHTransformedSNIServerName bool
+	TLSOSSHSNIServerName            string
+	TLSOSSHObfuscatorPaddingSeed    *prng.Seed
+
 	SelectedUserAgent bool
 	UserAgent         string
 
@@ -195,7 +198,6 @@ func MakeDialParameters(
 	replayObfuscatorPadding := p.Bool(parameters.ReplayObfuscatorPadding)
 	replayFragmentor := p.Bool(parameters.ReplayFragmentor)
 	replayTLSProfile := p.Bool(parameters.ReplayTLSProfile)
-	replayRandomizedTLSProfile := p.Bool(parameters.ReplayRandomizedTLSProfile)
 	replayFronting := p.Bool(parameters.ReplayFronting)
 	replayHostname := p.Bool(parameters.ReplayHostname)
 	replayQUICVersion := p.Bool(parameters.ReplayQUICVersion)
@@ -512,6 +514,11 @@ func MakeDialParameters(
 			if err != nil {
 				return nil, errors.Trace(err)
 			}
+		} else if protocol.TunnelProtocolUsesTLSOSSH(dialParams.TunnelProtocol) {
+			dialParams.TLSOSSHObfuscatorPaddingSeed, err = prng.NewSeed()
+			if err != nil {
+				return nil, errors.Trace(err)
+			}
 		}
 	}
 
@@ -619,6 +626,7 @@ func MakeDialParameters(
 	}
 
 	usingTLS := protocol.TunnelProtocolUsesMeekHTTPS(dialParams.TunnelProtocol) ||
+		protocol.TunnelProtocolUsesTLSOSSH(dialParams.TunnelProtocol) ||
 		dialParams.ConjureAPIRegistration
 
 	if (!isReplay || !replayTLSProfile) && usingTLS {
@@ -628,49 +636,23 @@ func MakeDialParameters(
 		requireTLS12SessionTickets := protocol.TunnelProtocolRequiresTLS12SessionTickets(
 			dialParams.TunnelProtocol)
 
+		requireTLS13Support := protocol.TunnelProtocolRequiresTLS13Support(dialParams.TunnelProtocol)
+
 		isFronted := protocol.TunnelProtocolUsesFrontedMeek(dialParams.TunnelProtocol) ||
 			dialParams.ConjureAPIRegistration
 
-		dialParams.TLSProfile = SelectTLSProfile(
-			requireTLS12SessionTickets, isFronted, serverEntry.FrontingProviderID, p)
-
-		dialParams.NoDefaultTLSSessionID = p.WeightedCoinFlip(
-			parameters.NoDefaultTLSSessionIDProbability)
-	}
-
-	if (!isReplay || !replayRandomizedTLSProfile) && usingTLS &&
-		protocol.TLSProfileIsRandomized(dialParams.TLSProfile) {
-
-		dialParams.RandomizedTLSProfileSeed, err = prng.NewSeed()
+		dialParams.TLSProfile, dialParams.TLSVersion, dialParams.RandomizedTLSProfileSeed, err = SelectTLSProfile(
+			requireTLS12SessionTickets, requireTLS13Support, isFronted, serverEntry.FrontingProviderID, p)
 		if err != nil {
 			return nil, errors.Trace(err)
 		}
-	}
 
-	if (!isReplay || !replayTLSProfile) && usingTLS {
-
-		// Since "Randomized-v2"/CustomTLSProfiles may be TLS 1.2 or TLS 1.3,
-		// construct the ClientHello to determine if it's TLS 1.3. This test also
-		// covers non-randomized TLS 1.3 profiles. This check must come after
-		// dialParams.TLSProfile and dialParams.RandomizedTLSProfileSeed are set. No
-		// actual dial is made here.
-
-		utlsClientHelloID, utlsClientHelloSpec, err := getUTLSClientHelloID(
-			p, dialParams.TLSProfile)
-		if err != nil {
-			return nil, errors.Trace(err)
-		}
-
-		if protocol.TLSProfileIsRandomized(dialParams.TLSProfile) {
-			utlsClientHelloID.Seed = new(utls.PRNGSeed)
-			*utlsClientHelloID.Seed = [32]byte(*dialParams.RandomizedTLSProfileSeed)
+		if dialParams.TLSProfile == "" && (requireTLS12SessionTickets || requireTLS13Support) {
+			return nil, errors.TraceNew("required TLS profile not found")
 		}
 
-		dialParams.TLSVersion, err = getClientHelloVersion(
-			utlsClientHelloID, utlsClientHelloSpec)
-		if err != nil {
-			return nil, errors.Trace(err)
-		}
+		dialParams.NoDefaultTLSSessionID = p.WeightedCoinFlip(
+			parameters.NoDefaultTLSSessionIDProbability)
 	}
 
 	if (!isReplay || !replayFronting) &&
@@ -718,6 +700,14 @@ func MakeDialParameters(
 					hostname, strconv.Itoa(serverEntry.MeekServerPort))
 			}
 
+		} else if protocol.TunnelProtocolUsesTLSOSSH(dialParams.TunnelProtocol) {
+
+			dialParams.TLSOSSHSNIServerName = ""
+			if p.WeightedCoinFlip(parameters.TransformHostNameProbability) {
+				dialParams.TLSOSSHSNIServerName = selectHostName(dialParams.TunnelProtocol, p)
+				dialParams.TLSOSSHTransformedSNIServerName = true
+			}
+
 		} else if protocol.TunnelProtocolUsesMeekHTTP(dialParams.TunnelProtocol) {
 
 			dialParams.MeekHostHeader = ""
@@ -958,7 +948,8 @@ func MakeDialParameters(
 		protocol.TUNNEL_PROTOCOL_OBFUSCATED_SSH,
 		protocol.TUNNEL_PROTOCOL_TAPDANCE_OBFUSCATED_SSH,
 		protocol.TUNNEL_PROTOCOL_CONJURE_OBFUSCATED_SSH,
-		protocol.TUNNEL_PROTOCOL_QUIC_OBFUSCATED_SSH:
+		protocol.TUNNEL_PROTOCOL_QUIC_OBFUSCATED_SSH,
+		protocol.TUNNEL_PROTOCOL_TLS_OBFUSCATED_SSH:
 
 		dialParams.DirectDialAddress = net.JoinHostPort(serverEntry.IpAddress, dialParams.DialPortNumber)
 
@@ -1175,6 +1166,32 @@ func (dialParams *DialParameters) GetDialConfig() *DialConfig {
 	return dialParams.dialConfig
 }
 
+func (dialParams *DialParameters) GetTLSOSSHConfig(config *Config) *TLSTunnelConfig {
+
+	return &TLSTunnelConfig{
+		CustomTLSConfig: &CustomTLSConfig{
+			Parameters:               config.GetParameters(),
+			DialAddr:                 dialParams.DirectDialAddress,
+			SNIServerName:            dialParams.TLSOSSHSNIServerName,
+			SkipVerify:               true,
+			VerifyServerName:         "",
+			VerifyPins:               nil,
+			TLSProfile:               dialParams.TLSProfile,
+			NoDefaultTLSSessionID:    &dialParams.NoDefaultTLSSessionID,
+			RandomizedTLSProfileSeed: dialParams.RandomizedTLSProfileSeed,
+		},
+		// Obfuscated session tickets are not used because TLS-OSSH uses TLS 1.3.
+		UseObfuscatedSessionTickets: false,
+		// Meek obfuscated key used to allow clients with legacy unfronted
+		// meek-https server entries, that have the passthrough capability, to
+		// connect with TLS-OSSH to the servers corresponding to those server
+		// entries, which now support TLS-OSSH by demultiplexing meek-https and
+		// TLS-OSSH over the meek-https port.
+		ObfuscatedKey:         dialParams.ServerEntry.MeekObfuscatedKey,
+		ObfuscatorPaddingSeed: dialParams.TLSOSSHObfuscatorPaddingSeed,
+	}
+}
+
 func (dialParams *DialParameters) GetMeekConfig() *MeekConfig {
 	return dialParams.meekConfig
 }

+ 0 - 1
psiphon/dialParameters_test.go

@@ -457,7 +457,6 @@ func runDialParametersAndReplay(t *testing.T, tunnelProtocol string) {
 	applyParameters[parameters.ReplaySSH] = false
 	applyParameters[parameters.ReplayObfuscatorPadding] = false
 	applyParameters[parameters.ReplayFragmentor] = false
-	applyParameters[parameters.ReplayRandomizedTLSProfile] = false
 	applyParameters[parameters.ReplayObfuscatedQUIC] = false
 	applyParameters[parameters.ReplayLivenessTest] = false
 	applyParameters[parameters.ReplayAPIRequestPadding] = false

+ 8 - 0
psiphon/notice.go

@@ -505,6 +505,14 @@ func noticeWithDialParameters(noticeType string, dialParams *DialParameters, pos
 			args = append(args, "meekTransformedHostName", dialParams.MeekTransformedHostName)
 		}
 
+		if dialParams.TLSOSSHSNIServerName != "" {
+			args = append(args, "tlsOSSHSNIServerName", dialParams.TLSOSSHSNIServerName)
+		}
+
+		if dialParams.TLSOSSHTransformedSNIServerName {
+			args = append(args, "tlsOSSHTransformedSNIServerName", dialParams.TLSOSSHTransformedSNIServerName)
+		}
+
 		if dialParams.SelectedUserAgent {
 			args = append(args, "userAgent", dialParams.UserAgent)
 		}

+ 76 - 50
psiphon/server/config.go

@@ -156,7 +156,7 @@ type Config struct {
 	// TunnelProtocolPorts specifies which tunnel protocols to run
 	// and which ports to listen on for each protocol. Valid tunnel
 	// protocols include:
-	// "SSH", "OSSH", "UNFRONTED-MEEK-OSSH", "UNFRONTED-MEEK-HTTPS-OSSH",
+	// "SSH", "OSSH", "TLS-OSSH", "UNFRONTED-MEEK-OSSH", "UNFRONTED-MEEK-HTTPS-OSSH",
 	// "UNFRONTED-MEEK-SESSION-TICKET-OSSH", "FRONTED-MEEK-OSSH",
 	// "FRONTED-MEEK-QUIC-OSSH", "FRONTED-MEEK-HTTP-OSSH", "QUIC-OSSH",
 	// "TAPDANCE-OSSH", abd "CONJURE-OSSH".
@@ -168,8 +168,8 @@ type Config struct {
 	// the passthrough target when the client fails anti-probing tests.
 	//
 	// TunnelProtocolPassthroughAddresses is supported for:
-	// "UNFRONTED-MEEK-HTTPS-OSSH", "UNFRONTED-MEEK-SESSION-TICKET-OSSH",
-	// "UNFRONTED-MEEK-OSSH".
+	// "TLS-OSSH", "UNFRONTED-MEEK-HTTPS-OSSH",
+	// "UNFRONTED-MEEK-SESSION-TICKET-OSSH", "UNFRONTED-MEEK-OSSH".
 	TunnelProtocolPassthroughAddresses map[string]string
 
 	// LegacyPassthrough indicates whether to expect legacy passthrough messages
@@ -225,6 +225,12 @@ type Config struct {
 	// MeekObfuscatedKey is the secret key used for obfuscating
 	// meek cookies sent from clients. The same key is used for all
 	// meek protocols run by this server instance.
+	//
+	// NOTE: this key is also used by the TLS-OSSH protocol, which allows
+	// clients with legacy unfronted meek-https server entries, that have the
+	// passthrough capability, to connect with TLS-OSSH to the servers
+	// corresponding to those server entries, which now support TLS-OSSH by
+	// demultiplexing meek-https and TLS-OSSH over the meek-https port.
 	MeekObfuscatedKey string
 
 	// MeekProhibitedHeaders is a list of HTTP headers to check for
@@ -593,6 +599,15 @@ func LoadConfig(configJSON []byte) (*Config, error) {
 					tunnelProtocol)
 			}
 		}
+		if protocol.TunnelProtocolUsesTLSOSSH(tunnelProtocol) {
+			// Meek obfuscated key used for legacy reasons. See comment for
+			// MeekObfuscatedKey.
+			if config.MeekObfuscatedKey == "" {
+				return nil, errors.Tracef(
+					"Tunnel protocol %s requires MeekObfuscatedKey",
+					tunnelProtocol)
+			}
+		}
 		if protocol.TunnelProtocolUsesMeekHTTP(tunnelProtocol) ||
 			protocol.TunnelProtocolUsesMeekHTTPS(tunnelProtocol) {
 			if config.MeekCookieEncryptionPrivateKey == "" || config.MeekObfuscatedKey == "" {
@@ -737,22 +752,23 @@ func validateNetworkAddress(address string, requireIPaddress bool) error {
 // GenerateConfigParams specifies customizations to be applied to
 // a generated server config.
 type GenerateConfigParams struct {
-	LogFilename                 string
-	SkipPanickingLogWriter      bool
-	LogLevel                    string
-	ServerIPAddress             string
-	WebServerPort               int
-	EnableSSHAPIRequests        bool
-	TunnelProtocolPorts         map[string]int
-	TrafficRulesConfigFilename  string
-	OSLConfigFilename           string
-	TacticsConfigFilename       string
-	TacticsRequestPublicKey     string
-	TacticsRequestObfuscatedKey string
-	Passthrough                 bool
-	LegacyPassthrough           bool
-	LimitQUICVersions           protocol.QUICVersions
-	EnableGQUIC                 bool
+	LogFilename                        string
+	SkipPanickingLogWriter             bool
+	LogLevel                           string
+	ServerIPAddress                    string
+	WebServerPort                      int
+	EnableSSHAPIRequests               bool
+	TunnelProtocolPorts                map[string]int
+	TunnelProtocolPassthroughAddresses map[string]string
+	TrafficRulesConfigFilename         string
+	OSLConfigFilename                  string
+	TacticsConfigFilename              string
+	TacticsRequestPublicKey            string
+	TacticsRequestObfuscatedKey        string
+	Passthrough                        bool
+	LegacyPassthrough                  bool
+	LimitQUICVersions                  protocol.QUICVersions
+	EnableGQUIC                        bool
 }
 
 // GenerateConfig creates a new Psiphon server config. It returns JSON encoded
@@ -784,6 +800,7 @@ func GenerateConfig(params *GenerateConfigParams) ([]byte, []byte, []byte, []byt
 	}
 
 	usingMeek := false
+	usingTLSOSSH := false
 
 	for tunnelProtocol, port := range params.TunnelProtocolPorts {
 
@@ -796,6 +813,10 @@ func GenerateConfig(params *GenerateConfigParams) ([]byte, []byte, []byte, []byt
 		}
 		usedPort[port] = true
 
+		if protocol.TunnelProtocolUsesTLSOSSH(tunnelProtocol) {
+			usingTLSOSSH = true
+		}
+
 		if protocol.TunnelProtocolUsesMeekHTTP(tunnelProtocol) ||
 			protocol.TunnelProtocolUsesMeekHTTPS(tunnelProtocol) {
 			usingMeek = true
@@ -889,7 +910,9 @@ func GenerateConfig(params *GenerateConfigParams) ([]byte, []byte, []byte, []byt
 
 		meekCookieEncryptionPublicKey = base64.StdEncoding.EncodeToString(rawMeekCookieEncryptionPublicKey[:])
 		meekCookieEncryptionPrivateKey = base64.StdEncoding.EncodeToString(rawMeekCookieEncryptionPrivateKey[:])
+	}
 
+	if usingMeek || usingTLSOSSH {
 		meekObfuscatedKeyBytes, err := common.MakeSecureRandomBytes(SSH_OBFUSCATED_KEY_BYTE_LENGTH)
 		if err != nil {
 			return nil, nil, nil, nil, nil, errors.Trace(err)
@@ -920,37 +943,38 @@ func GenerateConfig(params *GenerateConfigParams) ([]byte, []byte, []byte, []byt
 	createMode := 0666
 
 	config := &Config{
-		LogLevel:                       logLevel,
-		LogFilename:                    params.LogFilename,
-		LogFileCreateMode:              &createMode,
-		SkipPanickingLogWriter:         params.SkipPanickingLogWriter,
-		GeoIPDatabaseFilenames:         nil,
-		HostID:                         "example-host-id",
-		ServerIPAddress:                params.ServerIPAddress,
-		DiscoveryValueHMACKey:          discoveryValueHMACKey,
-		WebServerPort:                  params.WebServerPort,
-		WebServerSecret:                webServerSecret,
-		WebServerCertificate:           webServerCertificate,
-		WebServerPrivateKey:            webServerPrivateKey,
-		WebServerPortForwardAddress:    webServerPortForwardAddress,
-		SSHPrivateKey:                  string(sshPrivateKey),
-		SSHServerVersion:               sshServerVersion,
-		SSHUserName:                    sshUserName,
-		SSHPassword:                    sshPassword,
-		ObfuscatedSSHKey:               obfuscatedSSHKey,
-		TunnelProtocolPorts:            params.TunnelProtocolPorts,
-		DNSResolverIPAddress:           "8.8.8.8",
-		UDPInterceptUdpgwServerAddress: "127.0.0.1:7300",
-		MeekCookieEncryptionPrivateKey: meekCookieEncryptionPrivateKey,
-		MeekObfuscatedKey:              meekObfuscatedKey,
-		MeekProhibitedHeaders:          nil,
-		MeekProxyForwardedForHeaders:   []string{"X-Forwarded-For"},
-		LoadMonitorPeriodSeconds:       300,
-		TrafficRulesFilename:           params.TrafficRulesConfigFilename,
-		OSLConfigFilename:              params.OSLConfigFilename,
-		TacticsConfigFilename:          params.TacticsConfigFilename,
-		LegacyPassthrough:              params.LegacyPassthrough,
-		EnableGQUIC:                    params.EnableGQUIC,
+		LogLevel:                           logLevel,
+		LogFilename:                        params.LogFilename,
+		LogFileCreateMode:                  &createMode,
+		SkipPanickingLogWriter:             params.SkipPanickingLogWriter,
+		GeoIPDatabaseFilenames:             nil,
+		HostID:                             "example-host-id",
+		ServerIPAddress:                    params.ServerIPAddress,
+		DiscoveryValueHMACKey:              discoveryValueHMACKey,
+		WebServerPort:                      params.WebServerPort,
+		WebServerSecret:                    webServerSecret,
+		WebServerCertificate:               webServerCertificate,
+		WebServerPrivateKey:                webServerPrivateKey,
+		WebServerPortForwardAddress:        webServerPortForwardAddress,
+		SSHPrivateKey:                      string(sshPrivateKey),
+		SSHServerVersion:                   sshServerVersion,
+		SSHUserName:                        sshUserName,
+		SSHPassword:                        sshPassword,
+		ObfuscatedSSHKey:                   obfuscatedSSHKey,
+		TunnelProtocolPorts:                params.TunnelProtocolPorts,
+		TunnelProtocolPassthroughAddresses: params.TunnelProtocolPassthroughAddresses,
+		DNSResolverIPAddress:               "8.8.8.8",
+		UDPInterceptUdpgwServerAddress:     "127.0.0.1:7300",
+		MeekCookieEncryptionPrivateKey:     meekCookieEncryptionPrivateKey,
+		MeekObfuscatedKey:                  meekObfuscatedKey,
+		MeekProhibitedHeaders:              nil,
+		MeekProxyForwardedForHeaders:       []string{"X-Forwarded-For"},
+		LoadMonitorPeriodSeconds:           300,
+		TrafficRulesFilename:               params.TrafficRulesConfigFilename,
+		OSLConfigFilename:                  params.OSLConfigFilename,
+		TacticsConfigFilename:              params.TacticsConfigFilename,
+		LegacyPassthrough:                  params.LegacyPassthrough,
+		EnableGQUIC:                        params.EnableGQUIC,
 	}
 
 	encodedConfig, err := json.MarshalIndent(config, "\n", "    ")
@@ -1073,6 +1097,7 @@ func GenerateConfig(params *GenerateConfigParams) ([]byte, []byte, []byte, []byt
 	sshPort := params.TunnelProtocolPorts[protocol.TUNNEL_PROTOCOL_SSH]
 	obfuscatedSSHPort := params.TunnelProtocolPorts[protocol.TUNNEL_PROTOCOL_OBFUSCATED_SSH]
 	obfuscatedSSHQUICPort := params.TunnelProtocolPorts[protocol.TUNNEL_PROTOCOL_QUIC_OBFUSCATED_SSH]
+	tlsOSSHPort := params.TunnelProtocolPorts[protocol.TUNNEL_PROTOCOL_TLS_OBFUSCATED_SSH]
 
 	// Meek port limitations
 	// - fronted meek protocols are hard-wired in the client to be port 443 or 80.
@@ -1105,6 +1130,7 @@ func GenerateConfig(params *GenerateConfigParams) ([]byte, []byte, []byte, []byt
 		WebServerPort:                 serverEntryWebServerPort,
 		WebServerSecret:               webServerSecret,
 		WebServerCertificate:          strippedWebServerCertificate,
+		TlsOSSHPort:                   tlsOSSHPort,
 		SshPort:                       sshPort,
 		SshUsername:                   sshUserName,
 		SshPassword:                   sshPassword,

+ 272 - 0
psiphon/server/demux.go

@@ -0,0 +1,272 @@
+/*
+ * Copyright (c) 2023, 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"
+	"net"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
+)
+
+// protocolDemux enables a single listener to support multiple protocols
+// by demultiplexing each conn it accepts into the corresponding protocol
+// handler.
+type protocolDemux struct {
+	ctx           context.Context
+	cancelFunc    context.CancelFunc
+	innerListener net.Listener
+	classifiers   []protocolClassifier
+	accept        chan struct{}
+
+	conns []chan net.Conn
+}
+
+type protocolClassifier struct {
+	// If set, then the classifier only needs a sample of at least this many
+	// bytes to determine whether there is a match or not.
+	minBytesToMatch int
+	// If set, then the classifier only needs a sample of up to this many bytes
+	// to determine whether there is a match or not. If match returns false with
+	// a sample of size greater than or equal to maxBytesToMatch, then match
+	// will always return false regardless of which bytes are appended to
+	// the given sample.
+	maxBytesToMatch int
+	// Returns true if the sample corresponds to the protocol represented by
+	// this classifier.
+	match func(sample []byte) bool
+}
+
+// newProtocolDemux returns a newly initialized ProtocolDemux and an
+// array of protocol listeners. For each protocol classifier in classifiers
+// there will be a corresponding protocol listener at the same index in the
+// array of returned protocol listeners.
+func newProtocolDemux(ctx context.Context, listener net.Listener, classifiers []protocolClassifier) (*protocolDemux, []protoListener) {
+
+	ctx, cancelFunc := context.WithCancel(ctx)
+
+	conns := make([]chan net.Conn, len(classifiers))
+	for i := range classifiers {
+		conns[i] = make(chan net.Conn)
+	}
+
+	p := protocolDemux{
+		ctx:           ctx,
+		cancelFunc:    cancelFunc,
+		innerListener: listener,
+		conns:         conns,
+		classifiers:   classifiers,
+		accept:        make(chan struct{}, 1),
+	}
+
+	protoListeners := make([]protoListener, len(classifiers))
+	for i := range classifiers {
+		protoListeners[i] = protoListener{
+			index: i,
+			mux:   &p,
+		}
+	}
+
+	return &p, protoListeners
+}
+
+// run runs the protocol demultiplexer; this function blocks while the
+// ProtocolDemux accepts new conns and routes them to the corresponding
+// protocol listener returned from NewProtocolDemux.
+//
+// To stop the protocol demultiplexer and cleanup underlying resources
+// call Close().
+func (mux *protocolDemux) run() error {
+
+	maxBytesToMatch := 0
+	for _, classifer := range mux.classifiers {
+		if classifer.maxBytesToMatch == 0 {
+			maxBytesToMatch = 0
+			break
+		} else if classifer.maxBytesToMatch > maxBytesToMatch {
+			maxBytesToMatch = classifer.maxBytesToMatch
+		}
+	}
+
+	// Set read buffer to max amount of bytes needed to classify each
+	// Conn if finite.
+	readBufferSize := 512 // default size
+	if maxBytesToMatch > 0 {
+		readBufferSize = maxBytesToMatch
+	}
+
+	for mux.ctx.Err() == nil {
+
+		// Accept first conn immediately and then wait for downstream listeners
+		// to request new conns.
+
+		conn, err := mux.innerListener.Accept()
+		if err != nil {
+			if mux.ctx.Err() == nil {
+				log.WithTraceFields(LogFields{"error": err}).Debug("accept failed")
+				// TODO: add backoff before continue?
+			}
+			continue
+		}
+
+		go func() {
+
+			var acc bytes.Buffer
+			b := make([]byte, readBufferSize)
+
+			for mux.ctx.Err() == nil {
+
+				n, err := conn.Read(b)
+				if err != nil {
+					log.WithTraceFields(LogFields{"error": err}).Debug("read conn failed")
+					break // conn will be closed
+				}
+
+				acc.Write(b[:n])
+
+				for i, detector := range mux.classifiers {
+
+					if acc.Len() >= detector.minBytesToMatch {
+
+						if detector.match(acc.Bytes()) {
+
+							// Found a match, replay buffered bytes in new conn
+							// and downstream.
+							go func() {
+								bConn := newBufferedConn(conn, acc)
+								select {
+								case mux.conns[i] <- bConn:
+								case <-mux.ctx.Done():
+									bConn.Close()
+								}
+							}()
+
+							return
+						}
+					}
+				}
+
+				if maxBytesToMatch != 0 && acc.Len() > maxBytesToMatch {
+
+					// No match. Sample does not match any detector and is
+					// longer than required by each.
+					log.WithTrace().Warning("no detector match for conn")
+
+					break // conn will be closed
+				}
+			}
+
+			// cleanup conn
+			err := conn.Close()
+			if err != nil {
+				log.WithTraceFields(LogFields{"error": err}).Debug("close conn failed")
+			}
+		}()
+
+		// Wait for one of the downstream listeners to request another conn.
+		select {
+		case <-mux.accept:
+		case <-mux.ctx.Done():
+			return mux.ctx.Err()
+		}
+	}
+
+	return mux.ctx.Err()
+}
+
+func (mux *protocolDemux) acceptForIndex(index int) (net.Conn, error) {
+
+	// First check pool of accepted and classified conns.
+
+	for mux.ctx.Err() == nil {
+		select {
+		case conn := <-mux.conns[index]:
+			// trigger another accept
+			select {
+			case mux.accept <- struct{}{}:
+			default: // don't block when a signal is already buffered
+			}
+			return conn, nil
+		case <-mux.ctx.Done():
+			return nil, errors.Trace(mux.ctx.Err())
+		}
+	}
+
+	return nil, mux.ctx.Err()
+}
+
+func (mux *protocolDemux) Close() error {
+
+	mux.cancelFunc()
+
+	err := mux.innerListener.Close()
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	return nil
+}
+
+type protoListener struct {
+	index int
+	mux   *protocolDemux
+}
+
+func (p protoListener) Accept() (net.Conn, error) {
+	return p.mux.acceptForIndex(p.index)
+}
+
+func (p protoListener) Close() error {
+	// Do nothing. Listeners must be shutdown with ProtocolDemux.Close.
+	return nil
+}
+
+func (p protoListener) Addr() net.Addr {
+	return p.mux.innerListener.Addr()
+}
+
+type bufferedConn struct {
+	buffer *bytes.Buffer
+	net.Conn
+}
+
+func newBufferedConn(conn net.Conn, buffer bytes.Buffer) *bufferedConn {
+	return &bufferedConn{
+		Conn:   conn,
+		buffer: &buffer,
+	}
+}
+
+func (conn *bufferedConn) Read(b []byte) (n int, err error) {
+
+	if conn.buffer != nil && conn.buffer.Len() > 0 {
+		n := copy(b, conn.buffer.Bytes())
+		conn.buffer.Next(n)
+
+		return n, err
+	}
+
+	// Allow memory to be reclaimed by gc now because Conn may be long
+	// lived and otherwise this memory would be held for its duration.
+	conn.buffer = nil
+
+	return conn.Conn.Read(b)
+}

+ 426 - 0
psiphon/server/demux_test.go

@@ -0,0 +1,426 @@
+/*
+ * Copyright (c) 2023, 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"
+	stderrors "errors"
+	"fmt"
+	"math/rand"
+	"net"
+	"testing"
+	"time"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
+)
+
+type protocolDemuxTest struct {
+	name           string
+	classifiers    []protocolClassifier
+	classifierType []string
+	// conns made on demand so the same test instance can be reused across
+	// tests.
+	conns []func() net.Conn
+	// NOTE: duplicate expected key and value not supported. E.g.
+	// {"1": {"A", "A"}} will result in a test failure, but
+	// {"1": {"A"}, "2": {"A"}} will not.
+	// Expected stream of bytes to read from each conn type. Test will halt
+	// if any of the values are not observed.
+	expected map[string][]string
+}
+
+func runProtocolDemuxTest(tt *protocolDemuxTest) error {
+	conns := make(chan net.Conn)
+	l := testListener{conns: conns}
+
+	go func() {
+		// send conns downstream in random order
+		randOrd := rand.Perm(len(tt.conns))
+		for i := range randOrd {
+			conns <- tt.conns[i]()
+		}
+	}()
+
+	mux, protoListeners := newProtocolDemux(context.Background(), l, tt.classifiers)
+
+	errs := make([]chan error, len(protoListeners))
+	for i := range errs {
+		errs[i] = make(chan error)
+	}
+
+	for i, protoListener := range protoListeners {
+
+		ind := i
+		l := protoListener
+
+		go func() {
+
+			defer close(errs[ind])
+
+			protoListenerType := tt.classifierType[ind]
+
+			expectedValues, ok := tt.expected[protoListenerType]
+			if !ok {
+				errs[ind] <- fmt.Errorf("conn type %s not found", protoListenerType)
+				return
+			}
+
+			expectedValuesNotSeen := make(map[string]struct{})
+			for _, v := range expectedValues {
+				expectedValuesNotSeen[v] = struct{}{}
+			}
+
+			// Keep accepting conns until all conns of
+			// protoListenerType are retrieved from the mux.
+			for len(expectedValuesNotSeen) > 0 {
+
+				conn, err := l.Accept()
+				if err != nil {
+					errs[ind] <- err
+					return
+				}
+
+				connType := conn.(*bufferedConn).Conn.(*testConn).connType
+				if connType != protoListenerType {
+					errs[ind] <- fmt.Errorf("expected conn type %s but got %s for %s", protoListenerType, connType, conn.(*bufferedConn).buffer.String())
+					return
+				}
+
+				var acc []byte
+				b := make([]byte, 1) // TODO: randomize read buffer size
+
+				for {
+					n, err := conn.Read(b)
+					if err != nil {
+						errs[ind] <- err
+						return
+					}
+					if n == 0 {
+						break
+					}
+					acc = append(acc, b[:n]...)
+				}
+
+				if _, ok := expectedValuesNotSeen[string(acc)]; !ok {
+					errs[ind] <- fmt.Errorf("unexpected value %s", string(acc))
+					return
+				}
+
+				delete(expectedValuesNotSeen, string(acc))
+			}
+		}()
+	}
+
+	runErr := make(chan error)
+
+	go func() {
+		defer close(runErr)
+
+		err := mux.run()
+		if err != nil && !stderrors.Is(err, context.Canceled) {
+			runErr <- err
+		}
+	}()
+
+	for i := range errs {
+		err := <-errs[i]
+		if err != nil {
+			return errors.Trace(err)
+		}
+	}
+
+	err := mux.Close()
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	err = <-runErr
+	if err != nil && !stderrors.Is(err, net.ErrClosed) {
+		return errors.Trace(err)
+	}
+
+	return nil
+}
+
+func TestProtocolDemux(t *testing.T) {
+
+	aClassifier := protocolClassifier{
+		match: func(b []byte) bool {
+			return bytes.HasPrefix(b, []byte("AAA"))
+		},
+	}
+
+	bClassifier := protocolClassifier{
+		match: func(b []byte) bool {
+			return bytes.HasPrefix(b, []byte("BBBB"))
+		},
+	}
+	// TODO: could add delay between each testConn returning bytes to simulate
+	// network delay.
+	tests := []protocolDemuxTest{
+		{
+			name: "single conn",
+			classifiers: []protocolClassifier{
+				aClassifier,
+			},
+			classifierType: []string{"A"},
+			conns: []func() net.Conn{
+				func() net.Conn {
+					return &testConn{connType: "A", b: []byte("AAA")}
+				},
+			},
+			expected: map[string][]string{
+				"A": {"AAA"},
+			},
+		},
+		{
+			name: "multiple conns one of each type",
+			classifiers: []protocolClassifier{
+				aClassifier,
+				bClassifier,
+			},
+			classifierType: []string{"A", "B"},
+			conns: []func() net.Conn{
+				func() net.Conn {
+					return &testConn{connType: "A", b: []byte("AAAzzzzz")}
+				},
+				func() net.Conn {
+					return &testConn{connType: "B", b: []byte("BBBBzzzzz")}
+				},
+			},
+			expected: map[string][]string{
+				"A": {"AAAzzzzz"},
+				"B": {"BBBBzzzzz"},
+			},
+		},
+		{
+			name: "multiple conns multiple of each type",
+			classifiers: []protocolClassifier{
+				aClassifier,
+				bClassifier,
+			},
+			classifierType: []string{"A", "B"},
+			conns: []func() net.Conn{
+				func() net.Conn {
+					return &testConn{connType: "A", b: []byte("AAA1zzzzz")}
+				},
+				func() net.Conn {
+					return &testConn{connType: "B", b: []byte("BBBB1zzzzz")}
+				},
+				func() net.Conn {
+					return &testConn{connType: "A", b: []byte("AAA2zzzzz")}
+				},
+				func() net.Conn {
+					return &testConn{connType: "B", b: []byte("BBBB2zzzzz")}
+				},
+			},
+			expected: map[string][]string{
+				"A": {"AAA1zzzzz", "AAA2zzzzz"},
+				"B": {"BBBB1zzzzz", "BBBB2zzzzz"},
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+
+			err := runProtocolDemuxTest(&tt)
+			if err != nil {
+				t.Fatalf("runProtocolDemuxTest failed: %v", err)
+			}
+		})
+	}
+}
+
+func BenchmarkProtocolDemux(b *testing.B) {
+
+	rand.Seed(time.Now().UnixNano())
+
+	aClassifier := protocolClassifier{
+		match: func(b []byte) bool {
+			return bytes.HasPrefix(b, []byte("AAA"))
+		},
+		minBytesToMatch: 3,
+		maxBytesToMatch: 3,
+	}
+
+	bClassifier := protocolClassifier{
+		match: func(b []byte) bool {
+			return bytes.HasPrefix(b, []byte("BBBB"))
+		},
+		minBytesToMatch: 4,
+		maxBytesToMatch: 4,
+	}
+
+	cClassifier := protocolClassifier{
+		match: func(b []byte) bool {
+			return bytes.HasPrefix(b, []byte("C"))
+		},
+		minBytesToMatch: 1,
+		maxBytesToMatch: 1,
+	}
+
+	connTypeToPrefix := map[string]string{
+		"A": "AAA",
+		"B": "BBBB",
+		"C": "C",
+	}
+	var conns []func() net.Conn
+	connsPerConnType := 100
+	expected := make(map[string][]string)
+
+	for connType, connTypePrefix := range connTypeToPrefix {
+
+		for i := 0; i < connsPerConnType; i++ {
+
+			s := fmt.Sprintf("%s%s%d", connTypePrefix, getRandAlphanumericString(9999), i) // include index to prevent collision even though improbable
+
+			connTypeCopy := connType // avoid capturing loop variable
+
+			conns = append(conns, func() net.Conn {
+				conn := testConn{
+					connType: connTypeCopy,
+					b:        []byte(s),
+				}
+				return &conn
+			})
+
+			expected[connType] = append(expected[connType], s)
+		}
+	}
+
+	test := &protocolDemuxTest{
+		name: "multiple conns multiple of each type",
+		classifiers: []protocolClassifier{
+			aClassifier,
+			bClassifier,
+			cClassifier,
+		},
+		classifierType: []string{"A", "B", "C"},
+		conns:          conns,
+		expected:       expected,
+	}
+
+	for n := 0; n < b.N; n++ {
+		err := runProtocolDemuxTest(test)
+		if err != nil {
+			b.Fatalf("runProtocolDemuxTest failed: %v", err)
+		}
+	}
+}
+
+func getRandAlphanumericString(n int) string {
+	var alphanumericals = []rune("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
+	b := make([]rune, n)
+	for i := range b {
+		b[i] = alphanumericals[rand.Intn(len(alphanumericals))]
+	}
+	return string(b)
+}
+
+type testListener struct {
+	conns chan net.Conn
+}
+
+func (l testListener) Accept() (net.Conn, error) {
+
+	conn := <-l.conns
+	if conn == nil {
+		// no more conns
+		return nil, net.ErrClosed
+	}
+
+	return conn, nil
+}
+
+func (l testListener) Close() error {
+	close(l.conns)
+	return nil
+}
+
+func (l testListener) Addr() net.Addr {
+	return nil
+}
+
+type testConn struct {
+	// connType is the type of the underlying connection.
+	connType string
+	// b is the bytes to return over Read() calls.
+	b []byte
+	// maxReadLen is the maximum number of bytes to return from b in a single
+	// Read() call if > 0; otherwise no limit is imposed.
+	maxReadLen int
+	// readErrs are returned from Read() calls in order. If empty, then a nil
+	// error is returned.
+	readErrs []error
+}
+
+func (c *testConn) Read(b []byte) (n int, err error) {
+	if len(c.readErrs) > 0 {
+		err := c.readErrs[0]
+		c.readErrs = c.readErrs[1:]
+		return 0, err
+	}
+
+	numBytes := len(b)
+
+	if numBytes > c.maxReadLen && c.maxReadLen != 0 {
+		numBytes = c.maxReadLen
+	}
+
+	if numBytes > len(c.b) {
+		numBytes = len(c.b)
+	}
+
+	n = copy(b, c.b[:numBytes])
+
+	c.b = c.b[n:]
+
+	return n, nil
+}
+
+func (c *testConn) Write(b []byte) (n int, err error) {
+	return 0, stderrors.New("not supported")
+}
+
+func (c *testConn) Close() error {
+	return nil
+}
+
+func (c *testConn) LocalAddr() net.Addr {
+	return nil
+}
+
+func (c *testConn) RemoteAddr() net.Addr {
+	return nil
+}
+
+func (c *testConn) SetDeadline(t time.Time) error {
+	return nil
+}
+
+func (c *testConn) SetReadDeadline(t time.Time) error {
+	return nil
+}
+
+func (c *testConn) SetWriteDeadline(t time.Time) error {
+	return nil
+}

+ 91 - 11
psiphon/server/server_test.go

@@ -173,6 +173,21 @@ func TestPrefixedOSSH(t *testing.T) {
 		})
 }
 
+// NOTE: breaks the naming convention of dropping the OSSH suffix
+// because TestTLS is ambiguous as there are other protocols which
+// use TLS, e.g. UNFRONTED-MEEK-HTTPS-OSSH.
+func TestTLSOSSH(t *testing.T) {
+	runServer(t,
+		&runServerConfig{
+			tunnelProtocol:       "TLS-OSSH",
+			enableSSHAPIRequests: true,
+			requireAuthorization: true,
+			doTunneledWebRequest: true,
+			doTunneledNTPRequest: true,
+			doDanglingTCPConn:    true,
+		})
+}
+
 func TestUnfrontedMeek(t *testing.T) {
 	runServer(t,
 		&runServerConfig{
@@ -271,6 +286,36 @@ func TestUnfrontedMeekSessionTicketTLS13(t *testing.T) {
 		})
 }
 
+func TestTLSOverUnfrontedMeekHTTPSDemux(t *testing.T) {
+	runServer(t,
+		&runServerConfig{
+			tunnelProtocol:       "UNFRONTED-MEEK-HTTPS-OSSH",
+			clientTunnelProtocol: "TLS-OSSH",
+			passthrough:          true,
+			tlsProfile:           protocol.TLS_PROFILE_CHROME_96, // TLS-OSSH requires TLS 1.3 support
+			enableSSHAPIRequests: true,
+			requireAuthorization: true,
+			doTunneledWebRequest: true,
+			doTunneledNTPRequest: true,
+			doDanglingTCPConn:    true,
+		})
+}
+
+func TestTLSOverUnfrontedMeekSessionTicketDemux(t *testing.T) {
+	runServer(t,
+		&runServerConfig{
+			tunnelProtocol:       "UNFRONTED-MEEK-SESSION-TICKET-OSSH",
+			clientTunnelProtocol: "TLS-OSSH",
+			passthrough:          true,
+			tlsProfile:           protocol.TLS_PROFILE_CHROME_96, // TLS-OSSH requires TLS 1.3 support
+			enableSSHAPIRequests: true,
+			requireAuthorization: true,
+			doTunneledWebRequest: true,
+			doTunneledNTPRequest: true,
+			doDanglingTCPConn:    true,
+		})
+}
+
 func TestQUICOSSH(t *testing.T) {
 	if !quic.Enabled() {
 		t.Skip("QUIC is not enabled")
@@ -501,6 +546,8 @@ func TestOmitProvider(t *testing.T) {
 
 type runServerConfig struct {
 	tunnelProtocol       string
+	clientTunnelProtocol string
+	passthrough          bool
 	tlsProfile           string
 	enableSSHAPIRequests bool
 	doHotReload          bool
@@ -610,13 +657,24 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 		limitQUICVersions = protocol.QUICVersions{selectedQUICVersion}
 	}
 
+	var tunnelProtocolPassthroughAddresses map[string]string
+
+	if runConfig.passthrough {
+		tunnelProtocolPassthroughAddresses = map[string]string{
+			// Tests do not trigger passthrough so set invalid IP and port.
+			runConfig.tunnelProtocol: "x.x.x.x:x",
+		}
+	}
+
 	generateConfigParams := &GenerateConfigParams{
-		ServerIPAddress:      psiphonServerIPAddress,
-		EnableSSHAPIRequests: runConfig.enableSSHAPIRequests,
-		WebServerPort:        8000,
-		TunnelProtocolPorts:  map[string]int{runConfig.tunnelProtocol: psiphonServerPort},
-		LimitQUICVersions:    limitQUICVersions,
-		EnableGQUIC:          !runConfig.limitQUICVersions,
+		ServerIPAddress:                    psiphonServerIPAddress,
+		EnableSSHAPIRequests:               runConfig.enableSSHAPIRequests,
+		WebServerPort:                      8000,
+		TunnelProtocolPorts:                map[string]int{runConfig.tunnelProtocol: psiphonServerPort},
+		TunnelProtocolPassthroughAddresses: tunnelProtocolPassthroughAddresses,
+		Passthrough:                        runConfig.passthrough,
+		LimitQUICVersions:                  limitQUICVersions,
+		EnableGQUIC:                        !runConfig.limitQUICVersions,
 	}
 
 	if doServerTactics {
@@ -664,13 +722,19 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 	// case where the tactics config is omitted.
 	if doServerTactics {
 		tacticsConfigFilename = filepath.Join(testDataDirName, "tactics_config.json")
+
+		tacticsTunnelProtocol := runConfig.tunnelProtocol
+		if runConfig.clientTunnelProtocol != "" {
+			tacticsTunnelProtocol = runConfig.clientTunnelProtocol
+		}
+
 		paveTacticsConfigFile(
 			t,
 			tacticsConfigFilename,
 			tacticsRequestPublicKey,
 			tacticsRequestPrivateKey,
 			tacticsRequestObfuscatedKey,
-			runConfig.tunnelProtocol,
+			tacticsTunnelProtocol,
 			propagationChannelID,
 			livenessTestSize,
 			runConfig.doBurstMonitor,
@@ -889,6 +953,11 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 
 	testClientFeaturesJSON, _ := json.Marshal(testClientFeatures)
 
+	clientTunnelProtocol := runConfig.tunnelProtocol
+	if runConfig.clientTunnelProtocol != "" {
+		clientTunnelProtocol = runConfig.clientTunnelProtocol
+	}
+
 	clientConfigJSON := fmt.Sprintf(`
     {
         "ClientPlatform" : "Android_10_com.test.app",
@@ -906,7 +975,7 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
     }`,
 		string(testClientFeaturesJSON),
 		numTunnels,
-		runConfig.tunnelProtocol,
+		clientTunnelProtocol,
 		jsonLimitTLSProfiles,
 		jsonNetworkID)
 
@@ -1065,6 +1134,12 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 			switch noticeType {
 
 			case "ConnectedServer":
+				// Check that client connected with the expected protocol.
+				protocol := payload["protocol"].(string)
+				if protocol != clientTunnelProtocol {
+					// TODO: wrong goroutine for t.FatalNow()
+					t.Errorf("unexpected protocol: %s", protocol)
+				}
 				sendNotificationReceived(connectedServer)
 
 			case "Tunnels":
@@ -1174,6 +1249,11 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 		_, _ = pavePsinetDatabaseFile(
 			t, psinetFilename, sponsorID, runConfig.doDefaultSponsorID, false, psinetValidServerEntryTags)
 
+		tacticsTunnelProtocol := runConfig.tunnelProtocol
+		if runConfig.clientTunnelProtocol != "" {
+			tacticsTunnelProtocol = runConfig.clientTunnelProtocol
+		}
+
 		// Pave tactics without destination bytes.
 		paveTacticsConfigFile(
 			t,
@@ -1181,7 +1261,7 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 			tacticsRequestPublicKey,
 			tacticsRequestPrivateKey,
 			tacticsRequestObfuscatedKey,
-			runConfig.tunnelProtocol,
+			tacticsTunnelProtocol,
 			propagationChannelID,
 			livenessTestSize,
 			runConfig.doBurstMonitor,
@@ -1607,7 +1687,7 @@ func checkExpectedServerTunnelLogFields(
 		}
 	}
 
-	if protocol.TunnelProtocolUsesMeek(runConfig.tunnelProtocol) {
+	if protocol.TunnelProtocolUsesMeek(runConfig.tunnelProtocol) && (runConfig.clientTunnelProtocol == "" || protocol.TunnelProtocolUsesMeekHTTPS(runConfig.clientTunnelProtocol)) {
 
 		for _, name := range []string{
 			"user_agent",
@@ -1656,7 +1736,7 @@ func checkExpectedServerTunnelLogFields(
 		}
 	}
 
-	if protocol.TunnelProtocolUsesMeekHTTPS(runConfig.tunnelProtocol) {
+	if protocol.TunnelProtocolUsesMeekHTTPS(runConfig.tunnelProtocol) && (runConfig.clientTunnelProtocol == "" || protocol.TunnelProtocolUsesMeekHTTPS(runConfig.clientTunnelProtocol)) {
 
 		for _, name := range []string{
 			"tls_profile",

+ 194 - 0
psiphon/server/tlsTunnel.go

@@ -0,0 +1,194 @@
+/*
+ * Copyright (c) 2023, 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 (
+	"net"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/obfuscator"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/values"
+	tris "github.com/Psiphon-Labs/tls-tris"
+)
+
+// TLSTunnelServer tunnels TCP traffic (in the case of Psiphon, Obfuscated SSH
+// traffic) over TLS.
+type TLSTunnelServer struct {
+	support                *SupportServices
+	listener               net.Listener
+	listenerTunnelProtocol string
+	listenerPort           int
+	passthroughAddress     string
+	tlsConfig              *tris.Config
+	obfuscatorSeedHistory  *obfuscator.SeedHistory
+}
+
+func ListenTLSTunnel(
+	support *SupportServices,
+	listener net.Listener,
+	listenerTunnelProtocol string,
+	listenerPort int,
+) (net.Listener, error) {
+
+	server, err := NewTLSTunnelServer(support, listener, listenerTunnelProtocol, listenerPort)
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+
+	return tris.NewListener(server.listener, server.tlsConfig), nil
+}
+
+// NewTLSTunnelServer initializes a new TLSTunnelServer.
+func NewTLSTunnelServer(
+	support *SupportServices,
+	listener net.Listener,
+	listenerTunnelProtocol string,
+	listenerPort int) (*TLSTunnelServer, error) {
+
+	passthroughAddress := support.Config.TunnelProtocolPassthroughAddresses[listenerTunnelProtocol]
+
+	tlsServer := &TLSTunnelServer{
+		support:                support,
+		listener:               listener,
+		listenerTunnelProtocol: listenerTunnelProtocol,
+		listenerPort:           listenerPort,
+		passthroughAddress:     passthroughAddress,
+		obfuscatorSeedHistory:  obfuscator.NewSeedHistory(nil),
+	}
+
+	tlsConfig, err := tlsServer.makeTLSTunnelConfig()
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+	tlsServer.tlsConfig = tlsConfig
+
+	return tlsServer, nil
+}
+
+// makeTLSTunnelConfig creates a TLS config for a TLSTunnelServer listener.
+func (server *TLSTunnelServer) makeTLSTunnelConfig() (*tris.Config, error) {
+
+	// Limitation: certificate value changes on restart.
+
+	certificate, privateKey, err := common.GenerateWebServerCertificate(values.GetHostName())
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+
+	tlsCertificate, err := tris.X509KeyPair(
+		[]byte(certificate), []byte(privateKey))
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+
+	var minVersion uint16
+	if protocol.TunnelProtocolUsesTLSOSSH(server.listenerTunnelProtocol) {
+		// Use min TLS 1.3 so cert is not plaintext on the wire.
+		minVersion = tris.VersionTLS13
+	} else {
+		// Need to support older TLS versions for backwards compatibility.
+		// Vary the minimum version to frustrate scanning/fingerprinting of unfronted servers.
+		// Limitation: like the certificate, this value changes on restart.
+		minVersionCandidates := []uint16{tris.VersionTLS10, tris.VersionTLS11, tris.VersionTLS12}
+		minVersion = minVersionCandidates[prng.Intn(len(minVersionCandidates))]
+	}
+
+	config := &tris.Config{
+		Certificates:            []tris.Certificate{tlsCertificate},
+		NextProtos:              []string{"http/1.1"},
+		MinVersion:              minVersion,
+		UseExtendedMasterSecret: true,
+	}
+
+	// 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
+
+		config.PassthroughVerifyMessage = func(
+			message []byte) bool {
+
+			return obfuscator.VerifyTLSPassthroughMessage(
+				true,
+				// Meek obfuscated key used for legacy reasons. See comment for
+				// MeekObfuscatedKey.
+				server.support.Config.MeekObfuscatedKey,
+				message)
+		}
+
+		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 {
+
+			// Use a custom, shorter TTL based on the validity period of the
+			// passthrough message.
+			TTL := obfuscator.TLS_PASSTHROUGH_TIME_PERIOD
+
+			// strictMode is true as legitimate clients never retry TLS
+			// connections using a previous random value.
+
+			ok, logFields := server.obfuscatorSeedHistory.AddNewWithTTL(
+				true,
+				clientIP,
+				"client-random",
+				clientRandom,
+				TTL)
+
+			if logFields != nil {
+				logIrregularTunnel(
+					server.support,
+					server.listenerTunnelProtocol,
+					server.listenerPort,
+					clientIP,
+					errors.TraceNew("duplicate passthrough message"),
+					LogFields(*logFields))
+			}
+
+			return ok
+		}
+	}
+
+	return config, nil
+}

+ 169 - 28
psiphon/server/tunnelServer.go

@@ -116,7 +116,7 @@ func NewTunnelServer(
 }
 
 // Run runs the tunnel server; this function blocks while running a selection of
-// listeners that handle connection using various obfuscation protocols.
+// listeners that handle connections using various obfuscation protocols.
 //
 // Run listens on each designated tunnel port and spawns new goroutines to handle
 // each client connection. It halts when shutdownBroadcast is signaled. A list of active
@@ -188,6 +188,13 @@ func (server *TunnelServer) Run() error {
 			// Only direct, unfronted protocol listeners use TCP BPF circumvention
 			// programs.
 			listener, BPFProgramName, err = newTCPListenerWithBPF(support, localAddress)
+
+			if protocol.TunnelProtocolUsesTLSOSSH(tunnelProtocol) {
+				listener, err = ListenTLSTunnel(support, listener, tunnelProtocol, listenPort)
+				if err != nil {
+					return errors.Trace(err)
+				}
+			}
 		}
 
 		if err != nil {
@@ -521,15 +528,147 @@ func (sshServer *sshServer) runListener(sshListener *sshListener, listenerError
 	// TunnelServer.Run will properly shut down instead of remaining
 	// running.
 
-	if protocol.TunnelProtocolUsesMeekHTTP(sshListener.tunnelProtocol) ||
-		protocol.TunnelProtocolUsesMeekHTTPS(sshListener.tunnelProtocol) {
+	if protocol.TunnelProtocolUsesMeekHTTP(sshListener.tunnelProtocol) || protocol.TunnelProtocolUsesMeekHTTPS(sshListener.tunnelProtocol) {
+
+		_, passthroughEnabled := sshServer.support.Config.TunnelProtocolPassthroughAddresses[sshListener.tunnelProtocol]
+
+		// Only use meek/TLS-OSSH demux if unfronted meek HTTPS with non-legacy passthrough.
+		useTLSDemux := protocol.TunnelProtocolUsesMeekHTTPS(sshListener.tunnelProtocol) && !protocol.TunnelProtocolUsesFrontedMeek(sshListener.tunnelProtocol) && passthroughEnabled && !sshServer.support.Config.LegacyPassthrough
+
+		if useTLSDemux {
+
+			sshServer.runMeekTLSOSSHDemuxListener(sshListener, listenerError, handleClient)
+
+		} else {
+			meekServer, err := NewMeekServer(
+				sshServer.support,
+				sshListener.Listener,
+				sshListener.tunnelProtocol,
+				sshListener.port,
+				protocol.TunnelProtocolUsesMeekHTTPS(sshListener.tunnelProtocol),
+				protocol.TunnelProtocolUsesFrontedMeek(sshListener.tunnelProtocol),
+				protocol.TunnelProtocolUsesObfuscatedSessionTickets(sshListener.tunnelProtocol),
+				true,
+				handleClient,
+				sshServer.shutdownBroadcast)
+
+			if err == nil {
+				err = meekServer.Run()
+			}
+
+			if err != nil {
+				select {
+				case listenerError <- errors.Trace(err):
+				default:
+				}
+				return
+			}
+		}
+
+	} else {
+
+		runListener(sshListener.Listener, sshServer.shutdownBroadcast, listenerError, handleClient)
+	}
+}
+
+// runMeekTLSOSSHDemuxListener blocks running a listener which demuxes meek and
+// TLS-OSSH connections received on the same port.
+func (sshServer *sshServer) runMeekTLSOSSHDemuxListener(sshListener *sshListener, listenerError chan<- error, handleClient func(clientTunnelProtocol string, clientConn net.Conn)) {
+
+	meekClassifier := protocolClassifier{
+		minBytesToMatch: 4,
+		maxBytesToMatch: 4,
+		match: func(b []byte) bool {
+
+			// NOTE: HTTP transforms are only applied to plain HTTP
+			// meek so they are not a concern here.
+
+			return bytes.Contains(b, []byte("POST"))
+		},
+	}
+
+	tlsClassifier := protocolClassifier{
+		// NOTE: technically +1 not needed if detectors are evaluated
+		// in order by index in classifier array, which they are.
+		minBytesToMatch: meekClassifier.maxBytesToMatch + 1,
+		maxBytesToMatch: meekClassifier.maxBytesToMatch + 1,
+		match: func(b []byte) bool {
+			return len(b) > 4 // if not classified as meek, then tls
+		},
+	}
+
+	listener, err := ListenTLSTunnel(sshServer.support, sshListener.Listener, sshListener.tunnelProtocol, sshListener.port)
+	if err != nil {
+		select {
+		case listenerError <- errors.Trace(err):
+		default:
+		}
+		return
+	}
+
+	mux, listeners := newProtocolDemux(context.Background(), listener, []protocolClassifier{meekClassifier, tlsClassifier})
+
+	var wg sync.WaitGroup
+
+	wg.Add(1)
+
+	go func() {
+
+		// handle shutdown gracefully
+
+		defer wg.Done()
+
+		<-sshServer.shutdownBroadcast
+		err := mux.Close()
+		if err != nil {
+			log.WithTraceFields(LogFields{"error": err}).Error("close failed")
+		}
+	}()
+
+	wg.Add(1)
+
+	go func() {
+
+		// start demultiplexing TLS-OSSH and meek HTTPS connections
+
+		defer wg.Done()
+
+		err := mux.run()
+		if err != nil {
+			select {
+			case listenerError <- errors.Trace(err):
+			default:
+			}
+			return
+		}
+	}()
+
+	wg.Add(1)
+
+	go func() {
+
+		// start handling TLS-OSSH connections as they are demultiplexed
+
+		defer wg.Done()
+
+		runListener(listeners[1], sshServer.shutdownBroadcast, listenerError, handleClient)
+	}()
+
+	wg.Add(1)
+
+	go func() {
+
+		// start handling meek HTTPS connections as they are
+		// demultiplexed
+
+		defer wg.Done()
 
 		meekServer, err := NewMeekServer(
 			sshServer.support,
-			sshListener.Listener,
+			listeners[0],
 			sshListener.tunnelProtocol,
 			sshListener.port,
-			protocol.TunnelProtocolUsesMeekHTTPS(sshListener.tunnelProtocol),
+			false,
 			protocol.TunnelProtocolUsesFrontedMeek(sshListener.tunnelProtocol),
 			protocol.TunnelProtocolUsesObfuscatedSessionTickets(sshListener.tunnelProtocol),
 			true,
@@ -547,37 +686,39 @@ func (sshServer *sshServer) runListener(sshListener *sshListener, listenerError
 			}
 			return
 		}
+	}()
 
-	} else {
+	wg.Wait()
+}
 
-		for {
-			conn, err := sshListener.Listener.Accept()
+func runListener(listener net.Listener, shutdownBroadcast <-chan struct{}, listenerError chan<- error, handleClient func(clientTunnelProtocol string, clientConn net.Conn)) {
+	for {
+		conn, err := listener.Accept()
 
-			select {
-			case <-sshServer.shutdownBroadcast:
-				if err == nil {
-					conn.Close()
-				}
-				return
-			default:
+		select {
+		case <-shutdownBroadcast:
+			if err == nil {
+				conn.Close()
 			}
+			return
+		default:
+		}
 
-			if err != nil {
-				if e, ok := err.(net.Error); ok && e.Temporary() {
-					log.WithTraceFields(LogFields{"error": err}).Error("accept failed")
-					// Temporary error, keep running
-					continue
-				}
-
-				select {
-				case listenerError <- errors.Trace(err):
-				default:
-				}
-				return
+		if err != nil {
+			if e, ok := err.(net.Error); ok && e.Temporary() {
+				log.WithTraceFields(LogFields{"error": err}).Error("accept failed")
+				// Temporary error, keep running
+				continue
 			}
 
-			handleClient("", conn)
+			select {
+			case listenerError <- errors.Trace(err):
+			default:
+			}
+			return
 		}
+
+		handleClient("", conn)
 	}
 }
 

+ 8 - 0
psiphon/serverApi.go

@@ -988,6 +988,14 @@ func getBaseAPIParameters(
 			params["meek_transformed_host_name"] = transformedHostName
 		}
 
+		if dialParams.TLSOSSHSNIServerName != "" {
+			params["tls_ossh_sni_server_name"] = dialParams.TLSOSSHSNIServerName
+		}
+
+		if dialParams.TLSOSSHTransformedSNIServerName {
+			params["tls_ossh_transformed_host_name"] = "1"
+		}
+
 		if dialParams.SelectedUserAgent {
 			params["user_agent"] = dialParams.UserAgent
 		}

+ 73 - 9
psiphon/tlsDialer.go

@@ -330,10 +330,14 @@ func CustomTLSDial(
 		VerifyPeerCertificate: tlsConfigVerifyPeerCertificate,
 	}
 
+	var randomizedTLSProfileSeed *prng.Seed
 	selectedTLSProfile := config.TLSProfile
 
 	if selectedTLSProfile == "" {
-		selectedTLSProfile = SelectTLSProfile(false, false, "", p)
+		selectedTLSProfile, _, randomizedTLSProfileSeed, err = SelectTLSProfile(false, false, false, "", p)
+		if err != nil {
+			return nil, errors.Trace(err)
+		}
 	}
 
 	utlsClientHelloID, utlsClientHelloSpec, err := getUTLSClientHelloID(
@@ -342,11 +346,14 @@ func CustomTLSDial(
 		return nil, errors.Trace(err)
 	}
 
-	var randomizedTLSProfileSeed *prng.Seed
 	isRandomized := protocol.TLSProfileIsRandomized(selectedTLSProfile)
 	if isRandomized {
 
-		randomizedTLSProfileSeed = config.RandomizedTLSProfileSeed
+		// Give config.RandomizedTLSProfileSeed precedence over the seed
+		// generated by SelectTLSProfile if selectedTLSProfile == "".
+		if config.RandomizedTLSProfileSeed != nil {
+			randomizedTLSProfileSeed = config.RandomizedTLSProfileSeed
+		}
 
 		if randomizedTLSProfileSeed == nil {
 
@@ -660,12 +667,44 @@ func IsTLSConnUsingHTTP2(conn net.Conn) bool {
 	return false
 }
 
-// SelectTLSProfile picks a TLS profile at random from the available candidates.
+// SelectTLSProfile picks and returns a TLS profile at random from the
+// available candidates along with its version and a newly generated PRNG seed
+// if the profile is randomized, i.e. protocol.TLSProfileIsRandomized is true,
+// which should be used when generating a randomized TLS ClientHello.
 func SelectTLSProfile(
+	requireTLS12SessionTickets bool,
+	requireTLS13Support bool,
+	isFronted bool,
+	frontingProviderID string,
+	p parameters.ParametersAccessor) (tlsProfile, tlsVersion string, randomizedTLSProfileSeed *prng.Seed, err error) {
+
+	for {
+		tlsProfile, tlsVersion, randomizedTLSProfileSeed, err = selectTLSProfile(requireTLS12SessionTickets, isFronted, frontingProviderID, p)
+		if err != nil {
+			return "", "", nil, errors.Trace(err)
+		}
+
+		if requireTLS13Support && tlsVersion != protocol.TLS_VERSION_13 {
+			// Continue picking profiles at random until an eligible one is
+			// chosen. It is okay to loop in this way because the probability of
+			// selecting a TLS 1.3 profile is high enough that it should not
+			// take too many iterations until one is chosen.
+			continue
+		}
+
+		return
+	}
+}
+
+// selectTLSProfile is a helper that picks and returns a TLS profile at random
+// from the available candidates along with its version and a newly generated
+// PRNG seed if the profile is randomized, i.e. protocol.TLSProfileIsRandomized
+// is true.
+func selectTLSProfile(
 	requireTLS12SessionTickets bool,
 	isFronted bool,
 	frontingProviderID string,
-	p parameters.ParametersAccessor) string {
+	p parameters.ParametersAccessor) (tlsProfile string, tlsVersion string, randomizedTLSProfileSeed *prng.Seed, err error) {
 
 	// Two TLS profile lists are constructed, subject to limit constraints:
 	// stock, fixed parrots (non-randomized SupportedTLSProfiles) and custom
@@ -745,14 +784,39 @@ func SelectTLSProfile(
 		(len(parrotTLSProfiles) == 0 ||
 			p.WeightedCoinFlip(parameters.SelectRandomizedTLSProfileProbability)) {
 
-		return randomizedTLSProfiles[prng.Intn(len(randomizedTLSProfiles))]
+		tlsProfile = randomizedTLSProfiles[prng.Intn(len(randomizedTLSProfiles))]
 	}
 
-	if len(parrotTLSProfiles) == 0 {
-		return ""
+	if tlsProfile == "" {
+		if len(parrotTLSProfiles) == 0 {
+			return "", "", nil, nil
+		} else {
+			tlsProfile = parrotTLSProfiles[prng.Intn(len(parrotTLSProfiles))]
+		}
+	}
+
+	utlsClientHelloID, utlsClientHelloSpec, err := getUTLSClientHelloID(
+		p, tlsProfile)
+	if err != nil {
+		return "", "", nil, errors.Trace(err)
+	}
+
+	if protocol.TLSProfileIsRandomized(tlsProfile) {
+		randomizedTLSProfileSeed, err = prng.NewSeed()
+		if err != nil {
+			return "", "", nil, errors.Trace(err)
+		}
+		utlsClientHelloID.Seed = new(utls.PRNGSeed)
+		*utlsClientHelloID.Seed = [32]byte(*randomizedTLSProfileSeed)
+	}
+
+	tlsVersion, err = getClientHelloVersion(
+		utlsClientHelloID, utlsClientHelloSpec)
+	if err != nil {
+		return "", "", nil, errors.Trace(err)
 	}
 
-	return parrotTLSProfiles[prng.Intn(len(parrotTLSProfiles))]
+	return tlsProfile, tlsVersion, randomizedTLSProfileSeed, nil
 }
 
 func getUTLSClientHelloID(

+ 70 - 12
psiphon/tlsDialer_test.go

@@ -215,19 +215,19 @@ func TestTLSCertificateVerification(t *testing.T) {
 // certificate, for serverName, signed by that Root CA, and runs a web server
 // that uses that server certificate. initRootCAandWebServer returns:
 //
-// - the file name containing the Root CA, to be used with
-//   CustomTLSConfig.TrustedCACertificatesFilename
+//   - the file name containing the Root CA, to be used with
+//     CustomTLSConfig.TrustedCACertificatesFilename
 //
-// - pin values for the Root CA and server certificare, to be used with
-//   CustomTLSConfig.VerifyPins
+//   - pin values for the Root CA and server certificare, to be used with
+//     CustomTLSConfig.VerifyPins
 //
-// - a shutdown function which the caller must invoked to terminate the web
-//   server
+//   - a shutdown function which the caller must invoked to terminate the web
+//     server
 //
 // - the web server dial address: serverName and port
 //
-// - and a dialer function, which bypasses DNS resolution of serverName, to be
-//   used with CustomTLSConfig.Dial
+//   - and a dialer function, which bypasses DNS resolution of serverName, to be
+//     used with CustomTLSConfig.Dial
 func initTestCertificatesAndWebServer(
 	t *testing.T,
 	testDataDirName string,
@@ -565,7 +565,13 @@ func TestSelectTLSProfile(t *testing.T) {
 	numSelections := 10000
 
 	for i := 0; i < numSelections; i++ {
-		profile := SelectTLSProfile(false, false, "", params.Get())
+		profile, _, seed, err := SelectTLSProfile(false, false, false, "", params.Get())
+		if err != nil {
+			t.Fatalf("SelectTLSProfile failed: %v", err)
+		}
+		if protocol.TLSProfileIsRandomized(profile) && seed == nil {
+			t.Errorf("expected non-nil seed for randomized TLS profile")
+		}
 		selected[profile] += 1
 	}
 
@@ -644,10 +650,16 @@ func TestSelectTLSProfile(t *testing.T) {
 	customTLSProfileNames := params.Get().CustomTLSProfileNames()
 
 	for i := 0; i < numSelections; i++ {
-		profile := SelectTLSProfile(false, false, "", params.Get())
+		profile, _, seed, err := SelectTLSProfile(false, false, false, "", params.Get())
+		if err != nil {
+			t.Fatalf("SelectTLSProfile failed: %v", err)
+		}
 		if !common.Contains(customTLSProfileNames, profile) {
 			t.Errorf("unexpected non-custom TLS profile selected")
 		}
+		if protocol.TLSProfileIsRandomized(profile) && seed == nil {
+			t.Errorf("expected non-nil seed for randomized TLS profile")
+		}
 	}
 
 	// Disabled TLS profiles should not be selected
@@ -663,19 +675,65 @@ func TestSelectTLSProfile(t *testing.T) {
 	}
 
 	for i := 0; i < numSelections; i++ {
-		profile := SelectTLSProfile(false, true, frontingProviderID, params.Get())
+		profile, _, seed, err := SelectTLSProfile(false, false, true, frontingProviderID, params.Get())
+		if err != nil {
+			t.Fatalf("SelectTLSProfile failed: %v", err)
+		}
 		if common.Contains(disableTLSProfiles, profile) {
 			t.Errorf("unexpected disabled TLS profile selected")
 		}
+		if protocol.TLSProfileIsRandomized(profile) && seed == nil {
+			t.Errorf("expected non-nil seed for randomized TLS profile")
+		}
 	}
 
 	// Session ticket incapable TLS 1.2 profiles should not be selected
 
 	for i := 0; i < numSelections; i++ {
-		profile := SelectTLSProfile(true, false, "", params.Get())
+		profile, _, seed, err := SelectTLSProfile(true, false, false, "", params.Get())
+		if err != nil {
+			t.Fatalf("SelectTLSProfile failed: %v", err)
+		}
 		if protocol.TLS12ProfileOmitsSessionTickets(profile) {
 			t.Errorf("unexpected session ticket incapable TLS profile selected")
 		}
+		if protocol.TLSProfileIsRandomized(profile) && seed == nil {
+			t.Errorf("expected non-nil seed for randomized TLS profile")
+		}
+	}
+
+	// Only TLS 1.3 profiles should be selected
+
+	for i := 0; i < numSelections; i++ {
+		profile, tlsVersion, seed, err := SelectTLSProfile(false, true, false, "", params.Get())
+		if err != nil {
+			t.Fatalf("SelectTLSProfile failed: %v", err)
+		}
+		if tlsVersion != protocol.TLS_VERSION_13 {
+			t.Errorf("expected TLS 1.3 profile to be selected")
+		}
+		if protocol.TLSProfileIsRandomized(profile) && seed == nil {
+			t.Errorf("expected non-nil seed for randomized TLS profile")
+		}
+	}
+
+	// Only TLS 1.3 profiles should be selected. All TLS 1.3 profiles should be
+	// session ticket capable.
+
+	for i := 0; i < numSelections; i++ {
+		profile, tlsVersion, seed, err := SelectTLSProfile(true, true, false, "", params.Get())
+		if err != nil {
+			t.Fatalf("SelectTLSProfile failed: %v", err)
+		}
+		if protocol.TLS12ProfileOmitsSessionTickets(profile) {
+			t.Errorf("unexpected session ticket incapable TLS profile selected")
+		}
+		if tlsVersion != protocol.TLS_VERSION_13 {
+			t.Errorf("expected TLS 1.3 profile to be selected")
+		}
+		if protocol.TLSProfileIsRandomized(profile) && seed == nil {
+			t.Errorf("expected non-nil seed for randomized TLS profile")
+		}
 	}
 }
 

+ 173 - 0
psiphon/tlsTunnelConn.go

@@ -0,0 +1,173 @@
+/*
+ * Copyright (c) 2023, 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 psiphon
+
+import (
+	"context"
+	"net"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/obfuscator"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
+)
+
+// TLSTunnelConfig specifies the behavior of a TLSTunnelConn.
+type TLSTunnelConfig struct {
+
+	// CustomTLSConfig is the parameters that will be used to esablish a new
+	// TLS connection with CustomTLSDial.
+	CustomTLSConfig *CustomTLSConfig
+
+	// UseObfuscatedSessionTickets indicates whether to use obfuscated session
+	// tickets.
+	UseObfuscatedSessionTickets bool
+
+	// The following values are used to create the TLS passthrough message.
+
+	ObfuscatedKey         string
+	ObfuscatorPaddingSeed *prng.Seed
+}
+
+// TLSTunnelConn is a network connection that tunnels net.Conn flows over TLS.
+type TLSTunnelConn struct {
+	net.Conn
+	tlsPadding int
+}
+
+// DialTLSTunnel returns an initialized tls-tunnel connection.
+func DialTLSTunnel(
+	ctx context.Context,
+	tlsTunnelConfig *TLSTunnelConfig,
+	dialConfig *DialConfig,
+	tlsOSSHApplyTrafficShaping bool,
+	tlsOSSHMinTLSPadding,
+	tlsOSSHMaxTLSPadding int,
+) (*TLSTunnelConn, error) {
+
+	tlsPadding,
+		err :=
+		tlsTunnelTLSPadding(
+			tlsOSSHApplyTrafficShaping,
+			tlsOSSHMinTLSPadding,
+			tlsOSSHMaxTLSPadding,
+			tlsTunnelConfig.ObfuscatorPaddingSeed)
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+
+	tlsConfig := &CustomTLSConfig{
+		Parameters:                    tlsTunnelConfig.CustomTLSConfig.Parameters,
+		Dial:                          NewTCPDialer(dialConfig),
+		DialAddr:                      tlsTunnelConfig.CustomTLSConfig.DialAddr,
+		SNIServerName:                 tlsTunnelConfig.CustomTLSConfig.SNIServerName,
+		VerifyServerName:              tlsTunnelConfig.CustomTLSConfig.VerifyServerName,
+		VerifyPins:                    tlsTunnelConfig.CustomTLSConfig.VerifyPins,
+		SkipVerify:                    tlsTunnelConfig.CustomTLSConfig.SkipVerify,
+		TLSProfile:                    tlsTunnelConfig.CustomTLSConfig.TLSProfile,
+		NoDefaultTLSSessionID:         tlsTunnelConfig.CustomTLSConfig.NoDefaultTLSSessionID,
+		RandomizedTLSProfileSeed:      tlsTunnelConfig.CustomTLSConfig.RandomizedTLSProfileSeed,
+		TLSPadding:                    tlsPadding,
+		TrustedCACertificatesFilename: dialConfig.TrustedCACertificatesFilename,
+	}
+	tlsConfig.EnableClientSessionCache()
+
+	if tlsTunnelConfig.UseObfuscatedSessionTickets {
+		tlsConfig.ObfuscatedSessionTicketKey = tlsTunnelConfig.ObfuscatedKey
+	}
+
+	// As the passthrough message is unique and indistinguishable 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(
+		true,
+		tlsTunnelConfig.ObfuscatedKey)
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+	tlsConfig.PassthroughMessage = passthroughMessage
+
+	tlsDialer := NewCustomTLSDialer(tlsConfig)
+
+	// As DialAddr is set in the CustomTLSConfig, no address is required here.
+	conn, err := tlsDialer(ctx, "tcp", "")
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+
+	return &TLSTunnelConn{
+		Conn:       conn,
+		tlsPadding: tlsPadding,
+	}, nil
+}
+
+// tlsTunnelTLSPadding returns the padding length to apply with the TLS padding
+// extension to the TLS conn established with NewCustomTLSDialer. See
+// CustomTLSConfig.TLSPadding for details.
+func tlsTunnelTLSPadding(
+	tlsOSSHApplyTrafficShaping bool,
+	tlsOSSHMinTLSPadding int,
+	tlsOSSHMaxTLSPadding int,
+	obfuscatorPaddingPRNGSeed *prng.Seed,
+) (tlsPadding int,
+	err error) {
+
+	tlsPadding = 0
+
+	if tlsOSSHApplyTrafficShaping {
+
+		minPadding := tlsOSSHMinTLSPadding
+		maxPadding := tlsOSSHMaxTLSPadding
+
+		// Maximum padding size per RFC 7685
+		if maxPadding > 65535 {
+			maxPadding = 65535
+		}
+
+		if maxPadding > 0 {
+			tlsPaddingPRNG, err := prng.NewPRNGWithSaltedSeed(obfuscatorPaddingPRNGSeed, "tls-padding")
+			if err != nil {
+				return 0, errors.Trace(err)
+			}
+
+			tlsPadding = tlsPaddingPRNG.Range(minPadding, maxPadding)
+		}
+	}
+
+	return tlsPadding, nil
+}
+
+func (conn *TLSTunnelConn) GetMetrics() common.LogFields {
+	logFields := make(common.LogFields)
+
+	logFields["tls_padding"] = conn.tlsPadding
+
+	// Include metrics, such as fragmentor metrics, from the underlying dial
+	// conn. Properties of subsequent underlying dial conns are not reflected
+	// in these metrics; we assume that the first dial conn, which most likely
+	// transits the various protocol handshakes, is most significant.
+	underlyingMetrics, ok := conn.Conn.(common.MetricsSource)
+	if ok {
+		logFields.Add(underlyingMetrics.GetMetrics())
+	}
+	return logFields
+}

+ 17 - 1
psiphon/tunnel.go

@@ -689,6 +689,9 @@ func dialTunnel(
 	burstUpstreamDeadline := p.Duration(parameters.ClientBurstUpstreamDeadline)
 	burstDownstreamTargetBytes := int64(p.Int(parameters.ClientBurstDownstreamTargetBytes))
 	burstDownstreamDeadline := p.Duration(parameters.ClientBurstDownstreamDeadline)
+	tlsOSSHApplyTrafficShaping := p.WeightedCoinFlip(parameters.TLSTunnelTrafficShapingProbability)
+	tlsOSSHMinTLSPadding := p.Int(parameters.TLSTunnelMinTLSPadding)
+	tlsOSSHMaxTLSPadding := p.Int(parameters.TLSTunnelMaxTLSPadding)
 	p.Close()
 
 	// Ensure that, unless the base context is cancelled, any replayed dial
@@ -746,7 +749,20 @@ func dialTunnel(
 
 	var dialConn net.Conn
 
-	if protocol.TunnelProtocolUsesMeek(dialParams.TunnelProtocol) {
+	if protocol.TunnelProtocolUsesTLSOSSH(dialParams.TunnelProtocol) {
+
+		dialConn, err = DialTLSTunnel(
+			ctx,
+			dialParams.GetTLSOSSHConfig(config),
+			dialParams.GetDialConfig(),
+			tlsOSSHApplyTrafficShaping,
+			tlsOSSHMinTLSPadding,
+			tlsOSSHMaxTLSPadding)
+		if err != nil {
+			return nil, errors.Trace(err)
+		}
+
+	} else if protocol.TunnelProtocolUsesMeek(dialParams.TunnelProtocol) {
 
 		dialConn, err = DialMeek(
 			ctx,