Преглед изворни кода

Add dial parameters and replay tests; related bug fixes

Rod Hynes пре 7 година
родитељ
комит
a6ab92f9a2
4 измењених фајлова са 653 додато и 75 уклоњено
  1. 62 0
      psiphon/common/prng/prng_test.go
  2. 6 5
      psiphon/dataStore.go
  3. 72 70
      psiphon/dialParameters.go
  4. 513 0
      psiphon/dialParameters_test.go

+ 62 - 0
psiphon/common/prng/prng_test.go

@@ -20,6 +20,7 @@
 package prng
 
 import (
+	"bytes"
 	crypto_rand "crypto/rand"
 	"fmt"
 	"math"
@@ -28,6 +29,67 @@ import (
 	"time"
 )
 
+func TestSeed(t *testing.T) {
+
+	seed, err := NewSeed()
+	if err != nil {
+		t.Fatalf("NewSeed failed: %s", err)
+	}
+
+	prng1 := NewPRNGWithSeed(seed)
+	prng2 := NewPRNGWithSeed(seed)
+
+	for i := 1; i < 10000; i++ {
+
+		bytes1 := make([]byte, i)
+		prng1.Read(bytes1)
+
+		bytes2 := make([]byte, i)
+		prng2.Read(bytes2)
+
+		zeroes := make([]byte, i)
+		if 0 == bytes.Compare(zeroes, bytes1) {
+			t.Fatalf("unexpected zero bytes")
+		}
+
+		if 0 != bytes.Compare(bytes1, bytes2) {
+			t.Fatalf("unexpected different bytes")
+		}
+	}
+
+	prng1 = NewPRNGWithSeed(seed)
+
+	prng3, err := NewPRNGWithSaltedSeed(seed, "3")
+	if err != nil {
+		t.Fatalf("NewPRNGWithSaltedSeed failed: %s", err)
+	}
+
+	prng4, err := NewPRNGWithSaltedSeed(seed, "4")
+	if err != nil {
+		t.Fatalf("NewPRNGWithSaltedSeed failed: %s", err)
+	}
+
+	for i := 1; i < 10000; i++ {
+
+		bytes1 := make([]byte, i)
+		prng1.Read(bytes1)
+
+		bytes3 := make([]byte, i)
+		prng3.Read(bytes3)
+
+		bytes4 := make([]byte, i)
+		prng4.Read(bytes4)
+
+		if 0 == bytes.Compare(bytes1, bytes3) {
+			t.Fatalf("unexpected identical bytes")
+		}
+
+		if 0 == bytes.Compare(bytes3, bytes4) {
+			t.Fatalf("unexpected identical bytes")
+		}
+	}
+}
+
 func TestFlipWeightedCoin(t *testing.T) {
 
 	runs := 100000

+ 6 - 5
psiphon/dataStore.go

@@ -324,12 +324,13 @@ func hasServerEntryFilterChanged(config *Config) (bool, error) {
 	changed := false
 	err = datastoreView(func(tx *datastoreTx) error {
 
-		// previousFilter will be nil not found (not previously
-		// set) which will never match any current filter.
-
 		bucket := tx.bucket(datastoreKeyValueBucket)
 		previousFilter := bucket.get(datastoreLastServerEntryFilterKey)
-		if bytes.Compare(previousFilter, currentFilter) != 0 {
+
+		// When not found, previousFilter will be nil; ensure this
+		// results in "changed", even if currentFilter is len(0).
+		if previousFilter == nil ||
+			bytes.Compare(previousFilter, currentFilter) != 0 {
 			changed = true
 		}
 		return nil
@@ -556,7 +557,7 @@ func (iterator *ServerEntryIterator) reset(isInitialRound bool) error {
 					}
 				}
 				for ; i < j; j-- {
-					key := makeDialParametersKey(serverEntryIDs[i], networkID)
+					key := makeDialParametersKey(serverEntryIDs[j], networkID)
 					if dialParamsBucket.get(key) != nil {
 						break
 					}

+ 72 - 70
psiphon/dialParameters.go

@@ -175,7 +175,9 @@ func MakeDialParameters(
 		// In this case, existing dial parameters are invalid and cleared.
 
 		err = DeleteDialParameters(serverEntry.IpAddress, networkID)
-		NoticeAlert("DeleteDialParameters failed: %s", err)
+		if err != nil {
+			NoticeAlert("DeleteDialParameters failed: %s", err)
+		}
 		dialParams = nil
 	}
 
@@ -215,7 +217,7 @@ func MakeDialParameters(
 
 	if !isReplay {
 
-		// TODO: should there be a pre-check  of selectProtocol before incurring
+		// TODO: should there be a pre-check of selectProtocol before incurring
 		// overhead of unmarshaling dial parameters? In may be that a server entry
 		// is fully incapable of satisfying the current protocol selection
 		// constraints.
@@ -304,11 +306,11 @@ func MakeDialParameters(
 			if p.WeightedCoinFlip(parameters.TransformHostNameProbability) {
 				hostname = common.GenerateHostName()
 				dialParams.MeekTransformedHostName = true
-				if serverEntry.MeekServerPort == 80 {
-					dialParams.MeekHostHeader = hostname
-				} else {
-					dialParams.MeekHostHeader = fmt.Sprintf("%s:%d", hostname, serverEntry.MeekServerPort)
-				}
+			}
+			if serverEntry.MeekServerPort == 80 {
+				dialParams.MeekHostHeader = hostname
+			} else {
+				dialParams.MeekHostHeader = fmt.Sprintf("%s:%d", hostname, serverEntry.MeekServerPort)
 			}
 		}
 	}
@@ -344,92 +346,92 @@ func MakeDialParameters(
 		}
 	}
 
-	if !isReplay || !replayFronting || !replayHostname {
-
-		switch dialParams.TunnelProtocol {
+	// Set dial address fields. This portion of configuration is
+	// deterministic, given the parameters established or replayed so far.
 
-		case protocol.TUNNEL_PROTOCOL_SSH:
-			dialParams.DirectDialAddress = fmt.Sprintf("%s:%d", serverEntry.IpAddress, serverEntry.SshPort)
+	switch dialParams.TunnelProtocol {
 
-		case protocol.TUNNEL_PROTOCOL_OBFUSCATED_SSH:
-			dialParams.DirectDialAddress = fmt.Sprintf("%s:%d", serverEntry.IpAddress, serverEntry.SshObfuscatedPort)
+	case protocol.TUNNEL_PROTOCOL_SSH:
+		dialParams.DirectDialAddress = fmt.Sprintf("%s:%d", serverEntry.IpAddress, serverEntry.SshPort)
 
-		case protocol.TUNNEL_PROTOCOL_TAPDANCE_OBFUSCATED_SSH:
-			dialParams.DirectDialAddress = fmt.Sprintf("%s:%d", serverEntry.IpAddress, serverEntry.SshObfuscatedTapdancePort)
+	case protocol.TUNNEL_PROTOCOL_OBFUSCATED_SSH:
+		dialParams.DirectDialAddress = fmt.Sprintf("%s:%d", serverEntry.IpAddress, serverEntry.SshObfuscatedPort)
 
-		case protocol.TUNNEL_PROTOCOL_QUIC_OBFUSCATED_SSH:
-			dialParams.DirectDialAddress = fmt.Sprintf("%s:%d", serverEntry.IpAddress, serverEntry.SshObfuscatedQUICPort)
+	case protocol.TUNNEL_PROTOCOL_TAPDANCE_OBFUSCATED_SSH:
+		dialParams.DirectDialAddress = fmt.Sprintf("%s:%d", serverEntry.IpAddress, serverEntry.SshObfuscatedTapdancePort)
 
-		case protocol.TUNNEL_PROTOCOL_MARIONETTE_OBFUSCATED_SSH:
-			// Note: port comes from marionnete "format"
-			dialParams.DirectDialAddress = serverEntry.IpAddress
+	case protocol.TUNNEL_PROTOCOL_QUIC_OBFUSCATED_SSH:
+		dialParams.DirectDialAddress = fmt.Sprintf("%s:%d", serverEntry.IpAddress, serverEntry.SshObfuscatedQUICPort)
 
-		case protocol.TUNNEL_PROTOCOL_FRONTED_MEEK:
-			dialParams.MeekDialAddress = fmt.Sprintf("%s:443", dialParams.MeekFrontingDialAddress)
-			dialParams.MeekHostHeader = dialParams.MeekFrontingHost
-			if serverEntry.MeekFrontingDisableSNI {
-				dialParams.MeekSNIServerName = ""
-			} else if !dialParams.MeekTransformedHostName {
-				dialParams.MeekSNIServerName = dialParams.MeekFrontingDialAddress
-			}
+	case protocol.TUNNEL_PROTOCOL_MARIONETTE_OBFUSCATED_SSH:
+		// Note: port comes from marionnete "format"
+		dialParams.DirectDialAddress = serverEntry.IpAddress
 
-		case protocol.TUNNEL_PROTOCOL_FRONTED_MEEK_HTTP:
-			dialParams.MeekDialAddress = fmt.Sprintf("%s:80", dialParams.MeekFrontingDialAddress)
-			dialParams.MeekHostHeader = dialParams.MeekFrontingHost
-
-		case protocol.TUNNEL_PROTOCOL_UNFRONTED_MEEK:
-			dialParams.MeekDialAddress = fmt.Sprintf("%s:%d", serverEntry.IpAddress, serverEntry.MeekServerPort)
-			if !dialParams.MeekTransformedHostName {
-				if serverEntry.MeekServerPort == 80 {
-					dialParams.MeekHostHeader = serverEntry.IpAddress
-				} else {
-					dialParams.MeekHostHeader = dialParams.MeekDialAddress
-				}
-			}
+	case protocol.TUNNEL_PROTOCOL_FRONTED_MEEK:
+		dialParams.MeekDialAddress = fmt.Sprintf("%s:443", dialParams.MeekFrontingDialAddress)
+		dialParams.MeekHostHeader = dialParams.MeekFrontingHost
+		if serverEntry.MeekFrontingDisableSNI {
+			dialParams.MeekSNIServerName = ""
+		} else if !dialParams.MeekTransformedHostName {
+			dialParams.MeekSNIServerName = dialParams.MeekFrontingDialAddress
+		}
 
-		case protocol.TUNNEL_PROTOCOL_UNFRONTED_MEEK_HTTPS,
-			protocol.TUNNEL_PROTOCOL_UNFRONTED_MEEK_SESSION_TICKET:
+	case protocol.TUNNEL_PROTOCOL_FRONTED_MEEK_HTTP:
+		dialParams.MeekDialAddress = fmt.Sprintf("%s:80", dialParams.MeekFrontingDialAddress)
+		dialParams.MeekHostHeader = dialParams.MeekFrontingHost
 
-			dialParams.MeekDialAddress = fmt.Sprintf("%s:%d", serverEntry.IpAddress, serverEntry.MeekServerPort)
-			if !dialParams.MeekTransformedHostName {
-				// Note: IP address in SNI field will be omitted.
-				dialParams.MeekSNIServerName = serverEntry.IpAddress
-			}
-			if serverEntry.MeekServerPort == 443 {
+	case protocol.TUNNEL_PROTOCOL_UNFRONTED_MEEK:
+		dialParams.MeekDialAddress = fmt.Sprintf("%s:%d", serverEntry.IpAddress, serverEntry.MeekServerPort)
+		if !dialParams.MeekTransformedHostName {
+			if serverEntry.MeekServerPort == 80 {
 				dialParams.MeekHostHeader = serverEntry.IpAddress
 			} else {
 				dialParams.MeekHostHeader = dialParams.MeekDialAddress
 			}
+		}
 
-		default:
-			return nil, common.ContextError(
-				fmt.Errorf("unknown tunnel protocol: %s", dialParams.TunnelProtocol))
+	case protocol.TUNNEL_PROTOCOL_UNFRONTED_MEEK_HTTPS,
+		protocol.TUNNEL_PROTOCOL_UNFRONTED_MEEK_SESSION_TICKET:
 
+		dialParams.MeekDialAddress = fmt.Sprintf("%s:%d", serverEntry.IpAddress, serverEntry.MeekServerPort)
+		if !dialParams.MeekTransformedHostName {
+			// Note: IP address in SNI field will be omitted.
+			dialParams.MeekSNIServerName = serverEntry.IpAddress
+		}
+		if serverEntry.MeekServerPort == 443 {
+			dialParams.MeekHostHeader = serverEntry.IpAddress
+		} else {
+			dialParams.MeekHostHeader = dialParams.MeekDialAddress
 		}
 
-		if protocol.TunnelProtocolUsesMeek(dialParams.TunnelProtocol) {
+	default:
+		return nil, common.ContextError(
+			fmt.Errorf("unknown tunnel protocol: %s", dialParams.TunnelProtocol))
 
-			host, port, _ := net.SplitHostPort(dialParams.MeekDialAddress)
+	}
 
-			if p.Bool(parameters.MeekDialDomainsOnly) {
-				if net.ParseIP(host) != nil {
-					// No error, as this is a "not supported" case.
-					return nil, nil
-				}
-			}
+	if protocol.TunnelProtocolUsesMeek(dialParams.TunnelProtocol) {
 
-			dialParams.DialPortNumber = port
+		host, port, _ := net.SplitHostPort(dialParams.MeekDialAddress)
 
-			// The underlying TLS will automatically disable SNI for IP address server name
-			// values; we have this explicit check here so we record the correct value for stats.
-			if net.ParseIP(dialParams.MeekSNIServerName) != nil {
-				dialParams.MeekSNIServerName = ""
+		if p.Bool(parameters.MeekDialDomainsOnly) {
+			if net.ParseIP(host) != nil {
+				// No error, as this is a "not supported" case.
+				return nil, nil
 			}
+		}
 
-		} else {
+		dialParams.DialPortNumber = port
 
-			_, dialParams.DialPortNumber, _ = net.SplitHostPort(dialParams.DirectDialAddress)
+		// The underlying TLS will automatically disable SNI for IP address server name
+		// values; we have this explicit check here so we record the correct value for stats.
+		if net.ParseIP(dialParams.MeekSNIServerName) != nil {
+			dialParams.MeekSNIServerName = ""
 		}
+
+	} else {
+
+		_, dialParams.DialPortNumber, _ = net.SplitHostPort(dialParams.DirectDialAddress)
 	}
 
 	// Initialize/replay User-Agent header for HTTP upstream proxy and meek protocols.
@@ -519,7 +521,7 @@ func (dialParams *DialParameters) GetMeekConfig() *MeekConfig {
 }
 
 func (dialParams *DialParameters) Succeeded() {
-	NoticeAlert("Set dial parameters for %s", dialParams.ServerEntry.IpAddress)
+	NoticeInfo("Set dial parameters for %s", dialParams.ServerEntry.IpAddress)
 	err := SetDialParameters(dialParams.ServerEntry.IpAddress, dialParams.NetworkID, dialParams)
 	if err != nil {
 		NoticeAlert("SetDialParameters failed: %s", err)
@@ -537,7 +539,7 @@ func (dialParams *DialParameters) Failed() {
 	// exercised and may still be efective.
 
 	if dialParams.IsReplay {
-		NoticeAlert("Delete dial parameters for %s", dialParams.ServerEntry.IpAddress)
+		NoticeInfo("Delete dial parameters for %s", dialParams.ServerEntry.IpAddress)
 		err := DeleteDialParameters(dialParams.ServerEntry.IpAddress, dialParams.NetworkID)
 		if err != nil {
 			NoticeAlert("DeleteDialParameters failed: %s", err)

+ 513 - 0
psiphon/dialParameters_test.go

@@ -0,0 +1,513 @@
+/*
+ * 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 (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"os"
+	"testing"
+	"time"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/parameters"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
+)
+
+func TestDialParametersAndReplay(t *testing.T) {
+	for _, tunnelProtocol := range protocol.SupportedTunnelProtocols {
+		if !common.Contains(protocol.DefaultDisabledTunnelProtocols, tunnelProtocol) {
+			runDialParametersAndReplay(t, tunnelProtocol)
+		}
+	}
+}
+
+var testNetworkID = prng.HexString(8)
+
+type testNetworkGetter struct {
+}
+
+func (t *testNetworkGetter) GetNetworkID() string {
+	return testNetworkID
+}
+
+func runDialParametersAndReplay(t *testing.T, tunnelProtocol string) {
+
+	t.Logf("Test %s...", tunnelProtocol)
+
+	SetNoticeWriter(ioutil.Discard)
+
+	testDataDirName, err := ioutil.TempDir("", "psiphon-dial-parameters-test")
+	if err != nil {
+		t.Fatalf("TempDir failed: %s", err)
+	}
+	defer os.RemoveAll(testDataDirName)
+
+	clientConfig := &Config{
+		PropagationChannelId: "0",
+		SponsorId:            "0",
+		DataStoreDirectory:   testDataDirName,
+		NetworkIDGetter:      new(testNetworkGetter),
+	}
+
+	err = clientConfig.Commit()
+	if err != nil {
+		t.Fatalf("error committing configuration file: %s", err)
+	}
+
+	applyParameters := make(map[string]interface{})
+	applyParameters[parameters.TransformHostNameProbability] = 1.0
+	applyParameters[parameters.PickUserAgentProbability] = 1.0
+	err = clientConfig.SetClientParameters("tag1", true, applyParameters)
+	if err != nil {
+		t.Fatalf("SetClientParameters failed: %s", err)
+	}
+
+	err = OpenDataStore(clientConfig)
+	if err != nil {
+		t.Fatalf("error initializing client datastore: %s", err)
+	}
+	defer CloseDataStore()
+
+	serverEntries := makeMockServerEntries(tunnelProtocol, 100)
+
+	canReplay := func(serverEntry *protocol.ServerEntry, replayProtocol string) bool {
+		return replayProtocol == tunnelProtocol
+	}
+
+	selectProtocol := func(serverEntry *protocol.ServerEntry) (string, bool) {
+		return tunnelProtocol, true
+	}
+
+	RegisterSSHClientVersionPicker(func() string {
+		versions := []string{"v1", "v2", "v3"}
+		return versions[prng.Intn(len(versions))]
+	})
+
+	RegisterUserAgentPicker(func() string {
+		versions := []string{"ua1", "ua2", "ua3"}
+		return versions[prng.Intn(len(versions))]
+	})
+
+	// Test: expected dial parameter fields set
+
+	dialParams, err := MakeDialParameters(clientConfig, canReplay, selectProtocol, serverEntries[0], false)
+	if err != nil {
+		t.Fatalf("MakeDialParameters failed: %s", err)
+	}
+
+	if dialParams.ServerEntry != serverEntries[0] {
+		t.Fatalf("unexpected server entry")
+	}
+
+	if dialParams.NetworkID != testNetworkID {
+		t.Fatalf("unexpected network ID")
+	}
+
+	if dialParams.IsReplay {
+		t.Fatalf("unexpected replay")
+	}
+
+	if dialParams.TunnelProtocol != tunnelProtocol {
+		t.Fatalf("unexpected tunnel protocol")
+	}
+
+	if !protocol.TunnelProtocolUsesMeek(tunnelProtocol) &&
+		dialParams.DirectDialAddress == "" {
+		t.Fatalf("missing direct dial fields")
+	}
+
+	if dialParams.DialPortNumber == "" {
+		t.Fatalf("missing port number fields")
+	}
+
+	if dialParams.SSHClientVersion == "" || dialParams.SSHKEXSeed == nil {
+		t.Fatalf("missing SSH fields")
+	}
+
+	if protocol.TunnelProtocolUsesObfuscatedSSH(tunnelProtocol) &&
+		dialParams.ObfuscatorPaddingSeed == nil {
+		t.Fatalf("missing obfuscator fields")
+	}
+
+	if dialParams.FragmentorSeed == nil {
+		t.Fatalf("missing fragmentor field")
+	}
+
+	if protocol.TunnelProtocolUsesMeek(tunnelProtocol) &&
+		(dialParams.MeekDialAddress == "" ||
+			dialParams.MeekHostHeader == "" ||
+			dialParams.MeekObfuscatorPaddingSeed == nil) {
+		t.Fatalf("missing meek fields")
+	}
+
+	if protocol.TunnelProtocolUsesFrontedMeek(tunnelProtocol) &&
+		(dialParams.MeekFrontingDialAddress == "" ||
+			dialParams.MeekFrontingHost == "") {
+		t.Fatalf("missing meek fronting fields")
+	}
+
+	if protocol.TunnelProtocolUsesMeekHTTP(tunnelProtocol) &&
+		dialParams.UserAgent == "" {
+		t.Fatalf("missing meek HTTP fields")
+	}
+
+	if protocol.TunnelProtocolUsesMeekHTTPS(tunnelProtocol) &&
+		(dialParams.MeekSNIServerName == "" ||
+			dialParams.TLSProfile == "") {
+		t.Fatalf("missing meek HTTPS fields")
+	}
+
+	if protocol.TunnelProtocolUsesQUIC(tunnelProtocol) &&
+		(dialParams.QUICVersion == "" ||
+			dialParams.QUICDialSNIAddress == "") {
+		t.Fatalf("missing meek HTTPS fields")
+	}
+
+	if dialParams.LivenessTestSeed == nil {
+		t.Fatalf("missing liveness test fields")
+	}
+
+	if dialParams.APIRequestPaddingSeed == nil {
+		t.Fatalf("missing API request fields")
+	}
+
+	// Test: no replay after dial reported to fail
+
+	dialParams.Failed()
+
+	dialParams, err = MakeDialParameters(clientConfig, canReplay, selectProtocol, serverEntries[0], false)
+	if err != nil {
+		t.Fatalf("MakeDialParameters failed: %s", err)
+	}
+
+	if dialParams.IsReplay {
+		t.Fatalf("unexpected replay")
+	}
+
+	// Test: no replay after network ID changes
+
+	dialParams.Succeeded()
+
+	testNetworkID = prng.HexString(8)
+
+	dialParams, err = MakeDialParameters(clientConfig, canReplay, selectProtocol, serverEntries[0], false)
+	if err != nil {
+		t.Fatalf("MakeDialParameters failed: %s", err)
+	}
+
+	if dialParams.NetworkID != testNetworkID {
+		t.Fatalf("unexpected network ID")
+	}
+
+	if dialParams.IsReplay {
+		t.Fatalf("unexpected replay")
+	}
+
+	// Test: replay after dial reported to succeed, and replay fields match previous dial parameters
+
+	dialParams.Succeeded()
+
+	replayDialParams, err := MakeDialParameters(clientConfig, canReplay, selectProtocol, serverEntries[0], false)
+	if err != nil {
+		t.Fatalf("MakeDialParameters failed: %s", err)
+	}
+
+	if !replayDialParams.IsReplay {
+		t.Fatalf("unexpected non-replay")
+	}
+
+	if !replayDialParams.LastUsedTimestamp.After(dialParams.LastUsedTimestamp) {
+		t.Fatalf("unexpected non-updated timestamp")
+	}
+
+	if replayDialParams.TunnelProtocol != dialParams.TunnelProtocol {
+		t.Fatalf("mismatching tunnel protocol")
+	}
+
+	if replayDialParams.DirectDialAddress != dialParams.DirectDialAddress ||
+		replayDialParams.DialPortNumber != dialParams.DialPortNumber {
+		t.Fatalf("mismatching dial fields")
+	}
+
+	identicalSeeds := func(seed1, seed2 *prng.Seed) bool {
+		if seed1 == nil {
+			return seed2 == nil
+		}
+		return bytes.Compare(seed1[:], seed2[:]) == 0
+	}
+
+	if replayDialParams.SelectedSSHClientVersion != dialParams.SelectedSSHClientVersion ||
+		replayDialParams.SSHClientVersion != dialParams.SSHClientVersion ||
+		!identicalSeeds(replayDialParams.SSHKEXSeed, dialParams.SSHKEXSeed) {
+		t.Fatalf("mismatching SSH fields")
+	}
+
+	if !identicalSeeds(replayDialParams.ObfuscatorPaddingSeed, dialParams.ObfuscatorPaddingSeed) {
+		t.Fatalf("mismatching obfuscator fields")
+	}
+
+	if !identicalSeeds(replayDialParams.FragmentorSeed, dialParams.FragmentorSeed) {
+		t.Fatalf("mismatching fragmentor fields")
+	}
+
+	if replayDialParams.MeekFrontingDialAddress != dialParams.MeekFrontingDialAddress ||
+		replayDialParams.MeekFrontingHost != dialParams.MeekFrontingHost ||
+		replayDialParams.MeekDialAddress != dialParams.MeekDialAddress ||
+		replayDialParams.MeekTransformedHostName != dialParams.MeekTransformedHostName ||
+		replayDialParams.MeekSNIServerName != dialParams.MeekSNIServerName ||
+		replayDialParams.MeekHostHeader != dialParams.MeekHostHeader ||
+		!identicalSeeds(replayDialParams.MeekObfuscatorPaddingSeed, dialParams.MeekObfuscatorPaddingSeed) {
+		t.Fatalf("mismatching meek fields")
+	}
+
+	if replayDialParams.SelectedUserAgent != dialParams.SelectedUserAgent ||
+		replayDialParams.UserAgent != dialParams.UserAgent {
+		t.Fatalf("mismatching user agent fields")
+	}
+
+	if replayDialParams.SelectedTLSProfile != dialParams.SelectedTLSProfile ||
+		replayDialParams.TLSProfile != dialParams.TLSProfile ||
+		!identicalSeeds(replayDialParams.RandomizedTLSProfileSeed, dialParams.RandomizedTLSProfileSeed) {
+		t.Fatalf("mismatching TLS fields")
+	}
+
+	if replayDialParams.QUICVersion != dialParams.QUICVersion ||
+		replayDialParams.QUICDialSNIAddress != dialParams.QUICDialSNIAddress ||
+		!identicalSeeds(replayDialParams.ObfuscatedQUICPaddingSeed, dialParams.ObfuscatedQUICPaddingSeed) {
+		t.Fatalf("mismatching QUIC fields")
+	}
+
+	if !identicalSeeds(replayDialParams.LivenessTestSeed, dialParams.LivenessTestSeed) {
+		t.Fatalf("mismatching liveness test fields")
+	}
+
+	if !identicalSeeds(replayDialParams.APIRequestPaddingSeed, dialParams.APIRequestPaddingSeed) {
+		t.Fatalf("mismatching API request fields")
+	}
+
+	// Test: no replay after change tactics
+
+	applyParameters[parameters.ReplayDialParametersTTL] = "1s"
+	err = clientConfig.SetClientParameters("tag2", true, applyParameters)
+	if err != nil {
+		t.Fatalf("SetClientParameters failed: %s", err)
+	}
+
+	dialParams, err = MakeDialParameters(clientConfig, canReplay, selectProtocol, serverEntries[0], false)
+	if err != nil {
+		t.Fatalf("MakeDialParameters failed: %s", err)
+	}
+
+	if dialParams.IsReplay {
+		t.Fatalf("unexpected replay")
+	}
+
+	// Test: no replay after dial parameters expired
+
+	dialParams.Succeeded()
+
+	time.Sleep(1 * time.Second)
+
+	dialParams, err = MakeDialParameters(clientConfig, canReplay, selectProtocol, serverEntries[0], false)
+	if err != nil {
+		t.Fatalf("MakeDialParameters failed: %s", err)
+	}
+
+	if dialParams.IsReplay {
+		t.Fatalf("unexpected replay")
+	}
+
+	// Test: no replay after server entry changes
+
+	dialParams.Succeeded()
+
+	serverEntries[0].ConfigurationVersion += 1
+
+	dialParams, err = MakeDialParameters(clientConfig, canReplay, selectProtocol, serverEntries[0], false)
+	if err != nil {
+		t.Fatalf("MakeDialParameters failed: %s", err)
+	}
+
+	if dialParams.IsReplay {
+		t.Fatalf("unexpected replay")
+	}
+
+	// Test: disable replay elements (partial coverage)
+
+	applyParameters[parameters.ReplayDialParametersTTL] = "24h"
+	applyParameters[parameters.ReplaySSH] = false
+	applyParameters[parameters.ReplayObfuscatorPadding] = false
+	applyParameters[parameters.ReplayFragmentor] = false
+	applyParameters[parameters.ReplayRandomizedTLSProfile] = false
+	applyParameters[parameters.ReplayObfuscatedQUIC] = false
+	applyParameters[parameters.ReplayLivenessTest] = false
+	applyParameters[parameters.ReplayAPIRequestPadding] = false
+	err = clientConfig.SetClientParameters("tag3", true, applyParameters)
+	if err != nil {
+		t.Fatalf("SetClientParameters failed: %s", err)
+	}
+
+	dialParams, err = MakeDialParameters(clientConfig, canReplay, selectProtocol, serverEntries[0], false)
+	if err != nil {
+		t.Fatalf("MakeDialParameters failed: %s", err)
+	}
+
+	dialParams.Succeeded()
+
+	replayDialParams, err = MakeDialParameters(clientConfig, canReplay, selectProtocol, serverEntries[0], false)
+	if err != nil {
+		t.Fatalf("MakeDialParameters failed: %s", err)
+	}
+
+	if !replayDialParams.IsReplay {
+		t.Fatalf("unexpected non-replay")
+	}
+
+	if identicalSeeds(replayDialParams.SSHKEXSeed, dialParams.SSHKEXSeed) ||
+		(protocol.TunnelProtocolUsesObfuscatedSSH(tunnelProtocol) &&
+			identicalSeeds(replayDialParams.ObfuscatorPaddingSeed, dialParams.ObfuscatorPaddingSeed)) ||
+		identicalSeeds(replayDialParams.FragmentorSeed, dialParams.FragmentorSeed) ||
+		(protocol.TunnelProtocolUsesMeek(tunnelProtocol) &&
+			identicalSeeds(replayDialParams.MeekObfuscatorPaddingSeed, dialParams.MeekObfuscatorPaddingSeed)) ||
+		(protocol.TunnelProtocolUsesMeekHTTPS(tunnelProtocol) &&
+			identicalSeeds(replayDialParams.RandomizedTLSProfileSeed, dialParams.RandomizedTLSProfileSeed) &&
+			replayDialParams.RandomizedTLSProfileSeed != nil) ||
+		(protocol.TunnelProtocolUsesQUIC(tunnelProtocol) &&
+			identicalSeeds(replayDialParams.ObfuscatedQUICPaddingSeed, dialParams.ObfuscatedQUICPaddingSeed) &&
+			replayDialParams.ObfuscatedQUICPaddingSeed != nil) ||
+		identicalSeeds(replayDialParams.LivenessTestSeed, dialParams.LivenessTestSeed) ||
+		identicalSeeds(replayDialParams.APIRequestPaddingSeed, dialParams.APIRequestPaddingSeed) {
+		t.Fatalf("unexpected replayed fields")
+	}
+
+	// Test: iterator shuffles
+
+	for i, serverEntry := range serverEntries {
+
+		data, err := json.Marshal(serverEntry)
+		if err != nil {
+			t.Fatalf("json.Marshal failed: %s", err)
+		}
+
+		var serverEntryFields protocol.ServerEntryFields
+		err = json.Unmarshal(data, &serverEntryFields)
+		if err != nil {
+			t.Fatalf("json.Unmarshal failed: %s", err)
+		}
+
+		err = StoreServerEntry(serverEntryFields, false)
+		if err != nil {
+			t.Fatalf("StoreServerEntry failed: %s", err)
+		}
+
+		if i%10 == 0 {
+
+			dialParams, err := MakeDialParameters(clientConfig, canReplay, selectProtocol, serverEntry, false)
+			if err != nil {
+				t.Fatalf("MakeDialParameters failed: %s", err)
+			}
+
+			dialParams.Succeeded()
+		}
+	}
+
+	for i := 0; i < 5; i++ {
+
+		hasAffinity, iterator, err := NewServerEntryIterator(clientConfig)
+		if err != nil {
+			t.Fatalf("NewServerEntryIterator failed: %s", err)
+		}
+
+		if hasAffinity {
+			t.Fatalf("unexpected affinity server")
+		}
+
+		// Test: the first shuffle should move the replay candidates to the front
+
+		for j := 0; j < 10; j++ {
+
+			serverEntry, err := iterator.Next()
+			if err != nil {
+				t.Fatalf("ServerEntryIterator.Next failed: %s", err)
+			}
+
+			dialParams, err := MakeDialParameters(clientConfig, canReplay, selectProtocol, serverEntry, false)
+			if err != nil {
+				t.Fatalf("MakeDialParameters failed: %s", err)
+			}
+
+			if !dialParams.IsReplay {
+				t.Fatalf("unexpected non-replay")
+			}
+		}
+
+		iterator.Reset()
+
+		// Test: subsequent shuffles should not move the replay candidates
+
+		allReplay := true
+		for j := 0; j < 10; j++ {
+
+			serverEntry, err := iterator.Next()
+			if err != nil {
+				t.Fatalf("ServerEntryIterator.Next failed: %s", err)
+			}
+
+			dialParams, err := MakeDialParameters(clientConfig, canReplay, selectProtocol, serverEntry, false)
+			if err != nil {
+				t.Fatalf("MakeDialParameters failed: %s", err)
+			}
+
+			if !dialParams.IsReplay {
+				allReplay = false
+			}
+		}
+
+		if allReplay {
+			t.Fatalf("unexpected all replay")
+		}
+
+		iterator.Close()
+	}
+}
+
+func makeMockServerEntries(tunnelProtocol string, count int) []*protocol.ServerEntry {
+
+	serverEntries := make([]*protocol.ServerEntry, count)
+
+	for i := 0; i < count; i++ {
+		serverEntries[i] = &protocol.ServerEntry{
+			IpAddress:                  fmt.Sprintf("192.168.0.%d", i),
+			SshPort:                    1,
+			SshObfuscatedPort:          2,
+			SshObfuscatedQUICPort:      3,
+			SshObfuscatedTapdancePort:  4,
+			MeekServerPort:             5,
+			MeekFrontingHosts:          []string{"www1.example.org", "www2.example.org", "www3.example.org"},
+			MeekFrontingAddressesRegex: "[a-z0-9]{1,64}.example.org",
+		}
+	}
+
+	return serverEntries
+}