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

Merge pull request #657 from mirokuratczyk/master

Add FrontingSpecs to TransferURLs
Rod Hynes 2 лет назад
Родитель
Сommit
52bd6be222

+ 2 - 2
MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/PsiphonTunnel.m

@@ -838,7 +838,7 @@ typedef NS_ERROR_ENUM(PsiphonTunnelErrorDomain, PsiphonTunnelErrorCode) {
 
     }
 
-    // Where required, enable TransferURLsAlwaysSkipVerify, which overrides
+    // Where required, enable DisableSystemRootCAs, which overrides
     // the TransferURL.SkipVerify configuration for remote server list
     // downloads and feedback uploads. Both of these operations have
     // additional security at the payload level. Verifying TLS certificates
@@ -854,7 +854,7 @@ typedef NS_ERROR_ENUM(PsiphonTunnelErrorDomain, PsiphonTunnelErrorCode) {
     } else if (@available(iOS 12.0, *)) {
         alwaysSkipVerify = *tunnelWholeDevice;
     }
-    config[@"TransferURLsAlwaysSkipVerify"] = @(alwaysSkipVerify);
+    config[@"DisableSystemRootCAs"] = @(alwaysSkipVerify);
 
     NSString *finalConfigStr = [[[SBJson4Writer alloc] init] stringWithObject:config];
     

+ 6 - 0
psiphon/common/parameters/transferURLs.go

