Parcourir la source

Add domain fronted Conjure API registration

Rod Hynes il y a 4 ans
Parent
commit
f1ed1c4571

+ 18 - 0
psiphon/common/net.go

@@ -79,6 +79,24 @@ type FragmentorReplayAccessor interface {
 	GetReplay() (*prng.Seed, bool)
 }
 
+// HTTPRoundTripper is an adapter that allows using a function as a
+// http.RoundTripper.
+type HTTPRoundTripper struct {
+	roundTrip func(*http.Request) (*http.Response, error)
+}
+
+// NewHTTPRoundTripper creates a new HTTPRoundTripper, using the specified
+// roundTrip function for HTTP round trips.
+func NewHTTPRoundTripper(
+	roundTrip func(*http.Request) (*http.Response, error)) *HTTPRoundTripper {
+	return &HTTPRoundTripper{roundTrip: roundTrip}
+}
+
+// RoundTrip implements http.RoundTripper RoundTrip.
+func (h HTTPRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) {
+	return h.roundTrip(request)
+}
+
 // TerminateHTTPConnection sends a 404 response to a client and also closes
 // the persistent connection.
 func TerminateHTTPConnection(

+ 130 - 0
psiphon/common/parameters/frontingSpec.go

@@ -0,0 +1,130 @@
+/*
+ * Copyright (c) 2021, Psiphon Inc.
+ * All rights reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+package parameters
+
+import (
+	"net"
+
+	"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"
+)
+
+// FrontingSpecs is a list of domain fronting specs.
+type FrontingSpecs []*FrontingSpec
+
+// FrontingSpec specifies a domain fronting configuration, to be used with
+// MeekConn and MeekModePlaintextRoundTrip. In MeekModePlaintextRoundTrip, the
+// fronted origin is an arbitrary web server, not a Psiphon server. This
+// MeekConn mode requires HTTPS and server certificate validation:
+// VerifyServerName is required; VerifyPins is recommended. See also
+// psiphon.MeekConfig and psiphon.MeekConn.
+//
+// FrontingSpec.Addresses supports the functionality of both
+// ServerEntry.MeekFrontingAddressesRegex and
+// ServerEntry.MeekFrontingAddresses: multiple candidates are supported, and
+// each candidate may be a regex, or a static value (with regex syntax).
+type FrontingSpec struct {
+	FrontingProviderID string
+	Addresses          []string
+	DisableSNI         bool
+	VerifyServerName   string
+	VerifyPins         []string
+	Host               string
+}
+
+// SelectParameters selects fronting parameters from the given FrontingSpecs,
+// first selecting a spec at random. SelectParameters is similar to
+// psiphon.selectFrontingParameters, which operates on server entries.
+//
+// The return values are:
+// - Dial Address (domain or IP address)
+// - SNI (which may be transformed; unless it is "", which indicates omit SNI)
+// - VerifyServerName (see psiphon.CustomTLSConfig)
+// - VerifyPins (see psiphon.CustomTLSConfig)
+// - Host (Host header value)
+func (specs FrontingSpecs) SelectParameters() (
+	string, string, string, string, []string, string, error) {
+
+	if len(specs) == 0 {
+		return "", "", "", "", nil, "", errors.TraceNew("missing fronting spec")
+	}
+
+	spec := specs[prng.Intn(len(specs))]
+
+	if len(spec.Addresses) == 0 {
+		return "", "", "", "", nil, "", errors.TraceNew("missing fronting address")
+	}
+
+	frontingDialAddr, err := regen.Generate(
+		spec.Addresses[prng.Intn(len(spec.Addresses))])
+	if err != nil {
+		return "", "", "", "", nil, "", errors.Trace(err)
+	}
+
+	SNIServerName := frontingDialAddr
+	if spec.DisableSNI || net.ParseIP(frontingDialAddr) != nil {
+		SNIServerName = ""
+	}
+
+	return spec.FrontingProviderID,
+		frontingDialAddr,
+		SNIServerName,
+		spec.VerifyServerName,
+		spec.VerifyPins,
+		spec.Host,
+		nil
+}
+
+// Validate checks that the JSON values are well-formed.
+func (specs FrontingSpecs) Validate() error {
+
+	// An empty FrontingSpecs is allowed as a tactics setting, but
+	// SelectParameters will fail at runtime: code that uses FrontingSpecs must
+	// provide some mechanism -- or check for an empty FrontingSpecs -- to
+	// enable/disable features that use FrontingSpecs.
+
+	for _, spec := range specs {
+		if len(spec.FrontingProviderID) == 0 {
+			return errors.TraceNew("empty fronting provider ID")
+		}
+		if len(spec.Addresses) == 0 {
+			return errors.TraceNew("missing fronting addresses")
+		}
+		for _, addr := range spec.Addresses {
+			if len(addr) == 0 {
+				return errors.TraceNew("empty fronting address")
+			}
+		}
+		if len(spec.VerifyServerName) == 0 {
+			return errors.TraceNew("empty verify server name")
+		}
+		// An empty VerifyPins is allowed.
+		for _, pin := range spec.VerifyPins {
+			if len(pin) == 0 {
+				return errors.TraceNew("empty verify pin")
+			}
+		}
+		if len(spec.Host) == 0 {
+			return errors.TraceNew("empty fronting host")
+		}
+	}
+	return nil
+}

+ 25 - 1
psiphon/common/parameters/parameters.go

@@ -273,7 +273,15 @@ const (
 	ClientBurstUpstreamTargetBytes                   = "ClientBurstUpstreamTargetBytes"
 	ClientBurstDownstreamDeadline                    = "ClientBurstDownstreamDeadline"
 	ClientBurstDownstreamTargetBytes                 = "ClientBurstDownstreamTargetBytes"
+	ConjureCachedRegistrationTTL                     = "ConjureCachedRegistrationTTL"
+	ConjureAPIRegistrarURL                           = "ConjureAPIRegistrarURL"
+	ConjureAPIRegistrarFrontingSpecs                 = "ConjureAPIRegistrarFrontingSpecs"
+	ConjureAPIRegistrarMinDelay                      = "ConjureAPIRegistrarMinDelay"
+	ConjureAPIRegistrarMaxDelay                      = "ConjureAPIRegistrarMaxDelay"
+	ConjureDecoyRegistrarProbability                 = "ConjureDecoyRegistrarProbability"
 	ConjureDecoyRegistrarWidth                       = "ConjureDecoyRegistrarWidth"
+	ConjureDecoyRegistrarMinDelay                    = "ConjureDecoyRegistrarMinDelay"
+	ConjureDecoyRegistrarMaxDelay                    = "ConjureDecoyRegistrarMaxDelay"
 	ConjureTransportObfs4Probability                 = "ConjureTransportObfs4Probability"
 	CustomHostNameRegexes                            = "CustomHostNameRegexes"
 	CustomHostNameProbability                        = "CustomHostNameProbability"
@@ -577,7 +585,16 @@ var defaultParameters = map[string]struct {
 	ClientBurstDownstreamTargetBytes: {value: 0, minimum: 0},
 	ClientBurstDownstreamDeadline:    {value: time.Duration(0), minimum: time.Duration(0)},
 
-	ConjureDecoyRegistrarWidth:       {value: 5, minimum: 1},
+	ConjureCachedRegistrationTTL:     {value: time.Duration(0), minimum: time.Duration(0)},
+	ConjureAPIRegistrarURL:           {value: ""},
+	ConjureAPIRegistrarFrontingSpecs: {value: FrontingSpecs{}},
+	ConjureAPIRegistrarMinDelay:      {value: time.Duration(0), minimum: time.Duration(0)},
+	ConjureAPIRegistrarMaxDelay:      {value: time.Duration(0), minimum: time.Duration(0)},
+	ConjureDecoyRegistrarProbability: {value: 0.0, minimum: 0.0},
+	ConjureDecoyRegistrarWidth:       {value: 5, minimum: 0},
+	ConjureDecoyRegistrarMinDelay:    {value: time.Duration(0), minimum: time.Duration(0)},
+	ConjureDecoyRegistrarMaxDelay:    {value: time.Duration(0), minimum: time.Duration(0)},
+
 	ConjureTransportObfs4Probability: {value: 0.0, minimum: 0.0},
 
 	CustomHostNameRegexes:        {value: RegexStrings{}},
@@ -1343,3 +1360,10 @@ func (p ParametersAccessor) RegexStrings(name string) RegexStrings {
 	p.snapshot.getValue(name, &value)
 	return value
 }
+
+// FrontingSpecs returns a FrontingSpecs parameter value.
+func (p ParametersAccessor) FrontingSpecs(name string) FrontingSpecs {
+	value := FrontingSpecs{}
+	p.snapshot.getValue(name, &value)
+	return value
+}

+ 88 - 0
psiphon/common/refraction/config.go

@@ -0,0 +1,88 @@
+/*
+ * Copyright (c) 2021, Psiphon Inc.
+ * All rights reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+package refraction
+
+import (
+	"net/http"
+	"time"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+)
+
+// ConjureConfig specifies the additional configuration for a Conjure dial.
+type ConjureConfig struct {
+
+	// RegistrationCacheTTL specifies how long to retain a successful Conjure
+	// registration for reuse in a subsequent dial. This value should be
+	// synchronized with the Conjure station configuration. When
+	// RegistrationCacheTTL is 0, registrations are not cached.
+	RegistrationCacheTTL time.Duration
+
+	// RegistrationCacheKey defines a scope or affinity for cached Conjure
+	// registrations. For example, the key can reflect the target Psiphon server
+	// as well as the current network ID. This ensures that any replay will
+	// always use the same cached registration, including its phantom IP(s). And
+	// ensures that the cache scope is restricted to the current network: when
+	// the network changes, the client's public IP changes, and previous
+	// registrations will become invalid. When the client returns to the original
+	// network, the previous registrations may be valid once again (assuming
+	// the client reverts back to its original public IP).
+	RegistrationCacheKey string
+
+	// APIRegistrarURL specifies the API registration endpoint. Setting
+	// APIRegistrarURL enables API registration. The domain fronting
+	// configuration provided by APIRegistrarHTTPClient may ignore the host
+	// portion of this URL, implicitly providing another value; the path portion
+	// is always used in the request. Only one of API registration or decoy
+	// registration can be enabled for a single dial.
+	APIRegistrarURL string
+
+	// APIRegistrarHTTPClient specifies a custom HTTP client (and underlying
+	// dialers) to be used for Conjure API registration. The
+	// APIRegistrarHTTPClient enables domain fronting of API registration web
+	// requests. This parameter is required when API registration is enabled.
+	APIRegistrarHTTPClient *http.Client
+
+	// APIRegistrarDelay specifies how long to wait after a successful API
+	// registration before initiating the phantom dial(s), as required by the
+	// Conjure protocol. This value depends on Conjure station operations and
+	// should be synchronized with the Conjure station configuration.
+	APIRegistrarDelay time.Duration
+
+	// DecoyRegistrarDialer specifies a custom dialer to be used for decoy
+	// registration. Only one of API registration or decoy registration can be
+	// enabled for a single dial.
+	DecoyRegistrarDialer common.NetDialer
+
+	// DecoyRegistrarWidth specifies how many decoys to use per registration.
+	DecoyRegistrarWidth int
+
+	// DecoyRegistrarDelay specifies how long to wait after a successful API
+	// registration before initiating the phantom dial(s), as required by the
+	// Conjure protocol.
+	//
+	// Limitation: this value is not exposed by gotapdance and is currently
+	// ignored.
+	DecoyRegistrarDelay time.Duration
+
+	// Transport may be protocol.CONJURE_TRANSPORT_MIN_OSSH or
+	// protocol.CONJURE_TRANSPORT_OBFS4_OSSH.
+	Transport string
+}

+ 287 - 26
psiphon/common/refraction/refraction.go

@@ -30,6 +30,7 @@ package refraction
 import (
 	"context"
 	"crypto/sha256"
+	"fmt"
 	"io/ioutil"
 	"net"
 	"os"
@@ -42,12 +43,14 @@ import (
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
 	"github.com/armon/go-proxyproto"
+	lrucache "github.com/cognusion/go-cache-lru"
 	refraction_networking_proto "github.com/refraction-networking/gotapdance/protobuf"
 	refraction_networking_client "github.com/refraction-networking/gotapdance/tapdance"
 )
 
 const (
 	READ_PROXY_PROTOCOL_HEADER_TIMEOUT = 5 * time.Second
+	REGISTRATION_CACHE_MAX_ENTRIES     = 256
 )
 
 // Enabled indicates if Refraction Networking functionality is enabled.
@@ -178,6 +181,8 @@ func (c *stationConn) GetMetrics() common.LogFields {
 // assets) are read from dataDirectory/"refraction-networking". When no config
 // is found, default assets are paved.
 //
+// dialer specifies the custom dialer for underlying TCP dials.
+//
 // The input ctx is expected to have a timeout for the dial.
 //
 // Limitation: the parameters emitLogs and dataDirectory are used for one-time
@@ -194,36 +199,31 @@ func DialTapDance(
 		emitLogs,
 		dataDirectory,
 		dialer,
-		false,
-		nil,
-		0,
-		"",
-		address)
+		address,
+		nil)
 }
 
 // DialConjure establishes a new Conjure connection to a Conjure station.
 //
+// dialer specifies the custom dialer to use for phantom dials. Additional
+// Conjure-specific parameters are specified in conjureConfig.
+//
 // See DialTapdance comment.
 func DialConjure(
 	ctx context.Context,
 	emitLogs bool,
 	dataDirectory string,
 	dialer common.NetDialer,
-	conjureDecoyRegistrarDialer common.NetDialer,
-	conjureDecoyRegistrarWidth int,
-	conjureTransport string,
-	address string) (net.Conn, error) {
+	address string,
+	conjureConfig *ConjureConfig) (net.Conn, error) {
 
 	return dial(
 		ctx,
 		emitLogs,
 		dataDirectory,
 		dialer,
-		true,
-		conjureDecoyRegistrarDialer,
-		conjureDecoyRegistrarWidth,
-		conjureTransport,
-		address)
+		address,
+		conjureConfig)
 }
 
 func dial(
@@ -231,11 +231,8 @@ func dial(
 	emitLogs bool,
 	dataDirectory string,
 	dialer common.NetDialer,
-	useConjure bool,
-	conjureDecoyRegistrarDialer common.NetDialer,
-	conjureDecoyRegistrarWidth int,
-	conjureTransport string,
-	address string) (net.Conn, error) {
+	address string,
+	conjureConfig *ConjureConfig) (net.Conn, error) {
 
 	err := initRefractionNetworking(emitLogs, dataDirectory)
 	if err != nil {
@@ -246,6 +243,8 @@ func dial(
 		return nil, errors.TraceNew("dial context has no timeout")
 	}
 
+	useConjure := conjureConfig != nil
+
 	manager := newDialManager()
 
 	refractionDialer := &refraction_networking_client.Dialer{
@@ -253,23 +252,125 @@ func dial(
 		UseProxyHeader: true,
 	}
 
+	conjureCached := false
+	conjureDelay := time.Duration(0)
+
+	var conjureCachedRegistration *refraction_networking_client.ConjureReg
+	var conjureRecordRegistrar *recordRegistrar
+
 	if useConjure {
 
+		// Our strategy is to try one registration per dial attempt: a cached
+		// registration, if it exists, or API or decoy registration, as configured.
+		// This assumes Psiphon establishment will try/retry many candidates as
+		// required, and that the desired mix of API/decoy registrations will be
+		// configured and generated. In good network conditions, internal gotapdance
+		// retries (via APIRegistrar.MaxRetries or APIRegistrar.SecondaryRegistrar)
+		// are unlikely to start before the Conjure dial is canceled.
+
+		// Caching registrations reduces average Conjure dial time by often
+		// eliminating the registration phase. This is especially impactful for
+		// short duration tunnels, such as on mobile. Caching also reduces domain
+		// fronted traffic and load on the API registrar and decoys.
+		//
+		// We implement a simple in-memory registration cache with the following
+		// behavior:
+		//
+		// - If a new registration succeeds, but the overall Conjure dial is
+		//   _canceled_, the registration is optimistically cached.
+		// - If the Conjure phantom dial fails, any associated cached registration
+		//   is discarded.
+		// - A cached registration's TTL is extended upon phantom dial success.
+		// - If the configured TTL changes, the cache is cleared.
+		//
+		// Limitations:
+		// - The cache is not persistent.
+		// - There is no TTL extension during a long connection.
+		// - Caching a successful registration when the phantom dial is canceled may
+		//   skip the necessary "delay" step (however, an immediate re-establishment
+		//   to the same candidate is unlikely in this case).
+		//
+		// TODO:
+		// - Revisit when gotapdance adds its own caching.
+		// - Consider "pre-registering" Conjure when already connected with a
+		//   different protocol, so a Conjure registration is available on the next
+		//   establishment; in this scenario, a tunneled API registration would not
+		//   require domain fronting.
+
 		refractionDialer.DarkDecoy = true
 
-		refractionDialer.DarkDecoyRegistrar = refraction_networking_client.DecoyRegistrar{
-			TcpDialer: manager.makeManagedDialer(conjureDecoyRegistrarDialer.DialContext),
+		// The pop operation removes the registration from the cache. This
+		// eliminates the possibility of concurrent candidates (with the same cache
+		// key) using and modifying the same registration, a potential race
+		// condition. The popped cached registration must be reinserted in the cache
+		// after canceling or success, but not on phantom dial failure.
+
+		conjureCachedRegistration = conjureRegistrationCache.pop(
+			conjureConfig.RegistrationCacheTTL,
+			conjureConfig.RegistrationCacheKey)
+
+		if conjureCachedRegistration != nil {
+
+			refractionDialer.DarkDecoyRegistrar = &cachedRegistrar{
+				registration: conjureCachedRegistration,
+			}
+
+			conjureCached = true
+			conjureDelay = 0 // report no delay
+
+		} else if conjureConfig.APIRegistrarURL != "" {
+
+			if conjureConfig.APIRegistrarHTTPClient == nil {
+				// While not a guaranteed check, if the APIRegistrarHTTPClient isn't set
+				// then the API registration would certainly be unfronted, resulting in a
+				// fingerprintable connection leak.
+				return nil, errors.TraceNew("missing APIRegistrarHTTPClient")
+			}
+
+			refractionDialer.DarkDecoyRegistrar = &refraction_networking_client.APIRegistrar{
+				Endpoint:        conjureConfig.APIRegistrarURL,
+				ConnectionDelay: conjureConfig.APIRegistrarDelay,
+				MaxRetries:      0,
+				Client:          conjureConfig.APIRegistrarHTTPClient,
+			}
+
+			conjureDelay = conjureConfig.APIRegistrarDelay
+
+		} else if conjureConfig.DecoyRegistrarDialer != nil {
+
+			refractionDialer.DarkDecoyRegistrar = &refraction_networking_client.DecoyRegistrar{
+				TcpDialer: manager.makeManagedDialer(
+					conjureConfig.DecoyRegistrarDialer.DialContext),
+			}
+
+			refractionDialer.Width = conjureConfig.DecoyRegistrarWidth
+
+			// Limitation: the decoy regsitration delay is not currently exposed in the
+			// gotapdance API.
+			conjureDelay = -1 // don't report delay
+
+		} else {
+
+			return nil, errors.TraceNew("no conjure registrar specified")
+		}
+
+		if conjureCachedRegistration == nil && conjureConfig.RegistrationCacheTTL != 0 {
+
+			// Record the registration result in order to cache it.
+			conjureRecordRegistrar = &recordRegistrar{
+				registrar: refractionDialer.DarkDecoyRegistrar,
+			}
+			refractionDialer.DarkDecoyRegistrar = conjureRecordRegistrar
 		}
-		refractionDialer.Width = conjureDecoyRegistrarWidth
 
-		switch conjureTransport {
+		switch conjureConfig.Transport {
 		case protocol.CONJURE_TRANSPORT_MIN_OSSH:
 			refractionDialer.Transport = refraction_networking_proto.TransportType_Min
 			refractionDialer.TcpDialer = newMinTransportDialer(refractionDialer.TcpDialer)
 		case protocol.CONJURE_TRANSPORT_OBFS4_OSSH:
 			refractionDialer.Transport = refraction_networking_proto.TransportType_Obfs4
 		default:
-			return nil, errors.Tracef("invalid Conjure transport: %s", conjureTransport)
+			return nil, errors.Tracef("invalid Conjure transport: %s", conjureConfig.Transport)
 		}
 	}
 
@@ -294,17 +395,152 @@ func dial(
 
 	conn, err := refractionDialer.DialContext(ctx, "tcp", address)
 	close(dialComplete)
+
 	if err != nil {
+		// Call manager.close before updating cache, to synchronously shutdown dials
+		// and ensure there are no further concurrent reads/writes to the recorded
+		// registration before referencing it.
 		manager.close()
+	}
+
+	// Cache (or put back) a successful registration. Also put back in the
+	// specific error case where the phantom dial was canceled, as the
+	// registration may still be valid. This operation implicitly extends the TTL
+	// of a reused cached registration; we assume the Conjure station is also
+	// extending the TTL by the same amount.
+	//
+	// Limitation: the cancel case shouldn't extend the TTL.
+
+	if useConjure &&
+		(err == nil || ctx.Err() == context.Canceled) &&
+		(conjureCachedRegistration != nil || conjureRecordRegistrar != nil) {
+
+		registration := conjureCachedRegistration
+		if registration == nil {
+			// We assume gotapdance is no longer accessing the Registrar.
+			registration = conjureRecordRegistrar.registration
+		}
+
+		// conjureRecordRegistrar.registration will be nil there was no cached
+		// registration _and_ registration didn't succeed before a cancel.
+		if registration != nil {
+			conjureRegistrationCache.put(
+				conjureConfig.RegistrationCacheTTL,
+				conjureConfig.RegistrationCacheKey,
+				registration)
+		}
+	}
+
+	if err != nil {
 		return nil, errors.Trace(err)
 	}
 
 	manager.startUsingRunCtx()
 
-	return &refractionConn{
+	refractionConn := &refractionConn{
 		Conn:    conn,
 		manager: manager,
-	}, nil
+	}
+
+	if useConjure {
+		// Retain these values for logging metrics.
+		refractionConn.isConjure = true
+		refractionConn.conjureCached = conjureCached
+		refractionConn.conjureDelay = conjureDelay
+		refractionConn.conjureTransport = conjureConfig.Transport
+	}
+
+	return refractionConn, nil
+}
+
+type registrationCache struct {
+	mutex sync.Mutex
+	TTL   time.Duration
+	cache *lrucache.Cache
+}
+
+func newRegistrationCache() *registrationCache {
+	return &registrationCache{
+		cache: lrucache.NewWithLRU(
+			lrucache.NoExpiration,
+			1*time.Minute,
+			REGISTRATION_CACHE_MAX_ENTRIES),
+	}
+}
+
+func (c *registrationCache) put(
+	TTL time.Duration,
+	key string,
+	registration *refraction_networking_client.ConjureReg) {
+
+	c.mutex.Lock()
+	defer c.mutex.Unlock()
+
+	// Clear the entire cache if the configured TTL changes to avoid retaining
+	// items for too long. This is expected to be an infrequent event. The
+	// go-cache-lru API does not offer a mechanism to inspect and adjust the TTL
+	// of all existing items.
+	if c.TTL != TTL {
+		c.cache.Flush()
+		c.TTL = TTL
+	}
+
+	c.cache.Set(
+		key,
+		registration,
+		c.TTL)
+}
+
+func (c *registrationCache) pop(
+	TTL time.Duration,
+	key string) *refraction_networking_client.ConjureReg {
+
+	c.mutex.Lock()
+	defer c.mutex.Unlock()
+
+	// See TTL/Flush comment in put.
+	if c.TTL != TTL {
+		c.cache.Flush()
+		c.TTL = TTL
+	}
+
+	entry, found := c.cache.Get(key)
+	if found {
+		c.cache.Delete(key)
+		return entry.(*refraction_networking_client.ConjureReg)
+	}
+
+	return nil
+}
+
+var conjureRegistrationCache = newRegistrationCache()
+
+type cachedRegistrar struct {
+	registration *refraction_networking_client.ConjureReg
+}
+
+func (r *cachedRegistrar) Register(
+	_ *refraction_networking_client.ConjureSession,
+	_ context.Context) (*refraction_networking_client.ConjureReg, error) {
+
+	return r.registration, nil
+}
+
+type recordRegistrar struct {
+	registrar    refraction_networking_client.Registrar
+	registration *refraction_networking_client.ConjureReg
+}
+
+func (r *recordRegistrar) Register(
+	session *refraction_networking_client.ConjureSession,
+	ctx context.Context) (*refraction_networking_client.ConjureReg, error) {
+
+	registration, err := r.registrar.Register(session, ctx)
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+	r.registration = registration
+	return registration, nil
 }
 
 // minTransportConn buffers the first 32-byte random HMAC write performed by
@@ -516,6 +752,11 @@ type refractionConn struct {
 	net.Conn
 	manager  *dialManager
 	isClosed int32
+
+	isConjure        bool
+	conjureCached    bool
+	conjureDelay     time.Duration
+	conjureTransport string
 }
 
 func (conn *refractionConn) Close() error {
@@ -529,6 +770,26 @@ func (conn *refractionConn) IsClosed() bool {
 	return atomic.LoadInt32(&conn.isClosed) == 1
 }
 
+// GetMetrics implements the common.MetricsSource interface.
+func (conn *refractionConn) GetMetrics() common.LogFields {
+	logFields := make(common.LogFields)
+	if conn.isConjure {
+
+		cached := "0"
+		if conn.conjureCached {
+			cached = "1"
+		}
+		logFields["conjure_cached"] = cached
+
+		if conn.conjureDelay != -1 {
+			logFields["conjure_delay"] = fmt.Sprintf("%d", conn.conjureDelay/time.Millisecond)
+		}
+
+		logFields["conjure_transport"] = conn.conjureTransport
+	}
+	return logFields
+}
+
 var initRefractionNetworkingOnce sync.Once
 
 func initRefractionNetworking(emitLogs bool, dataDirectory string) error {

+ 1 - 1
psiphon/common/refraction/refraction_disabled.go

@@ -50,6 +50,6 @@ func DialTapDance(_ context.Context, _ bool, _ string, _ common.NetDialer, _ str
 }
 
 // DialConjure establishes a new Conjure connection to a Conjure station.
-func DialConjure(_ context.Context, _ bool, _ string, _, _ common.NetDialer, _ int, _, _ string) (net.Conn, error) {
+func DialConjure(_ context.Context, _ bool, _ string, _ common.NetDialer, _ string, _ *ConjureConfig) (net.Conn, error) {
 	return nil, errors.TraceNew("operation is not enabled")
 }

+ 48 - 0
psiphon/config.go

@@ -718,6 +718,18 @@ type Config struct {
 	CustomHostNameProbability    *float64
 	CustomHostNameLimitProtocols []string
 
+	// ConjureCachedRegistrationTTLSeconds and other Conjure fields are for
+	// testing purposes.
+	ConjureCachedRegistrationTTLSeconds       *int
+	ConjureAPIRegistrarURL                    string
+	ConjureAPIRegistrarFrontingSpecs          parameters.FrontingSpecs
+	ConjureAPIRegistrarMinDelayMilliseconds   *int
+	ConjureAPIRegistrarMaxDelayMilliseconds   *int
+	ConjureDecoyRegistrarProbability          *float64
+	ConjureDecoyRegistrarWidth                *int
+	ConjureDecoyRegistrarMinDelayMilliseconds *int
+	ConjureDecoyRegistrarMaxDelayMilliseconds *int
+
 	// params is the active parameters.Parameters with defaults, config values,
 	// and, optionally, tactics applied.
 	//
@@ -1615,6 +1627,42 @@ func (config *Config) makeConfigParameters() map[string]interface{} {
 		applyParameters[parameters.CustomHostNameLimitProtocols] = protocol.TunnelProtocols(config.CustomHostNameLimitProtocols)
 	}
 
+	if config.ConjureCachedRegistrationTTLSeconds != nil {
+		applyParameters[parameters.ConjureCachedRegistrationTTL] = fmt.Sprintf("%dms", *config.ConjureCachedRegistrationTTLSeconds)
+	}
+
+	if config.ConjureAPIRegistrarURL != "" {
+		applyParameters[parameters.ConjureAPIRegistrarURL] = config.ConjureAPIRegistrarURL
+	}
+
+	if config.ConjureAPIRegistrarFrontingSpecs != nil {
+		applyParameters[parameters.ConjureAPIRegistrarFrontingSpecs] = config.ConjureAPIRegistrarFrontingSpecs
+	}
+
+	if config.ConjureAPIRegistrarMinDelayMilliseconds != nil {
+		applyParameters[parameters.ConjureAPIRegistrarMinDelay] = fmt.Sprintf("%dms", *config.ConjureAPIRegistrarMinDelayMilliseconds)
+	}
+
+	if config.ConjureAPIRegistrarMaxDelayMilliseconds != nil {
+		applyParameters[parameters.ConjureAPIRegistrarMaxDelay] = fmt.Sprintf("%dms", *config.ConjureAPIRegistrarMaxDelayMilliseconds)
+	}
+
+	if config.ConjureDecoyRegistrarProbability != nil {
+		applyParameters[parameters.ConjureDecoyRegistrarProbability] = *config.ConjureDecoyRegistrarProbability
+	}
+
+	if config.ConjureDecoyRegistrarWidth != nil {
+		applyParameters[parameters.ConjureDecoyRegistrarWidth] = *config.ConjureDecoyRegistrarWidth
+	}
+
+	if config.ConjureDecoyRegistrarMinDelayMilliseconds != nil {
+		applyParameters[parameters.ConjureDecoyRegistrarMinDelay] = fmt.Sprintf("%dms", *config.ConjureDecoyRegistrarMinDelayMilliseconds)
+	}
+
+	if config.ConjureDecoyRegistrarMaxDelayMilliseconds != nil {
+		applyParameters[parameters.ConjureDecoyRegistrarMaxDelay] = fmt.Sprintf("%dms", *config.ConjureDecoyRegistrarMaxDelayMilliseconds)
+	}
+
 	return applyParameters
 }
 

+ 128 - 31
psiphon/dialParameters.go

@@ -95,6 +95,8 @@ type DialParameters struct {
 	MeekDialAddress           string
 	MeekTransformedHostName   bool
 	MeekSNIServerName         string
+	MeekVerifyServerName      string
+	MeekVerifyPins            []string
 	MeekHostHeader            string
 	MeekObfuscatorPaddingSeed *prng.Seed
 	MeekTLSPaddingSize        int
@@ -113,8 +115,14 @@ type DialParameters struct {
 	QUICDialSNIAddress        string
 	ObfuscatedQUICPaddingSeed *prng.Seed
 
-	ConjureDecoyRegistrarWidth int
-	ConjureTransport           string
+	ConjureCachedRegistrationTTL time.Duration
+	ConjureAPIRegistration       bool
+	ConjureAPIRegistrarURL       string
+	ConjureAPIRegistrarDelay     time.Duration
+	ConjureDecoyRegistration     bool
+	ConjureDecoyRegistrarDelay   time.Duration
+	ConjureDecoyRegistrarWidth   int
+	ConjureTransport             string
 
 	LivenessTestSeed *prng.Seed
 
@@ -392,14 +400,115 @@ func MakeDialParameters(
 		}
 	}
 
-	if (!isReplay || !replayTLSProfile) &&
-		protocol.TunnelProtocolUsesMeekHTTPS(dialParams.TunnelProtocol) {
+	if (!isReplay || !replayConjureRegistration) &&
+		protocol.TunnelProtocolUsesConjure(dialParams.TunnelProtocol) {
+
+		dialParams.ConjureCachedRegistrationTTL = p.Duration(parameters.ConjureCachedRegistrationTTL)
+
+		apiURL := p.String(parameters.ConjureAPIRegistrarURL)
+		decoyWidth := p.Int(parameters.ConjureDecoyRegistrarWidth)
+
+		dialParams.ConjureAPIRegistration = apiURL != ""
+		dialParams.ConjureDecoyRegistration = decoyWidth != 0
+
+		// We select only one of API or decoy registration. When both are enabled,
+		// ConjureDecoyRegistrarProbability determines the probability of using
+		// decoy registration.
+		//
+		// In general, we disable retries in gotapdance and rely on Psiphon
+		// establishment to try/retry different registration schemes. This allows us
+		// to control the proportion of registration types attempted. And, in good
+		// network conditions, individual candidates are most likely to be cancelled
+		// before they exhaust their retry options.
+
+		if dialParams.ConjureAPIRegistration && dialParams.ConjureDecoyRegistration {
+			if p.WeightedCoinFlip(parameters.ConjureDecoyRegistrarProbability) {
+				dialParams.ConjureAPIRegistration = false
+			}
+		}
+
+		if dialParams.ConjureAPIRegistration {
+
+			// While Conjure API registration uses MeekConn and specifies common meek
+			// parameters, the meek address and SNI configuration is implemented in this
+			// code block and not in common code blocks below. The exception is TLS
+			// configuration.
+			//
+			// Accordingly, replayFronting/replayHostname have no effect on Conjure API
+			// registration replay.
+
+			dialParams.ConjureAPIRegistrarURL = apiURL
+
+			frontingSpecs := p.FrontingSpecs(parameters.ConjureAPIRegistrarFrontingSpecs)
+			dialParams.FrontingProviderID,
+				dialParams.MeekFrontingDialAddress,
+				dialParams.MeekSNIServerName,
+				dialParams.MeekVerifyServerName,
+				dialParams.MeekVerifyPins,
+				dialParams.MeekFrontingHost,
+				err = frontingSpecs.SelectParameters()
+			if err != nil {
+				return nil, errors.Trace(err)
+			}
+
+			dialParams.MeekDialAddress = fmt.Sprintf("%s:443", dialParams.MeekFrontingDialAddress)
+			dialParams.MeekHostHeader = dialParams.MeekFrontingHost
+
+			// For a FrontingSpec, an SNI value of "" indicates to disable/omit SNI, so
+			// never transform in that case.
+			if dialParams.MeekSNIServerName != "" {
+				if p.WeightedCoinFlip(parameters.TransformHostNameProbability) {
+					dialParams.MeekSNIServerName = selectHostName(dialParams.TunnelProtocol, p)
+					dialParams.MeekTransformedHostName = true
+				}
+			}
+
+			// The minimum delay value is determined by the Conjure station, which
+			// performs an asynchronous "liveness test" against the selected phantom
+			// IPs. The min/max range allows us to introduce some jitter so that we
+			// don't present a trivial inter-flow fingerprint: CDN connection, fixed
+			// delay, phantom dial.
+
+			minDelay := p.Duration(parameters.ConjureAPIRegistrarMinDelay)
+			maxDelay := p.Duration(parameters.ConjureAPIRegistrarMaxDelay)
+			dialParams.ConjureAPIRegistrarDelay = prng.Period(minDelay, maxDelay)
+
+		} else if dialParams.ConjureDecoyRegistration {
+
+			dialParams.ConjureDecoyRegistrarWidth = decoyWidth
+			minDelay := p.Duration(parameters.ConjureDecoyRegistrarMinDelay)
+			maxDelay := p.Duration(parameters.ConjureDecoyRegistrarMaxDelay)
+			dialParams.ConjureAPIRegistrarDelay = prng.Period(minDelay, maxDelay)
+
+		} else {
+
+			return nil, errors.TraceNew("no Conjure registrar configured")
+		}
+	}
+
+	if (!isReplay || !replayConjureTransport) &&
+		protocol.TunnelProtocolUsesConjure(dialParams.TunnelProtocol) {
+
+		dialParams.ConjureTransport = protocol.CONJURE_TRANSPORT_MIN_OSSH
+		if p.WeightedCoinFlip(
+			parameters.ConjureTransportObfs4Probability) {
+			dialParams.ConjureTransport = protocol.CONJURE_TRANSPORT_OBFS4_OSSH
+		}
+	}
+
+	usingTLS := protocol.TunnelProtocolUsesMeekHTTPS(dialParams.TunnelProtocol) ||
+		dialParams.ConjureAPIRegistration
+
+	if (!isReplay || !replayTLSProfile) && usingTLS {
 
 		dialParams.SelectedTLSProfile = true
 
 		requireTLS12SessionTickets := protocol.TunnelProtocolRequiresTLS12SessionTickets(
 			dialParams.TunnelProtocol)
-		isFronted := protocol.TunnelProtocolUsesFrontedMeek(dialParams.TunnelProtocol)
+
+		isFronted := protocol.TunnelProtocolUsesFrontedMeek(dialParams.TunnelProtocol) ||
+			dialParams.ConjureAPIRegistration
+
 		dialParams.TLSProfile = SelectTLSProfile(
 			requireTLS12SessionTickets, isFronted, serverEntry.FrontingProviderID, p)
 
@@ -407,8 +516,7 @@ func MakeDialParameters(
 			parameters.NoDefaultTLSSessionIDProbability)
 	}
 
-	if (!isReplay || !replayRandomizedTLSProfile) &&
-		protocol.TunnelProtocolUsesMeekHTTPS(dialParams.TunnelProtocol) &&
+	if (!isReplay || !replayRandomizedTLSProfile) && usingTLS &&
 		protocol.TLSProfileIsRandomized(dialParams.TLSProfile) {
 
 		dialParams.RandomizedTLSProfileSeed, err = prng.NewSeed()
@@ -417,8 +525,7 @@ func MakeDialParameters(
 		}
 	}
 
-	if (!isReplay || !replayTLSProfile) &&
-		protocol.TunnelProtocolUsesMeekHTTPS(dialParams.TunnelProtocol) {
+	if (!isReplay || !replayTLSProfile) && usingTLS {
 
 		// Since "Randomized-v2"/CustomTLSProfiles may be TLS 1.2 or TLS 1.3,
 		// construct the ClientHello to determine if it's TLS 1.3. This test also
@@ -505,22 +612,6 @@ func MakeDialParameters(
 		}
 	}
 
-	if (!isReplay || !replayConjureRegistration) &&
-		protocol.TunnelProtocolUsesConjure(dialParams.TunnelProtocol) {
-
-		dialParams.ConjureDecoyRegistrarWidth = p.Int(parameters.ConjureDecoyRegistrarWidth)
-	}
-
-	if (!isReplay || !replayConjureTransport) &&
-		protocol.TunnelProtocolUsesConjure(dialParams.TunnelProtocol) {
-
-		dialParams.ConjureTransport = protocol.CONJURE_TRANSPORT_MIN_OSSH
-		if p.WeightedCoinFlip(
-			parameters.ConjureTransportObfs4Probability) {
-			dialParams.ConjureTransport = protocol.CONJURE_TRANSPORT_OBFS4_OSSH
-		}
-	}
-
 	if !isReplay || !replayLivenessTest {
 
 		// TODO: initialize only when LivenessTestMaxUp/DownstreamBytes > 0?
@@ -655,7 +746,9 @@ func MakeDialParameters(
 
 	dialCustomHeaders := makeDialCustomHeaders(config, p)
 
-	if protocol.TunnelProtocolUsesMeek(dialParams.TunnelProtocol) || dialParams.UpstreamProxyType == "http" {
+	if protocol.TunnelProtocolUsesMeek(dialParams.TunnelProtocol) ||
+		dialParams.UpstreamProxyType == "http" ||
+		dialParams.ConjureAPIRegistration {
 
 		if !isReplay || !replayUserAgent {
 			dialParams.SelectedUserAgent, dialParams.UserAgent = selectUserAgentIfUnset(p, dialCustomHeaders)
@@ -699,7 +792,8 @@ func MakeDialParameters(
 	// always be read.
 	dialParams.MeekResolvedIPAddress.Store("")
 
-	if protocol.TunnelProtocolUsesMeek(dialParams.TunnelProtocol) {
+	if protocol.TunnelProtocolUsesMeek(dialParams.TunnelProtocol) ||
+		dialParams.ConjureAPIRegistration {
 
 		dialParams.meekConfig = &MeekConfig{
 			DiagnosticID:                  serverEntry.GetDiagnosticID(),
@@ -707,12 +801,14 @@ func MakeDialParameters(
 			DialAddress:                   dialParams.MeekDialAddress,
 			UseQUIC:                       protocol.TunnelProtocolUsesFrontedMeekQUIC(dialParams.TunnelProtocol),
 			QUICVersion:                   dialParams.QUICVersion,
-			UseHTTPS:                      protocol.TunnelProtocolUsesMeekHTTPS(dialParams.TunnelProtocol),
+			UseHTTPS:                      usingTLS,
 			TLSProfile:                    dialParams.TLSProfile,
 			NoDefaultTLSSessionID:         dialParams.NoDefaultTLSSessionID,
 			RandomizedTLSProfileSeed:      dialParams.RandomizedTLSProfileSeed,
 			UseObfuscatedSessionTickets:   dialParams.TunnelProtocol == protocol.TUNNEL_PROTOCOL_UNFRONTED_MEEK_SESSION_TICKET,
 			SNIServerName:                 dialParams.MeekSNIServerName,
+			VerifyServerName:              dialParams.MeekVerifyServerName,
+			VerifyPins:                    dialParams.MeekVerifyPins,
 			HostHeader:                    dialParams.MeekHostHeader,
 			TransformedHostName:           dialParams.MeekTransformedHostName,
 			ClientTunnelProtocol:          dialParams.TunnelProtocol,
@@ -733,11 +829,11 @@ func MakeDialParameters(
 
 		if isTactics {
 			dialParams.meekConfig.Mode = MeekModeObfuscatedRoundTrip
+		} else if dialParams.ConjureAPIRegistration {
+			dialParams.meekConfig.Mode = MeekModePlaintextRoundTrip
 		} else {
 			dialParams.meekConfig.Mode = MeekModeRelay
 		}
-		// TODO: CONJURE-OSSH will use MeekModePlaintextRoundTrip for domain-fronted
-		// API registration.
 	}
 
 	return dialParams, nil
@@ -935,7 +1031,8 @@ func getConfigStateHash(
 	return hash.Sum(nil)
 }
 
-func selectFrontingParameters(serverEntry *protocol.ServerEntry) (string, string, error) {
+func selectFrontingParameters(
+	serverEntry *protocol.ServerEntry) (string, string, error) {
 
 	frontingDialHost := ""
 	frontingHost := ""

+ 86 - 10
psiphon/tunnel.go

@@ -30,6 +30,7 @@ import (
 	"io"
 	"io/ioutil"
 	"net"
+	"net/http"
 	"sync"
 	"sync/atomic"
 	"time"
@@ -790,22 +791,97 @@ func dialTunnel(
 
 	} else if protocol.TunnelProtocolUsesConjure(dialParams.TunnelProtocol) {
 
-		// The Conjure "phantom" connection is compatible with fragmentation, but
-		// the decoy registrar connection, like Tapdance, is not, so force it off.
-		// Any tunnel fragmentation metrics will refer to the "phantom" connection
-		// only.
-		decoyRegistrarDialer := NewNetDialer(
-			dialParams.GetDialConfig().WithoutFragmentor())
+		// Specify a cache key with a scope that ensures that:
+		//
+		// (a) cached registrations aren't used across different networks, as a
+		// registration requires the client's public IP to match the value at time
+		// of registration;
+		//
+		// (b) cached registrations are associated with specific Psiphon server
+		// candidates, to ensure that replay will use the same phantom IP(s).
+		//
+		// This scheme allows for reuse of cached registrations on network A when a
+		// client roams from network A to network B and back to network A.
+		//
+		// Using the network ID as a proxy for client public IP address is a
+		// heurisitic: it's possible that a clients public IP address changes
+		// without the network ID changing, and it's not guaranteed that the client
+		// will be assigned the original public IP on network A; so there's some
+		// chance the registration cannot be reused.
+
+		cacheKey := dialParams.NetworkID + dialParams.ServerEntry.IpAddress
+
+		conjureConfig := &refraction.ConjureConfig{
+			RegistrationCacheTTL: dialParams.ConjureCachedRegistrationTTL,
+			RegistrationCacheKey: cacheKey,
+			Transport:            dialParams.ConjureTransport,
+		}
+
+		if dialParams.ConjureAPIRegistration {
+
+			// Use MeekConn to domain front Conjure API registration.
+			//
+			// ConjureAPIRegistrarFrontingSpecs are applied via
+			// dialParams.GetMeekConfig, and will be subject to replay.
+			//
+			// Since DialMeek will create a TLS connection immediately, and a cached
+			// registration may be used, 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.
+			//
+			// In refraction.dial we configure 0 retries for API registration requests,
+			// assuming it's better to let another Psiphon candidate retry, with new
+			// domaing fronting parameters. As such, we expect only one round trip call
+			// per NewHTTPRoundTripper, so, in practise, there's no performance penalty
+			// from establishing a new MeekConn per round trip.
+			//
+			// Performing the full DialMeek/RoundTrip operation here allows us to call
+			// MeekConn.Close and ensure all resources are immediately cleaned up.
+			roundTrip := func(request *http.Request) (*http.Response, error) {
+				conn, err := DialMeek(
+					ctx, dialParams.GetMeekConfig(), dialParams.GetDialConfig())
+				if err != nil {
+					return nil, errors.Trace(err)
+				}
+				defer conn.Close()
+				response, err := conn.RoundTrip(request)
+				if err != nil {
+					return nil, errors.Trace(err)
+				}
+				// Currently, gotapdance does not read the response body. When that
+				// changes, we will need to ensure MeekConn.Close does not make the
+				// response body unavailable, perhaps by reading into a buffer and
+				// replacing reponse.Body. For now, we can immediately close it.
+				response.Body.Close()
+				return response, nil
+			}
+
+			conjureConfig.APIRegistrarHTTPClient = &http.Client{
+				Transport: common.NewHTTPRoundTripper(roundTrip),
+			}
+
+			conjureConfig.APIRegistrarURL = dialParams.ConjureAPIRegistrarURL
+			conjureConfig.APIRegistrarDelay = dialParams.ConjureAPIRegistrarDelay
+
+		} else if dialParams.ConjureDecoyRegistration {
+
+			// The Conjure "phantom" connection is compatible with fragmentation, but
+			// the decoy registrar connection, like Tapdance, is not, so force it off.
+			// Any tunnel fragmentation metrics will refer to the "phantom" connection
+			// only.
+			conjureConfig.DecoyRegistrarDialer = NewNetDialer(
+				dialParams.GetDialConfig().WithoutFragmentor())
+			conjureConfig.DecoyRegistrarWidth = dialParams.ConjureDecoyRegistrarWidth
+			conjureConfig.DecoyRegistrarDelay = dialParams.ConjureDecoyRegistrarDelay
+		}
 
 		dialConn, err = refraction.DialConjure(
 			ctx,
 			config.EmitRefractionNetworkingLogs,
 			config.GetPsiphonDataDirectory(),
 			NewNetDialer(dialParams.GetDialConfig()),
-			decoyRegistrarDialer,
-			dialParams.ConjureDecoyRegistrarWidth,
-			dialParams.ConjureTransport,
-			dialParams.DirectDialAddress)
+			dialParams.DirectDialAddress,
+			conjureConfig)
 		if err != nil {
 			return nil, errors.Trace(err)
 		}