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

Merge pull request #616 from Psiphon-Labs/meek-obfuscation

Meek obfuscation
Rod Hynes 3 лет назад
Родитель
Сommit
ce31e1f0f1

+ 12 - 3
psiphon/common/logger.go

@@ -54,10 +54,19 @@ func (a LogFields) Add(b LogFields) {
 	}
 }
 
-// MetricsSource is an object that provides metrics to be logged
+// MetricsSource is an object that provides metrics to be logged.
 type MetricsSource interface {
 
-	// GetMetrics returns a LogFields populated with
-	// metrics from the MetricsSource
+	// GetMetrics returns a LogFields populated with metrics from the
+	// MetricsSource.
 	GetMetrics() LogFields
 }
+
+// NoticeMetricsSource is an object that provides metrics to be logged
+// only in notices, for inclusion in diagnostics.
+type NoticeMetricsSource interface {
+
+	// GetNoticeMetrics returns a LogFields populated with metrics from
+	// the NoticeMetricsSource.
+	GetNoticeMetrics() LogFields
+}

+ 11 - 8
psiphon/common/parameters/parameters.go

@@ -207,6 +207,8 @@ const (
 	MeekMinLimitRequestPayloadLength                 = "MeekMinLimitRequestPayloadLength"
 	MeekMaxLimitRequestPayloadLength                 = "MeekMaxLimitRequestPayloadLength"
 	MeekRedialTLSProbability                         = "MeekRedialTLSProbability"
+	MeekAlternateCookieNameProbability               = "MeekAlternateCookieNameProbability"
+	MeekAlternateContentTypeProbability              = "MeekAlternateContentTypeProbability"
 	TransformHostNameProbability                     = "TransformHostNameProbability"
 	PickUserAgentProbability                         = "PickUserAgentProbability"
 	LivenessTestMinUpstreamBytes                     = "LivenessTestMinUpstreamBytes"
@@ -530,14 +532,15 @@ var defaultParameters = map[string]struct {
 	MeekRoundTripRetryMaxDelay:                 {value: 1 * time.Second, minimum: time.Duration(0)},
 	MeekRoundTripRetryMultiplier:               {value: 2.0, minimum: 0.0},
 	MeekRoundTripTimeout:                       {value: 20 * time.Second, minimum: 1 * time.Second, flags: useNetworkLatencyMultiplier},
-
-	MeekTrafficShapingProbability:    {value: 1.0, minimum: 0.0},
-	MeekTrafficShapingLimitProtocols: {value: protocol.TunnelProtocols{}},
-	MeekMinTLSPadding:                {value: 0, minimum: 0},
-	MeekMaxTLSPadding:                {value: 0, minimum: 0},
-	MeekMinLimitRequestPayloadLength: {value: 65536, minimum: 1},
-	MeekMaxLimitRequestPayloadLength: {value: 65536, minimum: 1},
-	MeekRedialTLSProbability:         {value: 0.0, minimum: 0.0},
+	MeekTrafficShapingProbability:              {value: 1.0, minimum: 0.0},
+	MeekTrafficShapingLimitProtocols:           {value: protocol.TunnelProtocols{}},
+	MeekMinTLSPadding:                          {value: 0, minimum: 0},
+	MeekMaxTLSPadding:                          {value: 0, minimum: 0},
+	MeekMinLimitRequestPayloadLength:           {value: 65536, minimum: 1},
+	MeekMaxLimitRequestPayloadLength:           {value: 65536, minimum: 1},
+	MeekRedialTLSProbability:                   {value: 0.0, minimum: 0.0},
+	MeekAlternateCookieNameProbability:         {value: 0.5, minimum: 0.0},
+	MeekAlternateContentTypeProbability:        {value: 0.5, minimum: 0.0},
 
 	TransformHostNameProbability: {value: 0.5, minimum: 0.0},
 	PickUserAgentProbability:     {value: 0.5, minimum: 0.0},

+ 4 - 0
psiphon/common/prng/prng.go

@@ -334,6 +334,10 @@ func (p *PRNG) Base64String(byteLength int) string {
 
 var p *PRNG
 
+func DefaultPRNG() *PRNG {
+	return p
+}
+
 func Read(b []byte) (int, error) {
 	return p.Read(b)
 }

+ 0 - 3
psiphon/common/transforms/transforms.go

@@ -163,9 +163,6 @@ func (spec Spec) Apply(seed *prng.Seed, input string) (string, error) {
 			panic(err.Error())
 		}
 		replacement := rg.Generate()
-		if err != nil {
-			panic(err.Error())
-		}
 
 		re := regexp.MustCompile(transform[0])
 		value = re.ReplaceAllString(value, replacement)

+ 141 - 15
psiphon/common/values/values.go

@@ -28,18 +28,23 @@ package values
 import (
 	"bytes"
 	"encoding/gob"
+	"fmt"
+	"regexp/syntax"
 	"strings"
+	"sync"
 	"sync/atomic"
 
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
+	regen "github.com/zach-klippenstein/goregen"
 	"golang.org/x/crypto/nacl/secretbox"
 )
 
 // ValueSpec specifies a value selection space.
 type ValueSpec struct {
-	Parts   []PartSpec
-	Padding []byte
+	Probability float64
+	Parts       []PartSpec
+	Padding     []byte
 }
 
 type PartSpec struct {
@@ -50,16 +55,18 @@ type PartSpec struct {
 // NewPickOneSpec creates a simple spec to select one item from a list as a
 // value.
 func NewPickOneSpec(items []string) *ValueSpec {
-	return &ValueSpec{Parts: []PartSpec{{Items: items, MinCount: 1, MaxCount: 1}}}
+	return &ValueSpec{
+		Probability: 1.0,
+		Parts:       []PartSpec{{Items: items, MinCount: 1, MaxCount: 1}},
+	}
 }
 
 // GetValue selects a value according to the spec. An optional seed may
 // be specified to support replay.
-func (spec *ValueSpec) GetValue(seed *prng.Seed) string {
+func (spec *ValueSpec) GetValue(PRNG *prng.PRNG) string {
 	rangeFunc := prng.Range
 	intnFunc := prng.Intn
-	if seed != nil {
-		PRNG := prng.NewPRNGWithSeed(seed)
+	if PRNG != nil {
 		rangeFunc = PRNG.Range
 		intnFunc = PRNG.Intn
 	}
@@ -126,6 +133,8 @@ var (
 	sshServerVersionsSpec atomic.Value
 	userAgentsSpec        atomic.Value
 	hostNamesSpec         atomic.Value
+	cookieNamesSpec       atomic.Value
+	contentTypeSpec       atomic.Value
 )
 
 // SetRevision set the revision value, which may be used to track which value
@@ -156,8 +165,8 @@ func SetSSHClientVersionsSpec(spec *ValueSpec) {
 // returns a default when no spec is set.
 func GetSSHClientVersion() string {
 	spec, ok := sshClientVersionsSpec.Load().(*ValueSpec)
-	if !ok {
-		return ""
+	if !ok || !prng.FlipWeightedCoin(spec.Probability) {
+		return generate(prng.DefaultPRNG(), "SSH-2\\.0-OpenSSH_[7-8]\\.[0-9]")
 	}
 	return spec.GetValue(nil)
 }
@@ -173,11 +182,15 @@ func SetSSHServerVersionsSpec(spec *ValueSpec) {
 // GetSSHServerVersion selects a value based on the previously set spec, or
 // returns a default when no spec is set.
 func GetSSHServerVersion(seed *prng.Seed) string {
+	var PRNG *prng.PRNG
+	if seed != nil {
+		PRNG = prng.NewPRNGWithSeed(seed)
+	}
 	spec, ok := sshServerVersionsSpec.Load().(*ValueSpec)
-	if !ok {
-		return ""
+	if !ok || !PRNG.FlipWeightedCoin(spec.Probability) {
+		return generate(PRNG, "SSH-2\\.0-OpenSSH_[7-8]\\.[0-9]")
 	}
-	return spec.GetValue(seed)
+	return spec.GetValue(PRNG)
 }
 
 // SetUserAgentsSpec sets the corresponding value spec.
@@ -192,8 +205,8 @@ func SetUserAgentsSpec(spec *ValueSpec) {
 // returns a default when no spec is set.
 func GetUserAgent() string {
 	spec, ok := userAgentsSpec.Load().(*ValueSpec)
-	if !ok {
-		return ""
+	if !ok || !prng.FlipWeightedCoin(spec.Probability) {
+		return generateUserAgent()
 	}
 	return spec.GetValue(nil)
 }
@@ -210,8 +223,121 @@ func SetHostNamesSpec(spec *ValueSpec) {
 // returns a default when no spec is set.
 func GetHostName() string {
 	spec, ok := hostNamesSpec.Load().(*ValueSpec)
-	if !ok {
-		return "www.example.org"
+	if !ok || !prng.FlipWeightedCoin(spec.Probability) {
+		return generate(prng.DefaultPRNG(), "[a-z]{4,15}(\\.com|\\.net|\\.org)")
 	}
 	return spec.GetValue(nil)
 }
+
+// SetCookieNamesSpec sets the corresponding value spec.
+func SetCookieNamesSpec(spec *ValueSpec) {
+	if spec == nil {
+		return
+	}
+	cookieNamesSpec.Store(spec)
+}
+
+// GetCookieName selects a value based on the previously set spec, or
+// returns a default when no spec is set.
+func GetCookieName(PRNG *prng.PRNG) string {
+	spec, ok := cookieNamesSpec.Load().(*ValueSpec)
+	if !ok || !PRNG.FlipWeightedCoin(spec.Probability) {
+		return generate(PRNG, "[a-z_]{2,10}")
+	}
+	return spec.GetValue(PRNG)
+}
+
+// SetContentTypesSpec sets the corresponding value spec.
+func SetContentTypesSpec(spec *ValueSpec) {
+	if spec == nil {
+		return
+	}
+	contentTypeSpec.Store(spec)
+}
+
+// GetContentType selects a value based on the previously set spec, or
+// returns a default when no spec is set.
+func GetContentType(PRNG *prng.PRNG) string {
+	spec, ok := contentTypeSpec.Load().(*ValueSpec)
+	if !ok || !PRNG.FlipWeightedCoin(spec.Probability) {
+		return generate(PRNG, "application/octet-stream|audio/mpeg|image/jpeg|video/mpeg")
+	}
+	return spec.GetValue(PRNG)
+}
+
+func generate(PRNG *prng.PRNG, pattern string) string {
+
+	args := &regen.GeneratorArgs{
+		RngSource: PRNG,
+		Flags:     syntax.OneLine | syntax.NonGreedy,
+	}
+	rg, err := regen.NewGenerator(pattern, args)
+	if err != nil {
+		panic(err.Error())
+	}
+	return rg.Generate()
+}
+
+var (
+	userAgentGeneratorMutex sync.Mutex
+	userAgentGenerators     []*userAgentGenerator
+)
+
+type userAgentGenerator struct {
+	version   func() string
+	generator regen.Generator
+}
+
+func generateUserAgent() string {
+
+	userAgentGeneratorMutex.Lock()
+	defer userAgentGeneratorMutex.Unlock()
+
+	if userAgentGenerators == nil {
+
+		// Initialize user agent generators once and reuse. This saves the
+		// overhead of parsing the relatively complex regular expressions on
+		// each GetUserAgent call.
+
+		// These regular expressions and version ranges are adapted from:
+		//
+		// https://github.com/tarampampam/random-user-agent/blob/d0dd4059ac518e8b0f79510d050877c685539fbc/src/useragent/generator.ts
+		// https://github.com/tarampampam/random-user-agent/blob/d0dd4059ac518e8b0f79510d050877c685539fbc/src/useragent/versions.ts
+
+		chromeVersion := func() string {
+			return fmt.Sprintf("%d.0.%d.%d",
+				prng.Range(101, 104), prng.Range(4951, 5162), prng.Range(80, 212))
+		}
+
+		safariVersion := func() string {
+			return fmt.Sprintf("%d.%d.%d",
+				prng.Range(537, 611), prng.Range(1, 36), prng.Range(1, 15))
+		}
+
+		makeGenerator := func(pattern string) regen.Generator {
+			args := &regen.GeneratorArgs{
+				RngSource: prng.DefaultPRNG(),
+				Flags:     syntax.OneLine | syntax.NonGreedy,
+			}
+			rg, err := regen.NewGenerator(pattern, args)
+			if err != nil {
+				panic(err.Error())
+			}
+			return rg
+		}
+
+		userAgentGenerators = []*userAgentGenerator{
+			&userAgentGenerator{chromeVersion, makeGenerator("Mozilla/5\\.0 \\(Macintosh; Intel Mac OS X 1[01]_(1|)[0-5]\\) AppleWebKit/537\\.36 \\(KHTML, like Gecko\\) Chrome/__VER__ Safari/537\\.36")},
+			&userAgentGenerator{chromeVersion, makeGenerator("Mozilla/5\\.0 \\(Windows NT 1(0|0|1)\\.0; (WOW64|Win64)(; x64|; x64|)\\) AppleWebKit/537\\.36 \\(KHTML, like Gecko\\) Chrome/__VER__ Safari/537\\.36")},
+			&userAgentGenerator{chromeVersion, makeGenerator("Mozilla/5\\.0 \\(Linux; Android (9|10|10|11|12); [a-zA-Z0-9_]{5,10}\\) AppleWebKit/537\\.36 \\(KHTML, like Gecko\\) Chrome/__VER__ Mobile Safari/537\\.36")},
+			&userAgentGenerator{safariVersion, makeGenerator("Mozilla/5\\.0 \\(iPhone; CPU iPhone OS 1[3-5]_[1-5] like Mac OS X\\) AppleWebKit/(__VER__|__VER__|600\\.[1-8]\\.[12][0-7]|537\\.36) \\(KHTML, like Gecko\\) Version/1[0-4]\\.[0-7](\\.[1-9][0-7]|) Mobile/[A-Z0-9]{6} Safari/__VER__")},
+			&userAgentGenerator{safariVersion, makeGenerator("Mozilla/5\\.0 \\(Macintosh; Intel Mac OS X 1[01]_(1|)[0-7](_[1-7]|)\\) AppleWebKit/(__VER__|__VER__|600\\.[1-8]\\.[12][0-7]|537\\.36) \\(KHTML, like Gecko\\) Version/1[0-4]\\.[0-7](\\.[1-9][0-7]|) Safari/__VER__")},
+		}
+	}
+
+	g := userAgentGenerators[prng.Range(0, len(userAgentGenerators)-1)]
+
+	value := g.generator.Generate()
+	value = strings.ReplaceAll(value, "__VER__", g.version())
+	return value
+}

+ 17 - 7
psiphon/config.go

@@ -687,13 +687,15 @@ type Config struct {
 
 	// MeekTrafficShapingProbability and associated fields are for testing
 	// purposes.
-	MeekTrafficShapingProbability    *float64
-	MeekTrafficShapingLimitProtocols []string
-	MeekMinTLSPadding                *int
-	MeekMaxTLSPadding                *int
-	MeekMinLimitRequestPayloadLength *int
-	MeekMaxLimitRequestPayloadLength *int
-	MeekRedialTLSProbability         *float64
+	MeekTrafficShapingProbability       *float64
+	MeekTrafficShapingLimitProtocols    []string
+	MeekMinTLSPadding                   *int
+	MeekMaxTLSPadding                   *int
+	MeekMinLimitRequestPayloadLength    *int
+	MeekMaxLimitRequestPayloadLength    *int
+	MeekRedialTLSProbability            *float64
+	MeekAlternateCookieNameProbability  *float64
+	MeekAlternateContentTypeProbability *float64
 
 	// ObfuscatedSSHAlgorithms and associated ObfuscatedSSH fields are for
 	// testing purposes. If specified, ObfuscatedSSHAlgorithms must have 4 SSH
@@ -1635,6 +1637,14 @@ func (config *Config) makeConfigParameters() map[string]interface{} {
 		applyParameters[parameters.MeekRedialTLSProbability] = *config.MeekRedialTLSProbability
 	}
 
+	if config.MeekAlternateCookieNameProbability != nil {
+		applyParameters[parameters.MeekAlternateCookieNameProbability] = *config.MeekAlternateCookieNameProbability
+	}
+
+	if config.MeekAlternateContentTypeProbability != nil {
+		applyParameters[parameters.MeekAlternateContentTypeProbability] = *config.MeekAlternateContentTypeProbability
+	}
+
 	if config.ObfuscatedSSHMinPadding != nil {
 		applyParameters[parameters.ObfuscatedSSHMinPadding] = *config.ObfuscatedSSHMinPadding
 	}

+ 3 - 2
psiphon/dialParameters.go

@@ -134,8 +134,9 @@ type DialParameters struct {
 
 	HoldOffTunnelDuration time.Duration
 
-	DialConnMetrics          common.MetricsSource `json:"-"`
-	ObfuscatedSSHConnMetrics common.MetricsSource `json:"-"`
+	DialConnMetrics          common.MetricsSource       `json:"-"`
+	DialConnNoticeMetrics    common.NoticeMetricsSource `json:"-"`
+	ObfuscatedSSHConnMetrics common.MetricsSource       `json:"-"`
 
 	DialDuration time.Duration `json:"-"`
 

+ 54 - 20
psiphon/meekConn.go

@@ -44,6 +44,7 @@ import (
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/quic"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/values"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/upstreamproxy"
 	"golang.org/x/crypto/nacl/box"
 	"golang.org/x/net/http2"
@@ -211,6 +212,7 @@ type MeekConn struct {
 	url                       *url.URL
 	additionalHeaders         http.Header
 	cookie                    *http.Cookie
+	contentType               string
 	cookieSize                int
 	tlsPadding                int
 	limitRequestPayloadLength int
@@ -314,6 +316,7 @@ func DialMeek(
 	if meek.mode == MeekModeRelay {
 		var err error
 		meek.cookie,
+			meek.contentType,
 			meek.tlsPadding,
 			meek.limitRequestPayloadLength,
 			meek.redialTLSProbability,
@@ -806,6 +809,19 @@ func (meek *MeekConn) GetMetrics() common.LogFields {
 	return logFields
 }
 
+// GetNoticeMetrics implements the common.NoticeMetricsSource interface.
+func (meek *MeekConn) GetNoticeMetrics() common.LogFields {
+
+	// These fields are logged only in notices, for diagnostics. The server
+	// will log the same values, but derives them from HTTP headers, so they
+	// don't need to be sent in the API request.
+
+	logFields := make(common.LogFields)
+	logFields["meek_cookie_name"] = meek.cookie.Name
+	logFields["meek_content_type"] = meek.contentType
+	return logFields
+}
+
 // ObfuscatedRoundTrip makes a request to the meek server and returns the
 // response. A new, obfuscated meek cookie is created for every request. The
 // specified end point is recorded in the cookie and is not exposed as
@@ -823,7 +839,7 @@ func (meek *MeekConn) ObfuscatedRoundTrip(
 		return nil, errors.TraceNew("operation unsupported")
 	}
 
-	cookie, _, _, _, err := makeMeekObfuscationValues(
+	cookie, contentType, _, _, _, err := makeMeekObfuscationValues(
 		meek.getCustomParameters(),
 		meek.meekCookieEncryptionPublicKey,
 		meek.meekObfuscatedKey,
@@ -843,7 +859,7 @@ func (meek *MeekConn) ObfuscatedRoundTrip(
 	// the concurrency constraints are satisfied.
 
 	request, err := meek.newRequest(
-		requestCtx, cookie, bytes.NewReader(requestBody), 0)
+		requestCtx, cookie, contentType, bytes.NewReader(requestBody), 0)
 	if err != nil {
 		return nil, errors.Trace(err)
 	}
@@ -1178,6 +1194,7 @@ func (r *readCloseSignaller) AwaitClosed() bool {
 func (meek *MeekConn) newRequest(
 	requestCtx context.Context,
 	cookie *http.Cookie,
+	contentType string,
 	body io.Reader,
 	contentLength int) (*http.Request, error) {
 
@@ -1199,7 +1216,7 @@ func (meek *MeekConn) newRequest(
 
 	meek.addAdditionalHeaders(request)
 
-	request.Header.Set("Content-Type", "application/octet-stream")
+	request.Header.Set("Content-Type", contentType)
 
 	if cookie == nil {
 		cookie = meek.cookie
@@ -1320,6 +1337,7 @@ func (meek *MeekConn) relayRoundTrip(sendBuffer *bytes.Buffer) (int64, error) {
 		request, err := meek.newRequest(
 			requestCtx,
 			nil,
+			meek.contentType,
 			requestBody,
 			contentLength)
 		if err != nil {
@@ -1535,13 +1553,14 @@ func makeMeekObfuscationValues(
 	endPoint string,
 
 ) (cookie *http.Cookie,
+	contentType string,
 	tlsPadding int,
 	limitRequestPayloadLength int,
 	redialTLSProbability float64,
 	err error) {
 
 	if meekCookieEncryptionPublicKey == "" {
-		return nil, 0, 0, 0.0, errors.TraceNew("missing public key")
+		return nil, "", 0, 0, 0.0, errors.TraceNew("missing public key")
 	}
 
 	cookieData := &protocol.MeekCookieData{
@@ -1551,7 +1570,7 @@ func makeMeekObfuscationValues(
 	}
 	serializedCookie, err := json.Marshal(cookieData)
 	if err != nil {
-		return nil, 0, 0, 0.0, errors.Trace(err)
+		return nil, "", 0, 0, 0.0, errors.Trace(err)
 	}
 
 	// Encrypt the JSON data
@@ -1565,12 +1584,12 @@ func makeMeekObfuscationValues(
 	var publicKey [32]byte
 	decodedPublicKey, err := base64.StdEncoding.DecodeString(meekCookieEncryptionPublicKey)
 	if err != nil {
-		return nil, 0, 0, 0.0, errors.Trace(err)
+		return nil, "", 0, 0, 0.0, errors.Trace(err)
 	}
 	copy(publicKey[:], decodedPublicKey)
 	ephemeralPublicKey, ephemeralPrivateKey, err := box.GenerateKey(rand.Reader)
 	if err != nil {
-		return nil, 0, 0, 0.0, errors.Trace(err)
+		return nil, "", 0, 0, 0.0, errors.Trace(err)
 	}
 	box := box.Seal(nil, serializedCookie, &nonce, &publicKey, ephemeralPrivateKey)
 	encryptedCookie := make([]byte, 32+len(box))
@@ -1587,7 +1606,7 @@ func makeMeekObfuscationValues(
 			PaddingPRNGSeed: meekObfuscatorPaddingPRNGSeed,
 			MaxPadding:      &maxPadding})
 	if err != nil {
-		return nil, 0, 0, 0.0, errors.Trace(err)
+		return nil, "", 0, 0, 0.0, errors.Trace(err)
 	}
 	obfuscatedCookie := obfuscator.SendSeedMessage()
 	seedLen := len(obfuscatedCookie)
@@ -1596,20 +1615,35 @@ func makeMeekObfuscationValues(
 
 	cookieNamePRNG, err := obfuscator.GetDerivedPRNG("meek-cookie-name")
 	if err != nil {
-		return nil, 0, 0, 0.0, errors.Trace(err)
+		return nil, "", 0, 0, 0.0, errors.Trace(err)
+	}
+	var cookieName string
+	if cookieNamePRNG.FlipWeightedCoin(p.Float(parameters.MeekAlternateCookieNameProbability)) {
+		cookieName = values.GetCookieName(cookieNamePRNG)
+	} else {
+		// Format the HTTP cookie
+		// The format is <random letter 'A'-'Z'>=<base64 data>, which is intended to match common cookie formats.
+		A := int('A')
+		Z := int('Z')
+		// letterIndex is integer in range [int('A'), int('Z')]
+		letterIndex := cookieNamePRNG.Intn(Z - A + 1)
+		cookieName = string(byte(A + letterIndex))
 	}
-
-	// Format the HTTP cookie
-	// The format is <random letter 'A'-'Z'>=<base64 data>, which is intended to match common cookie formats.
-	A := int('A')
-	Z := int('Z')
-	// letterIndex is integer in range [int('A'), int('Z')]
-	letterIndex := cookieNamePRNG.Intn(Z - A + 1)
 
 	cookie = &http.Cookie{
-		Name:  string(byte(A + letterIndex)),
+		Name:  cookieName,
 		Value: base64.StdEncoding.EncodeToString(obfuscatedCookie)}
 
+	contentTypePRNG, err := obfuscator.GetDerivedPRNG("meek-content-type")
+	if err != nil {
+		return nil, "", 0, 0, 0.0, errors.Trace(err)
+	}
+	if contentTypePRNG.FlipWeightedCoin(p.Float(parameters.MeekAlternateContentTypeProbability)) {
+		contentType = values.GetContentType(contentTypePRNG)
+	} else {
+		contentType = "application/octet-stream"
+	}
+
 	tlsPadding = 0
 	limitRequestPayloadLength = MEEK_MAX_REQUEST_PAYLOAD_LENGTH
 	redialTLSProbability = 0.0
@@ -1622,7 +1656,7 @@ func makeMeekObfuscationValues(
 		limitRequestPayloadLengthPRNG, err := obfuscator.GetDerivedPRNG(
 			"meek-limit-request-payload-length")
 		if err != nil {
-			return nil, 0, 0, 0.0, errors.Trace(err)
+			return nil, "", 0, 0, 0.0, errors.Trace(err)
 		}
 
 		minLength := p.Int(parameters.MeekMinLimitRequestPayloadLength)
@@ -1649,7 +1683,7 @@ func makeMeekObfuscationValues(
 			tlsPaddingPRNG, err := obfuscator.GetDerivedPRNG(
 				"meek-tls-padding")
 			if err != nil {
-				return nil, 0, 0, 0.0, errors.Trace(err)
+				return nil, "", 0, 0, 0.0, errors.Trace(err)
 			}
 
 			tlsPadding = tlsPaddingPRNG.Range(minPadding, maxPadding)
@@ -1658,5 +1692,5 @@ func makeMeekObfuscationValues(
 		redialTLSProbability = p.Float(parameters.MeekRedialTLSProbability)
 	}
 
-	return cookie, tlsPadding, limitRequestPayloadLength, redialTLSProbability, nil
+	return cookie, contentType, tlsPadding, limitRequestPayloadLength, redialTLSProbability, nil
 }

+ 7 - 0
psiphon/notice.go

@@ -588,6 +588,13 @@ func noticeWithDialParameters(noticeType string, dialParams *DialParameters, pos
 			}
 		}
 
+		if dialParams.DialConnNoticeMetrics != nil {
+			metrics := dialParams.DialConnNoticeMetrics.GetNoticeMetrics()
+			for name, value := range metrics {
+				args = append(args, name, value)
+			}
+		}
+
 		if dialParams.ObfuscatedSSHConnMetrics != nil {
 			metrics := dialParams.ObfuscatedSSHConnMetrics.GetMetrics()
 			for name, value := range metrics {

+ 6 - 0
psiphon/server/meek.go

@@ -790,6 +790,8 @@ func (server *MeekServer) getSessionOrEndpoint(
 		meekProtocolVersion: clientSessionData.MeekProtocolVersion,
 		sessionIDSent:       false,
 		cachedResponse:      cachedResponse,
+		cookieName:          meekCookie.Name,
+		contentType:         request.Header.Get("Content-Type"),
 	}
 
 	session.touch()
@@ -1278,6 +1280,8 @@ type meekSession struct {
 	meekProtocolVersion              int
 	sessionIDSent                    bool
 	cachedResponse                   *CachedResponse
+	cookieName                       string
+	contentType                      string
 }
 
 func (session *meekSession) touch() {
@@ -1346,6 +1350,8 @@ func (session *meekSession) GetMetrics() common.LogFields {
 	logFields["meek_peak_cached_response_hit_size"] = atomic.LoadInt64(&session.metricPeakCachedResponseHitSize)
 	logFields["meek_cached_response_miss_position"] = atomic.LoadInt64(&session.metricCachedResponseMissPosition)
 	logFields["meek_underlying_connection_count"] = atomic.LoadInt64(&session.metricUnderlyingConnCount)
+	logFields["meek_cookie_name"] = session.cookieName
+	logFields["meek_content_type"] = session.contentType
 	return logFields
 }
 

+ 1 - 1
psiphon/server/server_test.go

@@ -1670,7 +1670,7 @@ func checkExpectedServerTunnelLogFields(
 	}
 
 	if !common.Contains(testSSHClientVersions, fields["ssh_client_version"].(string)) {
-		return fmt.Errorf("unexpected relay_protocol '%s'", fields["ssh_client_version"])
+		return fmt.Errorf("unexpected ssh_client_version '%s'", fields["ssh_client_version"])
 	}
 
 	clientFeatures := fields["client_features"].([]interface{})

+ 4 - 0
psiphon/tunnel.go

@@ -945,6 +945,10 @@ func dialTunnel(
 		dialParams.DialConnMetrics = metricsSource
 	}
 
+	if noticeMetricsSource, ok := dialConn.(common.NoticeMetricsSource); ok {
+		dialParams.DialConnNoticeMetrics = noticeMetricsSource
+	}
+
 	// If dialConn is not a Closer, tunnel failure detection may be slower
 	if _, ok := dialConn.(common.Closer); !ok {
 		NoticeWarning("tunnel.dialTunnel: dialConn is not a Closer")