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

Merge pull request #646 from adotkhan/prefix

OSSH Prefix update
Rod Hynes 2 лет назад
Родитель
Сommit
4de7aadc12

+ 9 - 0
psiphon/common/fragmentor/fragmentor.go

@@ -273,6 +273,15 @@ func (c *Conn) GetReplay() (*prng.Seed, bool) {
 	return seed, c.isReplay
 }
 
+// Stops the fragmentor from fragmenting any further writes.
+func (c *Conn) StopFragmenting() {
+
+	c.writeMutex.Lock()
+	defer c.writeMutex.Unlock()
+
+	c.bytesToFragment = 0
+}
+
 func (c *Conn) Write(buffer []byte) (int, error) {
 
 	c.writeMutex.Lock()

+ 3 - 2
psiphon/common/net.go

@@ -72,11 +72,12 @@ type UnderlyingTCPAddrSource interface {
 	GetUnderlyingTCPAddrs() (*net.TCPAddr, *net.TCPAddr, bool)
 }
 
-// FragmentorReplayAccessor defines the interface for accessing replay properties
+// FragmentorAccessor defines the interface for accessing properties
 // of a fragmentor Conn.
-type FragmentorReplayAccessor interface {
+type FragmentorAccessor interface {
 	SetReplay(*prng.PRNG)
 	GetReplay() (*prng.Seed, bool)
+	StopFragmenting()
 }
 
 // HTTPRoundTripper is an adapter that allows using a function as a

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

@@ -21,11 +21,13 @@ package obfuscator
 
 import (
 	"bytes"
+	"context"
 	"encoding/binary"
 	std_errors "errors"
 	"io"
 	"io/ioutil"
 	"net"
+	"time"
 
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
@@ -60,6 +62,8 @@ const (
 type ObfuscatedSSHConn struct {
 	net.Conn
 	mode            ObfuscatedSSHConnMode
+	runCtx          context.Context
+	stopRunning     context.CancelFunc
 	obfuscator      *Obfuscator
 	readDeobfuscate func([]byte)
 	writeObfuscate  func([]byte)
@@ -129,6 +133,7 @@ func NewObfuscatedSSHConn(
 	obfuscatorSeedTransformerParameters *transforms.ObfuscatorSeedTransformerParameters,
 	clientPrefixSpec *OSSHPrefixSpec,
 	serverPrefixSepcs transforms.Specs,
+	osshPrefixSplitConfig *OSSHPrefixSplitConfig,
 	minPadding, maxPadding *int,
 	seedHistory *SeedHistory,
 	irregularLogger func(
@@ -151,6 +156,7 @@ func NewObfuscatedSSHConn(
 				IsOSSH:                              true,
 				Keyword:                             obfuscationKeyword,
 				ClientPrefixSpec:                    clientPrefixSpec,
+				OSSHPrefixSplitConfig:               osshPrefixSplitConfig,
 				PaddingPRNGSeed:                     obfuscationPaddingPRNGSeed,
 				MinPadding:                          minPadding,
 				MaxPadding:                          maxPadding,
@@ -163,7 +169,7 @@ func NewObfuscatedSSHConn(
 		writeObfuscate = obfuscator.ObfuscateClientToServer
 		writeState = OBFUSCATION_WRITE_STATE_CLIENT_SEND_PREAMBLE
 
-		if obfuscator.prefixHeader != nil {
+		if obfuscator.osshPrefixHeader != nil {
 			// Client expects prefix with terminator from the server.
 			readState = OBFUSCATION_READ_STATE_CLIENT_READ_PREFIX
 		}
@@ -200,9 +206,13 @@ func NewObfuscatedSSHConn(
 		return nil, errors.Trace(err)
 	}
 
+	runCtx, stopRunning := context.WithCancel(context.Background())
+
 	return &ObfuscatedSSHConn{
 		Conn:            conn,
 		mode:            mode,
+		runCtx:          runCtx,
+		stopRunning:     stopRunning,
 		obfuscator:      obfuscator,
 		readDeobfuscate: readDeobfuscate,
 		writeObfuscate:  writeObfuscate,
@@ -224,6 +234,7 @@ func NewClientObfuscatedSSHConn(
 	obfuscationPaddingPRNGSeed *prng.Seed,
 	obfuscatorSeedTransformerParameters *transforms.ObfuscatorSeedTransformerParameters,
 	prefixSpec *OSSHPrefixSpec,
+	osshPrefixSplitConfig *OSSHPrefixSplitConfig,
 	minPadding, maxPadding *int) (*ObfuscatedSSHConn, error) {
 
 	return NewObfuscatedSSHConn(
@@ -234,6 +245,7 @@ func NewClientObfuscatedSSHConn(
 		obfuscatorSeedTransformerParameters,
 		prefixSpec,
 		nil,
+		osshPrefixSplitConfig,
 		minPadding, maxPadding,
 		nil,
 		nil)
@@ -258,11 +270,18 @@ func NewServerObfuscatedSSHConn(
 		nil, nil,
 		nil,
 		serverPrefixSpecs,
+		nil,
 		nil, nil,
 		seedHistory,
 		irregularLogger)
 }
 
+// IsOSSHPrefixedStream returns true if client wrote a prefix to the Obfuscated SSH stream,
+// or the server read a prefixed Obfuscated SSH stream.
+func (conn *ObfuscatedSSHConn) IsOSSHPrefixStream() bool {
+	return conn.obfuscator.osshPrefixHeader != nil
+}
+
 // GetDerivedPRNG creates a new PRNG with a seed derived from the
 // ObfuscatedSSHConn padding seed and distinguished by the salt, which should
 // be a unique identifier for each usage context.
@@ -274,6 +293,27 @@ func (conn *ObfuscatedSSHConn) GetDerivedPRNG(salt string) (*prng.PRNG, error) {
 	return conn.obfuscator.GetDerivedPRNG(salt)
 }
 
+// SetOSSHPrefixSplitConfig sets the OSSHPrefixSplitConfig for the server.
+// This must be called before any data is written.
+func (conn *ObfuscatedSSHConn) SetOSSHPrefixSplitConfig(minDelay, maxDelay time.Duration) error {
+	if conn.mode != OBFUSCATION_CONN_MODE_SERVER {
+		return errors.TraceNew("SetOSSHPrefixSplitConfig() is only valid for server connections")
+	}
+	if conn.writeState != OBFUSCATION_WRITE_STATE_SERVER_SEND_PREFIX_AND_IDENTIFICATION_LINE_PADDING {
+		return errors.TraceNew("SetOSSHPrefixSplitConfig() must be called before any data is written")
+	}
+	seed, err := conn.obfuscator.GetDerivedPRNGSeed("obfuscated-ssh-prefix-split")
+	if err != nil {
+		return errors.Trace(err)
+	}
+	conn.obfuscator.osshPrefixSplitConfig = &OSSHPrefixSplitConfig{
+		Seed:     seed,
+		MinDelay: minDelay,
+		MaxDelay: maxDelay,
+	}
+	return nil
+}
+
 // GetMetrics implements the common.MetricsSource interface.
 func (conn *ObfuscatedSSHConn) GetMetrics() common.LogFields {
 	logFields := make(common.LogFields)
@@ -318,6 +358,11 @@ func (conn *ObfuscatedSSHConn) Write(buffer []byte) (int, error) {
 	return len(buffer), nil
 }
 
+func (conn *ObfuscatedSSHConn) Close() error {
+	conn.stopRunning()
+	return conn.Conn.Close()
+}
+
 // readAndTransform reads and transforms the downstream bytes stream
 // while in an obfucation state. It parses the stream of bytes read
 // looking for the first SSH_MSG_NEWKEYS packet sent from the peer,
@@ -499,9 +544,35 @@ func (conn *ObfuscatedSSHConn) transformAndWrite(buffer []byte) error {
 	// identification line padding (server) are injected before any standard SSH traffic.
 	if conn.writeState == OBFUSCATION_WRITE_STATE_CLIENT_SEND_PREAMBLE {
 
-		preamble := conn.obfuscator.SendPreamble()
+		preamble, prefixLen := conn.obfuscator.SendPreamble()
 
-		_, err := conn.Conn.Write(preamble)
+		// Writes the prefix first, then the rest of the preamble after a delay.
+		_, err := conn.Conn.Write(preamble[:prefixLen])
+		if err != nil {
+			return errors.Trace(err)
+		}
+
+		// Adds random delay defined by OSSH prefix split config.
+		if config := conn.obfuscator.osshPrefixSplitConfig; config != nil {
+			rng := prng.NewPRNGWithSeed(config.Seed)
+			delay := rng.Period(config.MinDelay, config.MaxDelay)
+
+			timer := time.NewTimer(delay)
+
+			var err error
+			select {
+			case <-conn.runCtx.Done():
+				err = conn.runCtx.Err()
+			case <-timer.C:
+			}
+			timer.Stop()
+
+			if err != nil {
+				return errors.Trace(err)
+			}
+		}
+
+		_, err = conn.Conn.Write(preamble[prefixLen:])
 		if err != nil {
 			return errors.Trace(err)
 		}
@@ -512,8 +583,34 @@ func (conn *ObfuscatedSSHConn) transformAndWrite(buffer []byte) error {
 
 		var buffer bytes.Buffer
 
-		if preamble := conn.obfuscator.SendPreamble(); preamble != nil {
-			_, err := buffer.Write(preamble)
+		if preamble, prefixLen := conn.obfuscator.SendPreamble(); preamble != nil {
+			// Prefix bytes are written to the underlying conn immediately, skipping the buffer.
+			_, err := conn.Conn.Write(preamble[:prefixLen])
+			if err != nil {
+				return errors.Trace(err)
+			}
+
+			// Adds random delay defined by OSSH prefix split config.
+			if config := conn.obfuscator.osshPrefixSplitConfig; config != nil {
+				rng := prng.NewPRNGWithSeed(config.Seed)
+				delay := rng.Period(config.MinDelay, config.MaxDelay)
+
+				timer := time.NewTimer(delay)
+
+				var err error
+				select {
+				case <-conn.runCtx.Done():
+					err = conn.runCtx.Err()
+				case <-timer.C:
+				}
+				timer.Stop()
+
+				if err != nil {
+					return errors.Trace(err)
+				}
+			}
+
+			_, err = buffer.Write(preamble[prefixLen:])
 			if err != nil {
 				return errors.Trace(err)
 			}

+ 91 - 52
psiphon/common/obfuscator/obfuscator.go

@@ -27,6 +27,7 @@ import (
 	"encoding/binary"
 	"fmt"
 	"io"
+	"time"
 
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
@@ -67,6 +68,14 @@ type OSSHPrefixHeader struct {
 	SpecName string
 }
 
+// OSSHPrefixSplitConfig are parameters for splitting the
+// preamble into two writes: prefix followed by rest of the preamble.
+type OSSHPrefixSplitConfig struct {
+	Seed     *prng.Seed
+	MinDelay time.Duration
+	MaxDelay time.Duration
+}
+
 // Obfuscator implements the seed message, key derivation, and
 // stream ciphers for:
 // https://github.com/brl/obfuscated-openssh/blob/master/README.obfuscation
@@ -79,9 +88,14 @@ type OSSHPrefixHeader struct {
 type Obfuscator struct {
 	preamble []byte
 
-	// prefixHeader is the prefix header written by the client,
+	// Length of the prefix in the preamble.
+	preambleOSSHPrefixLength int
+
+	// osshPrefixHeader is the prefix header written by the client,
 	// or the prefix header read by the server.
-	prefixHeader *OSSHPrefixHeader
+	osshPrefixHeader *OSSHPrefixHeader
+
+	osshPrefixSplitConfig *OSSHPrefixSplitConfig
 
 	keyword              string
 	paddingLength        int
@@ -97,6 +111,7 @@ type ObfuscatorConfig struct {
 	Keyword                             string
 	ClientPrefixSpec                    *OSSHPrefixSpec
 	ServerPrefixSpecs                   transforms.Specs
+	OSSHPrefixSplitConfig               *OSSHPrefixSplitConfig
 	PaddingPRNGSeed                     *prng.Seed
 	MinPadding                          *int
 	MaxPadding                          *int
@@ -171,7 +186,7 @@ func NewClientObfuscator(
 		maxPadding = *config.MaxPadding
 	}
 
-	preamble, prefixHeader, paddingLength, err := makeClientPreamble(
+	preamble, prefixLen, prefixHeader, paddingLength, err := makeClientPreamble(
 		config.Keyword, config.ClientPrefixSpec,
 		paddingPRNG, minPadding, maxPadding, obfuscatorSeed,
 		clientToServerCipher)
@@ -180,14 +195,16 @@ func NewClientObfuscator(
 	}
 
 	return &Obfuscator{
-		preamble:             preamble,
-		prefixHeader:         prefixHeader,
-		keyword:              config.Keyword,
-		paddingLength:        paddingLength,
-		clientToServerCipher: clientToServerCipher,
-		serverToClientCipher: serverToClientCipher,
-		paddingPRNGSeed:      config.PaddingPRNGSeed,
-		paddingPRNG:          paddingPRNG}, nil
+		preamble:                 preamble,
+		preambleOSSHPrefixLength: prefixLen,
+		osshPrefixHeader:         prefixHeader,
+		osshPrefixSplitConfig:    config.OSSHPrefixSplitConfig,
+		keyword:                  config.Keyword,
+		paddingLength:            paddingLength,
+		clientToServerCipher:     clientToServerCipher,
+		serverToClientCipher:     serverToClientCipher,
+		paddingPRNGSeed:          config.PaddingPRNGSeed,
+		paddingPRNG:              paddingPRNG}, nil
 }
 
 // NewServerObfuscator creates a new Obfuscator, reading a seed message directly
@@ -212,20 +229,22 @@ func NewServerObfuscator(
 		return nil, errors.Trace(err)
 	}
 
-	preamble, err := makeServerPreamble(prefixHeader, config.ServerPrefixSpecs, config.Keyword)
+	preamble, prefixLen, err := makeServerPreamble(prefixHeader, config.ServerPrefixSpecs, config.Keyword)
 	if err != nil {
 		return nil, errors.Trace(err)
 	}
 
 	return &Obfuscator{
-		preamble:             preamble,
-		prefixHeader:         prefixHeader,
-		keyword:              config.Keyword,
-		paddingLength:        -1,
-		clientToServerCipher: clientToServerCipher,
-		serverToClientCipher: serverToClientCipher,
-		paddingPRNGSeed:      paddingPRNGSeed,
-		paddingPRNG:          prng.NewPRNGWithSeed(paddingPRNGSeed),
+		preamble:                 preamble,
+		preambleOSSHPrefixLength: prefixLen,
+		osshPrefixHeader:         prefixHeader,
+		osshPrefixSplitConfig:    config.OSSHPrefixSplitConfig,
+		keyword:                  config.Keyword,
+		paddingLength:            -1,
+		clientToServerCipher:     clientToServerCipher,
+		serverToClientCipher:     serverToClientCipher,
+		paddingPRNGSeed:          paddingPRNGSeed,
+		paddingPRNG:              prng.NewPRNGWithSeed(paddingPRNGSeed),
 	}, nil
 }
 
@@ -237,7 +256,20 @@ func NewServerObfuscator(
 // client, so derived PRNGs may be used to replay sequences post-initial
 // obfuscator message.
 func (obfuscator *Obfuscator) GetDerivedPRNG(salt string) (*prng.PRNG, error) {
-	return prng.NewPRNGWithSaltedSeed(obfuscator.paddingPRNGSeed, salt)
+	seed, err := prng.NewPRNGWithSaltedSeed(obfuscator.paddingPRNGSeed, salt)
+	return seed, errors.Trace(err)
+}
+
+// GetDerivedPRNGSeed creates a new PRNG seed derived from the obfuscator
+// padding seed and distinguished by the salt, which should be a unique
+// identifier for each usage context.
+//
+// For NewServerObfuscator, the obfuscator padding seed is obtained from the
+// client, so derived seeds may be used to replay sequences post-initial
+// obfuscator message.
+func (obfuscator *Obfuscator) GetDerivedPRNGSeed(salt string) (*prng.Seed, error) {
+	seed, err := prng.NewSaltedSeed(obfuscator.paddingPRNGSeed, salt)
+	return seed, errors.Trace(err)
 }
 
 // GetPaddingLength returns the client seed message padding length. Only valid
@@ -248,10 +280,12 @@ func (obfuscator *Obfuscator) GetPaddingLength() int {
 
 // SendPreamble returns the preamble created in NewObfuscatorClient or
 // NewServerObfuscator, removing the reference so that it may be garbage collected.
-func (obfuscator *Obfuscator) SendPreamble() []byte {
+func (obfuscator *Obfuscator) SendPreamble() ([]byte, int) {
 	msg := obfuscator.preamble
+	prefixLen := obfuscator.preambleOSSHPrefixLength
 	obfuscator.preamble = nil
-	return msg
+	obfuscator.preambleOSSHPrefixLength = 0
+	return msg, prefixLen
 }
 
 // ObfuscateClientToServer applies the client RC4 stream to the bytes in buffer.
@@ -341,42 +375,45 @@ func makeClientPreamble(
 	paddingPRNG *prng.PRNG,
 	minPadding, maxPadding int,
 	obfuscatorSeed []byte,
-	clientToServerCipher *rc4.Cipher) ([]byte, *OSSHPrefixHeader, int, error) {
+	clientToServerCipher *rc4.Cipher) ([]byte, int, *OSSHPrefixHeader, int, error) {
 
 	padding := paddingPRNG.Padding(minPadding, maxPadding)
 	buffer := new(bytes.Buffer)
 	magicValueStartIndex := len(obfuscatorSeed)
 
+	prefixLen := 0
+
 	if prefixSpec != nil {
-		// Writes the prefix and terminator to the buffer.
-		prefix, err := makePrefix(prefixSpec, keyword, OBFUSCATE_CLIENT_TO_SERVER_IV)
+		var b []byte
+		var err error
+		b, prefixLen, err = makeTerminatedPrefixWithPadding(prefixSpec, keyword, OBFUSCATE_CLIENT_TO_SERVER_IV)
 		if err != nil {
-			return nil, nil, 0, errors.Trace(err)
+			return nil, 0, nil, 0, errors.Trace(err)
 		}
 
-		_, err = buffer.Write(prefix)
+		_, err = buffer.Write(b)
 		if err != nil {
-			return nil, nil, 0, errors.Trace(err)
+			return nil, 0, nil, 0, errors.Trace(err)
 		}
 
-		magicValueStartIndex += len(prefix)
+		magicValueStartIndex += len(b)
 	}
 
 	err := binary.Write(buffer, binary.BigEndian, obfuscatorSeed)
 	if err != nil {
-		return nil, nil, 0, errors.Trace(err)
+		return nil, 0, nil, 0, errors.Trace(err)
 	}
 	err = binary.Write(buffer, binary.BigEndian, uint32(OBFUSCATE_MAGIC_VALUE))
 	if err != nil {
-		return nil, nil, 0, errors.Trace(err)
+		return nil, 0, nil, 0, errors.Trace(err)
 	}
 	err = binary.Write(buffer, binary.BigEndian, uint32(len(padding)))
 	if err != nil {
-		return nil, nil, 0, errors.Trace(err)
+		return nil, 0, nil, 0, errors.Trace(err)
 	}
 	err = binary.Write(buffer, binary.BigEndian, padding)
 	if err != nil {
-		return nil, nil, 0, errors.Trace(err)
+		return nil, 0, nil, 0, errors.Trace(err)
 	}
 
 	var prefixHeader *OSSHPrefixHeader = nil
@@ -384,7 +421,7 @@ func makeClientPreamble(
 		// Writes the prefix header after the padding.
 		err := prefixSpec.writePrefixHeader(buffer)
 		if err != nil {
-			return nil, nil, 0, errors.Trace(err)
+			return nil, 0, nil, 0, errors.Trace(err)
 		}
 
 		prefixHeader = &OSSHPrefixHeader{
@@ -399,7 +436,7 @@ func makeClientPreamble(
 		preamble[magicValueStartIndex:],
 		preamble[magicValueStartIndex:])
 
-	return preamble, prefixHeader, len(padding), nil
+	return preamble, prefixLen, prefixHeader, len(padding), nil
 }
 
 // makeServerPreamble generates a server preamble (prefix or nil).
@@ -410,10 +447,10 @@ func makeClientPreamble(
 func makeServerPreamble(
 	header *OSSHPrefixHeader,
 	serverSpecs transforms.Specs,
-	keyword string) ([]byte, error) {
+	keyword string) ([]byte, int, error) {
 
 	if header == nil {
-		return nil, nil
+		return nil, 0, nil
 	}
 
 	spec, ok := serverSpecs[header.SpecName]
@@ -424,7 +461,7 @@ func makeServerPreamble(
 
 	seed, err := prng.NewSeed()
 	if err != nil {
-		return nil, errors.Trace(err)
+		return nil, 0, errors.Trace(err)
 	}
 
 	prefixSpec := &OSSHPrefixSpec{
@@ -432,7 +469,7 @@ func makeServerPreamble(
 		Spec: spec,
 		Seed: seed,
 	}
-	return makePrefix(prefixSpec, keyword, OBFUSCATE_SERVER_TO_CLIENT_IV)
+	return makeTerminatedPrefixWithPadding(prefixSpec, keyword, OBFUSCATE_SERVER_TO_CLIENT_IV)
 }
 
 // readPreamble reads the preamble bytes from the client. If it does not detect
@@ -622,12 +659,12 @@ func readPreambleHelper(
 
 // makeTerminator generates a prefix terminator used in finding end of prefix
 // placed before OSSH stream.
-// prefix should be at least PREAMBLE_HEADER_LENGTH bytes and contain enough entropy.
-func makeTerminator(keyword string, prefix []byte, direction string) ([]byte, error) {
+// b should be at least PREAMBLE_HEADER_LENGTH bytes and contain enough entropy.
+func makeTerminator(keyword string, b []byte, direction string) ([]byte, error) {
 
-	// prefix length is at least equal to obfuscator seed message.
-	if len(prefix) < PREAMBLE_HEADER_LENGTH {
-		return nil, errors.TraceNew("prefix too short")
+	// Bytes length is at least equal to obfuscator seed message.
+	if len(b) < PREAMBLE_HEADER_LENGTH {
+		return nil, errors.TraceNew("bytes too short")
 	}
 
 	if (direction != OBFUSCATE_CLIENT_TO_SERVER_IV) &&
@@ -637,7 +674,7 @@ func makeTerminator(keyword string, prefix []byte, direction string) ([]byte, er
 
 	hkdf := hkdf.New(sha256.New,
 		[]byte(keyword),
-		prefix[:PREAMBLE_HEADER_LENGTH],
+		b[:PREAMBLE_HEADER_LENGTH],
 		[]byte(direction))
 
 	terminator := make([]byte, PREFIX_TERMINATOR_LENGTH)
@@ -649,24 +686,26 @@ func makeTerminator(keyword string, prefix []byte, direction string) ([]byte, er
 	return terminator, nil
 }
 
-// makePrefix generates a prefix followed by it's terminator using the given spec.
+// makeTerminatedPrefixWithPadding generates bytes starting with the prefix bytes defiend
+// by spec and ending with the generated terminator.
 // If the generated prefix is shorter than PREAMBLE_HEADER_LENGTH, it is padded
 // with random bytes.
-func makePrefix(spec *OSSHPrefixSpec, keyword, direction string) ([]byte, error) {
+// Returns the generated prefix with teminator, and the length of the prefix if no error.
+func makeTerminatedPrefixWithPadding(spec *OSSHPrefixSpec, keyword, direction string) ([]byte, int, error) {
 
-	prefix, err := spec.Spec.ApplyPrefix(spec.Seed, PREAMBLE_HEADER_LENGTH)
+	prefix, prefixLen, err := spec.Spec.ApplyPrefix(spec.Seed, PREAMBLE_HEADER_LENGTH)
 	if err != nil {
-		return nil, errors.Trace(err)
+		return nil, 0, errors.Trace(err)
 	}
 
 	terminator, err := makeTerminator(keyword, prefix, direction)
 
 	if err != nil {
-		return nil, errors.Trace(err)
+		return nil, 0, errors.Trace(err)
 	}
 	terminatedPrefix := append(prefix, terminator...)
 
-	return terminatedPrefix, nil
+	return terminatedPrefix, prefixLen, nil
 }
 
 // writePrefixHeader writes the prefix header to the given writer.

+ 42 - 30
psiphon/common/obfuscator/obfuscator_test.go

@@ -79,7 +79,7 @@ func TestObfuscator(t *testing.T) {
 		t.Fatalf("NewClientObfuscator failed: %s", err)
 	}
 
-	preamble := client.SendPreamble()
+	preamble, _ := client.SendPreamble()
 
 	server, err := NewServerObfuscator(config, "", bytes.NewReader(preamble))
 	if err != nil {
@@ -113,7 +113,7 @@ func TestObfuscator(t *testing.T) {
 		t.Fatalf("NewClientObfuscator failed: %s", err)
 	}
 
-	preamble = client.SendPreamble()
+	preamble, _ = client.SendPreamble()
 
 	clientIP := "192.168.0.1"
 
@@ -225,7 +225,7 @@ func TestObfuscatorSeedTransformParameters(t *testing.T) {
 				return
 			}
 
-			preamble := client.SendPreamble()
+			preamble, _ := client.SendPreamble()
 
 			if tt.expectedResult == nil {
 
@@ -269,32 +269,38 @@ func TestClientObfuscatorPrefix(t *testing.T) {
 			Spec: transforms.Spec{{"", spec}},
 			Seed: prefixSeed,
 		}
-		b, _ := makePrefix(&prefixSpec, keyword, OBFUSCATE_CLIENT_TO_SERVER_IV)
-		// return the prefix without the terminator
+		b, _, _ := makeTerminatedPrefixWithPadding(&prefixSpec, keyword, OBFUSCATE_CLIENT_TO_SERVER_IV)
+		// Strips the terminator.
 		return b[:len(b)-PREFIX_TERMINATOR_LENGTH]
 	}
 
 	type test struct {
-		name           string
-		prefixSpec     transforms.Spec
-		expectedPrefix []byte
+		name       string
+		prefixSpec transforms.Spec
+		// The expected prefix bytes with padding (if any) and terminator.
+		paddedTerminatedPrefixBytes []byte
+		// Length of the prefix without padding and terminator.
+		prefixLen int
 	}
 
 	tests := []test{
 		{
-			name:           "24 byte prefix",
-			prefixSpec:     transforms.Spec{{"", "\\x00{24}"}},
-			expectedPrefix: bytes.Repeat([]byte{0}, 24),
+			name:                        "24 byte prefix",
+			prefixSpec:                  transforms.Spec{{"", "\\x00{24}"}},
+			paddedTerminatedPrefixBytes: bytes.Repeat([]byte{0}, 24),
+			prefixLen:                   24,
 		},
 		{
-			name:           "long prefix",
-			prefixSpec:     transforms.Spec{{"", "\\x00{1000}\\x00{1000}\\x00{1000}\\x00{1000}"}},
-			expectedPrefix: bytes.Repeat([]byte{0}, 4000),
+			name:                        "long prefix",
+			prefixSpec:                  transforms.Spec{{"", "\\x00{1000}\\x00{1000}\\x00{1000}\\x00{1000}"}},
+			paddedTerminatedPrefixBytes: bytes.Repeat([]byte{0}, 4000),
+			prefixLen:                   4000,
 		},
 		{
-			name:           "short prefix spec",
-			prefixSpec:     transforms.Spec{{"", "\\x00\\x00\\x00\\x00"}},
-			expectedPrefix: generatePrefix("\\x00\\x00\\x00\\x00"),
+			name:                        "short prefix spec",
+			prefixSpec:                  transforms.Spec{{"", "\\x00\\x00\\x00\\x00"}},
+			paddedTerminatedPrefixBytes: generatePrefix("\\x00\\x00\\x00\\x00"),
+			prefixLen:                   4,
 		},
 	}
 
@@ -322,17 +328,23 @@ func TestClientObfuscatorPrefix(t *testing.T) {
 				t.Fatalf("NewClientObfuscator failed: %s", err)
 			}
 
-			preamble := bytes.NewBuffer(client.SendPreamble())
+			preambleBytes, prefixLen := client.SendPreamble()
+			preamble := bytes.NewBuffer(preambleBytes)
 
-			// check prefix
-			prefix := preamble.Next(len(tt.expectedPrefix))
-			if !bytes.Equal(prefix, tt.expectedPrefix) {
+			// check prefix excluding any padding
+			prefix := preamble.Next(prefixLen)
+			if !bytes.Equal(prefix, tt.paddedTerminatedPrefixBytes[:tt.prefixLen]) {
 				t.Fatalf("expected prefix to be all zeros")
 			}
 
+			// skips padding if any
+			if tt.prefixLen < PREAMBLE_HEADER_LENGTH {
+				preamble.Next(PREAMBLE_HEADER_LENGTH - tt.prefixLen)
+			}
+
 			// check terminator
 			terminator := preamble.Next(PREFIX_TERMINATOR_LENGTH)
-			expectedTerminator, err := makeTerminator(keyword, tt.expectedPrefix, OBFUSCATE_CLIENT_TO_SERVER_IV)
+			expectedTerminator, err := makeTerminator(keyword, tt.paddedTerminatedPrefixBytes[:PREAMBLE_HEADER_LENGTH], OBFUSCATE_CLIENT_TO_SERVER_IV)
 			if err != nil {
 				t.Fatalf("makeTerminator failed: %s", err)
 			}
@@ -406,7 +418,7 @@ func TestServerObfuscatorPrefix(t *testing.T) {
 		t.Fatalf("NewClientObfuscator failed: %s", err)
 	}
 
-	preamble := client.SendPreamble()
+	preamble, _ := client.SendPreamble()
 	reader := WrapConnWithSkipReader(newConn(preamble))
 
 	// test server obfuscator
@@ -416,7 +428,7 @@ func TestServerObfuscatorPrefix(t *testing.T) {
 	}
 
 	// check server prefix reply
-	serverPrefix := server.SendPreamble()
+	serverPrefix, _ := server.SendPreamble()
 	if !bytes.Equal(serverPrefix[:serverTermInd], expectedServerPrefix) {
 		t.Fatalf("unexpected server prefix")
 	}
@@ -507,11 +519,11 @@ func TestIrregularConnections(t *testing.T) {
 		t.Fatalf("NewClientObfuscator failed: %s", err)
 	}
 
-	if client.prefixHeader == nil {
+	if client.osshPrefixHeader == nil {
 		t.Fatalf("unexpected nil prefixHeader")
 	}
 
-	preamble := client.SendPreamble()
+	preamble, _ := client.SendPreamble()
 	seed := hex.EncodeToString(preamble[seedInd : seedInd+OBFUSCATE_SEED_LENGTH])
 
 	clientIP := "192.168.0.1"
@@ -522,7 +534,7 @@ func TestIrregularConnections(t *testing.T) {
 	if err != nil {
 		t.Fatalf("NewServerObfuscator failed: %s", err)
 	}
-	if server.prefixHeader == nil {
+	if server.osshPrefixHeader == nil {
 		t.Fatalf("unexpected nil prefixHeader")
 	}
 
@@ -616,7 +628,7 @@ func TestIrregularConnections(t *testing.T) {
 		t.Fatalf("NewClientObfuscator failed: %s", err)
 	}
 
-	preamble = client.SendPreamble()
+	preamble, _ = client.SendPreamble()
 	seedInd = 100 + PREFIX_TERMINATOR_LENGTH
 	preamble[seedInd+OBFUSCATE_SEED_LENGTH] = 0x00 // mutate magic value
 
@@ -639,7 +651,7 @@ func TestIrregularConnections(t *testing.T) {
 		t.Fatalf("NewClientObfuscator failed: %s", err)
 	}
 
-	preamble = client.SendPreamble()
+	preamble, _ = client.SendPreamble()
 	seedInd = 100 + PREFIX_TERMINATOR_LENGTH
 	preamble[seedInd+OBFUSCATE_SEED_LENGTH+4] = 0x00 // mutate padding length
 
@@ -776,7 +788,7 @@ func obfuscatedSSHConnTestHelper(
 				conn,
 				keyword,
 				paddingPRNGSeed,
-				nil, clientPrefixSpec, nil, nil)
+				nil, clientPrefixSpec, nil, nil, nil)
 		}
 
 		var KEXPRNGSeed *prng.Seed

+ 10 - 5
psiphon/common/parameters/parameters.go

@@ -341,6 +341,9 @@ const (
 	OSSHPrefixSpecs                                  = "OSSHPrefixSpecs"
 	OSSHPrefixScopedSpecNames                        = "OSSHPrefixScopedSpecNames"
 	OSSHPrefixProbability                            = "OSSHPrefixProbability"
+	OSSHPrefixSplitMinDelay                          = "OSSHPrefixSplitMinDelay"
+	OSSHPrefixSplitMaxDelay                          = "OSSHPrefixSplitMaxDelay"
+	OSSHPrefixEnableFragmentor                       = "OSSHPrefixEnableFragmentor"
 	ServerOSSHPrefixSpecs                            = "ServerOSSHPrefixSpecs"
 )
 
@@ -720,11 +723,13 @@ var defaultParameters = map[string]struct {
 	ObfuscatedQUICNonceTransformScopedSpecNames: {value: transforms.ScopedSpecNames{}},
 	ObfuscatedQUICNonceTransformProbability:     {value: 0.0, minimum: 0.0},
 
-	OSSHPrefixSpecs:           {value: transforms.Specs{}},
-	OSSHPrefixScopedSpecNames: {value: transforms.ScopedSpecNames{}},
-	OSSHPrefixProbability:     {value: 0.0, minimum: 0.0},
-
-	ServerOSSHPrefixSpecs: {value: transforms.Specs{}, flags: serverSideOnly},
+	OSSHPrefixSpecs:            {value: transforms.Specs{}},
+	OSSHPrefixScopedSpecNames:  {value: transforms.ScopedSpecNames{}},
+	OSSHPrefixProbability:      {value: 0.0, minimum: 0.0},
+	OSSHPrefixSplitMinDelay:    {value: time.Duration(0), minimum: time.Duration(0)},
+	OSSHPrefixSplitMaxDelay:    {value: time.Duration(0), minimum: time.Duration(0)},
+	OSSHPrefixEnableFragmentor: {value: false},
+	ServerOSSHPrefixSpecs:      {value: transforms.Specs{}, flags: serverSideOnly},
 }
 
 // IsServerSideOnly indicates if the parameter specified by name is used

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

@@ -1782,7 +1782,7 @@ func boxPayload(
 		return nil, errors.Trace(err)
 	}
 
-	obfuscatedBox := obfuscator.SendPreamble()
+	obfuscatedBox, _ := obfuscator.SendPreamble()
 	seedLen := len(obfuscatedBox)
 
 	obfuscatedBox = append(obfuscatedBox, box...)

+ 8 - 6
psiphon/common/transforms/transforms.go

@@ -68,7 +68,7 @@ func (specs Specs) Validate(prefixMode bool) error {
 			if len(spec) != 1 || len(spec[0]) != 2 {
 				return errors.TraceNew("prefix mode requires exactly one transform")
 			}
-			_, err := spec.ApplyPrefix(seed, 0)
+			_, _, err := spec.ApplyPrefix(seed, 0)
 			if err != nil {
 				return errors.Trace(err)
 			}
@@ -161,10 +161,10 @@ func (specs Specs) Select(scope string, scopedSpecs ScopedSpecNames) (string, Sp
 //
 // The input seed is used for all random number generation. The same seed can be
 // supplied to produce the same output, for replay.
-func (spec Spec) ApplyPrefix(seed *prng.Seed, minLength int) ([]byte, error) {
+func (spec Spec) ApplyPrefix(seed *prng.Seed, minLength int) ([]byte, int, error) {
 
 	if len(spec) != 1 || len(spec[0]) != 2 {
-		return nil, errors.TraceNew("prefix mode requires exactly one transform")
+		return nil, 0, errors.TraceNew("prefix mode requires exactly one transform")
 	}
 
 	rng := prng.NewPRNGWithSeed(seed)
@@ -175,21 +175,23 @@ func (spec Spec) ApplyPrefix(seed *prng.Seed, minLength int) ([]byte, error) {
 	}
 	gen, err := regen.NewGenerator(spec[0][1], args)
 	if err != nil {
-		return nil, errors.Trace(err)
+		return nil, 0, errors.Trace(err)
 	}
 
 	prefix, err := gen.Generate()
 	if err != nil {
-		return nil, errors.Trace(err)
+		return nil, 0, errors.Trace(err)
 	}
 
+	prefixLen := len(prefix)
+
 	if len(prefix) < minLength {
 		// Add random padding to fill up to minLength.
 		padding := rng.Bytes(minLength - len(prefix))
 		prefix = append(prefix, padding...)
 	}
 
-	return prefix, nil
+	return prefix, prefixLen, nil
 }
 
 // ApplyString applies the Spec to the input string, producing the output string.

+ 34 - 3
psiphon/config.go

@@ -840,9 +840,13 @@ type Config struct {
 	ObfuscatedQUICNonceTransformScopedSpecNames transforms.ScopedSpecNames
 	ObfuscatedQUICNonceTransformProbability     *float64
 
-	OSSHPrefixSpecs           transforms.Specs
-	OSSHPrefixScopedSpecNames transforms.ScopedSpecNames
-	OSSHPrefixProbability     *float64
+	// OSSHPrefix parameters are for testing purposes only.
+	OSSHPrefixSpecs                     transforms.Specs
+	OSSHPrefixScopedSpecNames           transforms.ScopedSpecNames
+	OSSHPrefixProbability               *float64
+	OSSHPrefixSplitMinDelayMilliseconds *int
+	OSSHPrefixSplitMaxDelayMilliseconds *int
+	OSSHPrefixEnableFragmentor          *bool
 
 	// params is the active parameters.Parameters with defaults, config values,
 	// and, optionally, tactics applied.
@@ -1990,6 +1994,18 @@ func (config *Config) makeConfigParameters() map[string]interface{} {
 		applyParameters[parameters.OSSHPrefixProbability] = *config.OSSHPrefixProbability
 	}
 
+	if config.OSSHPrefixSplitMinDelayMilliseconds != nil {
+		applyParameters[parameters.OSSHPrefixSplitMinDelay] = fmt.Sprintf("%dms", *config.OSSHPrefixSplitMinDelayMilliseconds)
+	}
+
+	if config.OSSHPrefixSplitMaxDelayMilliseconds != nil {
+		applyParameters[parameters.OSSHPrefixSplitMaxDelay] = fmt.Sprintf("%dms", *config.OSSHPrefixSplitMaxDelayMilliseconds)
+	}
+
+	if config.OSSHPrefixEnableFragmentor != nil {
+		applyParameters[parameters.OSSHPrefixEnableFragmentor] = *config.OSSHPrefixEnableFragmentor
+	}
+
 	// When adding new config dial parameters that may override tactics, also
 	// update setDialParametersHash.
 
@@ -2491,6 +2507,21 @@ func (config *Config) setDialParametersHash() {
 		binary.Write(hash, binary.LittleEndian, *config.OSSHPrefixProbability)
 	}
 
+	if config.OSSHPrefixSplitMinDelayMilliseconds != nil {
+		hash.Write([]byte("OSSHPrefixSplitMinDelayMilliseconds"))
+		binary.Write(hash, binary.LittleEndian, int64(*config.OSSHPrefixSplitMinDelayMilliseconds))
+	}
+
+	if config.OSSHPrefixSplitMaxDelayMilliseconds != nil {
+		hash.Write([]byte("OSSHPrefixSplitMaxDelayMilliseconds"))
+		binary.Write(hash, binary.LittleEndian, int64(*config.OSSHPrefixSplitMaxDelayMilliseconds))
+	}
+
+	if config.OSSHPrefixEnableFragmentor != nil {
+		hash.Write([]byte("OSSHPrefixEnableFragmentor"))
+		binary.Write(hash, binary.LittleEndian, *config.OSSHPrefixEnableFragmentor)
+	}
+
 	config.dialParametersHash = hash.Sum(nil)
 }
 

+ 42 - 6
psiphon/dialParameters.go

@@ -92,7 +92,8 @@ type DialParameters struct {
 	ObfuscatorPaddingSeed                   *prng.Seed
 	OSSHObfuscatorSeedTransformerParameters *transforms.ObfuscatorSeedTransformerParameters
 
-	OSSHPrefixSpec *obfuscator.OSSHPrefixSpec
+	OSSHPrefixSpec        *obfuscator.OSSHPrefixSpec
+	OSSHPrefixSplitConfig *obfuscator.OSSHPrefixSplitConfig
 
 	FragmentorSeed *prng.Seed
 
@@ -882,20 +883,30 @@ func MakeDialParameters(
 
 		if serverEntry.DisableOSSHPrefix {
 			dialParams.OSSHPrefixSpec = nil
+			dialParams.OSSHPrefixSplitConfig = nil
+
 		} else if !isReplay || !replayOSSHPrefix {
+
 			dialPortNumber, err := serverEntry.GetDialPortNumber(dialParams.TunnelProtocol)
 			if err != nil {
 				return nil, errors.Trace(err)
 			}
-			params, err := makeOSSHPrefixSpecParameters(p, strconv.Itoa(dialPortNumber))
+			prefixSpec, err := makeOSSHPrefixSpecParameters(p, strconv.Itoa(dialPortNumber))
 			if err != nil {
 				return nil, errors.Trace(err)
 			}
 
-			if params.Spec != nil {
-				dialParams.OSSHPrefixSpec = params
+			splitConfig, err := makeOSSHPrefixSplitConfig(p)
+			if err != nil {
+				return nil, errors.Trace(err)
+			}
+
+			if prefixSpec.Spec != nil {
+				dialParams.OSSHPrefixSpec = prefixSpec
+				dialParams.OSSHPrefixSplitConfig = splitConfig
 			} else {
 				dialParams.OSSHPrefixSpec = nil
+				dialParams.OSSHPrefixSplitConfig = nil
 			}
 		}
 
@@ -1069,9 +1080,16 @@ func MakeDialParameters(
 		return IPs, nil
 	}
 
+	// Fragmentor configuration.
+	// Note: fragmentorConfig is nil if fragmentor is disabled for prefixed OSSH.
+  //
 	// Limitation: when replaying and with ReplayIgnoreChangedConfigState set,
 	// fragmentor.NewUpstreamConfig may select a config using newer tactics
 	// parameters.
+	fragmentorConfig := fragmentor.NewUpstreamConfig(p, dialParams.TunnelProtocol, dialParams.FragmentorSeed)
+	if !p.Bool(parameters.OSSHPrefixEnableFragmentor) && dialParams.OSSHPrefixSpec != nil {
+		fragmentorConfig = nil
+	}
 
 	dialParams.dialConfig = &DialConfig{
 		DiagnosticID:                  serverEntry.GetDiagnosticID(),
@@ -1082,7 +1100,7 @@ func MakeDialParameters(
 		IPv6Synthesizer:               config.IPv6Synthesizer,
 		ResolveIP:                     resolveIP,
 		TrustedCACertificatesFilename: config.TrustedCACertificatesFilename,
-		FragmentorConfig:              fragmentor.NewUpstreamConfig(p, dialParams.TunnelProtocol, dialParams.FragmentorSeed),
+		FragmentorConfig:              fragmentorConfig,
 		UpstreamProxyErrorCallback:    upstreamProxyErrorCallback,
 	}
 
@@ -1632,7 +1650,8 @@ func makeSeedTransformerParameters(p parameters.ParametersAccessor,
 }
 
 func makeOSSHPrefixSpecParameters(
-	p parameters.ParametersAccessor, dialPortNumber string) (*obfuscator.OSSHPrefixSpec, error) {
+	p parameters.ParametersAccessor,
+	dialPortNumber string) (*obfuscator.OSSHPrefixSpec, error) {
 
 	if !p.WeightedCoinFlip(parameters.OSSHPrefixProbability) {
 		return &obfuscator.OSSHPrefixSpec{}, nil
@@ -1657,3 +1676,20 @@ func makeOSSHPrefixSpecParameters(
 		}, nil
 	}
 }
+
+func makeOSSHPrefixSplitConfig(p parameters.ParametersAccessor) (*obfuscator.OSSHPrefixSplitConfig, error) {
+
+	minDelay := p.Duration(parameters.OSSHPrefixSplitMinDelay)
+	maxDelay := p.Duration(parameters.OSSHPrefixSplitMaxDelay)
+
+	seed, err := prng.NewSeed()
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+
+	return &obfuscator.OSSHPrefixSplitConfig{
+		Seed:     seed,
+		MinDelay: minDelay,
+		MaxDelay: maxDelay,
+	}, nil
+}

+ 1 - 1
psiphon/meekConn.go

@@ -1676,7 +1676,7 @@ func makeMeekObfuscationValues(
 	if err != nil {
 		return nil, "", 0, 0, 0.0, errors.Trace(err)
 	}
-	obfuscatedCookie := obfuscator.SendPreamble()
+	obfuscatedCookie, _ := obfuscator.SendPreamble()
 	seedLen := len(obfuscatedCookie)
 	obfuscatedCookie = append(obfuscatedCookie, encryptedCookie...)
 	obfuscator.ObfuscateClientToServer(obfuscatedCookie[seedLen:])

+ 1 - 1
psiphon/server/listener.go

@@ -147,7 +147,7 @@ func (listener *TacticsListener) accept() (net.Conn, error) {
 			conn)
 
 		if doReplay && replaySeed != nil {
-			conn.(common.FragmentorReplayAccessor).SetReplay(
+			conn.(common.FragmentorAccessor).SetReplay(
 				prng.NewPRNGWithSeed(replaySeed))
 		}
 	}

+ 9 - 2
psiphon/server/meek.go

@@ -1656,7 +1656,7 @@ func (conn *meekConn) SetReplay(PRNG *prng.PRNG) {
 		}
 	}
 
-	fragmentor, ok := underlyingConn.(common.FragmentorReplayAccessor)
+	fragmentor, ok := underlyingConn.(common.FragmentorAccessor)
 	if ok {
 		fragmentor.SetReplay(PRNG)
 	}
@@ -1680,13 +1680,20 @@ func (conn *meekConn) GetReplay() (*prng.Seed, bool) {
 		}
 	}
 
-	fragmentor, ok := underlyingConn.(common.FragmentorReplayAccessor)
+	fragmentor, ok := underlyingConn.(common.FragmentorAccessor)
 	if ok {
 		return fragmentor.GetReplay()
 	}
 	return nil, false
 }
 
+func (conn *meekConn) StopFragmenting() {
+	fragmentor, ok := conn.firstUnderlyingConn.(common.FragmentorAccessor)
+	if ok {
+		fragmentor.StopFragmenting()
+	}
+}
+
 // pumpReads causes goroutines blocking on meekConn.Read() to read
 // from the specified reader. This function blocks until the reader
 // is fully consumed or the meekConn is closed. A read buffer allows

+ 70 - 2
psiphon/server/server_test.go

@@ -50,6 +50,7 @@ import (
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/quic"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/tactics"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/transforms"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/values"
 	"github.com/miekg/dns"
 	"golang.org/x/net/proxy"
@@ -196,6 +197,33 @@ func TestFragmentedOSSH(t *testing.T) {
 		})
 }
 
+func TestPrefixedOSSH(t *testing.T) {
+	runServer(t,
+		&runServerConfig{
+			tunnelProtocol:       "OSSH",
+			enableSSHAPIRequests: true,
+			doHotReload:          false,
+			doDefaultSponsorID:   false,
+			denyTrafficRules:     false,
+			requireAuthorization: true,
+			omitAuthorization:    false,
+			doTunneledWebRequest: true,
+			doTunneledNTPRequest: true,
+			applyPrefix:          true,
+			forceFragmenting:     false,
+			forceLivenessTest:    false,
+			doPruneServerEntries: false,
+			doDanglingTCPConn:    true,
+			doPacketManipulation: false,
+			doBurstMonitor:       false,
+			doSplitTunnel:        false,
+			limitQUICVersions:    false,
+			doDestinationBytes:   false,
+			doChangeBytesConfig:  false,
+			doLogHostProvider:    true,
+		})
+}
+
 func TestUnfrontedMeek(t *testing.T) {
 	runServer(t,
 		&runServerConfig{
@@ -842,6 +870,7 @@ type runServerConfig struct {
 	omitAuthorization    bool
 	doTunneledWebRequest bool
 	doTunneledNTPRequest bool
+	applyPrefix          bool
 	forceFragmenting     bool
 	forceLivenessTest    bool
 	doPruneServerEntries bool
@@ -1005,7 +1034,9 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 			propagationChannelID,
 			livenessTestSize,
 			runConfig.doBurstMonitor,
-			runConfig.doDestinationBytes)
+			runConfig.doDestinationBytes,
+			runConfig.applyPrefix,
+		)
 	}
 
 	blocklistFilename := filepath.Join(testDataDirName, "blocklist.csv")
@@ -1289,6 +1320,22 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 
 		applyParameters := make(map[string]interface{})
 
+		if runConfig.applyPrefix {
+
+			applyParameters[parameters.OSSHPrefixSpecs] = transforms.Specs{
+				"TEST": {{"", "\x00{24}"}},
+			}
+			applyParameters[parameters.OSSHPrefixScopedSpecNames] = transforms.ScopedSpecNames{
+				"": {"TEST"},
+			}
+			applyParameters[parameters.OSSHPrefixProbability] = 1.0
+			applyParameters[parameters.OSSHPrefixSplitMinDelay] = 1 * time.Millisecond
+			applyParameters[parameters.OSSHPrefixSplitMaxDelay] = 10 * time.Millisecond
+
+			applyParameters[parameters.OSSHPrefixEnableFragmentor] = runConfig.forceFragmenting
+
+		}
+
 		if runConfig.forceFragmenting {
 			applyParameters[parameters.FragmentorLimitProtocols] = protocol.TunnelProtocols{runConfig.tunnelProtocol}
 			applyParameters[parameters.FragmentorProbability] = 1.0
@@ -1498,6 +1545,7 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 			propagationChannelID,
 			livenessTestSize,
 			runConfig.doBurstMonitor,
+			false,
 			false)
 
 		p, _ := os.FindProcess(os.Getpid())
@@ -2025,6 +2073,13 @@ func checkExpectedServerTunnelLogFields(
 		}
 	}
 
+	if runConfig.applyPrefix {
+
+		if fields["ossh_prefix"] == nil || fmt.Sprintf("%s", fields["ossh_prefix"]) == "" {
+			return fmt.Errorf("missing expected field 'ossh_prefix'")
+		}
+	}
+
 	if runConfig.forceFragmenting {
 
 		for _, name := range []string{
@@ -2802,7 +2857,8 @@ func paveTacticsConfigFile(
 	propagationChannelID string,
 	livenessTestSize int,
 	doBurstMonitor bool,
-	doDestinationBytes bool) {
+	doDestinationBytes bool,
+	applyOsshPrefix bool) {
 
 	// Setting LimitTunnelProtocols passively exercises the
 	// server-side LimitTunnelProtocols enforcement.
@@ -2818,6 +2874,7 @@ func paveTacticsConfigFile(
         "Parameters" : {
           %s
           %s
+					%s
           "LimitTunnelProtocols" : ["%s"],
           "FragmentorLimitProtocols" : ["%s"],
           "FragmentorProbability" : 1.0,
@@ -2905,11 +2962,22 @@ func paveTacticsConfigFile(
 	`, testGeoIPASN)
 	}
 
+	osshPrefix := ""
+	if applyOsshPrefix {
+		osshPrefix = `
+          "ServerOSSHPrefixSpecs": {
+              "TEST": [["", "\\x00{20}"]],
+          },
+          "OSSHPrefixEnableFragmentor": true,
+					`
+	}
+
 	tacticsConfigJSON := fmt.Sprintf(
 		tacticsConfigJSONFormat,
 		tacticsRequestPublicKey, tacticsRequestPrivateKey, tacticsRequestObfuscatedKey,
 		burstParameters,
 		destinationBytesParameters,
+		osshPrefix,
 		tunnelProtocol,
 		tunnelProtocol,
 		tunnelProtocol,

+ 25 - 4
psiphon/server/tunnelServer.go

@@ -1792,7 +1792,7 @@ func (sshClient *sshClient) run(
 	if isReplayCandidate {
 
 		getFragmentorSeed := func() *prng.Seed {
-			fragmentor, ok := baseConn.(common.FragmentorReplayAccessor)
+			fragmentor, ok := baseConn.(common.FragmentorAccessor)
 			if ok {
 				fragmentorSeed, _ := fragmentor.GetReplay()
 				return fragmentorSeed
@@ -1831,7 +1831,7 @@ func (sshClient *sshClient) run(
 
 				replayedFragmentation := false
 				if sshClient.tunnelProtocol != protocol.TUNNEL_PROTOCOL_OBFUSCATED_SSH {
-					fragmentor, ok := baseConn.(common.FragmentorReplayAccessor)
+					fragmentor, ok := baseConn.(common.FragmentorAccessor)
 					if ok {
 						_, replayedFragmentation = fragmentor.GetReplay()
 					}
@@ -1916,9 +1916,14 @@ func (sshClient *sshClient) run(
 					"ServerTacticsParametersCache.Get failed")
 			}
 
+			var osshPrefixEnableFragmentor bool = false
 			var serverOsshPrefixSpecs transforms.Specs = nil
+			var minDelay, maxDelay time.Duration
 			if !p.IsNil() {
+				osshPrefixEnableFragmentor = p.Bool(parameters.OSSHPrefixEnableFragmentor)
 				serverOsshPrefixSpecs = p.ProtocolTransformSpecs(parameters.ServerOSSHPrefixSpecs)
+				minDelay = p.Duration(parameters.OSSHPrefixSplitMinDelay)
+				maxDelay = p.Duration(parameters.OSSHPrefixSplitMaxDelay)
 				// Allow garbage collection.
 				p.Close()
 			}
@@ -1946,12 +1951,22 @@ func (sshClient *sshClient) run(
 				conn = result.obfuscatedSSHConn
 			}
 
+			// Set the OSSH prefix split config.
+			if err == nil && result.obfuscatedSSHConn.IsOSSHPrefixStream() {
+				err = result.obfuscatedSSHConn.SetOSSHPrefixSplitConfig(minDelay, maxDelay)
+				// Log error, but continue.
+				if err != nil {
+					log.WithTraceFields(LogFields{"error": errors.Trace(err)}).Warning(
+						"SetOSSHPrefixSplitConfig failed")
+				}
+			}
+
 			// Seed the fragmentor, when present, with seed derived from initial
 			// obfuscator message. See tactics.Listener.Accept. This must preceed
 			// ssh.NewServerConn to ensure fragmentor is seeded before downstream bytes
 			// are written.
 			if err == nil && sshClient.tunnelProtocol == protocol.TUNNEL_PROTOCOL_OBFUSCATED_SSH {
-				fragmentor, ok := baseConn.(common.FragmentorReplayAccessor)
+				fragmentor, ok := baseConn.(common.FragmentorAccessor)
 				if ok {
 					var fragmentorPRNG *prng.PRNG
 					fragmentorPRNG, err = result.obfuscatedSSHConn.GetDerivedPRNG("server-side-fragmentor")
@@ -1960,6 +1975,12 @@ func (sshClient *sshClient) run(
 					} else {
 						fragmentor.SetReplay(fragmentorPRNG)
 					}
+
+					// Stops the fragmentor if disabled for prefixed OSSH streams.
+					if !osshPrefixEnableFragmentor && result.obfuscatedSSHConn.IsOSSHPrefixStream() {
+						fragmentor.StopFragmenting()
+					}
+
 				}
 			}
 		}
@@ -2068,7 +2089,7 @@ func (sshClient *sshClient) run(
 
 	replayMetrics := make(LogFields)
 	replayedFragmentation := false
-	fragmentor, ok := baseConn.(common.FragmentorReplayAccessor)
+	fragmentor, ok := baseConn.(common.FragmentorAccessor)
 	if ok {
 		_, replayedFragmentation = fragmentor.GetReplay()
 	}

+ 1 - 0
psiphon/tunnel.go

@@ -984,6 +984,7 @@ func dialTunnel(
 			dialParams.ObfuscatorPaddingSeed,
 			dialParams.OSSHObfuscatorSeedTransformerParameters,
 			dialParams.OSSHPrefixSpec,
+			dialParams.OSSHPrefixSplitConfig,
 			&obfuscatedSSHMinPadding,
 			&obfuscatedSSHMaxPadding)
 		if err != nil {