Browse Source

Protocol changes

- Add fragmentor.

- Make OSSH padding length range configurable.

- Apply less constrained random padding to
  plaintext KEX packets sent through OSSH.

- Disable SSH Encrypt-then-MAC modes for all but
  SSH tunnel protocol.
Rod Hynes 7 years ago
parent
commit
ccd1b83c17

+ 20 - 10
psiphon/common/obfuscator/obfuscatedSshConn.go

@@ -35,7 +35,6 @@ const (
 	SSH_MAX_PACKET_LENGTH      = 256 * 1024 // OpenSSH max packet length
 	SSH_MSG_NEWKEYS            = 21
 	SSH_MAX_PADDING_LENGTH     = 255 // RFC 4253 sec. 6
-	SSH_PADDING_MULTIPLE       = 16  // Default cipher block size
 )
 
 // ObfuscatedSshConn wraps a Conn and applies the obfuscated SSH protocol
@@ -108,7 +107,8 @@ const (
 func NewObfuscatedSshConn(
 	mode ObfuscatedSshConnMode,
 	conn net.Conn,
-	obfuscationKeyword string) (*ObfuscatedSshConn, error) {
+	obfuscationKeyword string,
+	minPadding, maxPadding *int) (*ObfuscatedSshConn, error) {
 
 	var err error
 	var obfuscator *Obfuscator
@@ -116,7 +116,12 @@ func NewObfuscatedSshConn(
 	var writeState ObfuscatedSshWriteState
 
 	if mode == OBFUSCATION_CONN_MODE_CLIENT {
-		obfuscator, err = NewClientObfuscator(&ObfuscatorConfig{Keyword: obfuscationKeyword})
+		obfuscator, err = NewClientObfuscator(
+			&ObfuscatorConfig{
+				Keyword:    obfuscationKeyword,
+				MinPadding: minPadding,
+				MaxPadding: maxPadding,
+			})
 		if err != nil {
 			return nil, common.ContextError(err)
 		}
@@ -542,16 +547,21 @@ func extractSshPackets(writeBuffer, transformBuffer *bytes.Buffer) (bool, error)
 		transformedPacket := transformBuffer.Bytes()[transformedPacketOffset:]
 
 		// Padding transformation
-		// See RFC 4253 sec. 6 for constraints
-		possiblePaddings := (SSH_MAX_PADDING_LENGTH - paddingLength) / SSH_PADDING_MULTIPLE
-		if possiblePaddings > 0 {
-
-			// selectedPadding is integer in range [0, possiblePaddings)
-			selectedPadding, err := common.MakeSecureRandomInt(possiblePaddings)
+		// This does not satisfy RFC 4253 sec. 6 constraints:
+		// - The goal is to vary packet sizes as much as possible.
+		// - We implement both the client and server sides and both sides accept
+		//   less constrained paddings (for plaintext packets).
+		possibleExtraPaddingLength := (SSH_MAX_PADDING_LENGTH - paddingLength)
+		if possibleExtraPaddingLength > 0 {
+
+			// TODO: proceed without padding if MakeSecureRandom* fails?
+
+			// selectedPadding is integer in range [0, possiblePadding + 1)
+			extraPaddingLength, err := common.MakeSecureRandomInt(
+				possibleExtraPaddingLength + 1)
 			if err != nil {
 				return false, common.ContextError(err)
 			}
-			extraPaddingLength := selectedPadding * SSH_PADDING_MULTIPLE
 			extraPadding, err := common.MakeSecureRandomBytes(extraPaddingLength)
 			if err != nil {
 				return false, common.ContextError(err)

+ 20 - 12
psiphon/common/obfuscator/obfuscator.go

@@ -51,12 +51,15 @@ type Obfuscator struct {
 
 type ObfuscatorConfig struct {
 	Keyword    string
-	MaxPadding int
+	MinPadding *int
+	MaxPadding *int
 }
 
 // NewClientObfuscator creates a new Obfuscator, staging a seed message to be
 // sent to the server (by the caller) and initializing stream ciphers to
 // obfuscate data.
+//
+//
 func NewClientObfuscator(
 	config *ObfuscatorConfig) (obfuscator *Obfuscator, err error) {
 
@@ -70,12 +73,22 @@ func NewClientObfuscator(
 		return nil, common.ContextError(err)
 	}
 
+	minPadding := 0
+	if config.MinPadding != nil &&
+		*config.MinPadding >= 0 &&
+		*config.MinPadding <= OBFUSCATE_MAX_PADDING {
+		minPadding = *config.MinPadding
+	}
+
 	maxPadding := OBFUSCATE_MAX_PADDING
-	if config.MaxPadding > 0 {
-		maxPadding = config.MaxPadding
+	if config.MaxPadding != nil &&
+		*config.MaxPadding >= 0 &&
+		*config.MaxPadding <= OBFUSCATE_MAX_PADDING &&
+		*config.MaxPadding >= minPadding {
+		maxPadding = *config.MaxPadding
 	}
 
-	seedMessage, err := makeSeedMessage(maxPadding, seed, clientToServerCipher)
+	seedMessage, err := makeSeedMessage(minPadding, maxPadding, seed, clientToServerCipher)
 	if err != nil {
 		return nil, common.ContextError(err)
 	}
@@ -163,13 +176,8 @@ func deriveKey(seed, keyword, iv []byte) ([]byte, error) {
 	return digest[0:OBFUSCATE_KEY_LENGTH], nil
 }
 
-func makeSeedMessage(maxPadding int, seed []byte, clientToServerCipher *rc4.Cipher) ([]byte, error) {
-	// paddingLength is integer in range [0, maxPadding]
-	paddingLength, err := common.MakeSecureRandomInt(maxPadding + 1)
-	if err != nil {
-		return nil, common.ContextError(err)
-	}
-	padding, err := common.MakeSecureRandomBytes(paddingLength)
+func makeSeedMessage(minPadding, maxPadding int, seed []byte, clientToServerCipher *rc4.Cipher) ([]byte, error) {
+	padding, err := common.MakeSecureRandomPadding(minPadding, maxPadding)
 	if err != nil {
 		return nil, common.ContextError(err)
 	}
@@ -182,7 +190,7 @@ func makeSeedMessage(maxPadding int, seed []byte, clientToServerCipher *rc4.Ciph
 	if err != nil {
 		return nil, common.ContextError(err)
 	}
-	err = binary.Write(buffer, binary.BigEndian, uint32(paddingLength))
+	err = binary.Write(buffer, binary.BigEndian, uint32(len(padding)))
 	if err != nil {
 		return nil, common.ContextError(err)
 	}

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

@@ -34,11 +34,13 @@ import (
 
 func TestObfuscator(t *testing.T) {
 
-	keyword, _ := common.MakeRandomStringHex(32)
+	keyword, _ := common.MakeSecureRandomStringHex(32)
+
+	maxPadding := 256
 
 	config := &ObfuscatorConfig{
 		Keyword:    keyword,
-		MaxPadding: 256,
+		MaxPadding: &maxPadding,
 	}
 
 	client, err := NewClientObfuscator(config)
@@ -76,7 +78,7 @@ func TestObfuscator(t *testing.T) {
 
 func TestObfuscatedSSHConn(t *testing.T) {
 
-	keyword, _ := common.MakeRandomStringHex(32)
+	keyword, _ := common.MakeSecureRandomStringHex(32)
 
 	serverAddress := "127.0.0.1:2222"
 
@@ -112,7 +114,7 @@ func TestObfuscatedSSHConn(t *testing.T) {
 
 		if err == nil {
 			conn, err = NewObfuscatedSshConn(
-				OBFUSCATION_CONN_MODE_SERVER, conn, keyword)
+				OBFUSCATION_CONN_MODE_SERVER, conn, keyword, nil, nil)
 		}
 
 		if err == nil {
@@ -138,7 +140,7 @@ func TestObfuscatedSSHConn(t *testing.T) {
 
 		if err == nil {
 			conn, err = NewObfuscatedSshConn(
-				OBFUSCATION_CONN_MODE_CLIENT, conn, keyword)
+				OBFUSCATION_CONN_MODE_CLIENT, conn, keyword, nil, nil)
 		}
 
 		if err == nil {

+ 30 - 2
psiphon/common/parameters/clientParameters.go

@@ -61,6 +61,7 @@ import (
 	"time"
 
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/obfuscator"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
 )
 
@@ -85,6 +86,16 @@ const (
 	PrioritizeTunnelProtocolsCandidateCount        = "PrioritizeTunnelProtocolsCandidateCount"
 	LimitTunnelProtocols                           = "LimitTunnelProtocols"
 	LimitTLSProfiles                               = "LimitTLSProfiles"
+	FragmentorProbability                          = "FragmentorProbability"
+	FragmentorLimitProtocols                       = "FragmentorLimitProtocols"
+	FragmentorMinTotalBytes                        = "FragmentorMinTotalBytes"
+	FragmentorMaxTotalBytes                        = "FragmentorMaxTotalBytes"
+	FragmentorMinWriteBytes                        = "FragmentorMinWriteBytes"
+	FragmentorMaxWriteBytes                        = "FragmentorMaxWriteBytes"
+	FragmentorMinDelay                             = "FragmentorMinDelay"
+	FragmentorMaxDelay                             = "FragmentorMaxDelay"
+	ObfuscatedSSHMinPadding                        = "ObfuscatedSSHMinPadding"
+	ObfuscatedSSHMaxPadding                        = "ObfuscatedSSHMaxPadding"
 	TunnelOperateShutdownTimeout                   = "TunnelOperateShutdownTimeout"
 	TunnelPortForwardDialTimeout                   = "TunnelPortForwardDialTimeout"
 	TunnelRateLimits                               = "TunnelRateLimits"
@@ -210,6 +221,23 @@ var defaultClientParameters = map[string]struct {
 
 	LimitTLSProfiles: {value: protocol.TLSProfiles{}},
 
+	FragmentorProbability:    {value: 0.5, minimum: 0.0},
+	FragmentorLimitProtocols: {value: protocol.TunnelProtocols{}},
+	FragmentorMinTotalBytes:  {value: 0, minimum: 0},
+	FragmentorMaxTotalBytes:  {value: 0, minimum: 0},
+	FragmentorMinWriteBytes:  {value: 1, minimum: 1},
+	FragmentorMaxWriteBytes:  {value: 1500, minimum: 1},
+	FragmentorMinDelay:       {value: time.Duration(0), minimum: time.Duration(0)},
+	FragmentorMaxDelay:       {value: 10 * time.Millisecond, minimum: time.Duration(0)},
+
+	// The Psiphon server will reject obfuscated SSH seed messages with
+	// padding greater than OBFUSCATE_MAX_PADDING.
+	// obfuscator.NewClientObfuscator will ignore invalid min/max padding
+	// configurations.
+
+	ObfuscatedSSHMinPadding: {value: 0, minimum: 0},
+	ObfuscatedSSHMaxPadding: {value: obfuscator.OBFUSCATE_MAX_PADDING, minimum: 0},
+
 	AdditionalCustomHeaders: {value: make(http.Header)},
 
 	// Speed test and SSH keep alive padding is intended to frustrate
@@ -302,8 +330,8 @@ var defaultClientParameters = map[string]struct {
 	MeekRoundTripRetryMultiplier:               {value: 2.0, minimum: 0.0},
 	MeekRoundTripTimeout:                       {value: 20 * time.Second, minimum: 1 * time.Second, flags: useNetworkLatencyMultiplier},
 
-	TransformHostNameProbability: {value: 0.5},
-	PickUserAgentProbability:     {value: 0.5},
+	TransformHostNameProbability: {value: 0.5, minimum: 0.0},
+	PickUserAgentProbability:     {value: 0.5, minimum: 0.0},
 }
 
 // ClientParameters is a set of client parameters. To use the parameters, call

+ 3 - 1
psiphon/common/tactics/tactics.go

@@ -1659,10 +1659,12 @@ func boxPayload(
 		box = bundledBox
 	}
 
+	maxPadding := TACTICS_PADDING_MAX_SIZE
+
 	obfuscator, err := obfuscator.NewClientObfuscator(
 		&obfuscator.ObfuscatorConfig{
 			Keyword:    string(obfuscatedKey),
-			MaxPadding: TACTICS_PADDING_MAX_SIZE})
+			MaxPadding: &maxPadding})
 	if err != nil {
 		return nil, common.ContextError(err)
 	}

+ 2 - 2
psiphon/common/tun/tun_test.go

@@ -380,9 +380,9 @@ func (server *testServer) run() {
 			defer server.workers.Done()
 			defer signalConn.Close()
 
-			sessionID, err := common.MakeRandomStringHex(SESSION_ID_LENGTH)
+			sessionID, err := common.MakeSecureRandomStringHex(SESSION_ID_LENGTH)
 			if err != nil {
-				fmt.Printf("testServer.run(): common.MakeRandomStringHex failed: %s\n", err)
+				fmt.Printf("testServer.run(): common.MakeSecureRandomStringHex failed: %s\n", err)
 				return
 			}
 

+ 22 - 10
psiphon/common/utils.go

@@ -168,26 +168,38 @@ func MakeSecureRandomBytes(length int) ([]byte, error) {
 	return randomBytes, nil
 }
 
+// MakeSecureRandomRange selects a random int in [min, max].
+// If max < min, min is returned.
+func MakeSecureRandomRange(min, max int) (int, error) {
+	if max < min {
+		return min, nil
+	}
+	n, err := MakeSecureRandomInt(max - min + 1)
+	if err != nil {
+		return 0, ContextError(err)
+	}
+	n += min
+	return n, nil
+}
+
 // MakeSecureRandomPadding selects a random padding length in the indicated
 // range and returns a random byte array of the selected length.
 // If maxLength <= minLength, the padding is minLength.
 func MakeSecureRandomPadding(minLength, maxLength int) ([]byte, error) {
-	var padding []byte
-	paddingSize, err := MakeSecureRandomInt(maxLength - minLength)
+	paddingSize, err := MakeSecureRandomRange(minLength, maxLength)
 	if err != nil {
 		return nil, ContextError(err)
 	}
-	paddingSize += minLength
-	padding, err = MakeSecureRandomBytes(paddingSize)
+	padding, err := MakeSecureRandomBytes(paddingSize)
 	if err != nil {
 		return nil, ContextError(err)
 	}
 	return padding, nil
 }
 
-// MakeRandomPeriod returns a random duration, within a given range.
+// MakeSecureRandomPeriod returns a random duration, within a given range.
 // If max <= min, the duration is min.
-func MakeRandomPeriod(min, max time.Duration) (time.Duration, error) {
+func MakeSecureRandomPeriod(min, max time.Duration) (time.Duration, error) {
 	period, err := MakeSecureRandomInt64(max.Nanoseconds() - min.Nanoseconds())
 	if err != nil {
 		return 0, ContextError(err)
@@ -195,9 +207,9 @@ func MakeRandomPeriod(min, max time.Duration) (time.Duration, error) {
 	return min + time.Duration(period), nil
 }
 
-// MakeRandomStringHex returns a hex encoded random string.
+// MakeSecureRandomStringHex returns a hex encoded random string.
 // byteLength specifies the pre-encoded data length.
-func MakeRandomStringHex(byteLength int) (string, error) {
+func MakeSecureRandomStringHex(byteLength int) (string, error) {
 	bytes, err := MakeSecureRandomBytes(byteLength)
 	if err != nil {
 		return "", ContextError(err)
@@ -205,9 +217,9 @@ func MakeRandomStringHex(byteLength int) (string, error) {
 	return hex.EncodeToString(bytes), nil
 }
 
-// MakeRandomStringBase64 returns a base64 encoded random string.
+// MakeSecureRandomStringBase64 returns a base64 encoded random string.
 // byteLength specifies the pre-encoded data length.
-func MakeRandomStringBase64(byteLength int) (string, error) {
+func MakeSecureRandomStringBase64(byteLength int) (string, error) {
 	bytes, err := MakeSecureRandomBytes(byteLength)
 	if err != nil {
 		return "", ContextError(err)

+ 46 - 17
psiphon/common/utils_test.go

@@ -75,32 +75,61 @@ func TestMakeSecureRandomPerm(t *testing.T) {
 	}
 }
 
-func TestMakeRandomPeriod(t *testing.T) {
+func TestMakeSecureRandomRange(t *testing.T) {
+	min := 1
+	max := 19
+	var gotMin, gotMax bool
+	for n := 0; n < 1000; n++ {
+		i, err := MakeSecureRandomRange(min, max)
+		if err != nil {
+			t.Errorf("MakeSecureRandomRange failed: %s", err)
+		}
+		if i < min || i > max {
+			t.Error("out of range")
+		}
+		if i == min {
+			gotMin = true
+		}
+		if i == max {
+			gotMax = true
+		}
+	}
+	if !gotMin {
+		t.Error("missing min")
+	}
+	if !gotMax {
+		t.Error("missing max")
+	}
+}
+
+func TestMakeSecureRandomPeriod(t *testing.T) {
 	min := 1 * time.Nanosecond
 	max := 10000 * time.Nanosecond
 
-	res1, err := MakeRandomPeriod(min, max)
+	for n := 0; n < 1000; n++ {
+		res1, err := MakeSecureRandomPeriod(min, max)
 
-	if err != nil {
-		t.Errorf("MakeRandomPeriod failed: %s", err)
-	}
+		if err != nil {
+			t.Errorf("MakeSecureRandomPeriod failed: %s", err)
+		}
 
-	if res1 < min {
-		t.Error("duration should not be less than min")
-	}
+		if res1 < min {
+			t.Error("duration should not be less than min")
+		}
 
-	if res1 > max {
-		t.Error("duration should not be more than max")
-	}
+		if res1 > max {
+			t.Error("duration should not be more than max")
+		}
 
-	res2, err := MakeRandomPeriod(min, max)
+		res2, err := MakeSecureRandomPeriod(min, max)
 
-	if err != nil {
-		t.Errorf("MakeRandomPeriod failed: %s", err)
-	}
+		if err != nil {
+			t.Errorf("MakeSecureRandomPeriod failed: %s", err)
+		}
 
-	if res1 == res2 {
-		t.Error("duration should have randomness difference between calls")
+		if res1 == res2 {
+			t.Error("duration should have randomness difference between calls")
+		}
 	}
 }
 

+ 51 - 0
psiphon/config.go

@@ -452,6 +452,18 @@ type Config struct {
 	// server.
 	Authorizations []string
 
+	// UseFragmentor and associated Fragmentor fields are for testing
+	// purposes.
+	UseFragmentor                  string
+	FragmentorMinTotalBytes        *int
+	FragmentorMaxTotalBytes        *int
+	FragmentorMinWriteBytes        *int
+	FragmentorMaxWriteBytes        *int
+	FragmentorMinDelayMicroseconds *int
+	FragmentorMaxDelayMicroseconds *int
+	ObfuscatedSSHMinPadding        *int
+	ObfuscatedSSHMaxPadding        *int
+
 	// clientParameters is the active ClientParameters with defaults, config
 	// values, and, optionally, tactics applied.
 	//
@@ -807,6 +819,45 @@ func (config *Config) makeConfigParameters() map[string]interface{} {
 
 	applyParameters[parameters.TunnelRateLimits] = config.RateLimits
 
+	switch config.UseFragmentor {
+	case "always":
+		applyParameters[parameters.FragmentorProbability] = 1.0
+	case "never":
+		applyParameters[parameters.FragmentorProbability] = 0.0
+	}
+
+	if config.FragmentorMinTotalBytes != nil {
+		applyParameters[parameters.FragmentorMinTotalBytes] = *config.FragmentorMinTotalBytes
+	}
+
+	if config.FragmentorMaxTotalBytes != nil {
+		applyParameters[parameters.FragmentorMaxTotalBytes] = *config.FragmentorMaxTotalBytes
+	}
+
+	if config.FragmentorMinWriteBytes != nil {
+		applyParameters[parameters.FragmentorMinWriteBytes] = *config.FragmentorMinWriteBytes
+	}
+
+	if config.FragmentorMaxWriteBytes != nil {
+		applyParameters[parameters.FragmentorMaxWriteBytes] = *config.FragmentorMaxWriteBytes
+	}
+
+	if config.FragmentorMinDelayMicroseconds != nil {
+		applyParameters[parameters.FragmentorMinDelay] = fmt.Sprintf("%dus", *config.FragmentorMinDelayMicroseconds)
+	}
+
+	if config.FragmentorMaxDelayMicroseconds != nil {
+		applyParameters[parameters.FragmentorMaxDelay] = fmt.Sprintf("%dus", *config.FragmentorMaxDelayMicroseconds)
+	}
+
+	if config.ObfuscatedSSHMinPadding != nil {
+		applyParameters[parameters.ObfuscatedSSHMinPadding] = *config.ObfuscatedSSHMinPadding
+	}
+
+	if config.ObfuscatedSSHMaxPadding != nil {
+		applyParameters[parameters.ObfuscatedSSHMaxPadding] = *config.ObfuscatedSSHMaxPadding
+	}
+
 	return applyParameters
 }
 

+ 69 - 0
psiphon/controller_test.go

@@ -106,6 +106,7 @@ func TestUntunneledUpgradeDownload(t *testing.T) {
 			useUpstreamProxy:         false,
 			disruptNetwork:           false,
 			transformHostNames:       false,
+			useFragmentor:            false,
 			runDuration:              0,
 		})
 }
@@ -123,6 +124,7 @@ func TestUntunneledResumableUpgradeDownload(t *testing.T) {
 			useUpstreamProxy:         false,
 			disruptNetwork:           true,
 			transformHostNames:       false,
+			useFragmentor:            false,
 			runDuration:              0,
 		})
 }
@@ -140,6 +142,7 @@ func TestUntunneledUpgradeClientIsLatestVersion(t *testing.T) {
 			useUpstreamProxy:         false,
 			disruptNetwork:           false,
 			transformHostNames:       false,
+			useFragmentor:            false,
 			runDuration:              0,
 		})
 }
@@ -157,6 +160,7 @@ func TestUntunneledResumableFetchRemoteServerList(t *testing.T) {
 			useUpstreamProxy:         false,
 			disruptNetwork:           true,
 			transformHostNames:       false,
+			useFragmentor:            false,
 			runDuration:              0,
 		})
 }
@@ -174,6 +178,7 @@ func TestTunneledUpgradeClientIsLatestVersion(t *testing.T) {
 			useUpstreamProxy:         false,
 			disruptNetwork:           false,
 			transformHostNames:       false,
+			useFragmentor:            false,
 			runDuration:              0,
 		})
 }
@@ -199,6 +204,7 @@ func TestImpairedProtocols(t *testing.T) {
 			useUpstreamProxy:         false,
 			disruptNetwork:           true,
 			transformHostNames:       false,
+			useFragmentor:            false,
 			runDuration:              1 * time.Minute,
 		})
 }
@@ -216,6 +222,7 @@ func TestSSH(t *testing.T) {
 			useUpstreamProxy:         false,
 			disruptNetwork:           false,
 			transformHostNames:       false,
+			useFragmentor:            false,
 			runDuration:              0,
 		})
 }
@@ -233,6 +240,7 @@ func TestObfuscatedSSH(t *testing.T) {
 			useUpstreamProxy:         false,
 			disruptNetwork:           false,
 			transformHostNames:       false,
+			useFragmentor:            false,
 			runDuration:              0,
 		})
 }
@@ -250,6 +258,7 @@ func TestUnfrontedMeek(t *testing.T) {
 			useUpstreamProxy:         false,
 			disruptNetwork:           false,
 			transformHostNames:       false,
+			useFragmentor:            false,
 			runDuration:              0,
 		})
 }
@@ -267,6 +276,7 @@ func TestUnfrontedMeekWithTransformer(t *testing.T) {
 			useUpstreamProxy:         false,
 			disruptNetwork:           false,
 			transformHostNames:       true,
+			useFragmentor:            false,
 			runDuration:              0,
 		})
 }
@@ -284,6 +294,7 @@ func TestFrontedMeek(t *testing.T) {
 			useUpstreamProxy:         false,
 			disruptNetwork:           false,
 			transformHostNames:       false,
+			useFragmentor:            false,
 			runDuration:              0,
 		})
 }
@@ -301,6 +312,7 @@ func TestFrontedMeekWithTransformer(t *testing.T) {
 			useUpstreamProxy:         false,
 			disruptNetwork:           false,
 			transformHostNames:       true,
+			useFragmentor:            false,
 			runDuration:              0,
 		})
 }
@@ -318,6 +330,7 @@ func TestFrontedMeekHTTP(t *testing.T) {
 			useUpstreamProxy:         false,
 			disruptNetwork:           false,
 			transformHostNames:       false,
+			useFragmentor:            false,
 			runDuration:              0,
 		})
 }
@@ -335,6 +348,7 @@ func TestUnfrontedMeekHTTPS(t *testing.T) {
 			useUpstreamProxy:         false,
 			disruptNetwork:           false,
 			transformHostNames:       false,
+			useFragmentor:            false,
 			runDuration:              0,
 		})
 }
@@ -352,6 +366,7 @@ func TestUnfrontedMeekHTTPSWithTransformer(t *testing.T) {
 			useUpstreamProxy:         false,
 			disruptNetwork:           false,
 			transformHostNames:       true,
+			useFragmentor:            false,
 			runDuration:              0,
 		})
 }
@@ -369,6 +384,7 @@ func TestDisabledApi(t *testing.T) {
 			useUpstreamProxy:         false,
 			disruptNetwork:           false,
 			transformHostNames:       false,
+			useFragmentor:            false,
 			runDuration:              0,
 		})
 }
