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

Add obfuscation enhancements

- DTLS ClientHello/ServerHello randomization
- Data Channel traffic shaping

Also documented UDPMux issues, including IPv6 address selection.
Rod Hynes 2 лет назад
Родитель
Сommit
31d01366a8

+ 84 - 21
psiphon/common/inproxy/api.go

@@ -211,13 +211,15 @@ type ProxyAnnounceRequest struct {
 // user interface for display to the user; for example, to alert the proxy
 // operator of configuration issue; the JSON schema is not defined here.
 type ProxyAnnounceResponse struct {
-	OperatorMessageJSON         string                    `cbor:"1,keyasint,omitempty"`
-	ConnectionID                ID                        `cbor:"2,keyasint,omitempty"`
-	ClientProxyProtocolVersion  int32                     `cbor:"3,keyasint,omitempty"`
-	ClientOfferSDP              webrtc.SessionDescription `cbor:"4,keyasint,omitempty"`
-	ClientRootObfuscationSecret ObfuscationSecret         `cbor:"5,keyasint,omitempty"`
-	NetworkProtocol             NetworkProtocol           `cbor:"6,keyasint,omitempty"`
-	DestinationAddress          string                    `cbor:"7,keyasint,omitempty"`
+	OperatorMessageJSON         string                               `cbor:"1,keyasint,omitempty"`
+	ConnectionID                ID                                   `cbor:"2,keyasint,omitempty"`
+	ClientProxyProtocolVersion  int32                                `cbor:"3,keyasint,omitempty"`
+	ClientOfferSDP              webrtc.SessionDescription            `cbor:"4,keyasint,omitempty"`
+	ClientRootObfuscationSecret ObfuscationSecret                    `cbor:"5,keyasint,omitempty"`
+	DoDTLSRandomization         bool                                 `cbor:"7,keyasint,omitempty"`
+	TrafficShapingParameters    *DataChannelTrafficShapingParameters `cbor:"8,keyasint,omitempty"`
+	NetworkProtocol             NetworkProtocol                      `cbor:"9,keyasint,omitempty"`
+	DestinationAddress          string                               `cbor:"10,keyasint,omitempty"`
 }
 
 // ClientOfferRequest is an API request sent from a client to a broker,
@@ -241,15 +243,33 @@ type ProxyAnnounceResponse struct {
 // domain, and destination port for a valid Psiphon tunnel protocol run by
 // the specified server entry.
 type ClientOfferRequest struct {
-	Metrics                     *ClientMetrics            `cbor:"1,keyasint,omitempty"`
-	CommonCompartmentIDs        []ID                      `cbor:"2,keyasint,omitempty"`
-	PersonalCompartmentIDs      []ID                      `cbor:"3,keyasint,omitempty"`
-	ClientOfferSDP              webrtc.SessionDescription `cbor:"4,keyasint,omitempty"`
-	ICECandidateTypes           ICECandidateTypes         `cbor:"5,keyasint,omitempty"`
-	ClientRootObfuscationSecret ObfuscationSecret         `cbor:"6,keyasint,omitempty"`
-	DestinationServerEntryJSON  []byte                    `cbor:"7,keyasint,omitempty"`
-	NetworkProtocol             NetworkProtocol           `cbor:"8,keyasint,omitempty"`
-	DestinationAddress          string                    `cbor:"9,keyasint,omitempty"`
+	Metrics                     *ClientMetrics                       `cbor:"1,keyasint,omitempty"`
+	CommonCompartmentIDs        []ID                                 `cbor:"2,keyasint,omitempty"`
+	PersonalCompartmentIDs      []ID                                 `cbor:"3,keyasint,omitempty"`
+	ClientOfferSDP              webrtc.SessionDescription            `cbor:"4,keyasint,omitempty"`
+	ICECandidateTypes           ICECandidateTypes                    `cbor:"5,keyasint,omitempty"`
+	ClientRootObfuscationSecret ObfuscationSecret                    `cbor:"6,keyasint,omitempty"`
+	DoDTLSRandomization         bool                                 `cbor:"7,keyasint,omitempty"`
+	TrafficShapingParameters    *DataChannelTrafficShapingParameters `cbor:"8,keyasint,omitempty"`
+	DestinationServerEntryJSON  []byte                               `cbor:"9,keyasint,omitempty"`
+	NetworkProtocol             NetworkProtocol                      `cbor:"10,keyasint,omitempty"`
+	DestinationAddress          string                               `cbor:"11,keyasint,omitempty"`
+}
+
+// DataChannelTrafficShapingParameters specifies a data channel traffic
+// shaping configuration, including random padding and decoy messages.
+// Clients determine their own traffic shaping configuration, and generate
+// and send a configuration for the peer proxy to use.
+type DataChannelTrafficShapingParameters struct {
+	MinPaddedMessages       int     `cbor:"1,keyasint,omitempty"`
+	MaxPaddedMessages       int     `cbor:"2,keyasint,omitempty"`
+	MinPaddingSize          int     `cbor:"3,keyasint,omitempty"`
+	MaxPaddingSize          int     `cbor:"4,keyasint,omitempty"`
+	MinDecoyMessages        int     `cbor:"5,keyasint,omitempty"`
+	MaxDecoyMessages        int     `cbor:"6,keyasint,omitempty"`
+	MinDecoySize            int     `cbor:"7,keyasint,omitempty"`
+	MaxDecoySize            int     `cbor:"8,keyasint,omitempty"`
+	DecoyMessageProbability float64 `cbor:"9,keyasint,omitempty"`
 }
 
 // TODO: Encode SDPs using CBOR without field names, simliar to base metrics
@@ -405,10 +425,15 @@ func DecodeBaseMetrics(metrics BaseMetrics) common.APIParameters {
 	return params
 }
 
-// Sanity check lengths for array inputs.
+// Sanity check values.
 const (
 	maxICECandidateTypes = 10
 	maxPortMappingTypes  = 10
+
+	maxPaddedMessages = 100
+	maxPaddingSize    = 16384
+	maxDecoyMessages  = 100
+	maxDecoySize      = 16384
 )
 
 // ValidateAndGetLogFields validates the ProxyMetrics and returns
@@ -535,7 +560,7 @@ func (request *ProxyAnnounceRequest) ValidateAndGetLogFields(
 	return logFields, nil
 }
 
-// ClientOfferRequest validates the ProxyAnnounceRequest and returns
+// ValidateAndGetLogFields validates the ClientOfferRequest and returns
 // common.LogFields for logging.
 func (request *ClientOfferRequest) ValidateAndGetLogFields(
 	lookupGeoIP LookupGeoIP,
@@ -577,6 +602,13 @@ func (request *ClientOfferRequest) ValidateAndGetLogFields(
 		return nil, errors.Trace(err)
 	}
 
+	if request.TrafficShapingParameters != nil {
+		err := request.TrafficShapingParameters.Validate()
+		if err != nil {
+			return nil, errors.Trace(err)
+		}
+	}
+
 	// CommonCompartmentIDs are generated and managed and are a form of
 	// obfuscation secret, so are not logged. PersonalCompartmentIDs are
 	// user-generated and shared out-of-band; values are not logged since
@@ -593,7 +625,38 @@ func (request *ClientOfferRequest) ValidateAndGetLogFields(
 	return logFields, nil
 }
 
-// ProxyAnswerRequest validates the ProxyAnnounceRequest and returns
+// Validate validates the that client has not specified excess traffic shaping
+// padding or decoy traffic.
+func (params *DataChannelTrafficShapingParameters) Validate() error {
+
+	if params.MinPaddedMessages < 0 ||
+		params.MinPaddedMessages > params.MaxPaddedMessages ||
+		params.MaxPaddedMessages > maxPaddedMessages {
+		return errors.TraceNew("invalid padded messages")
+	}
+
+	if params.MinPaddingSize < 0 ||
+		params.MinPaddingSize > params.MaxPaddingSize ||
+		params.MaxPaddingSize > maxPaddingSize {
+		return errors.TraceNew("invalid padding size")
+	}
+
+	if params.MinDecoyMessages < 0 ||
+		params.MinDecoyMessages > params.MaxDecoyMessages ||
+		params.MaxDecoyMessages > maxDecoyMessages {
+		return errors.TraceNew("invalid decoy messages")
+	}
+
+	if params.MinDecoySize < 0 ||
+		params.MinDecoySize > params.MaxDecoySize ||
+		params.MaxDecoySize > maxDecoySize {
+		return errors.TraceNew("invalid decoy size")
+	}
+
+	return nil
+}
+
+// ValidateAndGetLogFields validates the ProxyAnswerRequest and returns
 // common.LogFields for logging.
 func (request *ProxyAnswerRequest) ValidateAndGetLogFields(
 	lookupGeoIP LookupGeoIP,
@@ -631,7 +694,7 @@ func (request *ProxyAnswerRequest) ValidateAndGetLogFields(
 	return logFields, nil
 }
 
-// ClientRelayedPacketRequest validates the ProxyAnnounceRequest and returns
+// ValidateAndGetLogFields validates the ClientRelayedPacketRequest and returns
 // common.LogFields for logging.
 func (request *ClientRelayedPacketRequest) ValidateAndGetLogFields(
 	baseMetricsValidator common.APIParameterValidator,
@@ -646,7 +709,7 @@ func (request *ClientRelayedPacketRequest) ValidateAndGetLogFields(
 	return logFields, nil
 }
 
-// BrokerServerRequest validates the ProxyAnnounceRequest and returns
+// ValidateAndGetLogFields validates the BrokerServerRequest and returns
 // common.LogFields for logging.
 func (request *BrokerServerRequest) ValidateAndGetLogFields() (common.LogFields, error) {
 

+ 4 - 0
psiphon/common/inproxy/broker.go

@@ -466,6 +466,8 @@ func (b *Broker) handleProxyAnnounce(
 			ClientProxyProtocolVersion:  clientOffer.ClientProxyProtocolVersion,
 			ClientOfferSDP:              clientOffer.ClientOfferSDP,
 			ClientRootObfuscationSecret: clientOffer.ClientRootObfuscationSecret,
+			DoDTLSRandomization:         clientOffer.DoDTLSRandomization,
+			TrafficShapingParameters:    clientOffer.TrafficShapingParameters,
 			NetworkProtocol:             clientOffer.NetworkProtocol,
 			DestinationAddress:          clientOffer.DestinationAddress,
 		})
@@ -602,6 +604,8 @@ func (b *Broker) handleClientOffer(
 		ClientProxyProtocolVersion:  offerRequest.Metrics.ProxyProtocolVersion,
 		ClientOfferSDP:              offerRequest.ClientOfferSDP,
 		ClientRootObfuscationSecret: offerRequest.ClientRootObfuscationSecret,
+		DoDTLSRandomization:         offerRequest.DoDTLSRandomization,
+		TrafficShapingParameters:    offerRequest.TrafficShapingParameters,
 		NetworkProtocol:             offerRequest.NetworkProtocol,
 		DestinationAddress:          offerRequest.DestinationAddress,
 		DestinationServerID:         serverParams.serverID,

+ 6 - 0
psiphon/common/inproxy/client.go

@@ -275,6 +275,8 @@ func dialClientWebRTCConn(
 
 	// Initialize the WebRTC offer
 
+	doTLSRandomization := config.DialParameters.DoDTLSRandomization()
+	trafficShapingParameters := config.DialParameters.DataChannelTrafficShapingParameters()
 	clientRootObfuscationSecret := config.DialParameters.ClientRootObfuscationSecret()
 
 	webRTCConn, SDP, SDPMetrics, err := NewWebRTCConnWithOffer(
@@ -282,6 +284,8 @@ func dialClientWebRTCConn(
 			Logger:                      config.Logger,
 			DialParameters:              config.DialParameters,
 			ClientRootObfuscationSecret: clientRootObfuscationSecret,
+			DoDTLSRandomization:         doTLSRandomization,
+			TrafficShapingParameters:    trafficShapingParameters,
 			ReliableTransport:           config.ReliableTransport,
 		})
 	if err != nil {
@@ -324,6 +328,8 @@ func dialClientWebRTCConn(
 			ClientOfferSDP:              SDP,
 			ICECandidateTypes:           SDPMetrics.ICECandidateTypes,
 			ClientRootObfuscationSecret: clientRootObfuscationSecret,
+			DoDTLSRandomization:         doTLSRandomization,
+			TrafficShapingParameters:    trafficShapingParameters,
 			DestinationServerEntryJSON:  config.DestinationServerEntryJSON,
 			NetworkProtocol:             config.DialNetworkProtocol,
 			DestinationAddress:          config.DialAddress,

+ 9 - 3
psiphon/common/inproxy/dialParameters.go

@@ -134,11 +134,17 @@ type DialParameters interface {
 	// obfuscation/replay on both sides.
 	ClientRootObfuscationSecret() ObfuscationSecret
 
-	// DoDTLSRandomization indicates whether to perform DTLS ClientHello
-	// randomization. DoDTLSRandomization is specified by clients, which may
-	// use a weighted coin flip or a replay to determine the value.
+	// DoDTLSRandomization indicates whether to perform DTLS
+	// Client/ServerHello randomization. DoDTLSRandomization is specified by
+	// clients, which may use a weighted coin flip or a replay to determine
+	// the value.
 	DoDTLSRandomization() bool
 
+	// DataChannelTrafficShapingParameters returns parameters specifying how
+	// to perform data channel traffic shapping -- random padding and decoy
+	// message. Returns nil when no traffic shaping is to be performed.
+	DataChannelTrafficShapingParameters() *DataChannelTrafficShapingParameters
+
 	// STUNServerAddress selects a STUN server to use for this dial. When
 	// RFC5780 is true, the STUN server must support RFC5780 NAT discovery;
 	// otherwise, only basic STUN bind operation support is required. Clients

+ 7 - 0
psiphon/common/inproxy/dialParameters_test.go

@@ -47,6 +47,7 @@ type testDialParameters struct {
 	brokerClientRoundTripperFailed    func(RoundTripper)
 	clientRootObfuscationSecret       ObfuscationSecret
 	doDTLSRandomization               bool
+	trafficShapingParameters          *DataChannelTrafficShapingParameters
 	stunServerAddress                 string
 	stunServerAddressRFC5780          string
 	stunServerAddressSucceeded        func(RFC5780 bool, address string)
@@ -145,6 +146,12 @@ func (t *testDialParameters) DoDTLSRandomization() bool {
 	return t.doDTLSRandomization
 }
 
+func (t *testDialParameters) DataChannelTrafficShapingParameters() *DataChannelTrafficShapingParameters {
+	t.mutex.Lock()
+	defer t.mutex.Unlock()
+	return t.trafficShapingParameters
+}
+
 func (t *testDialParameters) STUNServerAddress(RFC5780 bool) string {
 	t.mutex.Lock()
 	defer t.mutex.Unlock()

+ 23 - 16
psiphon/common/inproxy/dtls.go → psiphon/common/inproxy/dtls/dtls.go

@@ -17,7 +17,7 @@
  *
  */
 
-package inproxy
+package dtls
 
 import (
 	"net"
@@ -38,35 +38,28 @@ const (
 )
 
 // SetDTLSSeed establishes a cached common/prng seed to be used when
-// randomizing DTLS ClientHellos.
+// randomizing DTLS Hellos.
 //
 // The seed is keyed by the specified conn's local address. This allows a fork
 // of pion/dtls to fetch the seed and apply randomization without having to
 // fork many pion layers to pass in seeds. Concurrent dials must use distinct
 // conns with distinct local addresses (including port number).
 //
-// Both sides of a WebRTC connection may randomize their ClientHello. isOffer
+// Both sides of a WebRTC connection may randomize their Hellos. isOffer
 // allows the same seed to be used, but produce two distinct random streams.
 // The client generates or replays an obfuscation secret used to derive the
 // seed, and the obfuscation secret is relayed to the proxy by the Broker.
 //
 // The caller may specify TTL, which can be used to retain the cached key for
 // a dial timeout duration; when TTL is <= 0, a default TTL is used.
-func SetDTLSSeed(conn net.PacketConn, obfuscationSecret ObfuscationSecret, isOffer bool, TTL time.Duration) error {
-
-	if len(obfuscationSecret) != prng.SEED_LENGTH {
-		return errors.TraceNew("unexpected obfuscation secret length")
-	}
-
-	var baseSeed prng.Seed
-	copy(baseSeed[:], obfuscationSecret[:])
+func SetDTLSSeed(localAddr net.Addr, baseSeed *prng.Seed, isOffer bool, TTL time.Duration) error {
 
 	salt := "inproxy-client-DTLS-seed"
 	if !isOffer {
 		salt = "inproxy-proxy-DTLS-seed"
 	}
 
-	seed, err := prng.NewSaltedSeed(&baseSeed, salt)
+	seed, err := prng.NewSaltedSeed(baseSeed, salt)
 	if err != nil {
 		return errors.Trace(err)
 	}
@@ -78,18 +71,32 @@ func SetDTLSSeed(conn net.PacketConn, obfuscationSecret ObfuscationSecret, isOff
 	// In the case where a previously used local port number is reused in a
 	// new dial, this will replace the previous seed.
 
-	dtlsSeedCache.Set(conn.LocalAddr().String(), seed, TTL)
+	dtlsSeedCache.Set(localAddr.String(), seed, TTL)
 
 	return nil
 }
 
+// SetNoDTLSSeed indicates to skip DTLS randomization for the conn specified
+// by the local address.
+func SetNoDTLSSeed(localAddr net.Addr, TTL time.Duration) {
+
+	if TTL <= 0 {
+		TTL = lrucache.DefaultExpiration
+	}
+
+	dtlsSeedCache.Set(localAddr.String(), nil, TTL)
+}
+
 // GetDTLSSeed fetches a seed established by SetDTLSSeed, or returns an error
-// if no seed is found for the specified conn.
-func GetDTLSSeed(conn *net.UDPConn) (*prng.Seed, error) {
-	seed, ok := dtlsSeedCache.Get(conn.LocalAddr().String())
+// if no seed is found for the specified conn, keyed by local/source address.
+func GetDTLSSeed(localAddr net.Addr) (*prng.Seed, error) {
+	seed, ok := dtlsSeedCache.Get(localAddr.String())
 	if !ok {
 		return nil, errors.TraceNew("missing seed")
 	}
+	if seed == nil {
+		return nil, nil
+	}
 	return seed.(*prng.Seed), nil
 }
 

+ 20 - 6
psiphon/common/inproxy/inproxy_test.go

@@ -60,7 +60,6 @@ func runTestInProxy() error {
 	numClients := 10
 
 	bytesToSend := 1 << 20
-	messageSize := 1 << 10
 	targetElapsedSeconds := 2
 
 	baseMetrics := common.APIParameters{
@@ -77,6 +76,7 @@ func runTestInProxy() error {
 	testNetworkType := NetworkTypeUnknown
 	testNATType := NATTypeUnknown
 	testSTUNServerAddress := "stun.nextcloud.com:443"
+	testDisableSTUN := false
 
 	// TODO: test port mapping
 
@@ -294,6 +294,7 @@ func runTestInProxy() error {
 			networkID:                  testNetworkID,
 			networkType:                testNetworkType,
 			natType:                    testNATType,
+			disableSTUN:                testDisableSTUN,
 			stunServerAddress:          testSTUNServerAddress,
 			stunServerAddressRFC5780:   testSTUNServerAddress,
 			stunServerAddressSucceeded: stunServerAddressSucceeded,
@@ -425,8 +426,8 @@ func runTestInProxy() error {
 			sendBytes := prng.Bytes(bytesToSend)
 
 			clientsGroup.Go(func() error {
-				for n := 0; n < bytesToSend; n += messageSize {
-					m := messageSize
+				for n := 0; n < bytesToSend; {
+					m := prng.Range(1024, 32768)
 					if bytesToSend-n < m {
 						m = bytesToSend - n
 					}
@@ -434,13 +435,14 @@ func runTestInProxy() error {
 					if err != nil {
 						return errors.Trace(err)
 					}
+					n += m
 				}
 				fmt.Printf("%d bytes sent\n", bytesToSend)
 				return nil
 			})
 
 			clientsGroup.Go(func() error {
-				buf := make([]byte, messageSize)
+				buf := make([]byte, 32768)
 				n := 0
 				for n < bytesToSend {
 					m, err := relayConn.Read(buf)
@@ -489,6 +491,7 @@ func runTestInProxy() error {
 			networkID:                  testNetworkID,
 			networkType:                testNetworkType,
 			natType:                    testNATType,
+			disableSTUN:                testDisableSTUN,
 			stunServerAddress:          testSTUNServerAddress,
 			stunServerAddressRFC5780:   testSTUNServerAddress,
 			stunServerAddressSucceeded: stunServerAddressSucceeded,
@@ -504,6 +507,17 @@ func runTestInProxy() error {
 
 			clientRootObfuscationSecret: clientRootObfuscationSecret,
 			doDTLSRandomization:         true,
+			trafficShapingParameters: &DataChannelTrafficShapingParameters{
+				MinPaddedMessages:       0,
+				MaxPaddedMessages:       10,
+				MinPaddingSize:          0,
+				MaxPaddingSize:          1500,
+				MinDecoyMessages:        0,
+				MaxDecoyMessages:        10,
+				MinDecoySize:            1,
+				MaxDecoySize:            1500,
+				DecoyMessageProbability: 0.5,
+			},
 
 			setNATType:          func(NATType) {},
 			setPortMappingTypes: func(PortMappingTypes) {},
@@ -732,7 +746,7 @@ func runTCPEchoServer(listener net.Listener) {
 			return
 		}
 		go func(conn net.Conn) {
-			buf := make([]byte, 1024)
+			buf := make([]byte, 32768)
 			for {
 				n, err := conn.Read(buf)
 				if n > 0 {
@@ -793,7 +807,7 @@ func (q *quicEchoServer) Run() {
 			return
 		}
 		go func(conn net.Conn) {
-			buf := make([]byte, 1024)
+			buf := make([]byte, 32768)
 			for {
 				n, err := conn.Read(buf)
 				if n > 0 {

+ 2 - 0
psiphon/common/inproxy/matcher.go

@@ -166,6 +166,8 @@ type MatchOffer struct {
 	ClientProxyProtocolVersion  int32
 	ClientOfferSDP              webrtc.SessionDescription
 	ClientRootObfuscationSecret ObfuscationSecret
+	DoDTLSRandomization         bool
+	TrafficShapingParameters    *DataChannelTrafficShapingParameters
 	NetworkProtocol             NetworkProtocol
 	DestinationAddress          string
 	DestinationServerID         string

+ 1 - 1
psiphon/common/inproxy/obfuscation.go

@@ -259,7 +259,7 @@ func deobfuscateSessionPacket(
 	timestamp := int64(0)
 	if replayHistory != nil {
 		timestamp, n = binary.Varint(plaintext[offset:])
-		if n < 1 {
+		if timestamp == 0 && n <= 0 {
 			return nil, errors.TraceNew("invalid timestamp")
 		}
 		offset += n

+ 8 - 10
psiphon/common/inproxy/proxy.go

@@ -396,6 +396,8 @@ func (p *Proxy) proxyOneClient(ctx context.Context) error {
 			Logger:                      p.config.Logger,
 			DialParameters:              p.config.DialParameters,
 			ClientRootObfuscationSecret: announceResponse.ClientRootObfuscationSecret,
+			DoDTLSRandomization:         announceResponse.DoDTLSRandomization,
+			TrafficShapingParameters:    announceResponse.TrafficShapingParameters,
 		},
 		announceResponse.ClientOfferSDP)
 	var webRTCRequestErr string
@@ -546,12 +548,11 @@ func (p *Proxy) proxyOneClient(ctx context.Context) error {
 	// The proxy operator's ISP may be able to observe that the operator's
 	// host has nearly matching ingress and egress traffic. The traffic
 	// content won't be the same: the ingress traffic is wrapped in a WebRTC
-	// data channel, and the egress traffic is a Psiphon tunnel protocol. But
-	// the traffic shape will be close to the same. As a future enhancement,
-	// consider adding data channel padding and decoy traffic, which is
-	// dropped on egress. For performance, traffic shaping could be ceased
-	// after some time. Even with this measure, over time the number of bytes
-	// in and out of the proxy may still indicate proxying.
+	// data channel, and the egress traffic is a Psiphon tunnel protocol.
+	// With padding and decoy packets, the ingress and egress traffic shape
+	// will differ beyond the basic WebRTC overheader. Even with this
+	// measure, over time the number of bytes in and out of the proxy may
+	// still indicate proxying.
 
 	waitGroup := new(sync.WaitGroup)
 	relayErrors := make(chan error, 2)
@@ -567,10 +568,7 @@ func (p *Proxy) proxyOneClient(ctx context.Context) error {
 		//
 		// As io.Copy uses a buffer size of 32K, each relayed message will be
 		// less than the maximum. Calls to ClientConn.Write are also expected
-		// to use io.Copy, keeping messages at most 32K in size. Note that
-		// testing with io.CopyBuffer and a buffer of size 65536 actually
-		// yielded the pion error io.ErrShortBuffer, "short buffer", while a
-		// buffer of size 65535 worked.
+		// to use io.Copy, keeping messages at most 32K in size.
 
 		_, err := io.Copy(webRTCConn, destinationConn)
 		if err != nil {

+ 1 - 1
psiphon/common/inproxy/records.go

@@ -157,7 +157,7 @@ func readRecordPreamble(expectedRecordType int, payload []byte) ([]byte, error)
 	}
 
 	recordDataLength, n := binary.Uvarint(payload[2:])
-	if n < 1 || 2+n > len(payload) {
+	if (recordDataLength == 0 && n <= 0) || 2+n > len(payload) {
 		return nil, errors.Tracef("invalid record preamble data length")
 	}
 

+ 416 - 58
psiphon/common/inproxy/webrtc.go

@@ -20,9 +20,10 @@
 package inproxy
 
 import (
+	"bytes"
 	"context"
+	"encoding/binary"
 	"fmt"
-	"math"
 	"net"
 	"strconv"
 	"sync"
@@ -31,6 +32,8 @@ import (
 
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
+	inproxy_dtls "github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/inproxy/dtls"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
 	"github.com/pion/datachannel"
 	"github.com/pion/ice/v2"
 	"github.com/pion/sdp/v3"
@@ -41,13 +44,16 @@ import (
 const (
 	dataChannelBufferedAmountLowThreshold uint64 = 512 * 1024
 	dataChannelMaxBufferedAmount          uint64 = 1024 * 1024
+	dataChannelMaxMessageSize                    = 65536
 )
 
 // WebRTCConn is a WebRTC connection between two peers, with a data channel
 // used to relay streams or packets between them. WebRTCConn implements the
 // net.Conn interface.
 type WebRTCConn struct {
-	config                       *WebRTCConfig
+	config                   *WebRTCConfig
+	trafficShapingParameters *DataChannelTrafficShapingParameters
+
 	mutex                        sync.Mutex
 	udpConn                      net.PacketConn
 	portMapper                   *portMapper
@@ -59,11 +65,21 @@ type WebRTCConn struct {
 	dataChannelOpenedSignal      chan struct{}
 	dataChannelOpenedOnce        sync.Once
 	dataChannelWriteBufferSignal chan struct{}
-	messageMutex                 sync.Mutex
-	messageBuffer                []byte
-	messageOffset                int
-	messageLength                int
-	messageError                 error
+	decoyDone                    bool
+
+	readMutex       sync.Mutex
+	readBuffer      []byte
+	readOffset      int
+	readLength      int
+	readError       error
+	peerPaddingDone bool
+
+	writeMutex           sync.Mutex
+	trafficShapingPRNG   *prng.PRNG
+	trafficShapingBuffer *bytes.Buffer
+	paddedMessageCount   int
+	decoyMessageCount    int
+	trafficShapingDone   bool
 }
 
 // WebRTCConfig specifies the configuration for a WebRTC dial.
@@ -84,6 +100,9 @@ type WebRTCConfig struct {
 	// DoDTLSRandomization indicates whether to perform DTLS randomization.
 	DoDTLSRandomization bool
 
+	// TrafficShapingParameters indicates whether and how to perform data channel traffic shaping.
+	TrafficShapingParameters *DataChannelTrafficShapingParameters
+
 	// ReliableTransport indicates whether to configure the WebRTC data
 	// channel to use reliable transport. Set ReliableTransport when proxying
 	// a TCP stream, and unset it when proxying a UDP packets flow with its
@@ -141,35 +160,6 @@ func newWebRTCConn(
 		return nil, nil, nil, errors.Trace(err)
 	}
 
-	// Facilitate DTLS Client/ServerHello randomization. The client decides
-	// whether to do DTLS randomization and generates and the proxy receives
-	// ClientRootObfuscationSecret, so the client can orchestrate replay on
-	// both ends of the connection by reusing an obfuscation secret. Derive a
-	// secret specific to DTLS. SetDTLSSeed will futher derive a secure PRNG
-	// seed specific to either the client or proxy end of the connection
-	// (so each peer's randomization will be distinct).
-	//
-	// To avoid forking many pion repos in order to pass the seed through to
-	// the DTLS implementation, SetDTLSSeed populates a cache that's keyed by
-	// the UDP conn.
-	//
-	// TODO: pion/dtls is not forked yet, so this is a no-op at this time.
-
-	if config.DoDTLSRandomization {
-
-		dtlsObfuscationSecret, err := deriveObfuscationSecret(
-			config.ClientRootObfuscationSecret, "in-proxy-DTLS-seed")
-		if err != nil {
-			return nil, nil, nil, errors.Trace(err)
-		}
-
-		deadline, _ := ctx.Deadline()
-		err = SetDTLSSeed(udpConn, dtlsObfuscationSecret, isOffer, time.Until(deadline))
-		if err != nil {
-			return nil, nil, nil, errors.Trace(err)
-		}
-	}
-
 	// Initialize WebRTC
 
 	// There is no explicit anti-probing measures for the proxy side of the
@@ -193,18 +183,176 @@ func newWebRTCConn(
 	// implementation: "DataChannel.readLoop goroutine leak",
 	// https://github.com/pion/webrtc/issues/2098.
 
+	// UDPMux Limitations:
+	//
+	// For Psiphon, DialParameters.UDPListen will call
+	// https://pkg.go.dev/net#ListenUDP with an unspecified IP address, in
+	// order to listen on all available interfaces, both IPv4 and IPv6.
+	// However, using webrtc.NewICEUDPMux and a UDP conn with an unspecifed
+	// IP address results in this log warning: "UDPMuxDefault should not
+	// listening on unspecified address, use NewMultiUDPMuxFromPort instead".
+	//
+	// With NewICEUDPMux and an unspecified IP address, pion currently
+	// enumerates local, active interfaces and derives a list of listening
+	// addresses, combining each interface's IP addresses with the assigned
+	// port:
+	// https://github.com/pion/ice/blob/8c5b0991ef3bb070e47afda96faf090e8bf94be6/net.go#L35.
+	// While this works ok in many cases, this PR,
+	// https://github.com/pion/ice/pull/475, indicates the nature of the
+	// issue with UDPMuxDefault:
+	//
+	// > When we have multiple host candidates and been mux to a single port,
+	// > if these candidates share a same conn (either tcp or udp), they
+	// > might read other's [messages causing failure].
+	//
+	// This PR, https://github.com/pion/ice/pull/473, also describes the issue:
+	//
+	// > When using UDPMux and UniversalUDPMux, it is possible that a
+	// > registerConnForAddress() could be called twice or more for the same
+	// > remote candidate (endpoint) by different candidates. E.g., when
+	// > different HOST candidates ping the same remote candidate, the
+	// > udpMuxedConn gets stored once. The second candidate will never
+	// > receive a response. This is also the case when a single socket is
+	// > used for gathering SRFLX and HOST candidates.
+	//
+	// PR 475 introduced MultiUDPMuxDefault to address the issue. However, at
+	// this time, https://github.com/pion/ice/releases/tag/v2.3.6, there's an
+	// open bug with MultiUDPMuxDefault
+	// https://github.com/pion/ice/issues/507: "Multi UDP Mux can't works
+	// when remote also enables Multi UDP Mux". Running the test program
+	// attached to the bug confirms that no data channel is established;
+	// while switching the test code to use NewICEUDPMux results in a
+	// successful data channel connection. Since we need to use a Mux API on
+	// both clients and proxies, we can't yet use MultiUDPMux.
+	//
+	// Another limitation and issue with NewICEUDPMux is that its enumeration
+	// of all local interfaces and IPs includes many IPv6 addresses for
+	// certain interfaces. For example, on macOS,
+	// https://apple.stackexchange.com/a/371661, there are "secured" IPv6
+	// addresses and many "temporary" IPv6 addresses, with all but one
+	// temporary address being "deprecated". Instead of a full enumeration,
+	// we should select only the non-deprecated temporary IPv6 address --
+	// both for performance (avoid excess STUN requests) and privacy.
+	//
+	// Go has a proposal to expose the necessary IPv6 address information:
+	// https://github.com/golang/go/issues/42694. However, as of Android SDK
+	// 30, Go's net.InterfaceAddrs doesn't work at all:
+	// https://github.com/pion/transport/issues/228,
+	// https://github.com/golang/go/issues/40569.
+	//
+	// Note that it's not currently possible to
+	// webrtc.SettingEngine.SetIPFilter to limit IPv6 selection to a single
+	// candidate; that IP filter is not passed through to localInterfaces in
+	// the NewUDPMuxDefault case. And even if it were, there's no guarantee
+	// that the the first IPv6 address passed to the filter would be the
+	// non-deprecated temporary address.
+	//
+	// TODO: get interface IP addresses using native code, apply proper IPv6
+	// filtering, and pass in to pion.
+
+	udpMux := webrtc.NewICEUDPMux(&webrtcLogger{logger: config.Logger}, udpConn)
+
 	settingEngine := webrtc.SettingEngine{}
 	settingEngine.DetachDataChannels()
 	settingEngine.SetICEMulticastDNSMode(ice.MulticastDNSModeDisabled)
-	settingEngine.SetICEUDPMux(webrtc.NewICEUDPMux(&webrtcLogger{logger: config.Logger}, udpConn))
-
-	// Set this behavior to like common web browser WebRTC stacks.
+	settingEngine.SetICEUDPMux(udpMux)
+	// Set this behavior to look like common web browser WebRTC stacks.
 	settingEngine.SetDTLSInsecureSkipHelloVerify(true)
 
+	// TODO: set settingEngine.SetDTLSConnectContextMaker?
+
 	webRTCAPI := webrtc.NewAPI(webrtc.WithSettingEngine(settingEngine))
 
 	dataChannelLabel := "in-proxy-data-channel"
 
+	// Initialize data channel obfuscation
+
+	config.Logger.WithTraceFields(common.LogFields{
+		"dtls_randomization":           config.DoDTLSRandomization,
+		"data_channel_traffic_shaping": config.TrafficShapingParameters != nil,
+	}).Info("data_channel_obfuscation")
+
+	// Facilitate DTLS Client/ServerHello randomization. The client decides
+	// whether to do DTLS randomization and generates and the proxy receives
+	// ClientRootObfuscationSecret, so the client can orchestrate replay on
+	// both ends of the connection by reusing an obfuscation secret. Derive a
+	// secret specific to DTLS. SetDTLSSeed will futher derive a secure PRNG
+	// seed specific to either the client or proxy end of the connection
+	// (so each peer's randomization will be distinct).
+	//
+	// To avoid forking many pion repos in order to pass the seed through to
+	// the DTLS implementation, SetDTLSSeed populates a cache that's keyed by
+	// the UDP conn.
+	//
+	// Either SetDTLSSeed or SetNoDTLSSeed should be set for each conn, as the
+	// pion/dtl fork treats no-seed as an error, as a check against the local
+	// address lookup mechanism.
+
+	deadline, _ := ctx.Deadline()
+	dtlsSeedTTL := time.Until(deadline)
+
+	if config.DoDTLSRandomization {
+
+		dtlsObfuscationSecret, err := deriveObfuscationSecret(
+			config.ClientRootObfuscationSecret, "in-proxy-DTLS-seed")
+		if err != nil {
+			return nil, nil, nil, errors.Trace(err)
+		}
+
+		baseSeed := prng.Seed(dtlsObfuscationSecret)
+
+		// We don't specify a listen address, so the UDP conn listens on all
+		// interfaces. Internally, pion/ice expands the UDPConn's LocalAddr
+		// to a concrete IP address per interface. We must set DTLS seeds for
+		// each address.
+		for _, localAddr := range udpMux.GetListenAddresses() {
+			err := inproxy_dtls.SetDTLSSeed(
+				localAddr, &baseSeed, isOffer, dtlsSeedTTL)
+			if err != nil {
+				return nil, nil, nil, errors.Trace(err)
+			}
+		}
+
+	} else {
+
+		for _, localAddr := range udpMux.GetListenAddresses() {
+			inproxy_dtls.SetNoDTLSSeed(localAddr, dtlsSeedTTL)
+		}
+	}
+
+	// Configure traffic shaping, which adds random padding and decoy messages
+	// to data channel message flows.
+
+	var trafficShapingPRNG *prng.PRNG
+	trafficShapingBuffer := new(bytes.Buffer)
+	paddedMessageCount := 0
+	decoyMessageCount := 0
+
+	if config.TrafficShapingParameters != nil {
+
+		trafficShapingContext := "in-proxy-data-channel-traffic-shaping-offer"
+		if !isOffer {
+			trafficShapingContext = "in-proxy-data-channel-traffic-shaping-answer"
+		}
+
+		trafficShapingObfuscationSecret, err := deriveObfuscationSecret(
+			config.ClientRootObfuscationSecret, trafficShapingContext)
+		if err != nil {
+			return nil, nil, nil, errors.Trace(err)
+		}
+
+		seed := prng.Seed(trafficShapingObfuscationSecret)
+		trafficShapingPRNG = prng.NewPRNGWithSeed(&seed)
+
+		paddedMessageCount = trafficShapingPRNG.Range(
+			config.TrafficShapingParameters.MinPaddedMessages,
+			config.TrafficShapingParameters.MaxPaddedMessages)
+
+		decoyMessageCount = trafficShapingPRNG.Range(
+			config.TrafficShapingParameters.MinDecoyMessages,
+			config.TrafficShapingParameters.MaxDecoyMessages)
+	}
+
 	// NAT traversal setup
 
 	// When DisableInboundForMobleNetworks is set, skip both STUN and port
@@ -280,7 +428,8 @@ func newWebRTCConn(
 	}
 
 	conn := &WebRTCConn{
-		config:                       config,
+		config: config,
+
 		udpConn:                      udpConn,
 		portMapper:                   portMapper,
 		closedSignal:                 make(chan struct{}),
@@ -293,7 +442,12 @@ func newWebRTCConn(
 		// https://github.com/pion/webrtc/blob/dce970438344727af9c9965f88d958c55d32e64d/datachannel.go#L19.
 		// This read buffer must be as large as the maximum message size or
 		// else a read may fail with io.ErrShortBuffer.
-		messageBuffer: make([]byte, math.MaxUint16),
+		readBuffer: make([]byte, dataChannelMaxMessageSize),
+
+		trafficShapingPRNG:   trafficShapingPRNG,
+		trafficShapingBuffer: trafficShapingBuffer,
+		paddedMessageCount:   paddedMessageCount,
+		decoyMessageCount:    decoyMessageCount,
 	}
 	defer func() {
 		if retErr != nil {
@@ -566,9 +720,23 @@ func (conn *WebRTCConn) Close() error {
 
 func (conn *WebRTCConn) Read(p []byte) (int, error) {
 
+	for {
+
+		n, err := conn.readMessage(p)
+		if err != nil || n > 0 {
+			return n, err
+		}
+
+		// A decoy message was read; discard and read again.
+	}
+}
+
+func (conn *WebRTCConn) readMessage(p []byte) (int, error) {
+
 	// Don't hold this lock, or else concurrent Writes will be blocked.
 	conn.mutex.Lock()
 	dataChannelConn := conn.dataChannelConn
+	decoyDone := conn.decoyDone
 	conn.mutex.Unlock()
 
 	if dataChannelConn == nil {
@@ -582,28 +750,77 @@ func (conn *WebRTCConn) Read(p []byte) (int, error) {
 	// dataChannelConn.Read returns an error; the error value is stored and
 	// returned with the Read call that consumes the end of the message buffer.
 
-	conn.messageMutex.Lock()
-	defer conn.messageMutex.Unlock()
+	conn.readMutex.Lock()
+	defer conn.readMutex.Unlock()
+
+	if conn.readOffset == conn.readLength {
+		n, err := dataChannelConn.Read(conn.readBuffer)
+		conn.readOffset = 0
+		conn.readLength = n
+		conn.readError = err
 
-	if conn.messageOffset == conn.messageLength {
-		n, err := dataChannelConn.Read(conn.messageBuffer)
-		conn.messageOffset = 0
-		conn.messageLength = n
-		conn.messageError = err
+		// Skip over padding.
+
+		if n > 0 && !conn.peerPaddingDone {
+
+			paddingSize, n := binary.Varint(conn.readBuffer[0:conn.readLength])
+			if (paddingSize == 0 && n <= 0) || paddingSize >= int64(conn.readLength) {
+				return 0, errors.TraceNew("invalid padding")
+			}
+
+			if paddingSize < 0 {
+
+				// When the padding header indicates a padding size of -1, the
+				// peer is indicating that padding is done. Subsequent
+				// messages will have no padding header or padding bytes.
+
+				conn.peerPaddingDone = true
+				conn.readOffset += n
+
+			} else {
+
+				conn.readOffset += n + int(paddingSize)
+			}
+		}
 	}
 
-	n := copy(p, conn.messageBuffer[conn.messageOffset:conn.messageLength])
-	conn.messageOffset += n
+	n := copy(p, conn.readBuffer[conn.readOffset:conn.readLength])
+	conn.readOffset += n
 
 	var err error
-	if conn.messageOffset == conn.messageLength {
-		err = conn.messageError
+	if conn.readOffset == conn.readLength {
+		err = conn.readError
+	}
+
+	// When decoy messages are enabled, periodically response to an incoming
+	// messages with an immediate outbound decoy message. This is similar to
+	// the design here:
+	// https://github.com/Psiphon-Labs/psiphon-tunnel-core/blob/c4f6a593a645db4479a7032a9e97d3c0b905cdfc/psiphon/common/quic/obfuscator.go#L361-L409
+	//
+	// writeMessage handles conn.decoyMessageCount, which is syncronized with
+	// conn.WriteMutex, as well as other specific logic. Here we just signal
+	// writeMessage based on the read event.
+	//
+	// When the data channel already has buffered writes in excess of a decoy
+	// message size, the writeMessage skips the decoy message and returns
+	// without blocking, so Read calls will not block.
+
+	if !decoyDone {
+		_, _ = conn.writeMessage(nil, true)
 	}
 
 	return n, errors.Trace(err)
 }
 
 func (conn *WebRTCConn) Write(p []byte) (int, error) {
+	return conn.writeMessage(p, false)
+}
+
+func (conn *WebRTCConn) writeMessage(p []byte, decoy bool) (int, error) {
+
+	if p != nil && decoy {
+		return 0, errors.TraceNew("invalid write parameters")
+	}
 
 	// Don't hold this lock, or else concurrent Reads will be blocked.
 	conn.mutex.Lock()
@@ -616,6 +833,119 @@ func (conn *WebRTCConn) Write(p []byte) (int, error) {
 		return 0, errors.TraceNew("not connected")
 	}
 
+	// Only proceed with a decoy message when no pending writes are buffered.
+	//
+	// This check is made before acquiring conn.writeMutex so that, in most
+	// cases, writeMessage won't block Read calls when a concurrent Write is
+	// holding conn.writeMutex and potentially blocking on flow control.
+	// There's still a chance that this test passes, and a concurrent Write
+	// arrives at the same time.
+
+	if decoy && bufferedAmount > 0 {
+		return 0, nil
+	}
+
+	conn.writeMutex.Lock()
+	defer conn.writeMutex.Unlock()
+
+	writeSize := len(p)
+
+	// Determine padding size and padding header size.
+
+	doPadding := false
+	paddingSize := 0
+	var paddingHeader [binary.MaxVarintLen32]byte
+	paddingHeaderSize := 0
+
+	if decoy {
+
+		if conn.decoyMessageCount < 1 {
+			return 0, nil
+		}
+
+		if !conn.trafficShapingPRNG.FlipWeightedCoin(
+			conn.config.TrafficShapingParameters.DecoyMessageProbability) {
+			return 0, nil
+		}
+
+		conn.decoyMessageCount -= 1
+
+		decoySize := conn.trafficShapingPRNG.Range(
+			conn.config.TrafficShapingParameters.MinDecoySize,
+			conn.config.TrafficShapingParameters.MaxDecoySize)
+
+		// When sending a decoy message, the entire message is padding.
+
+		doPadding = true
+		paddingSize = decoySize
+
+		if conn.decoyMessageCount == 0 {
+
+			// Set the shared flag that readMessage uses to stop invoking
+			// writeMessage for decoy events.
+
+			conn.mutex.Lock()
+			conn.decoyDone = true
+			conn.mutex.Unlock()
+		}
+
+	} else if conn.paddedMessageCount > 0 {
+
+		// Add padding to a normal write.
+
+		conn.paddedMessageCount -= 1
+
+		doPadding = true
+		paddingSize = prng.Range(
+			conn.config.TrafficShapingParameters.MinPaddingSize,
+			conn.config.TrafficShapingParameters.MaxPaddingSize)
+
+	} else if conn.decoyMessageCount > 0 {
+
+		// Padding normal messages is done, but there are still outstanding
+		// decoy messages, so add a padding header indicating padding size 0
+		// to this normal message.
+
+		doPadding = true
+		paddingSize = 0
+
+	} else if !conn.trafficShapingDone {
+
+		// Padding normal messages is done and all decoy messages are sent, so
+		// send a special padding header with padding size -1, signaling the
+		// peer that no additional padding will be performed and no
+		// subsequent messages will contain a padding header.
+
+		doPadding = true
+		paddingSize = -1
+
+	}
+
+	if doPadding {
+
+		// Reduce, if necessary, to stay within the maximum data channel
+		// message size. This is not expected to happen for the io.Copy use
+		// case, with 32K message size, plus reasonable padding sizes.
+
+		if writeSize+binary.MaxVarintLen32+paddingSize > dataChannelMaxMessageSize {
+			paddingSize -= (writeSize + binary.MaxVarintLen32 + paddingSize) - dataChannelMaxMessageSize
+			if paddingSize < 0 {
+				paddingSize = 0
+			}
+		}
+
+		// Add padding overhead to total writeSize before the flow control check.
+
+		writeSize += paddingSize
+
+		paddingHeaderSize = binary.PutVarint(paddingHeader[:], int64(paddingSize))
+		writeSize += paddingHeaderSize
+	}
+
+	if writeSize > dataChannelMaxMessageSize {
+		return 0, errors.TraceNew("write too large")
+	}
+
 	// Flow control is required to ensure that Write calls don't result in
 	// unbounded buffering in pion/webrtc. Use similar logic and the same
 	// buffer size thresholds as the pion sample code.
@@ -632,7 +962,7 @@ func (conn *WebRTCConn) Write(p []byte) (int, error) {
 
 	// If the pion write buffer is too full, wait for a signal that sufficient
 	// write data has been consumed before writing more.
-	if !isClosed && bufferedAmount+uint64(len(p)) > dataChannelMaxBufferedAmount {
+	if !isClosed && bufferedAmount+uint64(writeSize) > dataChannelMaxBufferedAmount {
 		select {
 		case <-conn.dataChannelWriteBufferSignal:
 		case <-conn.closedSignal:
@@ -640,12 +970,40 @@ func (conn *WebRTCConn) Write(p []byte) (int, error) {
 		}
 	}
 
-	// Limitation: if len(p) > 65536, the dataChannelConn.Write wil fail. In
-	// practise, this is not expected to happen with typical use cases such
-	// as io.Copy, which uses a 32K buffer.
+	if conn.trafficShapingDone {
 
-	n, err := dataChannelConn.Write(p)
-	return n, errors.Trace(err)
+		// When traffic shaping is done, p is written directly without the
+		// additional trafficShapingBuffer copy.
+
+		// Limitation: if len(p) > 65536, the dataChannelConn.Write will fail. In
+		// practise, this is not expected to happen with typical use cases such
+		// as io.Copy, which uses a 32K buffer.
+		n, err := dataChannelConn.Write(p)
+
+		return n, errors.Trace(err)
+	}
+
+	conn.trafficShapingBuffer.Reset()
+	conn.trafficShapingBuffer.Write(paddingHeader[:paddingHeaderSize])
+	if paddingSize > 0 {
+		conn.trafficShapingBuffer.Write(prng.Bytes(paddingSize))
+	}
+	conn.trafficShapingBuffer.Write(p)
+
+	// Limitation: see above; len(p) + padding must be <= 65536.
+	_, err := dataChannelConn.Write(conn.trafficShapingBuffer.Bytes())
+
+	if conn.paddedMessageCount == 0 && conn.decoyMessageCount == 0 && paddingSize == -1 {
+
+		// Set flag indicating -1 padding size was sent and release traffic
+		// shaping resources.
+
+		conn.trafficShapingDone = true
+		conn.trafficShapingPRNG = nil
+		conn.trafficShapingBuffer = nil
+	}
+
+	return len(p), errors.Trace(err)
 }
 
 func (conn *WebRTCConn) LocalAddr() net.Addr {

+ 81 - 1
replace/dtls/flight1handler.go

@@ -7,11 +7,15 @@ import (
 	"context"
 
 	"github.com/pion/dtls/v2/pkg/crypto/elliptic"
+	"github.com/pion/dtls/v2/pkg/crypto/signaturehash"
 	"github.com/pion/dtls/v2/pkg/protocol"
 	"github.com/pion/dtls/v2/pkg/protocol/alert"
 	"github.com/pion/dtls/v2/pkg/protocol/extension"
 	"github.com/pion/dtls/v2/pkg/protocol/handshake"
 	"github.com/pion/dtls/v2/pkg/protocol/recordlayer"
+
+	inproxy_dtls "github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/inproxy/dtls"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
 )
 
 func flight1Parse(ctx context.Context, c flightConn, state *State, cache *handshakeCache, cfg *handshakeConfig) (flightVal, *alert.Alert, error) {
@@ -118,6 +122,82 @@ func flight1Generate(c flightConn, state *State, _ *handshakeCache, cfg *handsha
 		}
 	}
 
+	cipherSuites := cipherSuiteIDs(cfg.localCipherSuites)
+
+	// [Psiphon]
+	// Randomize ClientHello
+	seed, err := inproxy_dtls.GetDTLSSeed(c.LocalAddr())
+	if err != nil {
+		return nil, nil, err
+	}
+	if seed != nil {
+
+		PRNG := prng.NewPRNGWithSeed(seed)
+
+		cut := func(length int) int {
+			n := length
+			for ; n > 1; n-- {
+				if !PRNG.FlipCoin() {
+					break
+				}
+			}
+			return n
+		}
+
+		PRNG.Shuffle(len(cipherSuites), func(i, j int) {
+			cipherSuites[i], cipherSuites[j] = cipherSuites[j], cipherSuites[i]
+		})
+		cipherSuites = cipherSuites[:cut(len(cipherSuites))]
+
+		for _, ext := range extensions {
+			switch e := ext.(type) {
+			case *extension.SupportedSignatureAlgorithms:
+
+				// Limitation: to ensure compatibility with the ECDSA P-256 certificates generated by pion/webrtc,
+				// https://github.com/pion/webrtc/blob/1df634e1188e06c08fe87753c7bdd576a29e0c92/dtlstransport.go#L84-L92,
+				// the corresponding signature/hash algorithm needs to remain in the first position.
+
+				e.SignatureHashAlgorithms = append([]signaturehash.Algorithm(nil), e.SignatureHashAlgorithms...)
+				PRNG.Shuffle(len(e.SignatureHashAlgorithms)-1, func(i, j int) {
+					e.SignatureHashAlgorithms[i+1], e.SignatureHashAlgorithms[j+1] =
+						e.SignatureHashAlgorithms[j+1], e.SignatureHashAlgorithms[i+1]
+				})
+				e.SignatureHashAlgorithms = e.SignatureHashAlgorithms[:cut(len(e.SignatureHashAlgorithms))]
+
+			case *extension.SupportedEllipticCurves:
+
+				e.EllipticCurves = append([]elliptic.Curve(nil), e.EllipticCurves...)
+				PRNG.Shuffle(len(e.EllipticCurves), func(i, j int) {
+					e.EllipticCurves[i], e.EllipticCurves[j] =
+						e.EllipticCurves[j], e.EllipticCurves[i]
+				})
+				e.EllipticCurves = e.EllipticCurves[:cut(len(e.EllipticCurves))]
+
+			case *extension.SupportedPointFormats:
+
+				e.PointFormats = append([]elliptic.CurvePointFormat(nil), e.PointFormats...)
+				PRNG.Shuffle(len(e.PointFormats), func(i, j int) {
+					e.PointFormats[i], e.PointFormats[j] =
+						e.PointFormats[j], e.PointFormats[i]
+				})
+				e.PointFormats = e.PointFormats[:cut(len(e.PointFormats))]
+
+			case *extension.UseSRTP:
+
+				e.ProtectionProfiles = append([]SRTPProtectionProfile(nil), e.ProtectionProfiles...)
+				PRNG.Shuffle(len(e.ProtectionProfiles), func(i, j int) {
+					e.ProtectionProfiles[i], e.ProtectionProfiles[j] =
+						e.ProtectionProfiles[j], e.ProtectionProfiles[i]
+				})
+				e.ProtectionProfiles = e.ProtectionProfiles[:cut(len(e.ProtectionProfiles))]
+			}
+		}
+
+		PRNG.Shuffle(len(extensions), func(i, j int) {
+			extensions[i], extensions[j] = extensions[j], extensions[i]
+		})
+	}
+
 	return []*packet{
 		{
 			record: &recordlayer.RecordLayer{
@@ -130,7 +210,7 @@ func flight1Generate(c flightConn, state *State, _ *handshakeCache, cfg *handsha
 						SessionID:          state.SessionID,
 						Cookie:             state.cookie,
 						Random:             state.localRandom,
-						CipherSuiteIDs:     cipherSuiteIDs(cfg.localCipherSuites),
+						CipherSuiteIDs:     cipherSuites,
 						CompressionMethods: defaultCompressionMethods(),
 						Extensions:         extensions,
 					},

+ 7 - 0
replace/dtls/flight2handler.go

@@ -6,6 +6,7 @@ package dtls
 import (
 	"bytes"
 	"context"
+	"errors"
 
 	"github.com/pion/dtls/v2/pkg/protocol"
 	"github.com/pion/dtls/v2/pkg/protocol/alert"
@@ -45,6 +46,12 @@ func flight2Parse(ctx context.Context, c flightConn, state *State, cache *handsh
 }
 
 func flight2Generate(_ flightConn, state *State, _ *handshakeCache, _ *handshakeConfig) ([]*packet, *alert.Alert, error) {
+
+	// [Psiphon]
+	// With SetDTLSInsecureSkipHelloVerify set, this should never be called,
+	// so handshake randomization is not implemented here.
+	return nil, nil, errors.New("unexpected flight2Generate call")
+
 	state.handshakeSendSequence = 0
 	return []*packet{
 		{

+ 7 - 0
replace/dtls/flight3handler.go

@@ -6,6 +6,7 @@ package dtls
 import (
 	"bytes"
 	"context"
+	"errors"
 
 	"github.com/pion/dtls/v2/internal/ciphersuite/types"
 	"github.com/pion/dtls/v2/pkg/crypto/elliptic"
@@ -228,6 +229,12 @@ func handleServerKeyExchange(_ flightConn, state *State, cfg *handshakeConfig, h
 }
 
 func flight3Generate(_ flightConn, state *State, _ *handshakeCache, cfg *handshakeConfig) ([]*packet, *alert.Alert, error) {
+
+	// [Psiphon]
+	// With SetDTLSInsecureSkipHelloVerify set, this should never be called,
+	// so handshake randomization is not implemented here.
+	return nil, nil, errors.New("unexpected flight3Generate call")
+
 	extensions := []extension.Extension{
 		&extension.SupportedSignatureAlgorithms{
 			SignatureHashAlgorithms: cfg.localSignatureSchemes,

+ 17 - 1
replace/dtls/flight4handler.go

@@ -18,6 +18,9 @@ import (
 	"github.com/pion/dtls/v2/pkg/protocol/extension"
 	"github.com/pion/dtls/v2/pkg/protocol/handshake"
 	"github.com/pion/dtls/v2/pkg/protocol/recordlayer"
+
+	inproxy_dtls "github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/inproxy/dtls"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
 )
 
 func flight4Parse(ctx context.Context, c flightConn, state *State, cache *handshakeCache, cfg *handshakeConfig) (flightVal, *alert.Alert, error) { //nolint:gocognit
@@ -218,7 +221,7 @@ func flight4Parse(ctx context.Context, c flightConn, state *State, cache *handsh
 	return flight6, nil, nil
 }
 
-func flight4Generate(_ flightConn, state *State, _ *handshakeCache, cfg *handshakeConfig) ([]*packet, *alert.Alert, error) {
+func flight4Generate(c flightConn, state *State, _ *handshakeCache, cfg *handshakeConfig) ([]*packet, *alert.Alert, error) {
 	extensions := []extension.Extension{&extension.RenegotiationInfo{
 		RenegotiatedConnection: 0,
 	}}
@@ -260,6 +263,19 @@ func flight4Generate(_ flightConn, state *State, _ *handshakeCache, cfg *handsha
 		}
 	}
 
+	// [Psiphon]
+	// Randomize ServerHello
+	seed, err := inproxy_dtls.GetDTLSSeed(c.LocalAddr())
+	if err != nil {
+		return nil, nil, err
+	}
+	if seed != nil {
+		PRNG := prng.NewPRNGWithSeed(seed)
+		PRNG.Shuffle(len(extensions), func(i, j int) {
+			extensions[i], extensions[j] = extensions[j], extensions[i]
+		})
+	}
+
 	pkts = append(pkts, &packet{
 		record: &recordlayer.RecordLayer{
 			Header: recordlayer.Header{

+ 4 - 0
replace/dtls/handshaker.go

@@ -9,6 +9,7 @@ import (
 	"crypto/x509"
 	"fmt"
 	"io"
+	"net"
 	"sync"
 	"time"
 
@@ -133,6 +134,9 @@ type flightConn interface {
 	setLocalEpoch(epoch uint16)
 	handleQueuedPackets(context.Context) error
 	sessionKey() []byte
+
+	// [Psiphon]
+	LocalAddr() net.Addr
 }
 
 func (c *handshakeConfig) writeKeyLog(label string, clientRandom, secret []byte) {