From 5998653420d54403297bdf349e9a7257ceefabb2 Mon Sep 17 00:00:00 2001 From: Divyank Katira Date: Fri, 16 Dec 2022 21:50:00 +0530 Subject: [PATCH] feat(echcheck): add an Encrypted Client Hello (ECH) experiment (#970) See https://github.com/ooni/probe/issues/1453 --- go.mod | 1 + go.sum | 5 + internal/engine/experiment/echcheck/config.go | 18 +++ .../engine/experiment/echcheck/config_test.go | 26 ++++ internal/engine/experiment/echcheck/doc.go | 4 + .../engine/experiment/echcheck/generate.go | 94 +++++++++++++ .../engine/experiment/echcheck/handshake.go | 58 +++++++++ .../experiment/echcheck/handshake_test.go | 48 +++++++ .../engine/experiment/echcheck/measure.go | 123 ++++++++++++++++++ .../experiment/echcheck/measure_test.go | 94 +++++++++++++ internal/engine/experiment/echcheck/utls.go | 90 +++++++++++++ internal/registry/echcheck.go | 22 ++++ 12 files changed, 583 insertions(+) create mode 100644 internal/engine/experiment/echcheck/config.go create mode 100644 internal/engine/experiment/echcheck/config_test.go create mode 100644 internal/engine/experiment/echcheck/doc.go create mode 100644 internal/engine/experiment/echcheck/generate.go create mode 100644 internal/engine/experiment/echcheck/handshake.go create mode 100644 internal/engine/experiment/echcheck/handshake_test.go create mode 100644 internal/engine/experiment/echcheck/measure.go create mode 100644 internal/engine/experiment/echcheck/measure_test.go create mode 100644 internal/engine/experiment/echcheck/utls.go create mode 100644 internal/registry/echcheck.go diff --git a/go.mod b/go.mod index 64cbb7fdd..1c250884a 100644 --- a/go.mod +++ b/go.mod @@ -44,6 +44,7 @@ require ( ) require ( + github.com/cloudflare/circl v1.2.0 // indirect github.com/inconshreveable/mousetrap v1.0.0 // indirect github.com/segmentio/fasthash v1.0.3 // indirect github.com/spf13/pflag v1.0.5 // indirect diff --git a/go.sum b/go.sum index aea655d2c..7b3477174 100644 --- a/go.sum +++ b/go.sum @@ -117,6 +117,7 @@ github.com/bifurcation/mint v0.0.0-20180306135233-198357931e61/go.mod h1:zVt7zX3 github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM= github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBTaaSFSlLx/70C2HPIMNZpVV8+vt/A+FMnYP11g= github.com/buger/jsonparser v0.0.0-20181115193947-bf1c66bbce23/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s= +github.com/bwesterb/go-ristretto v1.2.1/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= github.com/casbin/casbin/v2 v2.1.2/go.mod h1:YcPU1XXisHhLzuxH9coDNf2FbKpjGlbCg3n9yuLkIJQ= github.com/ccding/go-stun v0.1.5-0.20220419042218-44e89cab7805 h1:AkTX8U06UIH//16PyxKLOPjlGoqcTEYpjipeCNsASfQ= github.com/ccding/go-stun v0.1.5-0.20220419042218-44e89cab7805/go.mod h1:cCZjJ1J3WFSJV6Wj8Y9Di8JMTsEXh6uv2eNmLzKaUeM= @@ -134,6 +135,8 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn github.com/clarkduvall/hyperloglog v0.0.0-20171127014514-a0107a5d8004/go.mod h1:drodPoQNro6QBO6TJ/MpMZbz8Bn2eSDtRN6jpG4VGw8= github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4dUb/I5gc9Hdhagfvm9+RyrPryS/auMzxE= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cloudflare/circl v1.2.0 h1:NheeISPSUcYftKlfrLuOo4T62FkmD4t4jviLfFFYaec= +github.com/cloudflare/circl v1.2.0/go.mod h1:Ch2UgYr6ti2KTtlejELlROl0YIYj7SLjAC8M+INXlMk= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= @@ -992,6 +995,7 @@ golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220131195533-30dcbda58838/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220307211146-efcb8507fb70/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20220315160706-3147a52a75dd/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220427172511-eb4f295cb31f/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220516162934-403b01795ae8/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= @@ -1206,6 +1210,7 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220315194320-039c03cc5b86/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/engine/experiment/echcheck/config.go b/internal/engine/experiment/echcheck/config.go new file mode 100644 index 000000000..00c93bb38 --- /dev/null +++ b/internal/engine/experiment/echcheck/config.go @@ -0,0 +1,18 @@ +package echcheck + +const ( + defaultResolver = "https://mozilla.cloudflare-dns.com/dns-query" +) + +// Config contains the experiment config. +type Config struct { + // ResolverURL is the default DoH resolver + ResolverURL string `ooni:"URL for DoH resolver"` +} + +func (c Config) resolverURL() string { + if c.ResolverURL != "" { + return c.ResolverURL + } + return defaultResolver +} diff --git a/internal/engine/experiment/echcheck/config_test.go b/internal/engine/experiment/echcheck/config_test.go new file mode 100644 index 000000000..426867c31 --- /dev/null +++ b/internal/engine/experiment/echcheck/config_test.go @@ -0,0 +1,26 @@ +package echcheck + +import ( + "testing" +) + +func TestConfig(t *testing.T) { + + c := Config{ + ResolverURL: "", + } + s1 := c.resolverURL() + if s1 != defaultResolver { + t.Fatalf("expected: %s, got %s", defaultResolver, s1) + } + + testResolver := "testResolver" + + c = Config{ + ResolverURL: testResolver, + } + s1 = c.resolverURL() + if s1 != testResolver { + t.Fatalf("expected: %s, got %s", testResolver, s1) + } +} diff --git a/internal/engine/experiment/echcheck/doc.go b/internal/engine/experiment/echcheck/doc.go new file mode 100644 index 000000000..f95cb9c13 --- /dev/null +++ b/internal/engine/experiment/echcheck/doc.go @@ -0,0 +1,4 @@ +// Package echcheck contains the ECH blocking network experiment. +// +// https://github.com/ooni/spec/pull/263 +package echcheck diff --git a/internal/engine/experiment/echcheck/generate.go b/internal/engine/experiment/echcheck/generate.go new file mode 100644 index 000000000..acf70d474 --- /dev/null +++ b/internal/engine/experiment/echcheck/generate.go @@ -0,0 +1,94 @@ +package echcheck + +// Generates a 'GREASE ECH' extension, as described in section 6.2 of +// ietf.org/archive/id/draft-ietf-tls-esni-14.html + +import ( + "fmt" + "github.com/cloudflare/circl/hpke" + "golang.org/x/crypto/cryptobyte" + "io" +) + +const clientHelloOuter uint8 = 0 + +// echExtension is the Encrypted Client Hello extension that is part of +// ClientHelloOuter as specified in: +// ietf.org/archive/id/draft-ietf-tls-esni-14.html#section-5 +type echExtension struct { + kdfID uint16 + aeadID uint16 + configID uint8 + enc []byte + payload []byte +} + +func (ech *echExtension) marshal() []byte { + var b cryptobyte.Builder + b.AddUint8(clientHelloOuter) + b.AddUint16(ech.kdfID) + b.AddUint16(ech.aeadID) + b.AddUint8(ech.configID) + b.AddUint16LengthPrefixed(func(b *cryptobyte.Builder) { + b.AddBytes(ech.enc) + }) + b.AddUint16LengthPrefixed(func(b *cryptobyte.Builder) { + b.AddBytes(ech.payload) + }) + return b.BytesOrPanic() +} + +// generateGreaseExtension generates an ECH extension with random values as +// specified in ietf.org/archive/id/draft-ietf-tls-esni-14.html#section-6.2 +func generateGreaseExtension(rand io.Reader) ([]byte, error) { + // initialize HPKE suite parameters + kem := hpke.KEM(uint16(hpke.KEM_X25519_HKDF_SHA256)) + kdf := hpke.KDF(uint16(hpke.KDF_HKDF_SHA256)) + aead := hpke.AEAD(uint16(hpke.AEAD_AES128GCM)) + + if !kem.IsValid() || !kdf.IsValid() || !aead.IsValid() { + return nil, fmt.Errorf("required parameters not supported") + } + + defaultHPKESuite := hpke.NewSuite(kem, kdf, aead) + + // generate a public key to place in 'enc' field + publicKey, _, err := kem.Scheme().GenerateKeyPair() + if err != nil { + return nil, fmt.Errorf("failed to generate key pair: %s", err) + } + + // initiate HPKE Sender + sender, err := defaultHPKESuite.NewSender(publicKey, nil) + if err != nil { + return nil, fmt.Errorf("failed to create sender: %s", err) + } + + // Set ECH Extension Fields + var ech echExtension + + ech.kdfID = uint16(kdf) + ech.aeadID = uint16(aead) + + randomByte := make([]byte, 1) + _, err = io.ReadFull(rand, randomByte) + if err != nil { + return nil, err + } + ech.configID = randomByte[0] + + ech.enc, _, err = sender.Setup(rand) + if err != nil { + return nil, err + } + + // TODO: compute this correctly as per https://www.ietf.org/archive/id/draft-ietf-tls-esni-14.html#name-recommended-padding-scheme + randomEncodedClientHelloInnerLen := 100 + cipherLen := int(aead.CipherLen(uint(randomEncodedClientHelloInnerLen))) + ech.payload = make([]byte, randomEncodedClientHelloInnerLen+cipherLen) + if _, err = io.ReadFull(rand, ech.payload); err != nil { + return nil, err + } + + return ech.marshal(), nil +} diff --git a/internal/engine/experiment/echcheck/handshake.go b/internal/engine/experiment/echcheck/handshake.go new file mode 100644 index 000000000..0d0dbf591 --- /dev/null +++ b/internal/engine/experiment/echcheck/handshake.go @@ -0,0 +1,58 @@ +package echcheck + +import ( + "context" + "crypto/rand" + "crypto/tls" + "github.com/apex/log" + "github.com/ooni/probe-cli/v3/internal/measurexlite" + "github.com/ooni/probe-cli/v3/internal/model" + "github.com/ooni/probe-cli/v3/internal/netxlite" + utls "gitlab.com/yawning/utls.git" + "net" + "time" +) + +const echExtensionType uint16 = 0xfe0d + +func handshake(ctx context.Context, conn net.Conn, zeroTime time.Time, address string, sni string) *model.ArchivalTLSOrQUICHandshakeResult { + return handshakeWithExtension(ctx, conn, zeroTime, address, sni, []utls.TLSExtension{}) +} + +func handshakeWithEch(ctx context.Context, conn net.Conn, zeroTime time.Time, address string, sni string) *model.ArchivalTLSOrQUICHandshakeResult { + payload, err := generateGreaseExtension(rand.Reader) + if err != nil { + panic("failed to generate grease ECH: " + err.Error()) + } + + var utlsEchExtension utls.GenericExtension + + utlsEchExtension.Id = echExtensionType + utlsEchExtension.Data = payload + + return handshakeWithExtension(ctx, conn, zeroTime, address, sni, []utls.TLSExtension{&utlsEchExtension}) +} + +func handshakeWithExtension(ctx context.Context, conn net.Conn, zeroTime time.Time, address string, sni string, extensions []utls.TLSExtension) *model.ArchivalTLSOrQUICHandshakeResult { + tlsConfig := genTLSConfig(sni) + + handshakerConstructor := newHandshakerWithExtensions(extensions) + tracedHandshaker := handshakerConstructor(log.Log, &utls.HelloFirefox_Auto) + + start := time.Now() + _, connState, err := tracedHandshaker.Handshake(ctx, conn, tlsConfig) + finish := time.Now() + + return measurexlite.NewArchivalTLSOrQUICHandshakeResult(0, start.Sub(zeroTime), "tcp", address, tlsConfig, + connState, err, finish.Sub(zeroTime)) +} + +// genTLSConfig generates tls.Config from a given SNI +func genTLSConfig(sni string) *tls.Config { + return &tls.Config{ + RootCAs: netxlite.NewDefaultCertPool(), + ServerName: sni, + NextProtos: []string{"h2", "http/1.1"}, + InsecureSkipVerify: true, + } +} diff --git a/internal/engine/experiment/echcheck/handshake_test.go b/internal/engine/experiment/echcheck/handshake_test.go new file mode 100644 index 000000000..951916d96 --- /dev/null +++ b/internal/engine/experiment/echcheck/handshake_test.go @@ -0,0 +1,48 @@ +package echcheck + +import ( + "context" + "fmt" + "net" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" +) + +func TestHandshake(t *testing.T) { + ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, "success") + })) + defer ts.Close() + + parsed, err := url.Parse(ts.URL) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var dialer net.Dialer + conn, err := dialer.DialContext(ctx, "tcp", parsed.Host) + if err != nil { + t.Fatal(err) + } + + result := handshakeWithEch(ctx, conn, time.Now(), parsed.Host, "example.org") + if result == nil { + t.Fatal("expected result") + } + + if result.SoError != nil { + t.Fatal("did not expect error, got: ", result.SoError) + } + + if result.Failure != nil { + t.Fatal("did not expect error, got: ", *result.Failure) + } + + conn.Close() +} diff --git a/internal/engine/experiment/echcheck/measure.go b/internal/engine/experiment/echcheck/measure.go new file mode 100644 index 000000000..a2b2846ff --- /dev/null +++ b/internal/engine/experiment/echcheck/measure.go @@ -0,0 +1,123 @@ +package echcheck + +import ( + "context" + "errors" + "github.com/ooni/probe-cli/v3/internal/measurexlite" + "github.com/ooni/probe-cli/v3/internal/model" + "github.com/ooni/probe-cli/v3/internal/netxlite" + "github.com/ooni/probe-cli/v3/internal/runtimex" + "net" + "net/url" + "time" +) + +const ( + testName = "echcheck" + testVersion = "0.1.0" + defaultDomain = "https://example.org" +) + +var ( + // errInputIsNotAnURL indicates that input is not an URL + errInputIsNotAnURL = errors.New("input is not an URL") + + // errInvalidInputScheme indicates that the input scheme is invalid + errInvalidInputScheme = errors.New("input scheme must be https") +) + +// TestKeys contains echcheck test keys. +type TestKeys struct { + Control model.ArchivalTLSOrQUICHandshakeResult `json:"control"` + Target model.ArchivalTLSOrQUICHandshakeResult `json:"target"` +} + +// Measurer performs the measurement. +type Measurer struct { + config Config +} + +// ExperimentName implements ExperimentMeasurer.ExperiExperimentName. +func (m *Measurer) ExperimentName() string { + return testName +} + +// ExperimentVersion implements ExperimentMeasurer.ExperimentVersion. +func (m *Measurer) ExperimentVersion() string { + return testVersion +} + +// Run implements ExperimentMeasurer.Run. +func (m *Measurer) Run( + ctx context.Context, + args *model.ExperimentArgs, +) error { + if args.Measurement.Input == "" { + args.Measurement.Input = defaultDomain + } + parsed, err := url.Parse(string(args.Measurement.Input)) + if err != nil { + return errInputIsNotAnURL + } + if parsed.Scheme != "https" { + return errInvalidInputScheme + } + + // 1. perform a DNSLookup + trace := measurexlite.NewTrace(0, args.Measurement.MeasurementStartTimeSaved) + resolver := trace.NewParallelDNSOverHTTPSResolver(args.Session.Logger(), m.config.resolverURL()) + addrs, err := resolver.LookupHost(ctx, parsed.Host) + if err != nil { + return err + } + runtimex.Assert(len(addrs) > 0, "expected at least one entry in addrs") + address := net.JoinHostPort(addrs[0], "443") + + // 2. Set up TCP connections + var dialer net.Dialer + conn, err := dialer.DialContext(ctx, "tcp", address) + if err != nil { + return netxlite.NewErrWrapper(netxlite.ClassifyGenericError, netxlite.ConnectOperation, err) + } + + conn2, err := dialer.DialContext(ctx, "tcp", address) + if err != nil { + return netxlite.NewErrWrapper(netxlite.ClassifyGenericError, netxlite.ConnectOperation, err) + } + + // 3. Conduct and measure control and target TLS handshakes in parallel + controlChannel := make(chan model.ArchivalTLSOrQUICHandshakeResult) + targetChannel := make(chan model.ArchivalTLSOrQUICHandshakeResult) + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + go func() { + controlChannel <- *handshake(ctx, conn, args.Measurement.MeasurementStartTimeSaved, address, parsed.Host) + }() + + go func() { + targetChannel <- *handshakeWithEch(ctx, conn2, args.Measurement.MeasurementStartTimeSaved, address, parsed.Host) + }() + + control := <-controlChannel + target := <-targetChannel + + args.Measurement.TestKeys = TestKeys{Control: control, Target: target} + + return nil +} + +// NewExperimentMeasurer creates a new ExperimentMeasurer. +func NewExperimentMeasurer(config Config) model.ExperimentMeasurer { + return &Measurer{config: config} +} + +// SummaryKeys contains summary keys for this experiment. +type SummaryKeys struct { + IsAnomaly bool `json:"-"` +} + +// GetSummaryKeys implements model.ExperimentMeasurer.GetSummaryKeys. +func (m *Measurer) GetSummaryKeys(measurement *model.Measurement) (interface{}, error) { + return SummaryKeys{IsAnomaly: false}, nil +} diff --git a/internal/engine/experiment/echcheck/measure_test.go b/internal/engine/experiment/echcheck/measure_test.go new file mode 100644 index 000000000..e76237e70 --- /dev/null +++ b/internal/engine/experiment/echcheck/measure_test.go @@ -0,0 +1,94 @@ +package echcheck + +import ( + "context" + "github.com/apex/log" + "github.com/ooni/probe-cli/v3/internal/engine/mockable" + "github.com/ooni/probe-cli/v3/internal/model" + "testing" +) + +func TestNewExperimentMeasurer(t *testing.T) { + measurer := NewExperimentMeasurer(Config{}) + if measurer.ExperimentName() != "echcheck" { + t.Fatal("unexpected name") + } + if measurer.ExperimentVersion() != "0.1.0" { + t.Fatal("unexpected version") + } +} + +func TestMeasurerMeasureWithInvalidInput(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // immediately cancel the context + sess := &mockable.Session{MockableLogger: log.Log} + callbacks := model.NewPrinterCallbacks(sess.Logger()) + measurer := NewExperimentMeasurer(Config{}) + measurement := &model.Measurement{ + Input: "http://example.org", + } + args := &model.ExperimentArgs{ + Callbacks: callbacks, + Measurement: measurement, + Session: sess, + } + err := measurer.Run( + ctx, + args, + ) + if err == nil { + t.Fatal("expected an error here") + } +} + +func TestMeasurerMeasureWithInvalidInput2(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // immediately cancel the context + sess := &mockable.Session{MockableLogger: log.Log} + callbacks := model.NewPrinterCallbacks(sess.Logger()) + measurer := NewExperimentMeasurer(Config{}) + measurement := &model.Measurement{ + // leading space to test url.Parse failure + Input: " https://example.org", + } + args := &model.ExperimentArgs{ + Callbacks: callbacks, + Measurement: measurement, + Session: sess, + } + err := measurer.Run( + ctx, + args, + ) + if err == nil { + t.Fatal("expected an error here") + } +} + +func TestMeasurementSuccess(t *testing.T) { + sess := &mockable.Session{MockableLogger: log.Log} + callbacks := model.NewPrinterCallbacks(sess.Logger()) + measurer := NewExperimentMeasurer(Config{}) + args := &model.ExperimentArgs{ + Callbacks: callbacks, + Measurement: &model.Measurement{}, + Session: sess, + } + err := measurer.Run( + context.Background(), + args, + ) + if err != nil { + t.Fatal("unexpected error: ", err) + } + + summary, err := measurer.GetSummaryKeys(&model.Measurement{}) + + if summary.(SummaryKeys).IsAnomaly != false { + t.Fatal("expected false") + } +} + +func newsession() model.ExperimentSession { + return &mockable.Session{MockableLogger: log.Log} +} diff --git a/internal/engine/experiment/echcheck/utls.go b/internal/engine/experiment/echcheck/utls.go new file mode 100644 index 000000000..6c923fefb --- /dev/null +++ b/internal/engine/experiment/echcheck/utls.go @@ -0,0 +1,90 @@ +package echcheck + +import ( + "context" + "crypto/tls" + "github.com/ooni/probe-cli/v3/internal/model" + utls "gitlab.com/yawning/utls.git" + "net" +) + +type tlsHandshakerWithExtensions struct { + conn utlsConn + extensions []utls.TLSExtension + dl model.DebugLogger + id *utls.ClientHelloID +} + +var _ model.TLSHandshaker = &tlsHandshakerWithExtensions{} + +// newHandshakerWithExtensions returns a NewHandshaker function for creating +// tlsHandshakerWithExtensions instances. +func newHandshakerWithExtensions(extensions []utls.TLSExtension) func(dl model.DebugLogger, id *utls.ClientHelloID) model.TLSHandshaker { + return func(dl model.DebugLogger, id *utls.ClientHelloID) model.TLSHandshaker { + return &tlsHandshakerWithExtensions{ + extensions: extensions, + dl: dl, + id: id, + } + } +} + +func (t *tlsHandshakerWithExtensions) Handshake(ctx context.Context, conn net.Conn, tlsConfig *tls.Config) ( + net.Conn, tls.ConnectionState, error) { + t.conn = newConnUTLSWithHelloID(conn, tlsConfig, t.id) + + if t.extensions != nil && len(t.extensions) != 0 { + t.conn.BuildHandshakeState() + t.conn.Extensions = append(t.conn.Extensions, t.extensions...) + } + + err := t.conn.Handshake() + + return t.conn.NetConn(), t.conn.ConnectionState(), err +} + +// TODO remove after https://github.com/ooni/probe/issues/2378 +// utlsConn is a utls connection +type utlsConn struct { + *utls.UConn + nc net.Conn +} + +// newConnUTLSWithHelloID creates a new connection with the given client hello ID. +func newConnUTLSWithHelloID(conn net.Conn, config *tls.Config, cid *utls.ClientHelloID) utlsConn { + uConfig := &utls.Config{ + DynamicRecordSizingDisabled: config.DynamicRecordSizingDisabled, + InsecureSkipVerify: config.InsecureSkipVerify, + RootCAs: config.RootCAs, + NextProtos: config.NextProtos, + ServerName: config.ServerName, + } + tlsConn := utls.UClient(conn, uConfig, *cid) + oconn := utlsConn{ + UConn: tlsConn, + nc: conn, + } + return oconn +} + +func (c *utlsConn) ConnectionState() tls.ConnectionState { + uState := c.Conn.ConnectionState() + return tls.ConnectionState{ + Version: uState.Version, + HandshakeComplete: uState.HandshakeComplete, + DidResume: uState.DidResume, + CipherSuite: uState.CipherSuite, + NegotiatedProtocol: uState.NegotiatedProtocol, + NegotiatedProtocolIsMutual: uState.NegotiatedProtocolIsMutual, + ServerName: uState.ServerName, + PeerCertificates: uState.PeerCertificates, + VerifiedChains: uState.VerifiedChains, + SignedCertificateTimestamps: uState.SignedCertificateTimestamps, + OCSPResponse: uState.OCSPResponse, + TLSUnique: uState.TLSUnique, + } +} + +func (c *utlsConn) NetConn() net.Conn { + return c.nc +} diff --git a/internal/registry/echcheck.go b/internal/registry/echcheck.go new file mode 100644 index 000000000..ec6fe188a --- /dev/null +++ b/internal/registry/echcheck.go @@ -0,0 +1,22 @@ +package registry + +// +// Registers the `echcheck' experiment. +// + +import ( + "github.com/ooni/probe-cli/v3/internal/engine/experiment/echcheck" + "github.com/ooni/probe-cli/v3/internal/model" +) + +func init() { + AllExperiments["echcheck"] = &Factory{ + build: func(config interface{}) model.ExperimentMeasurer { + return echcheck.NewExperimentMeasurer( + *config.(*echcheck.Config), + ) + }, + config: &echcheck.Config{}, + inputPolicy: model.InputOptional, + } +}