@@ -386,6 +402,7 @@ func TestObfuscatedSSHWithUpstreamProxy(t *testing.T) {
 			useUpstreamProxy:         true,
 			disruptNetwork:           false,
 			transformHostNames:       false,
+			useFragmentor:            false,
 			runDuration:              0,
 		})
 }
@@ -403,6 +420,7 @@ func TestUnfrontedMeekWithUpstreamProxy(t *testing.T) {
 			useUpstreamProxy:         true,
 			disruptNetwork:           false,
 			transformHostNames:       false,
+			useFragmentor:            false,
 			runDuration:              0,
 		})
 }
@@ -420,6 +438,43 @@ func TestUnfrontedMeekHTTPSWithUpstreamProxy(t *testing.T) {
 			useUpstreamProxy:         true,
 			disruptNetwork:           false,
 			transformHostNames:       false,
+			useFragmentor:            false,
+			runDuration:              0,
+		})
+}
+
+func TestObfuscatedSSHFragmentor(t *testing.T) {
+	controllerRun(t,
+		&controllerRunConfig{
+			expectNoServerEntries:    false,
+			protocol:                 protocol.TUNNEL_PROTOCOL_OBFUSCATED_SSH,
+			clientIsLatestVersion:    false,
+			disableUntunneledUpgrade: true,
+			disableEstablishing:      false,
+			disableApi:               false,
+			tunnelPoolSize:           1,
+			useUpstreamProxy:         false,
+			disruptNetwork:           false,
+			transformHostNames:       false,
+			useFragmentor:            true,
+			runDuration:              0,
+		})
+}
+
+func TestFrontedMeekFragmentor(t *testing.T) {
+	controllerRun(t,
+		&controllerRunConfig{
+			expectNoServerEntries:    false,
+			protocol:                 protocol.TUNNEL_PROTOCOL_FRONTED_MEEK,
+			clientIsLatestVersion:    false,
+			disableUntunneledUpgrade: true,
+			disableEstablishing:      false,
+			disableApi:               false,
+			tunnelPoolSize:           1,
+			useUpstreamProxy:         false,
+			disruptNetwork:           false,
+			transformHostNames:       false,
+			useFragmentor:            true,
 			runDuration:              0,
 		})
 }