@@ -37,6 +37,8 @@ type TransferURL struct {
 	// SkipVerify indicates whether to verify HTTPS certificates. In some
 	// circumvention scenarios, verification is not possible. This must
 	// only be set to true when the resource has its own verification mechanism.
+	// Overridden when a FrontingSpec in FrontingSpecs has verification fields
+	// set.
 	SkipVerify bool
 
 	// OnlyAfterAttempts specifies how to schedule this URL when transferring
@@ -54,6 +56,10 @@ type TransferURL struct {
 	// RequestHeaders are optional HTTP headers to set on any requests made to
 	// the destination.
 	RequestHeaders map[string]string `json:",omitempty"`
+
+	// FrontingSpecs is an optional set of domain fronting configurations to
+	// apply to any requests made to the destination.
+	FrontingSpecs FrontingSpecs
 }
 
 // TransferURLs is a list of transfer URLs.

+ 79 - 10
psiphon/config.go

@@ -29,6 +29,7 @@ import (
 	"net/http"
 	"os"
 	"path/filepath"
+	"reflect"
 	"regexp"
 	"strconv"
 	"strings"
@@ -41,6 +42,7 @@ import (
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/resolver"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/transforms"
+	"golang.org/x/crypto/nacl/secretbox"
 )
 
 const (
@@ -418,15 +420,15 @@ type Config struct {
 	// operating system.
 	TrustedCACertificatesFilename string
 
-	// TransferURLsAlwaysSkipVerify, when true, forces TransferURL.SkipVerify
-	// to true for all remote server list downloads, upgrade downloads, and
-	// feedback uploads. Each of these transfers has additional security at
-	// the payload level. Verifying TLS certificates is preferred, as an
-	// additional security and circumvention layer; set
-	// TransferURLsAlwaysSkipVerify only in cases where system root CAs
-	// cannot be loaded; for example, if unsupported (iOS < 12) or
-	// insufficient memory (VPN extension on iOS < 15).
-	TransferURLsAlwaysSkipVerify bool
+	// DisableSystemRootCAs, when true, disables loading system root CAs when
+	// verifying TLS certificates for all remote server list downloads, upgrade
+	// downloads, and feedback uploads. Each of these transfers has additional
+	// security at the payload level. Verifying TLS certificates is preferred,
+	// as an additional security and circumvention layer; set
+	// DisableSystemRootCAs only in cases where system root CAs cannot be
+	// loaded; for example, if unsupported (iOS < 12) or insufficient memory
+	// (VPN extension on iOS < 15).
+	DisableSystemRootCAs bool
 
 	// DisablePeriodicSshKeepAlive indicates whether to send an SSH keepalive
 	// every 1-2 minutes, when the tunnel is idle. If the SSH keepalive times
@@ -853,6 +855,9 @@ type Config struct {
 	TLSTunnelMinTLSPadding             *int
 	TLSTunnelMaxTLSPadding             *int
 
+	// AdditionalParameters is used for testing.
+	AdditionalParameters string
+
 	// params is the active parameters.Parameters with defaults, config values,
 	// and, optionally, tactics applied.
 	//
@@ -956,6 +961,12 @@ func (config *Config) IsCommitted() bool {
 // be re-populated over time.
 func (config *Config) Commit(migrateFromLegacyFields bool) error {
 
+	// Apply any additional parameters first
+	additionalParametersInfoMsgs, err := config.applyAdditionalParameters()
+	if err != nil {
+		return errors.TraceMsg(err, "failed to apply additional parameters")
+	}
+
 	// Do SetEmitDiagnosticNotices first, to ensure config file errors are
 	// emitted.
 	if config.EmitDiagnosticNotices {
@@ -1041,6 +1052,9 @@ func (config *Config) Commit(migrateFromLegacyFields bool) error {
 	}
 
 	// Emit notices now that notice files are set if configured
+	for _, msg := range additionalParametersInfoMsgs {
+		NoticeInfo(msg)
+	}
 	for _, msg := range noticeMigrationAlertMsgs {
 		NoticeWarning(msg)
 	}
@@ -1132,7 +1146,7 @@ func (config *Config) Commit(migrateFromLegacyFields bool) error {
 		return errors.TraceNew("sponsor ID is missing from the configuration file")
 	}
 
-	_, err := strconv.Atoi(config.ClientVersion)
+	_, err = strconv.Atoi(config.ClientVersion)
 	if err != nil {
 		return errors.Tracef("invalid client version: %s", err)
 	}
@@ -2557,6 +2571,61 @@ func (config *Config) setDialParametersHash() {
 	config.dialParametersHash = hash.Sum(nil)
 }
 
+// applyAdditionalParameters decodes and applies any additional parameters
+// stored in config.AdditionalParameter to the Config and returns an array
+// of notices which should be logged at the info level. If there is no error,
+// then config.AdditionalParameter is set to "" to conserve memory and further
+// calls will do nothing. This function should only be called once.
+//
+// If there is an error, the existing Config is left entirely unmodified.
+func (config *Config) applyAdditionalParameters() ([]string, error) {
+
+	if config.AdditionalParameters == "" {
+		return nil, nil
+	}
+
+	b, err := base64.StdEncoding.DecodeString(config.AdditionalParameters)
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+
+	if len(b) < 32 {
+		return nil, errors.Tracef("invalid length, len(b) == %d", len(b))
+	}
+
+	var key [32]byte
+	copy(key[:], b[:32])
+
+	decrypted, ok := secretbox.Open(nil, b[32:], &[24]byte{}, &key)
+	if !ok {
+		return nil, errors.TraceNew("secretbox.Open failed")
+	}
+
+	var additionalParameters Config
+	err = json.Unmarshal(decrypted, &additionalParameters)
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+
+	src := reflect.ValueOf(&additionalParameters).Elem()
+	dest := reflect.ValueOf(config).Elem()
+
+	var infoNotices []string
+
+	for i := 0; i < src.NumField(); i++ {
+		if !src.Field(i).IsZero() {
+			dest.Field(i).Set(src.Field(i))
+			infoNotice := fmt.Sprintf("%s overridden by AdditionalParameters", dest.Type().Field(i).Name)
+			infoNotices = append(infoNotices, infoNotice)
+		}
+	}
+
+	// Reset field to conserve memory since this is a one-time operation.
+	config.AdditionalParameters = ""
+
+	return infoNotices, nil
+}
+
 func promoteLegacyTransferURL(URL string) parameters.TransferURLs {
 	transferURLs := make(parameters.TransferURLs, 1)
 	transferURLs[0] = &parameters.TransferURL{

+ 6 - 1
psiphon/controller.go

@@ -159,8 +159,13 @@ func NewController(config *Config) (controller *Controller, err error) {
 		DeviceBinder:     controller.config.deviceBinder,
 		IPv6Synthesizer:  controller.config.IPv6Synthesizer,
 		ResolveIP: func(ctx context.Context, hostname string) ([]net.IP, error) {
+			// Note: when domain fronting would be used for untunneled dials a
+			// copy of untunneledDialConfig should be used instead, which
+			// redefines ResolveIP such that the corresponding fronting
+			// provider ID is passed into UntunneledResolveIP to enable the use
+			// of pre-resolved IPs.
 			IPs, err := UntunneledResolveIP(
-				ctx, controller.config, controller.resolver, hostname)
+				ctx, controller.config, controller.resolver, hostname, "")
 			if err != nil {
 				return nil, errors.Trace(err)
 			}

+ 14 - 4
psiphon/dialParameters.go

@@ -580,6 +580,11 @@ func MakeDialParameters(
 				return nil, errors.Trace(err)
 			}
 
+			if config.DisableSystemRootCAs &&
+				(len(dialParams.MeekVerifyPins) == 0 || dialParams.MeekVerifyServerName == "") {
+				return nil, errors.TraceNew("TLS certificates must be verified in Conjure API registration")
+			}
+
 			dialParams.MeekDialAddress = net.JoinHostPort(dialParams.MeekFrontingDialAddress, "443")
 			dialParams.MeekHostHeader = dialParams.MeekFrontingHost
 
@@ -1131,6 +1136,7 @@ func MakeDialParameters(
 			AddPsiphonFrontingHeader:      addPsiphonFrontingHeader,
 			VerifyServerName:              dialParams.MeekVerifyServerName,
 			VerifyPins:                    dialParams.MeekVerifyPins,
+			DisableSystemRootCAs:          config.DisableSystemRootCAs,
 			HostHeader:                    dialParams.MeekHostHeader,
 			TransformedHostName:           dialParams.MeekTransformedHostName,
 			ClientTunnelProtocol:          dialParams.TunnelProtocol,
@@ -1259,11 +1265,15 @@ func (dialParams *DialParameters) Failed(config *Config) {
 }
 
 func (dialParams *DialParameters) GetTLSVersionForMetrics() string {
-	tlsVersion := dialParams.TLSVersion
-	if dialParams.NoDefaultTLSSessionID {
-		tlsVersion += "-no_def_id"
+	return getTLSVersionForMetrics(dialParams.TLSVersion, dialParams.NoDefaultTLSSessionID)
+}
+
+func getTLSVersionForMetrics(tlsVersion string, noDefaultTLSSessionID bool) string {
+	version := tlsVersion
+	if noDefaultTLSSessionID {
+		version += "-no_def_id"
 	}
-	return tlsVersion
+	return version
 }
 
 // ExchangedDialParameters represents the subset of DialParameters that is

+ 10 - 3
psiphon/feedback.go

@@ -157,8 +157,13 @@ func SendFeedback(ctx context.Context, config *Config, diagnostics, uploadPath s
 		DeviceBinder:     nil,
 		IPv6Synthesizer:  config.IPv6Synthesizer,
 		ResolveIP: func(ctx context.Context, hostname string) ([]net.IP, error) {
+			// Note: when domain fronting would be used for untunneled dials a
+			// copy of untunneledDialConfig should be used instead, which
+			// redefines ResolveIP such that the corresponding fronting
+			// provider ID is passed into UntunneledResolveIP to enable the use
+			// of pre-resolved IPs.
 			IPs, err := UntunneledResolveIP(
-				ctx, config, resolver, hostname)
+				ctx, config, resolver, hostname, "")
 			if err != nil {
 				return nil, errors.Trace(err)
 			}
@@ -194,11 +199,13 @@ func SendFeedback(ctx context.Context, config *Config, diagnostics, uploadPath s
 			feedbackUploadTimeout)
 		defer cancelFunc()
 
-		client, err := MakeUntunneledHTTPClient(
+		client, _, err := MakeUntunneledHTTPClient(
 			feedbackUploadCtx,
 			config,
 			untunneledDialConfig,
-			uploadURL.SkipVerify || config.TransferURLsAlwaysSkipVerify)
+			uploadURL.SkipVerify,
+			config.DisableSystemRootCAs,
+			uploadURL.FrontingSpecs)
 		if err != nil {
 			return errors.Trace(err)
 		}

+ 17 - 0
psiphon/meekConn.go

@@ -176,6 +176,16 @@ type MeekConfig struct {
 	// etc.
 	VerifyPins []string
 
+	// DisableSystemRootCAs, when true, disables loading system root CAs when
+	// verifying the server certificate chain. Set DisableSystemRootCAs only in
+	// cases where system root CAs cannot be loaded; for example, if
+	// unsupported (iOS < 12) or insufficient memory (VPN extension on iOS <
+	// 15).
+	//
+	// When DisableSystemRootCAs and VerifyServerName are set, VerifyPins must
+	// be set.
+	DisableSystemRootCAs bool
+
 	// ClientTunnelProtocol is the protocol the client is using. It's included in
 	// the meek cookie for optional use by the server, in cases where the server
 	// cannot unambiguously determine the tunnel protocol. ClientTunnelProtocol
@@ -292,6 +302,12 @@ func DialMeek(
 			"invalid config: VerifyServerName must be set when VerifyPins is set")
 	}
 
+	if meekConfig.DisableSystemRootCAs && !skipVerify &&
+		(len(meekConfig.VerifyServerName) == 0 || len(meekConfig.VerifyPins) == 0) {
+		return nil, errors.TraceNew(
+			"invalid config: VerifyServerName and VerifyPins must be set when DisableSystemRootCAs is set")
+	}
+
 	if meekConfig.Mode == MeekModePlaintextRoundTrip &&
 		(!meekConfig.UseHTTPS || skipVerify) {
 		return nil, errors.TraceNew(
@@ -436,6 +452,7 @@ func DialMeek(
 			SkipVerify:                    skipVerify,
 			VerifyServerName:              meekConfig.VerifyServerName,
 			VerifyPins:                    meekConfig.VerifyPins,
+			DisableSystemRootCAs:          meekConfig.DisableSystemRootCAs,
 			TLSProfile:                    meekConfig.TLSProfile,
 			NoDefaultTLSSessionID:         &meekConfig.NoDefaultTLSSessionID,
 			RandomizedTLSProfileSeed:      meekConfig.RandomizedTLSProfileSeed,

+ 267 - 16
psiphon/net.go

@@ -39,6 +39,8 @@ import (
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/fragmentor"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/parameters"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/resolver"
 	"golang.org/x/net/bpf"
 )
@@ -365,13 +367,14 @@ func UntunneledResolveIP(
 	ctx context.Context,
 	config *Config,
 	resolver *resolver.Resolver,
-	hostname string) ([]net.IP, error) {
+	hostname,
+	frontingProviderID string) ([]net.IP, error) {
 
 	// Limitations: for untunneled resolves, there is currently no resolve
 	// parameter replay, and no support for pre-resolved IPs.
 
 	params, err := resolver.MakeResolveParameters(
-		config.GetParameters().Get(), "")
+		config.GetParameters().Get(), frontingProviderID)
 	if err != nil {
 		return nil, errors.Trace(err)
 	}
@@ -388,8 +391,237 @@ func UntunneledResolveIP(
 	return IPs, nil
 }
 
+// makeUntunneledFrontedHTTPClient returns a net/http.Client which is
+// configured to use domain fronting and custom dialing features -- including
+// BindToDevice, etc. One or more fronting specs must be provided, i.e.
+// len(frontingSpecs) must be greater than 0. A function is returned which,
+// if non-nil, can be called after each request made with the net/http.Client
+// completes to retrieve the set of API parameter values applied to the request.
+//
+// The context is applied to underlying TCP dials. The caller is responsible
+// for applying the context to requests made with the returned http.Client.
+func makeUntunneledFrontedHTTPClient(ctx context.Context, config *Config, untunneledDialConfig *DialConfig, frontingSpecs parameters.FrontingSpecs, skipVerify, disableSystemRootCAs bool) (*http.Client, func() common.APIParameters, error) {
+
+	frontingProviderID, meekFrontingDialAddress, meekSNIServerName, meekVerifyServerName, meekVerifyPins, meekFrontingHost, err := parameters.FrontingSpecs(frontingSpecs).SelectParameters()
+	if err != nil {
+		return nil, nil, errors.Trace(err)
+	}
+
+	meekDialAddress := net.JoinHostPort(meekFrontingDialAddress, "443")
+	meekHostHeader := meekFrontingHost
+
+	p := config.GetParameters().Get()
+	effectiveTunnelProtocol := protocol.TUNNEL_PROTOCOL_FRONTED_MEEK
+
+	requireTLS12SessionTickets := protocol.TunnelProtocolRequiresTLS12SessionTickets(
+		effectiveTunnelProtocol)
+	requireTLS13Support := protocol.TunnelProtocolRequiresTLS13Support(effectiveTunnelProtocol)
+	isFronted := true
+
+	tlsProfile, tlsVersion, randomizedTLSProfileSeed, err := SelectTLSProfile(
+		requireTLS12SessionTickets, requireTLS13Support, isFronted, frontingProviderID, p)
+	if err != nil {
+		return nil, nil, errors.Trace(err)
+	}
+
+	if tlsProfile == "" && (requireTLS12SessionTickets || requireTLS13Support) {
+		return nil, nil, errors.TraceNew("required TLS profile not found")
+	}
+
+	noDefaultTLSSessionID := p.WeightedCoinFlip(
+		parameters.NoDefaultTLSSessionIDProbability)
+
+	// For a FrontingSpec, an SNI value of "" indicates to disable/omit SNI, so
+	// never transform in that case.
+	var meekTransformedHostName bool
+	if meekSNIServerName != "" {
+		if p.WeightedCoinFlip(parameters.TransformHostNameProbability) {
+			meekSNIServerName = selectHostName(effectiveTunnelProtocol, p)
+			meekTransformedHostName = true
+		}
+	}
+
+	addPsiphonFrontingHeader := false
+	if frontingProviderID != "" {
+		addPsiphonFrontingHeader = common.Contains(
+			p.LabeledTunnelProtocols(
+				parameters.AddFrontingProviderPsiphonFrontingHeader, frontingProviderID),
+			effectiveTunnelProtocol)
+	}
+
+	networkLatencyMultiplierMin := p.Float(parameters.NetworkLatencyMultiplierMin)
+	networkLatencyMultiplierMax := p.Float(parameters.NetworkLatencyMultiplierMax)
+
+	networkLatencyMultiplier := prng.ExpFloat64Range(
+		networkLatencyMultiplierMin,
+		networkLatencyMultiplierMax,
+		p.Float(parameters.NetworkLatencyMultiplierLambda))
+
+	meekConfig := &MeekConfig{
+		DiagnosticID:             frontingProviderID,
+		Parameters:               config.GetParameters(),
+		Mode:                     MeekModePlaintextRoundTrip,
+		DialAddress:              meekDialAddress,
+		UseHTTPS:                 true,
+		TLSProfile:               tlsProfile,
+		NoDefaultTLSSessionID:    noDefaultTLSSessionID,
+		RandomizedTLSProfileSeed: randomizedTLSProfileSeed,
+		SNIServerName:            meekSNIServerName,
+		AddPsiphonFrontingHeader: addPsiphonFrontingHeader,
+		HostHeader:               meekHostHeader,
+		TransformedHostName:      meekTransformedHostName,
+		ClientTunnelProtocol:     effectiveTunnelProtocol,
+		NetworkLatencyMultiplier: networkLatencyMultiplier,
+	}
+
+	if !skipVerify {
+		meekConfig.VerifyServerName = meekVerifyServerName
+		meekConfig.VerifyPins = meekVerifyPins
+		meekConfig.DisableSystemRootCAs = disableSystemRootCAs
+	}
+
+	var resolvedIPAddress atomic.Value
+	resolvedIPAddress.Store("")
+
+	// The default untunneled dial config does not support pre-resolved IPs so
+	// redefine the dial config to override ResolveIP with an implementation
+	// that enables their use by passing the fronting provider ID into
+	// UntunneledResolveIP.
+	meekDialConfig := &DialConfig{
+		UpstreamProxyURL: untunneledDialConfig.UpstreamProxyURL,
+		CustomHeaders:    untunneledDialConfig.CustomHeaders,
+		DeviceBinder:     untunneledDialConfig.DeviceBinder,
+		IPv6Synthesizer:  untunneledDialConfig.IPv6Synthesizer,
+		ResolveIP: func(ctx context.Context, hostname string) ([]net.IP, error) {
+			IPs, err := UntunneledResolveIP(
+				ctx, config, config.GetResolver(), hostname, frontingProviderID)
+			if err != nil {
+				return nil, errors.Trace(err)
+			}
+			return IPs, nil
+		},
+		ResolvedIPCallback: func(IPAddress string) {
+			resolvedIPAddress.Store(IPAddress)
+		},
+	}
+
+	selectedUserAgent, userAgent := selectUserAgentIfUnset(p, meekDialConfig.CustomHeaders)
+	if selectedUserAgent {
+		if meekDialConfig.CustomHeaders == nil {
+			meekDialConfig.CustomHeaders = make(http.Header)
+		}
+		meekDialConfig.CustomHeaders.Set("User-Agent", userAgent)
+	}
+
+	// Use MeekConn to domain front requests.
+	//
+	// DialMeek will create a TLS connection immediately. We will delay
+	// initializing the MeekConn-based RoundTripper until we know it's needed.
+	// This is implemented by passing in a RoundTripper that establishes a
+	// MeekConn when RoundTrip is called.
+	//
+	// Resources are cleaned up when the response body is closed.
+	roundTrip := func(request *http.Request) (*http.Response, error) {
+
+		conn, err := DialMeek(
+			ctx, meekConfig, meekDialConfig)
+		if err != nil {
+			return nil, errors.Trace(err)
+		}
+
+		response, err := conn.RoundTrip(request)
+		if err != nil {
+			return nil, errors.Trace(err)
+		}
+
+		// Do not read the response body into memory all at once because it may
+		// be large. Instead allow the caller to stream the response.
+		response.Body = newMeekHTTPResponseReadCloser(conn, response.Body)
+
+		return response, nil
+	}
+
+	params := func() common.APIParameters {
+		params := make(common.APIParameters)
+
+		params["fronting_provider_id"] = frontingProviderID
+
+		if meekConfig.DialAddress != "" {
+			params["meek_dial_address"] = meekConfig.DialAddress
+		}
+
+		meekResolvedIPAddress := resolvedIPAddress.Load()
+		if meekResolvedIPAddress != "" {
+			params["meek_resolved_ip_address"] = meekResolvedIPAddress
+		}
+
+		if meekConfig.SNIServerName != "" {
+			params["meek_sni_server_name"] = meekConfig.SNIServerName
+		}
+
+		if meekConfig.HostHeader != "" {
+			params["meek_host_header"] = meekConfig.HostHeader
+		}
+
+		transformedHostName := "0"
+		if meekTransformedHostName {
+			transformedHostName = "1"
+		}
+		params["meek_transformed_host_name"] = transformedHostName
+
+		if meekConfig.TLSProfile != "" {
+			params["tls_profile"] = meekConfig.TLSProfile
+		}
+
+		if selectedUserAgent {
+			params["user_agent"] = userAgent
+		}
+
+		if tlsVersion != "" {
+			params["tls_version"] = getTLSVersionForMetrics(tlsVersion, meekConfig.NoDefaultTLSSessionID)
+		}
+
+		return params
+	}
+
+	return &http.Client{
+		Transport: common.NewHTTPRoundTripper(roundTrip),
+	}, params, nil
+}
+
+// meekHTTPResponseReadCloser wraps an http.Response.Body received over a
+// MeekConn in MeekModePlaintextRoundTrip and exposes an io.ReadCloser. Close
+// closes the meek conn and response body.
+type meekHTTPResponseReadCloser struct {
+	conn         *MeekConn
+	responseBody io.ReadCloser
+}
+
+// newMeekHTTPResponseReadCloser creates a meekHTTPResponseReadCloser.
+func newMeekHTTPResponseReadCloser(meekConn *MeekConn, responseBody io.ReadCloser) *meekHTTPResponseReadCloser {
+	return &meekHTTPResponseReadCloser{
+		conn:         meekConn,
+		responseBody: responseBody,
+	}
+}
+
+// Read implements the io.Reader interface.
+func (meek *meekHTTPResponseReadCloser) Read(p []byte) (n int, err error) {
+	return meek.responseBody.Read(p)
+}
+
+// Read implements the io.Closer interface.
+func (meek *meekHTTPResponseReadCloser) Close() error {
+	err := meek.responseBody.Close()
+	_ = meek.conn.Close()
+	return err
+}
+
 // MakeUntunneledHTTPClient returns a net/http.Client which is configured to
-// use custom dialing features -- including BindToDevice, etc.
+// use custom dialing features -- including BindToDevice, etc. A function is
+// returned which, if non-nil, can be called after each request made with the
+// net/http.Client completes to retrieve the set of API parameter values
+// applied to the request.
 //
 // The context is applied to underlying TCP dials. The caller is responsible
 // for applying the context to requests made with the returned http.Client.
@@ -397,7 +629,20 @@ func MakeUntunneledHTTPClient(
 	ctx context.Context,
 	config *Config,
 	untunneledDialConfig *DialConfig,
-	skipVerify bool) (*http.Client, error) {
+	skipVerify bool,
+	disableSystemRootCAs bool,
+	frontingSpecs parameters.FrontingSpecs) (*http.Client, func() common.APIParameters, error) {
+
+	if len(frontingSpecs) > 0 {
+
+		// Ignore skipVerify because it only applies when there are no
+		// fronting specs.
+		httpClient, getParams, err := makeUntunneledFrontedHTTPClient(ctx, config, untunneledDialConfig, frontingSpecs, false, disableSystemRootCAs)
+		if err != nil {
+			return nil, nil, errors.Trace(err)
+		}
+		return httpClient, getParams, nil
+	}
 
 	dialer := NewTCPDialer(untunneledDialConfig)
 
@@ -407,6 +652,7 @@ func MakeUntunneledHTTPClient(
 		UseDialAddrSNI:                true,
 		SNIServerName:                 "",
 		SkipVerify:                    skipVerify,
+		DisableSystemRootCAs:          disableSystemRootCAs,
 		TrustedCACertificatesFilename: untunneledDialConfig.TrustedCACertificatesFilename,
 	}
 	tlsConfig.EnableClientSessionCache()
@@ -426,7 +672,7 @@ func MakeUntunneledHTTPClient(
 		Transport: transport,
 	}
 
-	return httpClient, nil
+	return httpClient, nil, nil
 }
 
 // MakeTunneledHTTPClient returns a net/http.Client which is
@@ -472,16 +718,23 @@ func MakeTunneledHTTPClient(
 	}, nil
 }
 
-// MakeDownloadHTTPClient is a helper that sets up a http.Client
-// for use either untunneled or through a tunnel.
+// MakeDownloadHTTPClient is a helper that sets up a http.Client for use either
+// untunneled or through a tunnel. True is returned if the http.Client is setup
+// for use through a tunnel; otherwise it is setup for untunneled use. A
+// function is returned which, if non-nil, can be called after each request
+// made with the http.Client completes to retrieve the set of API
+// parameter values applied to the request.
 func MakeDownloadHTTPClient(
 	ctx context.Context,
 	config *Config,
 	tunnel *Tunnel,
 	untunneledDialConfig *DialConfig,
-	skipVerify bool) (*http.Client, bool, error) {
+	skipVerify,
+	disableSystemRootCAs bool,
+	frontingSpecs parameters.FrontingSpecs) (*http.Client, bool, func() common.APIParameters, error) {
 
 	var httpClient *http.Client
+	var getParams func() common.APIParameters
 	var err error
 
 	tunneled := tunnel != nil
@@ -489,21 +742,20 @@ func MakeDownloadHTTPClient(
 	if tunneled {
 
 		httpClient, err = MakeTunneledHTTPClient(
-			config, tunnel, skipVerify)
+			config, tunnel, skipVerify || disableSystemRootCAs)
 		if err != nil {
-			return nil, false, errors.Trace(err)
+			return nil, false, nil, errors.Trace(err)
 		}
 
 	} else {
-
-		httpClient, err = MakeUntunneledHTTPClient(
-			ctx, config, untunneledDialConfig, skipVerify)
+		httpClient, getParams, err = MakeUntunneledHTTPClient(
+			ctx, config, untunneledDialConfig, skipVerify, disableSystemRootCAs, frontingSpecs)
 		if err != nil {
-			return nil, false, errors.Trace(err)
+			return nil, false, nil, errors.Trace(err)
 		}
 	}
 
-	return httpClient, tunneled, nil
+	return httpClient, tunneled, getParams, nil
 }
 
 // ResumeDownload is a reusable helper that downloads requestUrl via the
@@ -519,7 +771,6 @@ func MakeDownloadHTTPClient(
 // When ifNoneMatchETag is specified, no download is made if the remote
 // object has the same ETag. ifNoneMatchETag has an effect only when no
 // partial download is in progress.
-//
 func ResumeDownload(
 	ctx context.Context,
 	httpClient *http.Client,

+ 24 - 10
psiphon/remoteServerList.go

@@ -71,7 +71,9 @@ func FetchCommonRemoteServerList(
 		downloadTimeout,
 		downloadURL.URL,
 		canonicalURL,
-		downloadURL.SkipVerify || config.TransferURLsAlwaysSkipVerify,
+		downloadURL.FrontingSpecs,
+		downloadURL.SkipVerify,
+		config.DisableSystemRootCAs,
 		"",
 		config.GetRemoteServerListDownloadFilename())
 	if err != nil {
@@ -189,7 +191,9 @@ func FetchObfuscatedServerLists(
 		downloadTimeout,
 		downloadURL,
 		canonicalURL,
+		rootURL.FrontingSpecs,
 		rootURL.SkipVerify,
+		config.DisableSystemRootCAs,
 		"",
 		downloadFilename)
 	if err != nil {
@@ -265,9 +269,8 @@ func FetchObfuscatedServerLists(
 			tunnel,
 			untunneledDialConfig,
 			downloadTimeout,
-			rootURL.URL,
+			rootURL,
 			canonicalRootURL,
-			rootURL.SkipVerify,
 			publicKey,
 			lookupSLOKs,
 			oslFileSpec) {
@@ -319,9 +322,8 @@ func downloadOSLFileSpec(
 	tunnel *Tunnel,
 	untunneledDialConfig *DialConfig,
 	downloadTimeout time.Duration,
-	rootURL string,
+	rootURL *parameters.TransferURL,
 	canonicalRootURL string,
-	skipVerify bool,
 	publicKey string,
 	lookupSLOKs func(slokID []byte) []byte,
 	oslFileSpec *osl.OSLFileSpec) bool {
@@ -329,7 +331,7 @@ func downloadOSLFileSpec(
 	downloadFilename := osl.GetOSLFilename(
 		config.GetObfuscatedServerListDownloadDirectory(), oslFileSpec.ID)
 
-	downloadURL := osl.GetOSLFileURL(rootURL, oslFileSpec.ID)
+	downloadURL := osl.GetOSLFileURL(rootURL.URL, oslFileSpec.ID)
 	canonicalURL := osl.GetOSLFileURL(canonicalRootURL, oslFileSpec.ID)
 
 	hexID := hex.EncodeToString(oslFileSpec.ID)
@@ -346,7 +348,9 @@ func downloadOSLFileSpec(
 		downloadTimeout,
 		downloadURL,
 		canonicalURL,
-		skipVerify,
+		rootURL.FrontingSpecs,
+		rootURL.SkipVerify,
+		config.DisableSystemRootCAs,
 		sourceETag,
 		downloadFilename)
 	if err != nil {
@@ -428,7 +432,9 @@ func downloadRemoteServerListFile(
 	downloadTimeout time.Duration,
 	sourceURL string,
 	canonicalURL string,
+	frontingSpecs parameters.FrontingSpecs,
 	skipVerify bool,
+	disableSystemRootCAs bool,
 	sourceETag string,
 	destinationFilename string) (string, func(bool), error) {
 
@@ -455,12 +461,14 @@ func downloadRemoteServerListFile(
 	// MakeDownloadHttpClient will select either a tunneled
 	// or untunneled configuration.
 
-	httpClient, tunneled, err := MakeDownloadHTTPClient(
+	httpClient, tunneled, getParams, err := MakeDownloadHTTPClient(
 		ctx,
 		config,
 		tunnel,
 		untunneledDialConfig,
-		skipVerify)
+		skipVerify,
+		disableSystemRootCAs,
+		frontingSpecs)
 	if err != nil {
 		return "", nil, errors.Trace(err)
 	}
@@ -489,6 +497,12 @@ func downloadRemoteServerListFile(
 
 	NoticeRemoteServerListResourceDownloaded(sourceURL)
 
+	// Parameters can be retrieved now because the request has completed.
+	var additionalParameters common.APIParameters
+	if getParams != nil {
+		additionalParameters = getParams()
+	}
+
 	downloadStatRecorder := func(authenticated bool) {
 
 		// Invoke DNS cache extension (if enabled in the resolver) now that
@@ -507,7 +521,7 @@ func downloadRemoteServerListFile(
 		}
 
 		_ = RecordRemoteServerListStat(
-			config, tunneled, sourceURL, responseETag, bytes, duration, authenticated)
+			config, tunneled, sourceURL, responseETag, bytes, duration, authenticated, additionalParameters)
 	}
 
 	return responseETag, downloadStatRecorder, nil

+ 6 - 1
psiphon/serverApi.go

@@ -680,7 +680,8 @@ func RecordRemoteServerListStat(
 	etag string,
 	bytes int64,
 	duration time.Duration,
-	authenticated bool) error {
+	authenticated bool,
+	additionalParameters common.APIParameters) error {
 
 	if !config.GetParameters().Get().WeightedCoinFlip(
 		parameters.RecordRemoteServerListPersistentStatsProbability) {
@@ -718,6 +719,10 @@ func RecordRemoteServerListStat(
 	}
 	params["authenticated"] = authenticatedStr
 
+	for k, v := range additionalParameters {
+		params[k] = v
+	}
+
 	remoteServerListStatJson, err := json.Marshal(params)
 	if err != nil {
 		return errors.Trace(err)

+ 1 - 1
psiphon/tactics_test.go

@@ -96,7 +96,7 @@ func TestStandAloneGetTactics(t *testing.T) {
 	untunneledDialConfig := &DialConfig{
 		ResolveIP: func(ctx context.Context, hostname string) ([]net.IP, error) {
 			IPs, err := UntunneledResolveIP(
-				ctx, config, resolver, hostname)
+				ctx, config, resolver, hostname, "")
 			if err != nil {
 				return nil, errors.Trace(err)
 			}

+ 35 - 3
psiphon/tlsDialer.go

@@ -99,6 +99,16 @@ type CustomTLSConfig struct {
 	// SNIServerName is ignored when UseDialAddrSNI is true.
 	SNIServerName string
 
+	// DisableSystemRootCAs, when true, disables loading system root CAs when
+	// verifying the server certificate chain. Set DisableSystemRootCAs only in
+	// cases where system root CAs cannot be loaded; for example, if
+	// unsupported (iOS < 12) or insufficient memory (VPN extension on iOS <
+	// 15).
+	//
+	// When DisableSystemRootCAs is set, both VerifyServerName and VerifyPins
+	// must be set.
+	DisableSystemRootCAs bool
+
 	// VerifyServerName specifies a domain name that must appear in the server
 	// certificate. When specified, certificate verification checks for
 	// VerifyServerName in the server certificate, in place of the dial or SNI
@@ -204,7 +214,12 @@ func CustomTLSDial(
 		(config.VerifyLegacyCertificate != nil &&
 			(config.SkipVerify ||
 				len(config.VerifyServerName) > 0 ||
-				len(config.VerifyPins) > 0)) {
+				len(config.VerifyPins) > 0)) ||
+
+		(config.DisableSystemRootCAs &&
+			(!config.SkipVerify &&
+				(len(config.VerifyServerName) == 0 ||
+					len(config.VerifyPins) == 0))) {
 
 		return nil, errors.TraceNew("incompatible certification verification parameters")
 	}
@@ -306,7 +321,7 @@ func CustomTLSDial(
 				}
 				var err error
 				verifiedChains, err = verifyServerCertificate(
-					tlsConfigRootCAs, rawCerts, verifyServerName)
+					tlsConfigRootCAs, rawCerts, verifyServerName, config.DisableSystemRootCAs)
 				if err != nil {
 					return errors.Trace(err)
 				}
@@ -613,8 +628,13 @@ func verifyLegacyCertificate(rawCerts [][]byte, expectedCertificate *x509.Certif
 	return nil
 }
 
+// verifyServerCertificate parses and verifies the provided chain. If
+// successful, it returns the verified chains that were built.
+//
+// WARNING: disableSystemRootCAs must only be set when the certificate
+// chain has been, or will be, verified with verifyCertificatePins.
 func verifyServerCertificate(
-	rootCAs *x509.CertPool, rawCerts [][]byte, verifyServerName string) ([][]*x509.Certificate, error) {
+	rootCAs *x509.CertPool, rawCerts [][]byte, verifyServerName string, disableSystemRootCAs bool) ([][]*x509.Certificate, error) {
 
 	// This duplicates the verification logic in utls (and standard crypto/tls).
 
@@ -627,6 +647,18 @@ func verifyServerCertificate(
 		certs[i] = cert
 	}
 
+	// Ensure system root CAs are not loaded, which will cause verification to
+	// fail. Instead use the root certificate of the chain received from the
+	// server as a trusted root certificate, which allows the chain and server
+	// name to be verified while ignoring whether the root certificate is
+	// trusted by the system.
+	if rootCAs == nil && disableSystemRootCAs {
+		rootCAs = x509.NewCertPool()
+		if len(certs) > 0 {
+			rootCAs.AddCert(certs[len(certs)-1])
+		}
+	}
+
 	opts := x509.VerifyOptions{
 		Roots:         rootCAs,
 		DNSName:       verifyServerName,

+ 23 - 2
psiphon/tlsDialer_test.go

@@ -156,7 +156,7 @@ func TestTLSCertificateVerification(t *testing.T) {
 		t.Errorf("unexpected success without invalid pin")
 	}
 
-	// Test: with the root CA certirficate pinned, the TLS dial succeeds.
+	// Test: with the root CA certificate pinned, the TLS dial succeeds.
 
 	conn, err = CustomTLSDial(
 		context.Background(), "tcp", serverAddr,
@@ -209,6 +209,27 @@ func TestTLSCertificateVerification(t *testing.T) {
 	} else {
 		conn.Close()
 	}
+
+	// Test: with SNI changed, DisableSystemRootCAs set along with
+	// VerifyServerName and VerifyPins, and pinning the TLS dial
+	// succeeds.
+
+	conn, err = CustomTLSDial(
+		context.Background(), "tcp", serverAddr,
+		&CustomTLSConfig{
+			Parameters:           params,
+			Dial:                 dialer,
+			SNIServerName:        "not-" + serverName,
+			DisableSystemRootCAs: true,
+			VerifyServerName:     serverName,
+			VerifyPins:           []string{rootCACertificatePin},
+		})
+
+	if err != nil {
+		t.Errorf("CustomTLSDial failed: %v", err)
+	} else {
+		conn.Close()
+	}
 }
 
 // initTestCertificatesAndWebServer creates a Root CA, a web server
@@ -337,7 +358,7 @@ func initTestCertificatesAndWebServer(
 	// Run an HTTPS server with the server certificate.
 
 	serverKeyPair, err := tls.X509KeyPair(
-		pemServerCertificate, pemServerPrivateKey)
+		append(pemServerCertificate, pemRootCACertificate...), pemServerPrivateKey)
 	if err != nil {
 		t.Fatalf("tls.X509KeyPair failed: %v", err)
 	}

+ 4 - 2
psiphon/upgradeDownload.go

@@ -87,12 +87,14 @@ func DownloadUpgrade(
 
 	downloadURL := urls.Select(attempt)
 
-	httpClient, _, err := MakeDownloadHTTPClient(
+	httpClient, _, _, err := MakeDownloadHTTPClient(
 		ctx,
 		config,
 		tunnel,
 		untunneledDialConfig,
-		downloadURL.SkipVerify || config.TransferURLsAlwaysSkipVerify)
+		downloadURL.SkipVerify,
+		config.DisableSystemRootCAs,
+		downloadURL.FrontingSpecs)
 	if err != nil {
 		return errors.Trace(err)
 	}