Skip to content

Commit

Permalink
feat(echcheck): add an Encrypted Client Hello (ECH) experiment (#970)
Browse files Browse the repository at this point in the history
  • Loading branch information
d1vyank authored Dec 16, 2022
1 parent 9c7e0c6 commit 5998653
Show file tree
Hide file tree
Showing 12 changed files with 583 additions and 0 deletions.
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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=
Expand Down Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
18 changes: 18 additions & 0 deletions internal/engine/experiment/echcheck/config.go
Original file line number Diff line number Diff line change
@@ -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
}
26 changes: 26 additions & 0 deletions internal/engine/experiment/echcheck/config_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
4 changes: 4 additions & 0 deletions internal/engine/experiment/echcheck/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Package echcheck contains the ECH blocking network experiment.
//
// https://github.com/ooni/spec/pull/263
package echcheck
94 changes: 94 additions & 0 deletions internal/engine/experiment/echcheck/generate.go
Original file line number Diff line number Diff line change
@@ -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
}
58 changes: 58 additions & 0 deletions internal/engine/experiment/echcheck/handshake.go
Original file line number Diff line number Diff line change
@@ -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,
}
}
48 changes: 48 additions & 0 deletions internal/engine/experiment/echcheck/handshake_test.go
Original file line number Diff line number Diff line change
@@ -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()
}
Loading

0 comments on commit 5998653

Please sign in to comment.