@@ -435,6 +490,7 @@ type controllerRunConfig struct {
 	useUpstreamProxy         bool
 	disruptNetwork           bool
 	transformHostNames       bool
+	useFragmentor            bool
 	runDuration              time.Duration
 }
 
@@ -476,6 +532,19 @@ func controllerRun(t *testing.T, runConfig *controllerRunConfig) {
 		modifyConfig["TransformHostNames"] = "never"
 	}
 
+	if runConfig.useFragmentor {
+		modifyConfig["UseFragmentor"] = "always"
+		modifyConfig["FragmentorLimitProtocols"] = protocol.TunnelProtocols{runConfig.protocol}
+		modifyConfig["FragmentorMinTotalBytes"] = 1000
+		modifyConfig["FragmentorMaxTotalBytes"] = 2000
+		modifyConfig["FragmentorMinWriteBytes"] = 1
+		modifyConfig["FragmentorMaxWriteBytes"] = 100
+		modifyConfig["FragmentorMinDelayMicroseconds"] = 1000
+		modifyConfig["FragmentorMaxDelayMicroseconds"] = 10000
+		modifyConfig["ObfuscatedSSHMinPadding"] = 4096
+		modifyConfig["ObfuscatedSSHMaxPadding"] = 8192
+	}
+
 	configJSON, _ = json.Marshal(modifyConfig)
 
 	config, err := LoadConfig(configJSON)

