From e64f736cb418f2888f7cbd5f0930c25a3054aee6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arturo=20Filast=C3=B2?= Date: Wed, 20 Nov 2024 10:27:15 +0100 Subject: [PATCH] Switch to using cloudflare-ech.com as the target for the ech test (#1658) ## Checklist - [x] I have read the [contribution guidelines](https://github.com/ooni/probe-cli/blob/master/CONTRIBUTING.md) - [x] reference issue for this pull request: https://github.com/ooni/probe/issues/1453 - [x] if you changed anything related to how experiments work and you need to reflect these changes in the ooni/spec repository, please link to the related ooni/spec pull request: https://github.com/ooni/spec/pull/297 - [x] if you changed code inside an experiment, make sure you bump its version number ## Description Changes to the ECHCheck experiment. * Replace default URL with cloudflare-ech.com * Add support for performing an additional ECH handshake with a different ClientHelloOuter SNI * Randomize the order of the handshakes --- internal/experiment/echcheck/handshake.go | 51 ++++++++++- internal/experiment/echcheck/measure.go | 96 ++++++++++---------- internal/experiment/echcheck/measure_test.go | 13 ++- internal/model/archival.go | 2 + 4 files changed, 106 insertions(+), 56 deletions(-) diff --git a/internal/experiment/echcheck/handshake.go b/internal/experiment/echcheck/handshake.go index 05a4e25267..3ecc648262 100644 --- a/internal/experiment/echcheck/handshake.go +++ b/internal/experiment/echcheck/handshake.go @@ -17,6 +17,52 @@ import ( const echExtensionType uint16 = 0xfe0d +func connectAndHandshake( + ctx context.Context, + startTime time.Time, + address string, sni string, outerSni string, + logger model.Logger) (chan model.ArchivalTLSOrQUICHandshakeResult, error) { + + channel := make(chan model.ArchivalTLSOrQUICHandshakeResult) + + ol := logx.NewOperationLogger(logger, "echcheck: TCPConnect %s", address) + var dialer net.Dialer + conn, err := dialer.DialContext(ctx, "tcp", address) + ol.Stop(err) + if err != nil { + return nil, netxlite.NewErrWrapper(netxlite.ClassifyGenericError, netxlite.ConnectOperation, err) + } + + go func() { + var res *model.ArchivalTLSOrQUICHandshakeResult + if outerSni == "" { + res = handshake( + ctx, + conn, + startTime, + address, + sni, + logger, + ) + } else { + res = handshakeWithEch( + ctx, + conn, + startTime, + address, + outerSni, + logger, + ) + // We need to set this explicitly because otherwise it will get + // overridden with the outerSni in the case of ECH + res.ServerName = sni + } + channel <- *res + }() + + return channel, nil +} + func handshake(ctx context.Context, conn net.Conn, zeroTime time.Time, address string, sni string, logger model.Logger) *model.ArchivalTLSOrQUICHandshakeResult { return handshakeWithExtension(ctx, conn, zeroTime, address, sni, []utls.TLSExtension{}, logger) @@ -34,7 +80,10 @@ func handshakeWithEch(ctx context.Context, conn net.Conn, zeroTime time.Time, utlsEchExtension.Id = echExtensionType utlsEchExtension.Data = payload - return handshakeWithExtension(ctx, conn, zeroTime, address, sni, []utls.TLSExtension{&utlsEchExtension}, logger) + hs := handshakeWithExtension(ctx, conn, zeroTime, address, sni, []utls.TLSExtension{&utlsEchExtension}, logger) + hs.ECHConfig = "GREASE" + hs.OuterServerName = sni + return hs } func handshakeMaybePrintWithECH(doprint bool) string { diff --git a/internal/experiment/echcheck/measure.go b/internal/experiment/echcheck/measure.go index 807a43d969..243c44c88f 100644 --- a/internal/experiment/echcheck/measure.go +++ b/internal/experiment/echcheck/measure.go @@ -3,21 +3,20 @@ package echcheck import ( "context" "errors" + "math/rand" "net" "net/url" - "time" "github.com/ooni/probe-cli/v3/internal/logx" "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" ) const ( testName = "echcheck" - testVersion = "0.1.2" - defaultURL = "https://crypto.cloudflare.com/cdn-cgi/trace" + testVersion = "0.2.0" + defaultURL = "https://cloudflare-ech.com/cdn-cgi/trace" ) var ( @@ -30,8 +29,7 @@ var ( // TestKeys contains echcheck test keys. type TestKeys struct { - Control model.ArchivalTLSOrQUICHandshakeResult `json:"control"` - Target model.ArchivalTLSOrQUICHandshakeResult `json:"target"` + TLSHandshakes []*model.ArchivalTLSOrQUICHandshakeResult `json:"tls_handshakes"` } // Measurer performs the measurement. @@ -77,54 +75,52 @@ func (m *Measurer) Run( runtimex.Assert(len(addrs) > 0, "expected at least one entry in addrs") address := net.JoinHostPort(addrs[0], "443") - // 2. Set up TCP connections - ol = logx.NewOperationLogger(args.Session.Logger(), "echcheck: TCPConnect#1 %s", address) - var dialer net.Dialer - conn, err := dialer.DialContext(ctx, "tcp", address) - ol.Stop(err) - if err != nil { - return netxlite.NewErrWrapper(netxlite.ClassifyGenericError, netxlite.ConnectOperation, err) + handshakes := []func() (chan model.ArchivalTLSOrQUICHandshakeResult, error){ + // handshake with ECH disabled and SNI coming from the URL + func() (chan model.ArchivalTLSOrQUICHandshakeResult, error) { + return connectAndHandshake(ctx, args.Measurement.MeasurementStartTimeSaved, + address, parsed.Host, "", args.Session.Logger()) + }, + // handshake with ECH enabled and ClientHelloOuter SNI coming from the URL + func() (chan model.ArchivalTLSOrQUICHandshakeResult, error) { + return connectAndHandshake(ctx, args.Measurement.MeasurementStartTimeSaved, + address, parsed.Host, parsed.Host, args.Session.Logger()) + }, + // handshake with ECH enabled and hardcoded different ClientHelloOuter SNI + func() (chan model.ArchivalTLSOrQUICHandshakeResult, error) { + return connectAndHandshake(ctx, args.Measurement.MeasurementStartTimeSaved, + address, parsed.Host, "cloudflare.com", args.Session.Logger()) + }, } - ol = logx.NewOperationLogger(args.Session.Logger(), "echcheck: TCPConnect#2 %s", address) - conn2, err := dialer.DialContext(ctx, "tcp", address) - ol.Stop(err) - if err != nil { - return netxlite.NewErrWrapper(netxlite.ClassifyGenericError, netxlite.ConnectOperation, err) + // We shuffle the order in which the operations are done to avoid residual + // censorship issues. + rand.Shuffle(len(handshakes), func(i, j int) { + handshakes[i], handshakes[j] = handshakes[j], handshakes[i] + }) + + var channels [3](chan model.ArchivalTLSOrQUICHandshakeResult) + var results [3](model.ArchivalTLSOrQUICHandshakeResult) + + // Fire the handshakes in parallel + // TODO: currently if one of the connects fails we fail the whole result + // set. This is probably OK given that we only ever use the same address, + // but this may be something we want to change in the future. + for idx, hs := range handshakes { + channels[idx], err = hs() + if err != nil { + return err + } + } + + // Wait on each channel for the results to come in + for idx, ch := range channels { + results[idx] = <-ch } - // 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, - args.Session.Logger(), - ) - }() - - go func() { - targetChannel <- *handshakeWithEch( - ctx, - conn2, - args.Measurement.MeasurementStartTimeSaved, - address, - parsed.Host, - args.Session.Logger(), - ) - }() - - control := <-controlChannel - target := <-targetChannel - - args.Measurement.TestKeys = TestKeys{Control: control, Target: target} + args.Measurement.TestKeys = TestKeys{TLSHandshakes: []*model.ArchivalTLSOrQUICHandshakeResult{ + &results[0], &results[1], &results[2], + }} return nil } diff --git a/internal/experiment/echcheck/measure_test.go b/internal/experiment/echcheck/measure_test.go index 2a53fbd938..14ae2b2929 100644 --- a/internal/experiment/echcheck/measure_test.go +++ b/internal/experiment/echcheck/measure_test.go @@ -114,10 +114,13 @@ func TestMeasurementSuccessRealWorld(t *testing.T) { // check results tk := msrmnt.TestKeys.(TestKeys) - if tk.Control.Failure != nil { - t.Fatal("unexpected control failure:", *tk.Control.Failure) - } - if tk.Target.Failure != nil { - t.Fatal("unexpected target failure:", *tk.Target.Failure) + for _, hs := range tk.TLSHandshakes { + if hs.Failure != nil { + if hs.ECHConfig == "GREASE" { + t.Fatal("unexpected exp failure:", hs.Failure) + } else { + t.Fatal("unexpected ctrl failure:", hs.Failure) + } + } } } diff --git a/internal/model/archival.go b/internal/model/archival.go index 8a17e745ee..1930dc53c5 100644 --- a/internal/model/archival.go +++ b/internal/model/archival.go @@ -248,6 +248,8 @@ type ArchivalTLSOrQUICHandshakeResult struct { NoTLSVerify bool `json:"no_tls_verify"` PeerCertificates []ArchivalBinaryData `json:"peer_certificates"` ServerName string `json:"server_name"` + OuterServerName string `json:"outer_server_name,omitempty"` + ECHConfig string `json:"echconfig,omitempty"` T0 float64 `json:"t0,omitempty"` T float64 `json:"t"` Tags []string `json:"tags"`