Просмотр исходного кода

Add obfuscation seed transforms

Amir Khan 3 лет назад
Родитель
Сommit
dc95daef2d

+ 11 - 5
psiphon/common/obfuscator/obfuscatedSshConn.go

@@ -30,6 +30,7 @@ import (
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
 	"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/errors"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/transforms"
 )
 )
 
 
 const (
 const (
@@ -125,6 +126,7 @@ func NewObfuscatedSSHConn(
 	conn net.Conn,
 	conn net.Conn,
 	obfuscationKeyword string,
 	obfuscationKeyword string,
 	obfuscationPaddingPRNGSeed *prng.Seed,
 	obfuscationPaddingPRNGSeed *prng.Seed,
+	obfuscatorSeedTransformerParameters *transforms.ObfuscatorSeedTransformerParameters,
 	minPadding, maxPadding *int,
 	minPadding, maxPadding *int,
 	seedHistory *SeedHistory,
 	seedHistory *SeedHistory,
 	irregularLogger func(
 	irregularLogger func(
@@ -140,10 +142,12 @@ func NewObfuscatedSSHConn(
 	if mode == OBFUSCATION_CONN_MODE_CLIENT {
 	if mode == OBFUSCATION_CONN_MODE_CLIENT {
 		obfuscator, err = NewClientObfuscator(
 		obfuscator, err = NewClientObfuscator(
 			&ObfuscatorConfig{
 			&ObfuscatorConfig{
-				Keyword:         obfuscationKeyword,
-				PaddingPRNGSeed: obfuscationPaddingPRNGSeed,
-				MinPadding:      minPadding,
-				MaxPadding:      maxPadding,
+				IsOSSH:                              true,
+				Keyword:                             obfuscationKeyword,
+				PaddingPRNGSeed:                     obfuscationPaddingPRNGSeed,
+				MinPadding:                          minPadding,
+				MaxPadding:                          maxPadding,
+				ObfuscatorSeedTransformerParameters: obfuscatorSeedTransformerParameters,
 			})
 			})
 		if err != nil {
 		if err != nil {
 			return nil, errors.Trace(err)
 			return nil, errors.Trace(err)
@@ -204,6 +208,7 @@ func NewClientObfuscatedSSHConn(
 	conn net.Conn,
 	conn net.Conn,
 	obfuscationKeyword string,
 	obfuscationKeyword string,
 	obfuscationPaddingPRNGSeed *prng.Seed,
 	obfuscationPaddingPRNGSeed *prng.Seed,
+	obfuscatorSeedTransformerParameters *transforms.ObfuscatorSeedTransformerParameters,
 	minPadding, maxPadding *int) (*ObfuscatedSSHConn, error) {
 	minPadding, maxPadding *int) (*ObfuscatedSSHConn, error) {
 
 
 	return NewObfuscatedSSHConn(
 	return NewObfuscatedSSHConn(
@@ -211,6 +216,7 @@ func NewClientObfuscatedSSHConn(
 		conn,
 		conn,
 		obfuscationKeyword,
 		obfuscationKeyword,
 		obfuscationPaddingPRNGSeed,
 		obfuscationPaddingPRNGSeed,
+		obfuscatorSeedTransformerParameters,
 		minPadding, maxPadding,
 		minPadding, maxPadding,
 		nil,
 		nil,
 		nil)
 		nil)
@@ -231,7 +237,7 @@ func NewServerObfuscatedSSHConn(
 		OBFUSCATION_CONN_MODE_SERVER,
 		OBFUSCATION_CONN_MODE_SERVER,
 		conn,
 		conn,
 		obfuscationKeyword,
 		obfuscationKeyword,
-		nil,
+		nil, nil,
 		nil, nil,
 		nil, nil,
 		seedHistory,
 		seedHistory,
 		irregularLogger)
 		irregularLogger)

+ 17 - 4
psiphon/common/obfuscator/obfuscator.go

@@ -29,6 +29,7 @@ import (
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
 	"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/errors"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/transforms"
 )
 )
 
 
 const (
 const (
@@ -61,10 +62,12 @@ type Obfuscator struct {
 
 
 // ObfuscatorConfig specifies an Obfuscator configuration.
 // ObfuscatorConfig specifies an Obfuscator configuration.
 type ObfuscatorConfig struct {
 type ObfuscatorConfig struct {
-	Keyword         string
-	PaddingPRNGSeed *prng.Seed
-	MinPadding      *int
-	MaxPadding      *int
+	IsOSSH                              bool
+	Keyword                             string
+	PaddingPRNGSeed                     *prng.Seed
+	MinPadding                          *int
+	MaxPadding                          *int
+	ObfuscatorSeedTransformerParameters *transforms.ObfuscatorSeedTransformerParameters
 
 
 	// SeedHistory and IrregularLogger are optional parameters used only by
 	// SeedHistory and IrregularLogger are optional parameters used only by
 	// server obfuscators.
 	// server obfuscators.
@@ -98,6 +101,16 @@ func NewClientObfuscator(
 		return nil, errors.Trace(err)
 		return nil, errors.Trace(err)
 	}
 	}
 
 
+	// This transform may reduce the entropy of the seed, which decreases
+	// the security of the stream cipher key. However, the stream cipher is
+	// for obfuscation purposes only.
+	if config.IsOSSH && config.ObfuscatorSeedTransformerParameters != nil {
+		err = config.ObfuscatorSeedTransformerParameters.Apply(obfuscatorSeed)
+		if err != nil {
+			return nil, errors.Trace(err)
+		}
+	}
+
 	clientToServerCipher, serverToClientCipher, err := initObfuscatorCiphers(config, obfuscatorSeed)
 	clientToServerCipher, serverToClientCipher, err := initObfuscatorCiphers(config, obfuscatorSeed)
 	if err != nil {
 	if err != nil {
 		return nil, errors.Trace(err)
 		return nil, errors.Trace(err)

+ 102 - 2
psiphon/common/obfuscator/obfuscator_test.go

@@ -24,6 +24,7 @@ import (
 	"crypto/rand"
 	"crypto/rand"
 	"crypto/rsa"
 	"crypto/rsa"
 	"errors"
 	"errors"
+	"math/bits"
 	"net"
 	"net"
 	"testing"
 	"testing"
 	"time"
 	"time"
@@ -31,6 +32,7 @@ import (
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/crypto/ssh"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/crypto/ssh"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/transforms"
 )
 )
 
 
 func TestObfuscator(t *testing.T) {
 func TestObfuscator(t *testing.T) {
@@ -46,11 +48,19 @@ func TestObfuscator(t *testing.T) {
 
 
 	var irregularLogFields common.LogFields
 	var irregularLogFields common.LogFields
 
 
+	// creates a seed of fixed value for testing
+
 	config := &ObfuscatorConfig{
 	config := &ObfuscatorConfig{
+		IsOSSH:          true,
 		Keyword:         keyword,
 		Keyword:         keyword,
 		MaxPadding:      &maxPadding,
 		MaxPadding:      &maxPadding,
 		PaddingPRNGSeed: paddingPRNGSeed,
 		PaddingPRNGSeed: paddingPRNGSeed,
-		SeedHistory:     NewSeedHistory(&SeedHistoryConfig{ClientIPTTL: 500 * time.Millisecond}),
+		ObfuscatorSeedTransformerParameters: &transforms.ObfuscatorSeedTransformerParameters{
+			TransformName: "",
+			TransformSeed: &prng.Seed{1},
+			TransformSpec: transforms.Spec{{"^.{6}", "000000"}},
+		},
+		SeedHistory: NewSeedHistory(&SeedHistoryConfig{ClientIPTTL: 500 * time.Millisecond}),
 		IrregularLogger: func(_ string, err error, logFields common.LogFields) {
 		IrregularLogger: func(_ string, err error, logFields common.LogFields) {
 			if logFields == nil {
 			if logFields == nil {
 				logFields = make(common.LogFields)
 				logFields = make(common.LogFields)
@@ -225,7 +235,7 @@ func TestObfuscatedSSHConn(t *testing.T) {
 				conn,
 				conn,
 				keyword,
 				keyword,
 				paddingPRNGSeed,
 				paddingPRNGSeed,
-				nil, nil)
+				nil, nil, nil)
 		}
 		}
 
 
 		var KEXPRNGSeed *prng.Seed
 		var KEXPRNGSeed *prng.Seed
@@ -253,3 +263,93 @@ func TestObfuscatedSSHConn(t *testing.T) {
 		t.Fatalf("obfuscated SSH handshake failed: %s", err)
 		t.Fatalf("obfuscated SSH handshake failed: %s", err)
 	}
 	}
 }
 }
+
+func TestObfuscatorSeedTransformParamters(t *testing.T) {
+
+	keyword := prng.HexString(32)
+
+	maxPadding := 256
+
+	paddingPRNGSeed, err := prng.NewSeed()
+	if err != nil {
+		t.Fatalf("prng.NewSeed failed: %s", err)
+	}
+
+	type test struct {
+		name                 string
+		transformerParamters *transforms.ObfuscatorSeedTransformerParameters
+
+		// nil means seedMessage looks random (transformer was not applied)
+		expectedResult       []byte
+		expectedResultLength int
+	}
+
+	tests := []test{
+		{
+			name: "4 byte transform",
+			transformerParamters: &transforms.ObfuscatorSeedTransformerParameters{
+				TransformName: "four-zeros",
+				TransformSeed: &prng.Seed{0},
+				TransformSpec: transforms.Spec{{"^.{8}", "00000000"}},
+			},
+			expectedResult:       []byte{0, 0, 0, 0},
+			expectedResultLength: 4,
+		},
+		{
+			name: "invalid '%' character in the regex",
+			transformerParamters: &transforms.ObfuscatorSeedTransformerParameters{
+				TransformName: "invalid-spec",
+				TransformSeed: &prng.Seed{0},
+				TransformSpec: transforms.Spec{{"^.{8}", "%00000000"}},
+			},
+			expectedResult:       nil,
+			expectedResultLength: 0,
+		},
+	}
+
+	for _, tt := range tests {
+
+		t.Run(tt.name, func(t *testing.T) {
+
+			config := &ObfuscatorConfig{
+				IsOSSH:                              true,
+				Keyword:                             keyword,
+				MaxPadding:                          &maxPadding,
+				PaddingPRNGSeed:                     paddingPRNGSeed,
+				ObfuscatorSeedTransformerParameters: tt.transformerParamters,
+			}
+
+			client, err := NewClientObfuscator(config)
+			if err != nil {
+				// if there is a expectedResult, then the error is unexpected
+				if tt.expectedResult != nil {
+					t.Fatalf("NewClientObfuscator failed: %s", err)
+				}
+				return
+			}
+
+			seedMessage := client.SendSeedMessage()
+
+			if tt.expectedResult == nil {
+
+				// Verify that the seed message looks random.
+				// obfuscator seed is generated with common.MakeSecureRandomBytes,
+				// and is not affected by the config.
+				popcount := 0
+				for _, b := range seedMessage[:tt.expectedResultLength] {
+					popcount += bits.OnesCount(uint(b))
+				}
+				popcount_per_byte := float64(popcount) / float64(tt.expectedResultLength)
+				if popcount_per_byte < 3.6 || popcount_per_byte > 4.4 {
+					t.Fatalf("unexpected popcount_per_byte: %f", popcount_per_byte)
+				}
+
+			} else if !bytes.Equal(seedMessage[:tt.expectedResultLength], tt.expectedResult) {
+				t.Fatalf("unexpected seed message")
+			}
+
+		})
+
+	}
+
+}

+ 38 - 0
psiphon/common/parameters/parameters.go

@@ -232,6 +232,7 @@ const (
 	ReplayHostname                                   = "ReplayHostname"
 	ReplayHostname                                   = "ReplayHostname"
 	ReplayQUICVersion                                = "ReplayQUICVersion"
 	ReplayQUICVersion                                = "ReplayQUICVersion"
 	ReplayObfuscatedQUIC                             = "ReplayObfuscatedQUIC"
 	ReplayObfuscatedQUIC                             = "ReplayObfuscatedQUIC"
+	ReplayObfuscatedQUICNonceTransformer             = "ReplayObfuscatedQUICNonceTransformer"
 	ReplayConjureRegistration                        = "ReplayConjureRegistration"
 	ReplayConjureRegistration                        = "ReplayConjureRegistration"
 	ReplayConjureTransport                           = "ReplayConjureTransport"
 	ReplayConjureTransport                           = "ReplayConjureTransport"
 	ReplayLivenessTest                               = "ReplayLivenessTest"
 	ReplayLivenessTest                               = "ReplayLivenessTest"
@@ -240,6 +241,7 @@ const (
 	ReplayHoldOffTunnel                              = "ReplayHoldOffTunnel"
 	ReplayHoldOffTunnel                              = "ReplayHoldOffTunnel"
 	ReplayResolveParameters                          = "ReplayResolveParameters"
 	ReplayResolveParameters                          = "ReplayResolveParameters"
 	ReplayHTTPTransformerParameters                  = "ReplayHTTPTransformerParameters"
 	ReplayHTTPTransformerParameters                  = "ReplayHTTPTransformerParameters"
+	ReplayOSSHSeedTransformerParameters              = "ReplayOSSHSeedTransformerParameters"
 	APIRequestUpstreamPaddingMinBytes                = "APIRequestUpstreamPaddingMinBytes"
 	APIRequestUpstreamPaddingMinBytes                = "APIRequestUpstreamPaddingMinBytes"
 	APIRequestUpstreamPaddingMaxBytes                = "APIRequestUpstreamPaddingMaxBytes"
 	APIRequestUpstreamPaddingMaxBytes                = "APIRequestUpstreamPaddingMaxBytes"
 	APIRequestDownstreamPaddingMinBytes              = "APIRequestDownstreamPaddingMinBytes"
 	APIRequestDownstreamPaddingMinBytes              = "APIRequestDownstreamPaddingMinBytes"
@@ -328,6 +330,12 @@ const (
 	FrontedHTTPProtocolTransformSpecs                = "FrontedHTTPProtocolTransformSpecs"
 	FrontedHTTPProtocolTransformSpecs                = "FrontedHTTPProtocolTransformSpecs"
 	FrontedHTTPProtocolTransformScopedSpecNames      = "FrontedHTTPProtocolTransformScopedSpecNames"
 	FrontedHTTPProtocolTransformScopedSpecNames      = "FrontedHTTPProtocolTransformScopedSpecNames"
 	FrontedHTTPProtocolTransformProbability          = "FrontedHTTPProtocolTransformProbability"
 	FrontedHTTPProtocolTransformProbability          = "FrontedHTTPProtocolTransformProbability"
+	OSSHObfuscatorSeedTransformSpecs                 = "OSSHObfuscatorSeedTransformSpecs"
+	OSSHObfuscatorSeedTransformScopedSpecNames       = "OSSHObfuscatorSeedTransformScopedSpecNames"
+	OSSHObfuscatorSeedTransformProbability           = "OSSHObfuscatorSeedTransformProbability"
+	ObfuscatedQUICNonceTransformSpecs                = "ObfuscatedQUICNonceTransformSpecs"
+	ObfuscatedQUICNonceTransformScopedSpecNames      = "ObfuscatedQUICNonceTransformScopedSpecNames"
+	ObfuscatedQUICNonceTransformProbability          = "ObfuscatedQUICNonceTransformProbability"
 )
 )
 
 
 const (
 const (
@@ -575,6 +583,7 @@ var defaultParameters = map[string]struct {
 	ReplayHostname:                         {value: true},
 	ReplayHostname:                         {value: true},
 	ReplayQUICVersion:                      {value: true},
 	ReplayQUICVersion:                      {value: true},
 	ReplayObfuscatedQUIC:                   {value: true},
 	ReplayObfuscatedQUIC:                   {value: true},
+	ReplayObfuscatedQUICNonceTransformer:   {value: true},
 	ReplayConjureRegistration:              {value: true},
 	ReplayConjureRegistration:              {value: true},
 	ReplayConjureTransport:                 {value: true},
 	ReplayConjureTransport:                 {value: true},
 	ReplayLivenessTest:                     {value: true},
 	ReplayLivenessTest:                     {value: true},
@@ -583,6 +592,7 @@ var defaultParameters = map[string]struct {
 	ReplayHoldOffTunnel:                    {value: true},
 	ReplayHoldOffTunnel:                    {value: true},
 	ReplayResolveParameters:                {value: true},
 	ReplayResolveParameters:                {value: true},
 	ReplayHTTPTransformerParameters:        {value: true},
 	ReplayHTTPTransformerParameters:        {value: true},
+	ReplayOSSHSeedTransformerParameters:    {value: true},
 
 
 	APIRequestUpstreamPaddingMinBytes:   {value: 0, minimum: 0},
 	APIRequestUpstreamPaddingMinBytes:   {value: 0, minimum: 0},
 	APIRequestUpstreamPaddingMaxBytes:   {value: 1024, minimum: 0},
 	APIRequestUpstreamPaddingMaxBytes:   {value: 1024, minimum: 0},
@@ -693,6 +703,14 @@ var defaultParameters = map[string]struct {
 	FrontedHTTPProtocolTransformSpecs:           {value: transforms.Specs{}},
 	FrontedHTTPProtocolTransformSpecs:           {value: transforms.Specs{}},
 	FrontedHTTPProtocolTransformScopedSpecNames: {value: transforms.ScopedSpecNames{}},
 	FrontedHTTPProtocolTransformScopedSpecNames: {value: transforms.ScopedSpecNames{}},
 	FrontedHTTPProtocolTransformProbability:     {value: 0.0, minimum: 0.0},
 	FrontedHTTPProtocolTransformProbability:     {value: 0.0, minimum: 0.0},
+
+	OSSHObfuscatorSeedTransformSpecs:           {value: transforms.Specs{}},
+	OSSHObfuscatorSeedTransformScopedSpecNames: {value: transforms.ScopedSpecNames{}},
+	OSSHObfuscatorSeedTransformProbability:     {value: 0.0, minimum: 0.0},
+
+	ObfuscatedQUICNonceTransformSpecs:           {value: transforms.Specs{}},
+	ObfuscatedQUICNonceTransformScopedSpecNames: {value: transforms.ScopedSpecNames{}},
+	ObfuscatedQUICNonceTransformProbability:     {value: 0.0, minimum: 0.0},
 }
 }
 
 
 // IsServerSideOnly indicates if the parameter specified by name is used
 // IsServerSideOnly indicates if the parameter specified by name is used
@@ -890,6 +908,22 @@ func (p *Parameters) Set(
 	frontedHttpProtocolTransformSpecs, _ :=
 	frontedHttpProtocolTransformSpecs, _ :=
 		frontedHttpProtocolTransformSpecsValue.(transforms.Specs)
 		frontedHttpProtocolTransformSpecsValue.(transforms.Specs)
 
 
+	osshObfuscatorSeedTransformSpecsValue, err := getAppliedValue(
+		OSSHObfuscatorSeedTransformSpecs, parameters, applyParameters)
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+	osshObfuscatorSeedTransformSpecs, _ :=
+		osshObfuscatorSeedTransformSpecsValue.(transforms.Specs)
+
+	obfuscatedQuicNonceTransformSpecsValue, err := getAppliedValue(
+		ObfuscatedQUICNonceTransformSpecs, parameters, applyParameters)
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+	obfuscatedQuicNonceTransformSpecs, _ :=
+		obfuscatedQuicNonceTransformSpecsValue.(transforms.Specs)
+
 	for i := 0; i < len(applyParameters); i++ {
 	for i := 0; i < len(applyParameters); i++ {
 
 
 		count := 0
 		count := 0
@@ -1078,6 +1112,10 @@ func (p *Parameters) Set(
 					specs = directHttpProtocolTransformSpecs
 					specs = directHttpProtocolTransformSpecs
 				} else if name == FrontedHTTPProtocolTransformScopedSpecNames {
 				} else if name == FrontedHTTPProtocolTransformScopedSpecNames {
 					specs = frontedHttpProtocolTransformSpecs
 					specs = frontedHttpProtocolTransformSpecs
+				} else if name == OSSHObfuscatorSeedTransformScopedSpecNames {
+					specs = osshObfuscatorSeedTransformSpecs
+				} else if name == ObfuscatedQUICNonceTransformScopedSpecNames {
+					specs = obfuscatedQuicNonceTransformSpecs
 				}
 				}
 
 
 				err := v.Validate(specs)
 				err := v.Validate(specs)

+ 34 - 32
psiphon/common/protocol/serverEntry.go

@@ -44,38 +44,40 @@ import (
 // several protocols. Server entries are JSON records downloaded from
 // several protocols. Server entries are JSON records downloaded from
 // various sources.
 // various sources.
 type ServerEntry struct {
 type ServerEntry struct {
-	Tag                           string   `json:"tag"`
-	IpAddress                     string   `json:"ipAddress"`
-	WebServerPort                 string   `json:"webServerPort"` // not an int
-	WebServerSecret               string   `json:"webServerSecret"`
-	WebServerCertificate          string   `json:"webServerCertificate"`
-	SshPort                       int      `json:"sshPort"`
-	SshUsername                   string   `json:"sshUsername"`
-	SshPassword                   string   `json:"sshPassword"`
-	SshHostKey                    string   `json:"sshHostKey"`
-	SshObfuscatedPort             int      `json:"sshObfuscatedPort"`
-	SshObfuscatedQUICPort         int      `json:"sshObfuscatedQUICPort"`
-	LimitQUICVersions             []string `json:"limitQUICVersions"`
-	SshObfuscatedTapDancePort     int      `json:"sshObfuscatedTapdancePort"`
-	SshObfuscatedConjurePort      int      `json:"sshObfuscatedConjurePort"`
-	SshObfuscatedKey              string   `json:"sshObfuscatedKey"`
-	Capabilities                  []string `json:"capabilities"`
-	Region                        string   `json:"region"`
-	FrontingProviderID            string   `json:"frontingProviderID"`
-	MeekServerPort                int      `json:"meekServerPort"`
-	MeekCookieEncryptionPublicKey string   `json:"meekCookieEncryptionPublicKey"`
-	MeekObfuscatedKey             string   `json:"meekObfuscatedKey"`
-	MeekFrontingHost              string   `json:"meekFrontingHost"`
-	MeekFrontingHosts             []string `json:"meekFrontingHosts"`
-	MeekFrontingDomain            string   `json:"meekFrontingDomain"`
-	MeekFrontingAddresses         []string `json:"meekFrontingAddresses"`
-	MeekFrontingAddressesRegex    string   `json:"meekFrontingAddressesRegex"`
-	MeekFrontingDisableSNI        bool     `json:"meekFrontingDisableSNI"`
-	TacticsRequestPublicKey       string   `json:"tacticsRequestPublicKey"`
-	TacticsRequestObfuscatedKey   string   `json:"tacticsRequestObfuscatedKey"`
-	ConfigurationVersion          int      `json:"configurationVersion"`
-	Signature                     string   `json:"signature"`
-	DisableHTTPTransforms         bool     `json:"disableHTTPTransforms"`
+	Tag                             string   `json:"tag"`
+	IpAddress                       string   `json:"ipAddress"`
+	WebServerPort                   string   `json:"webServerPort"` // not an int
+	WebServerSecret                 string   `json:"webServerSecret"`
+	WebServerCertificate            string   `json:"webServerCertificate"`
+	SshPort                         int      `json:"sshPort"`
+	SshUsername                     string   `json:"sshUsername"`
+	SshPassword                     string   `json:"sshPassword"`
+	SshHostKey                      string   `json:"sshHostKey"`
+	SshObfuscatedPort               int      `json:"sshObfuscatedPort"`
+	SshObfuscatedQUICPort           int      `json:"sshObfuscatedQUICPort"`
+	LimitQUICVersions               []string `json:"limitQUICVersions"`
+	SshObfuscatedTapDancePort       int      `json:"sshObfuscatedTapdancePort"`
+	SshObfuscatedConjurePort        int      `json:"sshObfuscatedConjurePort"`
+	SshObfuscatedKey                string   `json:"sshObfuscatedKey"`
+	Capabilities                    []string `json:"capabilities"`
+	Region                          string   `json:"region"`
+	FrontingProviderID              string   `json:"frontingProviderID"`
+	MeekServerPort                  int      `json:"meekServerPort"`
+	MeekCookieEncryptionPublicKey   string   `json:"meekCookieEncryptionPublicKey"`
+	MeekObfuscatedKey               string   `json:"meekObfuscatedKey"`
+	MeekFrontingHost                string   `json:"meekFrontingHost"`
+	MeekFrontingHosts               []string `json:"meekFrontingHosts"`
+	MeekFrontingDomain              string   `json:"meekFrontingDomain"`
+	MeekFrontingAddresses           []string `json:"meekFrontingAddresses"`
+	MeekFrontingAddressesRegex      string   `json:"meekFrontingAddressesRegex"`
+	MeekFrontingDisableSNI          bool     `json:"meekFrontingDisableSNI"`
+	TacticsRequestPublicKey         string   `json:"tacticsRequestPublicKey"`
+	TacticsRequestObfuscatedKey     string   `json:"tacticsRequestObfuscatedKey"`
+	ConfigurationVersion            int      `json:"configurationVersion"`
+	Signature                       string   `json:"signature"`
+	DisableHTTPTransforms           bool     `json:"disableHTTPTransforms"`
+	DisableObfuscatedQUICTransforms bool     `json:"disableObfuscatedQUICTransforms"`
+	DisableOSSHTransforms           bool     `json:"disableOSSHTransforms"`
 
 
 	// These local fields are not expected to be present in downloaded server
 	// These local fields are not expected to be present in downloaded server
 	// entries. They are added by the client to record and report stats about
 	// entries. They are added by the client to record and report stats about

+ 77 - 22
psiphon/common/quic/obfuscator.go

@@ -34,6 +34,7 @@ import (
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/crypto/Yawning/chacha20"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/crypto/Yawning/chacha20"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/transforms"
 	ietf_quic "github.com/Psiphon-Labs/quic-go"
 	ietf_quic "github.com/Psiphon-Labs/quic-go"
 	"golang.org/x/crypto/hkdf"
 	"golang.org/x/crypto/hkdf"
 	"golang.org/x/net/ipv4"
 	"golang.org/x/net/ipv4"
@@ -103,19 +104,20 @@ const (
 // introducing some risk of fragmentation and/or dropped packets.
 // introducing some risk of fragmentation and/or dropped packets.
 type ObfuscatedPacketConn struct {
 type ObfuscatedPacketConn struct {
 	net.PacketConn
 	net.PacketConn
-	isServer         bool
-	isIETFClient     bool
-	isDecoyClient    bool
-	isClosed         int32
-	runWaitGroup     *sync.WaitGroup
-	stopBroadcast    chan struct{}
-	obfuscationKey   [32]byte
-	peerModesMutex   sync.Mutex
-	peerModes        map[string]*peerMode
-	noncePRNG        *prng.PRNG
-	paddingPRNG      *prng.PRNG
-	decoyPacketCount int32
-	decoyBuffer      []byte
+	isServer                   bool
+	isIETFClient               bool
+	isDecoyClient              bool
+	isClosed                   int32
+	runWaitGroup               *sync.WaitGroup
+	stopBroadcast              chan struct{}
+	obfuscationKey             [32]byte
+	peerModesMutex             sync.Mutex
+	peerModes                  map[string]*peerMode
+	noncePRNG                  *prng.PRNG
+	paddingPRNG                *prng.PRNG
+	nonceTransformerParameters *transforms.ObfuscatorSeedTransformerParameters
+	decoyPacketCount           int32
+	decoyBuffer                []byte
 }
 }
 
 
 type peerMode struct {
 type peerMode struct {
@@ -128,8 +130,28 @@ func (p *peerMode) isStale() bool {
 	return time.Since(p.lastPacketTime) >= SERVER_IDLE_TIMEOUT
 	return time.Since(p.lastPacketTime) >= SERVER_IDLE_TIMEOUT
 }
 }
 
 
-// NewObfuscatedPacketConn creates a new ObfuscatedPacketConn.
-func NewObfuscatedPacketConn(
+func NewClientObfuscatedPacketConn(
+	packetConn net.PacketConn,
+	isServer bool,
+	isIETFClient bool,
+	isDecoyClient bool,
+	obfuscationKey string,
+	paddingSeed *prng.Seed,
+	obfuscationNonceTransformerParameters *transforms.ObfuscatorSeedTransformerParameters,
+) (*ObfuscatedPacketConn, error) {
+
+	return newObfuscatedPacketConn(
+		packetConn,
+		isServer,
+		isIETFClient,
+		isDecoyClient,
+		obfuscationKey,
+		paddingSeed,
+		obfuscationNonceTransformerParameters)
+
+}
+
+func NewServerObfuscatedPacketConn(
 	packetConn net.PacketConn,
 	packetConn net.PacketConn,
 	isServer bool,
 	isServer bool,
 	isIETFClient bool,
 	isIETFClient bool,
@@ -137,6 +159,28 @@ func NewObfuscatedPacketConn(
 	obfuscationKey string,
 	obfuscationKey string,
 	paddingSeed *prng.Seed) (*ObfuscatedPacketConn, error) {
 	paddingSeed *prng.Seed) (*ObfuscatedPacketConn, error) {
 
 
+	return newObfuscatedPacketConn(
+		packetConn,
+		isServer,
+		isIETFClient,
+		isDecoyClient,
+		obfuscationKey,
+		paddingSeed,
+		nil)
+
+}
+
+// newObfuscatedPacketConn creates a new ObfuscatedPacketConn.
+func newObfuscatedPacketConn(
+	packetConn net.PacketConn,
+	isServer bool,
+	isIETFClient bool,
+	isDecoyClient bool,
+	obfuscationKey string,
+	paddingSeed *prng.Seed,
+	obfuscationNonceTransformerParameters *transforms.ObfuscatorSeedTransformerParameters,
+) (*ObfuscatedPacketConn, error) {
+
 	// There is no replay of obfuscation "encryption", just padding.
 	// There is no replay of obfuscation "encryption", just padding.
 	nonceSeed, err := prng.NewSeed()
 	nonceSeed, err := prng.NewSeed()
 	if err != nil {
 	if err != nil {
@@ -144,13 +188,14 @@ func NewObfuscatedPacketConn(
 	}
 	}
 
 
 	conn := &ObfuscatedPacketConn{
 	conn := &ObfuscatedPacketConn{
-		PacketConn:    packetConn,
-		isServer:      isServer,
-		isIETFClient:  isIETFClient,
-		isDecoyClient: isDecoyClient,
-		peerModes:     make(map[string]*peerMode),
-		noncePRNG:     prng.NewPRNGWithSeed(nonceSeed),
-		paddingPRNG:   prng.NewPRNGWithSeed(paddingSeed),
+		PacketConn:                 packetConn,
+		isServer:                   isServer,
+		isIETFClient:               isIETFClient,
+		isDecoyClient:              isDecoyClient,
+		peerModes:                  make(map[string]*peerMode),
+		noncePRNG:                  prng.NewPRNGWithSeed(nonceSeed),
+		paddingPRNG:                prng.NewPRNGWithSeed(paddingSeed),
+		nonceTransformerParameters: obfuscationNonceTransformerParameters,
 	}
 	}
 
 
 	secret := []byte(obfuscationKey)
 	secret := []byte(obfuscationKey)
@@ -633,6 +678,16 @@ func (conn *ObfuscatedPacketConn) writePacket(
 			nonce := buffer[0:NONCE_SIZE]
 			nonce := buffer[0:NONCE_SIZE]
 			conn.noncePRNG.Read(nonce)
 			conn.noncePRNG.Read(nonce)
 
 
+			// This transform may reduce the entropy of the nonce, which increases
+			// the chance of nonce reuse. However, this chacha20 encryption is for
+			// obfuscation purposes only.
+			if conn.nonceTransformerParameters != nil {
+				err := conn.nonceTransformerParameters.Apply(nonce)
+				if err != nil {
+					return 0, 0, errors.Trace(err)
+				}
+			}
+
 			maxPadding := getMaxPaddingSize(isIETF, addr, n)
 			maxPadding := getMaxPaddingSize(isIETF, addr, n)
 
 
 			paddingLen := conn.paddingPRNG.Intn(maxPadding + 1)
 			paddingLen := conn.paddingPRNG.Intn(maxPadding + 1)

+ 152 - 0
psiphon/common/quic/obfuscator_test.go

@@ -0,0 +1,152 @@
+//go:build !PSIPHON_DISABLE_QUIC
+// +build !PSIPHON_DISABLE_QUIC
+
+/*
+ * 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 quic
+
+import (
+	"context"
+	"encoding/hex"
+	"net"
+	"testing"
+	"time"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/transforms"
+	"golang.org/x/sync/errgroup"
+)
+
+func TestNonceTransformer(t *testing.T) {
+	for quicVersion := range supportedVersionNumbers {
+		if !isObfuscated(quicVersion) {
+			continue
+		}
+		t.Run(quicVersion, func(t *testing.T) {
+			runNonceTransformer(t, quicVersion)
+		})
+	}
+}
+
+func runNonceTransformer(t *testing.T, quicVersion string) {
+
+	serverIdleTimeout = 1 * time.Second
+
+	obfuscationKey := prng.HexString(32)
+
+	listener, err := net.ListenPacket("udp", "127.0.0.1:0")
+	if err != nil {
+		t.Fatalf("Listen failed: %s", err)
+	}
+
+	serverAddress := listener.LocalAddr().(*net.UDPAddr)
+
+	testGroup, testCtx := errgroup.WithContext(context.Background())
+
+	testGroup.Go(func() error {
+		var serverGroup errgroup.Group
+
+		// reads the first packet and verifies the nonce prefix
+		serverGroup.Go(func() error {
+			b := make([]byte, 1024)
+
+			_, _, err := listener.ReadFrom(b)
+			if err != nil {
+				return errors.Trace(err)
+			}
+
+			prefix := hex.EncodeToString(b[:NONCE_SIZE])
+			if prefix != "ffff00000000000000000000" {
+				return errors.Tracef("unexpected prefix: %s", prefix)
+			}
+
+			return nil
+		})
+
+		err = serverGroup.Wait()
+		if err != nil {
+			return errors.Trace(err)
+		}
+
+		return nil
+	})
+
+	// client
+
+	testGroup.Go(func() error {
+
+		ctx, cancelFunc := context.WithTimeout(
+			context.Background(), 1*time.Second)
+		defer cancelFunc()
+
+		packetConn, err := net.ListenPacket("udp4", "127.0.0.1:0")
+		if err != nil {
+			return errors.Trace(err)
+		}
+
+		obfuscationPaddingSeed, err := prng.NewSeed()
+		if err != nil {
+			return errors.Trace(err)
+		}
+
+		var clientHelloSeed *prng.Seed
+		if isClientHelloRandomized(quicVersion) {
+			clientHelloSeed, err = prng.NewSeed()
+			if err != nil {
+				return errors.Trace(err)
+			}
+		}
+
+		// Dial with nonce transformer
+
+		Dial(
+			ctx,
+			packetConn,
+			serverAddress,
+			serverAddress.String(),
+			quicVersion,
+			clientHelloSeed,
+			obfuscationKey,
+			obfuscationPaddingSeed,
+			&transforms.ObfuscatorSeedTransformerParameters{
+				TransformName: "",
+				TransformSeed: &prng.Seed{0},
+				TransformSpec: transforms.Spec{{"^.{24}", "ffff00000000000000000000"}},
+			},
+			false,
+		)
+
+		return nil
+	})
+
+	go func() {
+		testGroup.Wait()
+	}()
+
+	<-testCtx.Done()
+	listener.Close()
+
+	err = testGroup.Wait()
+	if err != nil {
+		t.Errorf("goroutine failed: %s", err)
+	}
+
+}

+ 6 - 3
psiphon/common/quic/quic.go

@@ -59,6 +59,7 @@ import (
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/obfuscator"
 	"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/prng"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/transforms"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/values"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/values"
 	ietf_quic "github.com/Psiphon-Labs/quic-go"
 	ietf_quic "github.com/Psiphon-Labs/quic-go"
 	"github.com/Psiphon-Labs/quic-go/http3"
 	"github.com/Psiphon-Labs/quic-go/http3"
@@ -172,7 +173,7 @@ func Listen(
 		return nil, errors.Trace(err)
 		return nil, errors.Trace(err)
 	}
 	}
 
 
-	obfuscatedPacketConn, err := NewObfuscatedPacketConn(
+	obfuscatedPacketConn, err := NewServerObfuscatedPacketConn(
 		udpConn, true, false, false, obfuscationKey, seed)
 		udpConn, true, false, false, obfuscationKey, seed)
 	if err != nil {
 	if err != nil {
 		udpConn.Close()
 		udpConn.Close()
@@ -356,6 +357,7 @@ func Dial(
 	clientHelloSeed *prng.Seed,
 	clientHelloSeed *prng.Seed,
 	obfuscationKey string,
 	obfuscationKey string,
 	obfuscationPaddingSeed *prng.Seed,
 	obfuscationPaddingSeed *prng.Seed,
+	obfuscationNonceTransformerParameters *transforms.ObfuscatorSeedTransformerParameters,
 	disablePathMTUDiscovery bool) (net.Conn, error) {
 	disablePathMTUDiscovery bool) (net.Conn, error) {
 
 
 	if quicVersion == "" {
 	if quicVersion == "" {
@@ -421,13 +423,14 @@ func Dial(
 	maxPacketSizeAdjustment := 0
 	maxPacketSizeAdjustment := 0
 
 
 	if isObfuscated(quicVersion) {
 	if isObfuscated(quicVersion) {
-		obfuscatedPacketConn, err := NewObfuscatedPacketConn(
+		obfuscatedPacketConn, err := NewClientObfuscatedPacketConn(
 			packetConn,
 			packetConn,
 			false,
 			false,
 			isIETFVersionNumber(versionNumber),
 			isIETFVersionNumber(versionNumber),
 			isDecoy(quicVersion),
 			isDecoy(quicVersion),
 			obfuscationKey,
 			obfuscationKey,
-			obfuscationPaddingSeed)
+			obfuscationPaddingSeed,
+			obfuscationNonceTransformerParameters)
 		if err != nil {
 		if err != nil {
 			return nil, errors.Trace(err)
 			return nil, errors.Trace(err)
 		}
 		}

+ 1 - 0
psiphon/common/quic/quic_test.go

@@ -197,6 +197,7 @@ func runQUIC(
 				clientHelloSeed,
 				clientHelloSeed,
 				clientObfuscationKey,
 				clientObfuscationKey,
 				obfuscationPaddingSeed,
 				obfuscationPaddingSeed,
+				nil,
 				disablePathMTUDiscovery)
 				disablePathMTUDiscovery)
 
 
 			if invokeAntiProbing {
 			if invokeAntiProbing {

+ 61 - 0
psiphon/common/transforms/obfuscatorSeedTransform.go

@@ -0,0 +1,61 @@
+/*
+ * 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 transforms
+
+import (
+	"encoding/hex"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
+)
+
+type ObfuscatorSeedTransformerParameters struct {
+	TransformName string
+	TransformSpec Spec
+	TransformSeed *prng.Seed
+}
+
+// Apply applies the transformation in-place to the given slice of bytes.
+// No change is made if the tranformation fails.
+func (t *ObfuscatorSeedTransformerParameters) Apply(b []byte) error {
+	if t.TransformSpec == nil {
+		return nil
+	}
+
+	input := hex.EncodeToString(b)
+	newSeedString, err := t.TransformSpec.ApplyString(t.TransformSeed, input)
+
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	newSeed, err := hex.DecodeString(newSeedString)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	if len(newSeed) != len(b) {
+		return errors.TraceNew("invalid transform spec")
+	}
+
+	copy(b, newSeed)
+
+	return nil
+}

+ 70 - 0
psiphon/config.go

@@ -831,6 +831,14 @@ type Config struct {
 	FrontedHTTPProtocolTransformScopedSpecNames transforms.ScopedSpecNames
 	FrontedHTTPProtocolTransformScopedSpecNames transforms.ScopedSpecNames
 	FrontedHTTPProtocolTransformProbability     *float64
 	FrontedHTTPProtocolTransformProbability     *float64
 
 
+	OSSHObfuscatorSeedTransformSpecs           transforms.Specs
+	OSSHObfuscatorSeedTransformScopedSpecNames transforms.ScopedSpecNames
+	OSSHObfuscatorSeedTransformProbability     *float64
+
+	ObfuscatedQUICNonceTransformSpecs           transforms.Specs
+	ObfuscatedQUICNonceTransformScopedSpecNames transforms.ScopedSpecNames
+	ObfuscatedQUICNonceTransformProbability     *float64
+
 	// params is the active parameters.Parameters with defaults, config values,
 	// params is the active parameters.Parameters with defaults, config values,
 	// and, optionally, tactics applied.
 	// and, optionally, tactics applied.
 	//
 	//
@@ -1937,6 +1945,30 @@ func (config *Config) makeConfigParameters() map[string]interface{} {
 		applyParameters[parameters.FrontedHTTPProtocolTransformProbability] = *config.FrontedHTTPProtocolTransformProbability
 		applyParameters[parameters.FrontedHTTPProtocolTransformProbability] = *config.FrontedHTTPProtocolTransformProbability
 	}
 	}
 
 
+	if config.OSSHObfuscatorSeedTransformSpecs != nil {
+		applyParameters[parameters.OSSHObfuscatorSeedTransformSpecs] = config.OSSHObfuscatorSeedTransformSpecs
+	}
+
+	if config.OSSHObfuscatorSeedTransformScopedSpecNames != nil {
+		applyParameters[parameters.OSSHObfuscatorSeedTransformScopedSpecNames] = config.OSSHObfuscatorSeedTransformScopedSpecNames
+	}
+
+	if config.OSSHObfuscatorSeedTransformProbability != nil {
+		applyParameters[parameters.OSSHObfuscatorSeedTransformProbability] = *config.OSSHObfuscatorSeedTransformProbability
+	}
+
+	if config.ObfuscatedQUICNonceTransformSpecs != nil {
+		applyParameters[parameters.ObfuscatedQUICNonceTransformSpecs] = config.ObfuscatedQUICNonceTransformSpecs
+	}
+
+	if config.ObfuscatedQUICNonceTransformScopedSpecNames != nil {
+		applyParameters[parameters.ObfuscatedQUICNonceTransformScopedSpecNames] = config.ObfuscatedQUICNonceTransformScopedSpecNames
+	}
+
+	if config.ObfuscatedQUICNonceTransformProbability != nil {
+		applyParameters[parameters.ObfuscatedQUICNonceTransformProbability] = *config.ObfuscatedQUICNonceTransformProbability
+	}
+
 	// When adding new config dial parameters that may override tactics, also
 	// When adding new config dial parameters that may override tactics, also
 	// update setDialParametersHash.
 	// update setDialParametersHash.
 
 
@@ -2383,6 +2415,44 @@ func (config *Config) setDialParametersHash() {
 		binary.Write(hash, binary.LittleEndian, *config.FrontedHTTPProtocolTransformProbability)
 		binary.Write(hash, binary.LittleEndian, *config.FrontedHTTPProtocolTransformProbability)
 	}
 	}
 
 
+	if config.OSSHObfuscatorSeedTransformSpecs != nil {
+		hash.Write([]byte("OSSHObfuscatorSeedTransformSpecs"))
+		encodedOSSHObfuscatorSeedTransformSpecs, _ :=
+			json.Marshal(config.OSSHObfuscatorSeedTransformSpecs)
+		hash.Write(encodedOSSHObfuscatorSeedTransformSpecs)
+	}
+
+	if config.OSSHObfuscatorSeedTransformScopedSpecNames != nil {
+		hash.Write([]byte(""))
+		encodedOSSHObfuscatorSeedTransformScopedSpecNames, _ :=
+			json.Marshal(config.OSSHObfuscatorSeedTransformScopedSpecNames)
+		hash.Write(encodedOSSHObfuscatorSeedTransformScopedSpecNames)
+	}
+
+	if config.OSSHObfuscatorSeedTransformProbability != nil {
+		hash.Write([]byte("OSSHObfuscatorSeedTransformProbability"))
+		binary.Write(hash, binary.LittleEndian, *config.OSSHObfuscatorSeedTransformProbability)
+	}
+
+	if config.ObfuscatedQUICNonceTransformSpecs != nil {
+		hash.Write([]byte("ObfuscatedQUICNonceTransformSpecs"))
+		encodedObfuscatedQUICNonceTransformSpecs, _ :=
+			json.Marshal(config.ObfuscatedQUICNonceTransformSpecs)
+		hash.Write(encodedObfuscatedQUICNonceTransformSpecs)
+	}
+
+	if config.ObfuscatedQUICNonceTransformScopedSpecNames != nil {
+		hash.Write([]byte(""))
+		encodedObfuscatedQUICNonceTransformScopedSpecNames, _ :=
+			json.Marshal(config.ObfuscatedQUICNonceTransformScopedSpecNames)
+		hash.Write(encodedObfuscatedQUICNonceTransformScopedSpecNames)
+	}
+
+	if config.ObfuscatedQUICNonceTransformProbability != nil {
+		hash.Write([]byte("ObfuscatedQUICNonceTransformProbability"))
+		binary.Write(hash, binary.LittleEndian, *config.ObfuscatedQUICNonceTransformProbability)
+	}
+
 	config.dialParametersHash = hash.Sum(nil)
 	config.dialParametersHash = hash.Sum(nil)
 }
 }
 
 

+ 85 - 6
psiphon/dialParameters.go

@@ -87,7 +87,8 @@ type DialParameters struct {
 	SSHClientVersion         string
 	SSHClientVersion         string
 	SSHKEXSeed               *prng.Seed
 	SSHKEXSeed               *prng.Seed
 
 
-	ObfuscatorPaddingSeed *prng.Seed
+	ObfuscatorPaddingSeed                   *prng.Seed
+	OSSHObfuscatorSeedTransformerParameters *transforms.ObfuscatorSeedTransformerParameters
 
 
 	FragmentorSeed *prng.Seed
 	FragmentorSeed *prng.Seed
 
 
@@ -114,11 +115,12 @@ type DialParameters struct {
 	TLSVersion               string
 	TLSVersion               string
 	RandomizedTLSProfileSeed *prng.Seed
 	RandomizedTLSProfileSeed *prng.Seed
 
 
-	QUICVersion                 string
-	QUICDialSNIAddress          string
-	QUICClientHelloSeed         *prng.Seed
-	ObfuscatedQUICPaddingSeed   *prng.Seed
-	QUICDisablePathMTUDiscovery bool
+	QUICVersion                              string
+	QUICDialSNIAddress                       string
+	QUICClientHelloSeed                      *prng.Seed
+	ObfuscatedQUICPaddingSeed                *prng.Seed
+	ObfuscatedQUICNonceTransformerParameters *transforms.ObfuscatorSeedTransformerParameters
+	QUICDisablePathMTUDiscovery              bool
 
 
 	ConjureCachedRegistrationTTL        time.Duration
 	ConjureCachedRegistrationTTL        time.Duration
 	ConjureAPIRegistration              bool
 	ConjureAPIRegistration              bool
@@ -192,6 +194,7 @@ func MakeDialParameters(
 	replayHostname := p.Bool(parameters.ReplayHostname)
 	replayHostname := p.Bool(parameters.ReplayHostname)
 	replayQUICVersion := p.Bool(parameters.ReplayQUICVersion)
 	replayQUICVersion := p.Bool(parameters.ReplayQUICVersion)
 	replayObfuscatedQUIC := p.Bool(parameters.ReplayObfuscatedQUIC)
 	replayObfuscatedQUIC := p.Bool(parameters.ReplayObfuscatedQUIC)
+	replayObfuscatedQUICNonceTransformer := p.Bool(parameters.ReplayObfuscatedQUICNonceTransformer)
 	replayConjureRegistration := p.Bool(parameters.ReplayConjureRegistration)
 	replayConjureRegistration := p.Bool(parameters.ReplayConjureRegistration)
 	replayConjureTransport := p.Bool(parameters.ReplayConjureTransport)
 	replayConjureTransport := p.Bool(parameters.ReplayConjureTransport)
 	replayLivenessTest := p.Bool(parameters.ReplayLivenessTest)
 	replayLivenessTest := p.Bool(parameters.ReplayLivenessTest)
@@ -200,6 +203,7 @@ func MakeDialParameters(
 	replayHoldOffTunnel := p.Bool(parameters.ReplayHoldOffTunnel)
 	replayHoldOffTunnel := p.Bool(parameters.ReplayHoldOffTunnel)
 	replayResolveParameters := p.Bool(parameters.ReplayResolveParameters)
 	replayResolveParameters := p.Bool(parameters.ReplayResolveParameters)
 	replayHTTPTransformerParameters := p.Bool(parameters.ReplayHTTPTransformerParameters)
 	replayHTTPTransformerParameters := p.Bool(parameters.ReplayHTTPTransformerParameters)
+	replayOSSHSeedTransformerParameters := p.Bool(parameters.ReplayOSSHSeedTransformerParameters)
 
 
 	// Check for existing dial parameters for this server/network ID.
 	// Check for existing dial parameters for this server/network ID.
 
 
@@ -472,6 +476,7 @@ func MakeDialParameters(
 		if err != nil {
 		if err != nil {
 			return nil, errors.Trace(err)
 			return nil, errors.Trace(err)
 		}
 		}
+
 		if protocol.TunnelProtocolUsesMeek(dialParams.TunnelProtocol) {
 		if protocol.TunnelProtocolUsesMeek(dialParams.TunnelProtocol) {
 			dialParams.MeekObfuscatorPaddingSeed, err = prng.NewSeed()
 			dialParams.MeekObfuscatorPaddingSeed, err = prng.NewSeed()
 			if err != nil {
 			if err != nil {
@@ -719,6 +724,28 @@ func MakeDialParameters(
 		}
 		}
 	}
 	}
 
 
+	if protocol.QUICVersionIsObfuscated(dialParams.QUICVersion) {
+
+		if serverEntry.DisableObfuscatedQUICTransforms {
+
+			dialParams.ObfuscatedQUICNonceTransformerParameters = nil
+
+		} else if !isReplay || !replayObfuscatedQUICNonceTransformer {
+
+			dialParams.ObfuscatedQUICNonceTransformerParameters, err = makeSeedTransformerParameters(
+				p,
+				parameters.ObfuscatedQUICNonceTransformProbability,
+				parameters.ObfuscatedQUICNonceTransformSpecs,
+				parameters.ObfuscatedQUICNonceTransformScopedSpecNames)
+
+			if err != nil {
+				return nil, errors.Trace(err)
+			}
+
+		}
+
+	}
+
 	if !isReplay || !replayLivenessTest {
 	if !isReplay || !replayLivenessTest {
 
 
 		// TODO: initialize only when LivenessTestMaxUp/DownstreamBytes > 0?
 		// TODO: initialize only when LivenessTestMaxUp/DownstreamBytes > 0?
@@ -776,6 +803,28 @@ func MakeDialParameters(
 
 
 	}
 	}
 
 
+	if protocol.TunnelProtocolUsesObfuscatedSSH(dialParams.TunnelProtocol) {
+
+		if serverEntry.DisableOSSHTransforms {
+
+			dialParams.OSSHObfuscatorSeedTransformerParameters = nil
+
+		} else if !isReplay || !replayOSSHSeedTransformerParameters {
+
+			dialParams.OSSHObfuscatorSeedTransformerParameters, err = makeSeedTransformerParameters(
+				p,
+				parameters.OSSHObfuscatorSeedTransformProbability,
+				parameters.OSSHObfuscatorSeedTransformSpecs,
+				parameters.OSSHObfuscatorSeedTransformScopedSpecNames)
+
+			if err != nil {
+				return nil, errors.Trace(err)
+			}
+
+		}
+
+	}
+
 	if protocol.TunnelProtocolUsesMeekHTTP(dialParams.TunnelProtocol) {
 	if protocol.TunnelProtocolUsesMeekHTTP(dialParams.TunnelProtocol) {
 
 
 		if serverEntry.DisableHTTPTransforms {
 		if serverEntry.DisableHTTPTransforms {
@@ -1464,3 +1513,33 @@ func makeHTTPTransformerParameters(p parameters.ParametersAccessor,
 
 
 	return &params, nil
 	return &params, nil
 }
 }
+
+// makeSeedTransformerParameters generates ObfuscatorSeedTransformerParameters
+// using the input tactics parameters.
+func makeSeedTransformerParameters(p parameters.ParametersAccessor,
+	probabilityFieldName, specsKey, scopedSpecsKey string) (*transforms.ObfuscatorSeedTransformerParameters, error) {
+
+	if !p.WeightedCoinFlip(probabilityFieldName) {
+		return &transforms.ObfuscatorSeedTransformerParameters{}, nil
+	}
+
+	seed, err := prng.NewSeed()
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+
+	specs := p.ProtocolTransformSpecs(specsKey)
+	scopedSpecNames := p.ProtocolTransformScopedSpecNames(scopedSpecsKey)
+
+	name, spec := specs.Select(transforms.SCOPE_ANY, scopedSpecNames)
+
+	if spec == nil {
+		return &transforms.ObfuscatorSeedTransformerParameters{}, nil
+	} else {
+		return &transforms.ObfuscatorSeedTransformerParameters{
+			TransformName: name,
+			TransformSpec: spec,
+			TransformSeed: seed,
+		}, nil
+	}
+}

+ 98 - 0
psiphon/dialParameters_test.go

@@ -93,6 +93,13 @@ func runDialParametersAndReplay(t *testing.T, tunnelProtocol string) {
 	applyParameters[parameters.DirectHTTPProtocolTransformProbability] = 1.0
 	applyParameters[parameters.DirectHTTPProtocolTransformProbability] = 1.0
 	applyParameters[parameters.DirectHTTPProtocolTransformSpecs] = transforms.Specs{"spec": transforms.Spec{{"", ""}}}
 	applyParameters[parameters.DirectHTTPProtocolTransformSpecs] = transforms.Specs{"spec": transforms.Spec{{"", ""}}}
 	applyParameters[parameters.DirectHTTPProtocolTransformScopedSpecNames] = transforms.ScopedSpecNames{"": {"spec"}}
 	applyParameters[parameters.DirectHTTPProtocolTransformScopedSpecNames] = transforms.ScopedSpecNames{"": {"spec"}}
+	applyParameters[parameters.OSSHObfuscatorSeedTransformProbability] = 1.0
+	applyParameters[parameters.OSSHObfuscatorSeedTransformSpecs] = transforms.Specs{"spec": transforms.Spec{{"", ""}}}
+	applyParameters[parameters.OSSHObfuscatorSeedTransformScopedSpecNames] = transforms.ScopedSpecNames{"": {"spec"}}
+	applyParameters[parameters.ObfuscatedQUICNonceTransformProbability] = 1.0
+	applyParameters[parameters.ObfuscatedQUICNonceTransformSpecs] = transforms.Specs{"spec": transforms.Spec{{"", ""}}}
+	applyParameters[parameters.ObfuscatedQUICNonceTransformScopedSpecNames] = transforms.ScopedSpecNames{"": {"spec"}}
+
 	err = clientConfig.SetParameters("tag1", false, applyParameters)
 	err = clientConfig.SetParameters("tag1", false, applyParameters)
 	if err != nil {
 	if err != nil {
 		t.Fatalf("SetParameters failed: %s", err)
 		t.Fatalf("SetParameters failed: %s", err)
@@ -366,6 +373,18 @@ func runDialParametersAndReplay(t *testing.T, tunnelProtocol string) {
 		t.Fatalf("mismatching HTTPTransformerParameters fields")
 		t.Fatalf("mismatching HTTPTransformerParameters fields")
 	}
 	}
 
 
+	if (replayDialParams.OSSHObfuscatorSeedTransformerParameters == nil) != (dialParams.OSSHObfuscatorSeedTransformerParameters == nil) ||
+		(replayDialParams.OSSHObfuscatorSeedTransformerParameters != nil &&
+			!reflect.DeepEqual(replayDialParams.OSSHObfuscatorSeedTransformerParameters, dialParams.OSSHObfuscatorSeedTransformerParameters)) {
+		t.Fatalf("mismatching ObfuscatorSeedTransformerParameters fields")
+	}
+
+	if (replayDialParams.ObfuscatedQUICNonceTransformerParameters == nil) != (dialParams.ObfuscatedQUICNonceTransformerParameters == nil) ||
+		(replayDialParams.ObfuscatedQUICNonceTransformerParameters != nil &&
+			!reflect.DeepEqual(replayDialParams.ObfuscatedQUICNonceTransformerParameters, dialParams.ObfuscatedQUICNonceTransformerParameters)) {
+		t.Fatalf("mismatching ObfuscatedQUICNonceTransformerParameters fields")
+	}
+
 	// Test: no replay after change tactics
 	// Test: no replay after change tactics
 
 
 	applyParameters[parameters.ReplayDialParametersTTL] = "1s"
 	applyParameters[parameters.ReplayDialParametersTTL] = "1s"
@@ -806,3 +825,82 @@ func TestMakeHTTPTransformerParameters(t *testing.T) {
 		})
 		})
 	}
 	}
 }
 }
+
+func TestMakeOSSHObfuscatorSeedTranformerParameters(t *testing.T) {
+
+	type test struct {
+		name                  string
+		paramValues           map[string]interface{}
+		expectedTransformName string
+		expectedTransformSpec transforms.Spec
+	}
+
+	tests := []test{
+		{
+			name: "transform",
+			paramValues: map[string]interface{}{
+				"OSSHObfuscatorSeedTransformProbability": 1,
+				"OSSHObfuscatorSeedTransformSpecs": transforms.Specs{
+					"spec1": {{"A", "B"}},
+				},
+				"OSSHObfuscatorSeedTransformScopedSpecNames": transforms.ScopedSpecNames{
+					"": {"spec1"},
+				},
+			},
+			expectedTransformName: "spec1",
+			expectedTransformSpec: [][2]string{{"A", "B"}},
+		},
+		{
+			name: "no transform, coinflip false",
+			paramValues: map[string]interface{}{
+				"OSSHObfuscatorSeedTransformProbability": 0,
+				"OSSHObfuscatorSeedTransformSpecs": transforms.Specs{
+					"spec1": {{"A", "B"}},
+				},
+				"OSSHObfuscatorSeedTransformScopedSpecNames": transforms.ScopedSpecNames{
+					"": {"spec1"},
+				},
+			},
+			expectedTransformName: "",
+			expectedTransformSpec: nil,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+
+			params, err := parameters.NewParameters(nil)
+			if err != nil {
+				t.Fatalf("parameters.NewParameters failed: %v", err)
+			}
+
+			_, err = params.Set("", false, tt.paramValues)
+			if err != nil {
+				t.Fatalf("params.Set failed: %v", err)
+			}
+
+			transformerParams, err := makeSeedTransformerParameters(
+				params.Get(),
+				parameters.OSSHObfuscatorSeedTransformProbability,
+				parameters.OSSHObfuscatorSeedTransformSpecs,
+				parameters.OSSHObfuscatorSeedTransformScopedSpecNames)
+
+			if err != nil {
+				t.Fatalf("makeSeedTransformerParameters failed: %v", err)
+			}
+			if transformerParams.TransformName != tt.expectedTransformName {
+				t.Fatalf("expected TransformName \"%s\" but got \"%s\"", tt.expectedTransformName, transformerParams.TransformName)
+			}
+			if !reflect.DeepEqual(transformerParams.TransformSpec, tt.expectedTransformSpec) {
+				t.Fatalf("expected TransformSpec %v but got %v", tt.expectedTransformSpec, transformerParams.TransformSpec)
+			}
+			if transformerParams.TransformSpec != nil {
+				if transformerParams.TransformSeed == nil {
+					t.Fatalf("expected non-nil seed")
+				}
+			}
+
+		})
+	}
+
+}

+ 2 - 0
psiphon/tunnel.go

@@ -775,6 +775,7 @@ func dialTunnel(
 			dialParams.QUICClientHelloSeed,
 			dialParams.QUICClientHelloSeed,
 			dialParams.ServerEntry.SshObfuscatedKey,
 			dialParams.ServerEntry.SshObfuscatedKey,
 			dialParams.ObfuscatedQUICPaddingSeed,
 			dialParams.ObfuscatedQUICPaddingSeed,
+			dialParams.ObfuscatedQUICNonceTransformerParameters,
 			dialParams.QUICDisablePathMTUDiscovery)
 			dialParams.QUICDisablePathMTUDiscovery)
 		if err != nil {
 		if err != nil {
 			return nil, errors.Trace(err)
 			return nil, errors.Trace(err)
@@ -981,6 +982,7 @@ func dialTunnel(
 			throttledConn,
 			throttledConn,
 			dialParams.ServerEntry.SshObfuscatedKey,
 			dialParams.ServerEntry.SshObfuscatedKey,
 			dialParams.ObfuscatorPaddingSeed,
 			dialParams.ObfuscatorPaddingSeed,
+			dialParams.OSSHObfuscatorSeedTransformerParameters,
 			&obfuscatedSSHMinPadding,
 			&obfuscatedSSHMinPadding,
 			&obfuscatedSSHMaxPadding)
 			&obfuscatedSSHMaxPadding)
 		if err != nil {
 		if err != nil {