+ 211 - 0
psiphon/fragmentor.go

@@ -0,0 +1,211 @@
+/*
+ * Copyright (c) 2018, Psiphon Inc.
+ * All rights reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+package psiphon
+
+import (
+	"context"
+	"fmt"
+	"net"
+	"sync"
+	"sync/atomic"
+	"time"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/parameters"
+)
+
+const (
+	NUM_FRAGMENTOR_NOTICES = 3
+)
+
+// NewTCPFragmentorDialer creates a TCP dialer that wraps dialed conns in
+// FragmentorConn. A single FragmentorProbability coin flip is made and all
+// conns get the same treatment.
+func NewTCPFragmentorDialer(
+	config *DialConfig,
+	tunnelProtocol string,
+	clientParameters *parameters.ClientParameters) Dialer {
+
+	p := clientParameters.Get()
+	coinFlip := p.WeightedCoinFlip(parameters.FragmentorProbability)
+	p = nil
+
+	return func(ctx context.Context, network, addr string) (net.Conn, error) {
+		if network != "tcp" {
+			return nil, common.ContextError(fmt.Errorf("%s unsupported", network))
+		}
+		return DialTCPFragmentor(ctx, addr, config, tunnelProtocol, clientParameters, &coinFlip)
+	}
+}
+
+// DialTCPFragmentor performs a DialTCP and wraps the dialed conn in a
+// FragmentorConn, subject to FragmentorProbability and FragmentorLimitProtocols.
+func DialTCPFragmentor(
+	ctx context.Context,
+	addr string,
+	config *DialConfig,
+	tunnelProtocol string,
+	clientParameters *parameters.ClientParameters,
+	coinFlip *bool) (net.Conn, error) {
+
+	conn, err := DialTCP(ctx, addr, config)
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	p := clientParameters.Get()
+
+	protocols := p.TunnelProtocols(parameters.FragmentorLimitProtocols)
+	if len(protocols) > 0 && !common.Contains(protocols, tunnelProtocol) {
+		return conn, nil
+	}
+
+	if !p.WeightedCoinFlip(parameters.FragmentorProbability) {
+		return conn, nil
+	}
+
+	totalBytes, err := common.MakeSecureRandomRange(
+		p.Int(parameters.FragmentorMinTotalBytes),
+		p.Int(parameters.FragmentorMaxTotalBytes))
+	if err != nil {
+		totalBytes = 0
+		NoticeAlert("MakeSecureRandomRange failed: %s", common.ContextError(err))
+	}
+
+	return &FragmentorConn{
+		Conn:            conn,
+		ctx:             ctx,
+		bytesToFragment: totalBytes,
+		minWriteBytes:   p.Int(parameters.FragmentorMinWriteBytes),
+		maxWriteBytes:   p.Int(parameters.FragmentorMaxWriteBytes),
+		minDelay:        p.Duration(parameters.FragmentorMinDelay),
+		maxDelay:        p.Duration(parameters.FragmentorMaxDelay),
+	}, nil
+}
+
+// FragmentorConn implements simple fragmentation of application-level
+// messages/packets into multiple TCP packets by splitting writes into smaller
+// sizes and adding delays between writes.
+//
+// The intent of FragmentorConn is both to frustrate firewalls that perform
+// DPI on application-level messages that cross TCP packets as well as to
+// perform a simple size and timing transformation to the traffic shape of the
+// initial portion of a TCP flow.
+type FragmentorConn struct {
+	net.Conn
+	ctx             context.Context
+	isClosed        int32
+	numNotices      int32
+	writeMutex      sync.Mutex
+	bytesToFragment int
+	bytesFragmented int
+	minWriteBytes   int
+	maxWriteBytes   int
+	minDelay        time.Duration
+	maxDelay        time.Duration
+}
+
+func (fragmentor *FragmentorConn) Write(buffer []byte) (int, error) {
+
+	fragmentor.writeMutex.Lock()
+	defer fragmentor.writeMutex.Unlock()
+
+	if fragmentor.bytesFragmented >= fragmentor.bytesToFragment {
+		return fragmentor.Conn.Write(buffer)
+	}
+
+	totalBytesWritten := 0
+
+	for len(buffer) > 0 {
+
+		delay, err := common.MakeSecureRandomPeriod(
+			fragmentor.minDelay, fragmentor.maxDelay)
+		if err != nil {
+			delay = fragmentor.minDelay
+		}
+
+		timer := time.NewTimer(delay)
+		err = nil
+		select {
+		case <-fragmentor.ctx.Done():
+			err = fragmentor.ctx.Err()
+		case <-timer.C:
+		}
+		defer timer.Stop()
+
+		if err != nil {
+			return totalBytesWritten, err
+		}
+
+		minWriteBytes := fragmentor.minWriteBytes
+		if minWriteBytes > len(buffer) {
+			minWriteBytes = len(buffer)
+		}
+
+		maxWriteBytes := fragmentor.maxWriteBytes
+		if maxWriteBytes > len(buffer) {
+			maxWriteBytes = len(buffer)
+		}
+
+		writeBytes, err := common.MakeSecureRandomRange(
+			minWriteBytes, maxWriteBytes)
+		if err != nil {
+			writeBytes = maxWriteBytes
+		}
+
+		bytesWritten, err := fragmentor.Conn.Write(buffer[:writeBytes])
+
+		totalBytesWritten += bytesWritten
+		fragmentor.bytesFragmented += bytesWritten
+
+		if err != nil {
+			return totalBytesWritten, err
+		}
+
+		numNotices := atomic.LoadInt32(&fragmentor.numNotices)
+		if numNotices < NUM_FRAGMENTOR_NOTICES &&
+			atomic.AddInt32(&fragmentor.numNotices, 1) <= NUM_FRAGMENTOR_NOTICES {
+
+			remoteAddrStr := "(nil)"
+			remoteAddr := fragmentor.Conn.RemoteAddr()
+			if remoteAddr != nil {
+				remoteAddrStr = remoteAddr.String()
+			}
+
+			NoticeInfo("fragmentor %s: %s delay, %d bytes",
+				remoteAddrStr, delay, bytesWritten)
+		}
+
+		buffer = buffer[writeBytes:]
+	}
+
+	return totalBytesWritten, nil
+}
+
+func (fragmentor *FragmentorConn) Close() (err error) {
+	if !atomic.CompareAndSwapInt32(&fragmentor.isClosed, 0, 1) {
+		return nil
+	}
+	return fragmentor.Conn.Close()
+}
+
+func (fragmentor *FragmentorConn) IsClosed() bool {
+	return atomic.LoadInt32(&fragmentor.isClosed) == 1
+}

+ 27 - 8
psiphon/meekConn.go

@@ -238,10 +238,15 @@ func DialMeek(
 
 		scheme = "https"
 
+		tcpDialer := NewTCPFragmentorDialer(
+			dialConfig,
+			meekConfig.ClientTunnelProtocol,
+			meekConfig.ClientParameters)
+
 		tlsConfig := &CustomTLSConfig{
 			ClientParameters:              meekConfig.ClientParameters,
 			DialAddr:                      meekConfig.DialAddress,
-			Dial:                          NewTCPDialer(dialConfig),
+			Dial:                          tcpDialer,
 			SNIServerName:                 meekConfig.SNIServerName,
 			SkipVerify:                    true,
 			UseIndistinguishableTLS:       dialConfig.UseIndistinguishableTLS,
@@ -329,11 +334,7 @@ func DialMeek(
 
 		scheme = "http"
 
-		// The dialer ignores address that http.Transport will pass in (derived
-		// from the HTTP request URL) and always dials meekConfig.DialAddress.
-		dialer := func(ctx context.Context, network, _ string) (net.Conn, error) {
-			return NewTCPDialer(dialConfig)(ctx, network, meekConfig.DialAddress)
-		}
+		var dialer Dialer
 
 		// For HTTP, and when the meekConfig.DialAddress matches the
 		// meekConfig.HostHeader, we let http.Transport handle proxying.
@@ -356,7 +357,23 @@ func DialMeek(
 			*copyDialConfig = *dialConfig
 			copyDialConfig.UpstreamProxyURL = ""
 
-			dialer = NewTCPDialer(copyDialConfig)
+			dialer = NewTCPFragmentorDialer(
+				copyDialConfig,
+				meekConfig.ClientTunnelProtocol,
+				meekConfig.ClientParameters)
+
+		} else {
+
+			baseDialer := NewTCPFragmentorDialer(
+				dialConfig,
+				meekConfig.ClientTunnelProtocol,
+				meekConfig.ClientParameters)
+
+			// The dialer ignores address that http.Transport will pass in (derived
+			// from the HTTP request URL) and always dials meekConfig.DialAddress.
+			dialer = func(ctx context.Context, network, _ string) (net.Conn, error) {
+				return baseDialer(ctx, network, meekConfig.DialAddress)
+			}
 		}
 
 		httpTransport := &http.Transport{
@@ -1258,11 +1275,13 @@ func makeMeekCookie(
 	copy(encryptedCookie[0:32], ephemeralPublicKey[0:32])
 	copy(encryptedCookie[32:], box)
 
+	maxPadding := clientParameters.Get().Int(parameters.MeekCookieMaxPadding)
+
 	// Obfuscate the encrypted data
 	obfuscator, err := obfuscator.NewClientObfuscator(
 		&obfuscator.ObfuscatorConfig{
 			Keyword:    meekObfuscatedKey,
-			MaxPadding: clientParameters.Get().Int(parameters.MeekCookieMaxPadding)})
+			MaxPadding: &maxPadding})
 	if err != nil {
 		return nil, common.ContextError(err)
 	}

+ 1 - 1
psiphon/remoteServerList_test.go

@@ -141,7 +141,7 @@ func testObfuscatedRemoteServerLists(t *testing.T, omitMD5Sums bool) {
 	epoch := now.Truncate(seedPeriod)
 	epochStr := epoch.Format(time.RFC3339Nano)
 
-	propagationChannelID, _ := common.MakeRandomStringHex(8)
+	propagationChannelID, _ := common.MakeSecureRandomStringHex(8)
 
 	oslConfigJSON := fmt.Sprintf(
 		oslConfigJSONTemplate,

+ 6 - 6
psiphon/server/config.go

@@ -508,7 +508,7 @@ func GenerateConfig(params *GenerateConfigParams) ([]byte, []byte, []byte, []byt
 
 	if params.WebServerPort != 0 {
 		var err error
-		webServerSecret, err = common.MakeRandomStringHex(WEB_SERVER_SECRET_BYTE_LENGTH)
+		webServerSecret, err = common.MakeSecureRandomStringHex(WEB_SERVER_SECRET_BYTE_LENGTH)
 		if err != nil {
 			return nil, nil, nil, nil, nil, common.ContextError(err)
 		}
@@ -543,14 +543,14 @@ func GenerateConfig(params *GenerateConfigParams) ([]byte, []byte, []byte, []byt
 
 	sshPublicKey := signer.PublicKey()
 
-	sshUserNameSuffix, err := common.MakeRandomStringHex(SSH_USERNAME_SUFFIX_BYTE_LENGTH)
+	sshUserNameSuffix, err := common.MakeSecureRandomStringHex(SSH_USERNAME_SUFFIX_BYTE_LENGTH)
 	if err != nil {
 		return nil, nil, nil, nil, nil, common.ContextError(err)
 	}
 
 	sshUserName := "psiphon_" + sshUserNameSuffix
 
-	sshPassword, err := common.MakeRandomStringHex(SSH_PASSWORD_BYTE_LENGTH)
+	sshPassword, err := common.MakeSecureRandomStringHex(SSH_PASSWORD_BYTE_LENGTH)
 	if err != nil {
 		return nil, nil, nil, nil, nil, common.ContextError(err)
 	}
@@ -559,7 +559,7 @@ func GenerateConfig(params *GenerateConfigParams) ([]byte, []byte, []byte, []byt
 
 	// Obfuscated SSH config
 
-	obfuscatedSSHKey, err := common.MakeRandomStringHex(SSH_OBFUSCATED_KEY_BYTE_LENGTH)
+	obfuscatedSSHKey, err := common.MakeSecureRandomStringHex(SSH_OBFUSCATED_KEY_BYTE_LENGTH)
 	if err != nil {
 		return nil, nil, nil, nil, nil, common.ContextError(err)
 	}
@@ -578,7 +578,7 @@ func GenerateConfig(params *GenerateConfigParams) ([]byte, []byte, []byte, []byt
 		meekCookieEncryptionPublicKey = base64.StdEncoding.EncodeToString(rawMeekCookieEncryptionPublicKey[:])
 		meekCookieEncryptionPrivateKey = base64.StdEncoding.EncodeToString(rawMeekCookieEncryptionPrivateKey[:])
 
-		meekObfuscatedKey, err = common.MakeRandomStringHex(SSH_OBFUSCATED_KEY_BYTE_LENGTH)
+		meekObfuscatedKey, err = common.MakeSecureRandomStringHex(SSH_OBFUSCATED_KEY_BYTE_LENGTH)
 		if err != nil {
 			return nil, nil, nil, nil, nil, common.ContextError(err)
 		}
@@ -586,7 +586,7 @@ func GenerateConfig(params *GenerateConfigParams) ([]byte, []byte, []byte, []byt
 
 	// Other config
 
-	discoveryValueHMACKey, err := common.MakeRandomStringBase64(DISCOVERY_VALUE_KEY_BYTE_LENGTH)
+	discoveryValueHMACKey, err := common.MakeSecureRandomStringBase64(DISCOVERY_VALUE_KEY_BYTE_LENGTH)
 	if err != nil {
 		return nil, nil, nil, nil, nil, common.ContextError(err)
 	}

+ 1 - 1
psiphon/server/meek.go

@@ -957,7 +957,7 @@ func makeMeekSessionID() (string, error) {
 		return "", common.ContextError(err)
 	}
 	size += n
-	sessionID, err := common.MakeRandomStringBase64(size)
+	sessionID, err := common.MakeSecureRandomStringBase64(size)
 	if err != nil {
 		return "", common.ContextError(err)
 	}

+ 2 - 2
psiphon/server/meek_test.go

@@ -230,9 +230,9 @@ func TestMeekResiliency(t *testing.T) {
 	}
 	meekCookieEncryptionPublicKey := base64.StdEncoding.EncodeToString(rawMeekCookieEncryptionPublicKey[:])
 	meekCookieEncryptionPrivateKey := base64.StdEncoding.EncodeToString(rawMeekCookieEncryptionPrivateKey[:])
-	meekObfuscatedKey, err := common.MakeRandomStringHex(SSH_OBFUSCATED_KEY_BYTE_LENGTH)
+	meekObfuscatedKey, err := common.MakeSecureRandomStringHex(SSH_OBFUSCATED_KEY_BYTE_LENGTH)
 	if err != nil {
-		t.Fatalf("common.MakeRandomStringHex failed: %s", err)
+		t.Fatalf("common.MakeSecureRandomStringHex failed: %s", err)
 	}
 
 	mockSupport := &SupportServices{

+ 5 - 5
psiphon/server/server_test.go

@@ -91,7 +91,7 @@ func TestMain(m *testing.M) {
 
 func runMockWebServer() (string, string) {
 
-	responseBody, _ := common.MakeRandomStringHex(100000)
+	responseBody, _ := common.MakeSecureRandomStringHex(100000)
 
 	serveMux := http.NewServeMux()
 	serveMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
@@ -1004,10 +1004,10 @@ func makeTunneledNTPRequestAttempt(
 func pavePsinetDatabaseFile(
 	t *testing.T, useDefaultSponsorID bool, psinetFilename string) (string, string) {
 
-	sponsorID, _ := common.MakeRandomStringHex(8)
+	sponsorID, _ := common.MakeSecureRandomStringHex(8)
 
-	fakeDomain, _ := common.MakeRandomStringHex(4)
-	fakePath, _ := common.MakeRandomStringHex(4)
+	fakeDomain, _ := common.MakeSecureRandomStringHex(4)
+	fakePath, _ := common.MakeSecureRandomStringHex(4)
 	expectedHomepageURL := fmt.Sprintf("https://%s.com/%s", fakeDomain, fakePath)
 
 	psinetJSONFormat := `
@@ -1177,7 +1177,7 @@ func paveOSLConfigFile(t *testing.T, oslConfigFilename string) string {
     }
     `
 
-	propagationChannelID, _ := common.MakeRandomStringHex(8)
+	propagationChannelID, _ := common.MakeSecureRandomStringHex(8)
 
 	now := time.Now().UTC()
 	epoch := now.Truncate(720 * time.Hour)

+ 17 - 1
psiphon/server/tunnelServer.go

@@ -1033,6 +1033,20 @@ func (sshClient *sshClient) run(
 		}
 		sshServerConfig.AddHostKey(sshClient.sshServer.sshHostKey)
 
+		if sshClient.tunnelProtocol != protocol.TUNNEL_PROTOCOL_SSH {
+			// This is the list of supported non-Encrypt-then-MAC algorithms from
+			// https://github.com/Psiphon-Labs/psiphon-tunnel-core/blob/3ef11effe6acd92c3aefd140ee09c42a1f15630b/psiphon/common/crypto/ssh/common.go#L60
+			//
+			// With Encrypt-then-MAC algorithms, packet length is transmitted in
+			// plaintext, which aids in traffic analysis; clients may still send
+			// Encrypt-then-MAC algorithms in their KEX_INIT message, but do not
+			// select these algorithms.
+			//
+			// The exception is TUNNEL_PROTOCOL_SSH, which is intended to appear
+			// like SSH on the wire.
+			sshServerConfig.MACs = []string{"hmac-sha2-256", "hmac-sha1", "hmac-sha1-96"}
+		}
+
 		result := &sshNewServerConnResult{}
 
 		// Wrap the connection in an SSH deobfuscator when required.
@@ -1043,7 +1057,9 @@ func (sshClient *sshClient) run(
 			conn, result.err = obfuscator.NewObfuscatedSshConn(
 				obfuscator.OBFUSCATION_CONN_MODE_SERVER,
 				conn,
-				sshClient.sshServer.support.Config.ObfuscatedSSHKey)
+				sshClient.sshServer.support.Config.ObfuscatedSSHKey,
+				nil,
+				nil)
 			if result.err != nil {
 				result.err = common.ContextError(result.err)
 			}

+ 37 - 7
psiphon/tunnel.go

@@ -873,7 +873,12 @@ func dialSsh(
 	selectedProtocol,
 	sessionId string) (*dialResult, error) {
 
-	timeout := config.clientParameters.Get().Duration(parameters.TunnelConnectTimeout)
+	p := config.clientParameters.Get()
+	timeout := p.Duration(parameters.TunnelConnectTimeout)
+	rateLimits := p.RateLimits(parameters.TunnelRateLimits)
+	obfuscatedSSHMinPadding := p.Int(parameters.ObfuscatedSSHMinPadding)
+	obfuscatedSSHMaxPadding := p.Int(parameters.ObfuscatedSSHMaxPadding)
+	p = nil
 
 	var cancelFunc context.CancelFunc
 	ctx, cancelFunc = context.WithTimeout(ctx, timeout)
@@ -931,12 +936,21 @@ func dialSsh(
 
 	var dialConn net.Conn
 	if meekConfig != nil {
-		dialConn, err = DialMeek(ctx, meekConfig, dialConfig)
+		dialConn, err = DialMeek(
+			ctx,
+			meekConfig,
+			dialConfig)
 		if err != nil {
 			return nil, common.ContextError(err)
 		}
 	} else {
-		dialConn, err = DialTCP(ctx, directTCPDialAddress, dialConfig)
+		dialConn, err = DialTCPFragmentor(
+			ctx,
+			directTCPDialAddress,
+			dialConfig,
+			selectedProtocol,
+			config.clientParameters,
+			nil)
 		if err != nil {
 			return nil, common.ContextError(err)
 		}
@@ -965,13 +979,17 @@ func dialSsh(
 	// Apply throttling (if configured)
 	throttledConn := common.NewThrottledConn(
 		monitoredConn,
-		config.clientParameters.Get().RateLimits(parameters.TunnelRateLimits))
+		rateLimits)
 
 	// Add obfuscated SSH layer
 	var sshConn net.Conn = throttledConn
 	if useObfuscatedSsh {
 		sshConn, err = obfuscator.NewObfuscatedSshConn(
-			obfuscator.OBFUSCATION_CONN_MODE_CLIENT, throttledConn, serverEntry.SshObfuscatedKey)
+			obfuscator.OBFUSCATION_CONN_MODE_CLIENT,
+			throttledConn,
+			serverEntry.SshObfuscatedKey,
+			&obfuscatedSSHMinPadding,
+			&obfuscatedSSHMaxPadding)
 		if err != nil {
 			return nil, common.ContextError(err)
 		}
@@ -1011,6 +1029,18 @@ func dialSsh(
 		ClientVersion:   SSHClientVersion,
 	}
 
+	// This is the list of supported non-Encrypt-then-MAC algorithms from
+	// https://github.com/Psiphon-Labs/psiphon-tunnel-core/blob/3ef11effe6acd92c3aefd140ee09c42a1f15630b/psiphon/common/crypto/ssh/common.go#L60
+	//
+	// With Encrypt-then-MAC algorithms, packet length is transmitted in
+	// plaintext, which aids in traffic analysis.
+	//
+	// TUNNEL_PROTOCOL_SSH is excepted since its KEX appears in plaintext,
+	// and the protocol is intended to look like SSH on the wire.
+	if selectedProtocol != protocol.TUNNEL_PROTOCOL_SSH {
+		sshClientConfig.MACs = []string{"hmac-sha2-256", "hmac-sha1", "hmac-sha1-96"}
+	}
+
 	// The ssh session establishment (via ssh.NewClientConn) is wrapped
 	// in a timeout to ensure it won't hang. We've encountered firewalls
 	// that allow the TCP handshake to complete but then send a RST to the
@@ -1094,9 +1124,9 @@ func dialSsh(
 }
 
 func makeRandomPeriod(min, max time.Duration) time.Duration {
-	period, err := common.MakeRandomPeriod(min, max)
+	period, err := common.MakeSecureRandomPeriod(min, max)
 	if err != nil {
-		NoticeAlert("MakeRandomPeriod failed: %s", err)
+		NoticeAlert("MakeSecureRandomPeriod failed: %s", err)
 		// Proceed without random period
 		period = max
 	}