From 8afa0d83f75ce9e0a14720141c796a074ab9cce0 Mon Sep 17 00:00:00 2001 From: Vinicius Fortuna Date: Fri, 29 Aug 2025 13:07:17 -0400 Subject: [PATCH 1/8] feat(x/tools): Add ech-config flag to fetch --- x/tools/fetch/main.go | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/x/tools/fetch/main.go b/x/tools/fetch/main.go index 955b867a8..3a7a91c9a 100644 --- a/x/tools/fetch/main.go +++ b/x/tools/fetch/main.go @@ -18,6 +18,7 @@ import ( "bufio" "context" "crypto/tls" + "encoding/base64" "flag" "fmt" "io" @@ -71,10 +72,14 @@ func overrideAddress(original string, newHost string, newPort string) (string, e func main() { verboseFlag := flag.Bool("v", false, "Enable debug output") + tlsKeyLogFlag := flag.String("tls-key-log", "", "Filename to write the TLS key log to allow for decryption on Wireshark") - protoFlag := flag.String("proto", "h1", "HTTP version to use (h1, h2, h3)") - transportFlag := flag.String("transport", "", "Transport config") + echConfigFlag := flag.String("ech-config", "", "Base64-encoded ECH config") + addressFlag := flag.String("address", "", "Address to connect to. If empty, use the URL authority") + transportFlag := flag.String("transport", "", "Transport config") + + protoFlag := flag.String("proto", "h1", "HTTP version to use (h1, h2, h3)") methodFlag := flag.String("method", "GET", "The HTTP method to use") var headersFlag stringArrayFlagValue flag.Var(&headersFlag, "H", "Raw HTTP Header line to add. It must not end in \\r\\n") @@ -118,6 +123,15 @@ func main() { defer httpClient.CloseIdleConnections() var tlsConfig tls.Config + if *echConfigFlag != "" { + // TODO(fortuna): Add support for GREASE and automatic fetching of the HTTPS RR. + echConfigBytes, err := base64.StdEncoding.DecodeString(*echConfigFlag) + if err != nil { + slog.Error("Failed to decode base64 ECH config", "error", err) + os.Exit(1) + } + tlsConfig.EncryptedClientHelloConfigList = echConfigBytes + } if *tlsKeyLogFlag != "" { f, err := os.Create(*tlsKeyLogFlag) if err != nil { @@ -144,13 +158,14 @@ func main() { } return dialer.DialStream(ctx, addressToDial) } - if *protoFlag == "h1" { + switch *protoFlag { + case "h1": tlsConfig.NextProtos = []string{"http/1.1"} httpClient.Transport = &http.Transport{ DialContext: dialContext, TLSClientConfig: &tlsConfig, } - } else if *protoFlag == "h2" { + case "h2": tlsConfig.NextProtos = []string{"h2"} httpClient.Transport = &http.Transport{ DialContext: dialContext, From 0dcfc0175f0f535c4ce5104bcca61f60f9f0deaf Mon Sep 17 00:00:00 2001 From: Vinicius Fortuna Date: Fri, 29 Aug 2025 17:57:41 -0400 Subject: [PATCH 2/8] Try ECH GREASE --- x/ech/ech_grease.go | 108 ++++++++++++++++++++++++++++++++ x/ech/ech_grease_test.go | 132 +++++++++++++++++++++++++++++++++++++++ x/tools/fetch/main.go | 35 ++++++++--- 3 files changed, 266 insertions(+), 9 deletions(-) create mode 100644 x/ech/ech_grease.go create mode 100644 x/ech/ech_grease_test.go diff --git a/x/ech/ech_grease.go b/x/ech/ech_grease.go new file mode 100644 index 000000000..5fb5d37f6 --- /dev/null +++ b/x/ech/ech_grease.go @@ -0,0 +1,108 @@ +// Copyright 2025 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ech + +import ( + "fmt" + "io" + + "github.com/cloudflare/circl/hpke" + "golang.org/x/crypto/cryptobyte" +) + +// addHpkeKeyConfig adds the HpkeKeyConfig +func addHpkeKeyConfig(b *cryptobyte.Builder, rand io.Reader) error { + randConfigID := make([]byte, 1) + if _, err := io.ReadFull(rand, randConfigID); err != nil { + return fmt.Errorf("failed to read random config ID: %w", err) + } + b.AddUint8(randConfigID[0]) // uint8 config_id + kem_id := uint16(hpke.KEM_X25519_HKDF_SHA256) + b.AddUint16(kem_id) // HpkeKemId (uint16) kem_id + + kem := hpke.KEM(kem_id) + publicKey, _, err := kem.Scheme().GenerateKeyPair() + if err != nil { + return fmt.Errorf("failed to generate KEM key pair: %w", err) + } + publicKeyBytes, err := publicKey.MarshalBinary() + if err != nil { + return fmt.Errorf("failed to marshal public key: %w", err) + } + // opaque public_key<1..2^16-1> (HpkePublicKey) + b.AddUint16LengthPrefixed(func(child *cryptobyte.Builder) { + child.AddBytes(publicKeyBytes) + }) + + // HpkeSymmetricCipherSuite cipher_suites<4..2^16-4> + b.AddUint16LengthPrefixed(func(child *cryptobyte.Builder) { + child.AddUint16(uint16(hpke.KDF_HKDF_SHA256)) // HpkeKdfId(uint16) kdf_id + // Note: BoringSSL chooses between AES128GCM and CHACHA20_PLOY1305 based on whether + // hardware acceleration is configured. + child.AddUint16(uint16(hpke.AEAD_AES128GCM)) // HpkeAeadId(uint16) aead_id + }) + return nil +} + +// addECHConfigContents appends the serialized ECHConfigContents to the given builder. +func addECHConfigContents(b *cryptobyte.Builder, rand io.Reader, publicName string) error { + // HpkeKeyConfig key_config + if err := addHpkeKeyConfig(b, rand); err != nil { + return fmt.Errorf("failed to add HPKE key config: %w", err) + } + + // uint8 maximum_name_length + b.AddUint8(uint8(42)) + + // opaque public_name<1..255> + publicNameBytes := []byte(publicName) + b.AddUint8LengthPrefixed(func(child *cryptobyte.Builder) { + child.AddBytes(publicNameBytes) + }) + + // ECHConfigExtension extensions<0..2^16-1> + b.AddUint16LengthPrefixed(func(child *cryptobyte.Builder) { + // No extensions + }) + + return nil +} + +// addECHConfig appends a serialized ECHConfig to the given builder. +func addECHConfig(b *cryptobyte.Builder, rand io.Reader, publicName string) { + // uint16 version + b.AddUint16(0xfe0d) + // uint16 length + b.AddUint16LengthPrefixed(func(child *cryptobyte.Builder) { + // ECHConfigContents contents + if err := addECHConfigContents(child, rand, publicName); err != nil { + b.SetError(fmt.Errorf("failed to add ECHConfigContents: %w", err)) + return + } + }) +} + +// GenerateGreaseECHConfigList creates a serialized ECHConfigList containing one +// GREASE ECHConfig. +// Client behavior: https://www.ietf.org/archive/id/draft-ietf-tls-esni-25.html#name-grease-ech +// ECHConfigList format https://www.ietf.org/archive/id/draft-ietf-tls-esni-25.html#name-encrypted-clienthello-confi +func GenerateGreaseECHConfigList(rand io.Reader, publicName string) ([]byte, error) { + var b cryptobyte.Builder + // ECHConfig ECHConfigList<4..2^16-1> + b.AddUint16LengthPrefixed(func(child *cryptobyte.Builder) { + addECHConfig(child, rand, publicName) + }) + return b.Bytes() +} diff --git a/x/ech/ech_grease_test.go b/x/ech/ech_grease_test.go new file mode 100644 index 000000000..b23c0be7d --- /dev/null +++ b/x/ech/ech_grease_test.go @@ -0,0 +1,132 @@ +// Copyright 2025 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ech + +import ( + "crypto/rand" + "fmt" + "testing" + + "github.com/cloudflare/circl/hpke" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/cryptobyte" +) + +// TestGenerateGreaseECHConfigListSuccess tests if the function executes without error. +func TestGenerateGreaseECHConfigListSuccess(t *testing.T) { + publicName := "grease.example.com" + _, err := GenerateGreaseECHConfigList(rand.Reader, publicName) + require.NoError(t, err) +} + +// TestParseGreaseECHConfigList tests if the generated list can be parsed and has the expected structure. +func TestParseGreaseECHConfigList(t *testing.T) { + publicName := "grease.example.com" + echConfigListBytes, err := GenerateGreaseECHConfigList(rand.Reader, publicName) + require.NoError(t, err) + + parser := cryptobyte.String(echConfigListBytes) + + var echConfigList cryptobyte.String + require.True(t, parser.ReadUint16LengthPrefixed(&echConfigList)) + require.True(t, parser.Empty()) + + // The list contains one ECHConfig. We parse it directly. + var version uint16 + require.True(t, echConfigList.ReadUint16(&version)) + require.Equal(t, uint16(0xfe0d), version) + + var contents cryptobyte.String + require.True(t, echConfigList.ReadUint16LengthPrefixed(&contents)) + require.True(t, echConfigList.Empty(), "ECHConfigList should contain only one ECHConfig for GREASE") + + // Parse ECHConfigContents + var configID uint8 + require.True(t, contents.ReadUint8(&configID)) + + var kemIDUint16 uint16 + require.True(t, contents.ReadUint16(&kemIDUint16)) + require.Equal(t, hpke.KEM_X25519_HKDF_SHA256, hpke.KEM(kemIDUint16)) + + var publicKey cryptobyte.String + require.True(t, contents.ReadUint16LengthPrefixed(&publicKey)) + require.False(t, publicKey.Empty()) + + var cipherSuites cryptobyte.String + require.True(t, contents.ReadUint16LengthPrefixed(&cipherSuites)) + + var kdfIDUint16 uint16 + require.True(t, cipherSuites.ReadUint16(&kdfIDUint16)) + require.Equal(t, hpke.KDF_HKDF_SHA256, hpke.KDF(kdfIDUint16)) + + var aeadIDUint16 uint16 + require.True(t, cipherSuites.ReadUint16(&aeadIDUint16)) + require.Equal(t, hpke.AEAD_AES128GCM, hpke.AEAD(aeadIDUint16)) + require.True(t, cipherSuites.Empty(), "Unexpected bytes after cipher suite") + + var maxNameLength uint8 + require.True(t, contents.ReadUint8(&maxNameLength)) + + var publicNameBytes cryptobyte.String + require.True(t, contents.ReadUint8LengthPrefixed(&publicNameBytes)) + require.Equal(t, publicName, string(publicNameBytes)) + + var extensions cryptobyte.String + require.True(t, contents.ReadUint16LengthPrefixed(&extensions)) + require.True(t, extensions.Empty(), "Extensions block should be empty for this GREASE config") + require.True(t, contents.Empty(), "Unexpected bytes at end of ECHConfigContents") +} + +// TestRandomness checks if consecutive calls produce different random elements. +func TestRandomness(t *testing.T) { + publicName := "grease.example.com" + list1, err1 := GenerateGreaseECHConfigList(rand.Reader, publicName) + require.NoError(t, err1) + list2, err2 := GenerateGreaseECHConfigList(rand.Reader, publicName) + require.NoError(t, err2) + + require.NotEqual(t, list1, list2, "Generated ECHConfigLists are identical, randomness failed") + + // Quick parse to check config_id and public_key + parseConfigIDAndKey := func(b []byte) (uint8, []byte, error) { + parser := cryptobyte.String(b) + var list, contents cryptobyte.String + var version uint16 + if !parser.ReadUint16LengthPrefixed(&list) || + !list.ReadUint16(&version) || // version + !list.ReadUint16LengthPrefixed(&contents) { + return 0, nil, fmt.Errorf("failed to parse basic structure") + } + var configID uint8 + var kemID uint16 + var publicKey cryptobyte.String + if !contents.ReadUint8(&configID) || + !contents.ReadUint16(&kemID) || + !contents.ReadUint16LengthPrefixed(&publicKey) { + return 0, nil, fmt.Errorf("failed to parse contents structure") + } + return configID, publicKey, nil + } + + configID1, key1, err1 := parseConfigIDAndKey(list1) + require.NoError(t, err1) + configID2, key2, err2 := parseConfigIDAndKey(list2) + require.NoError(t, err2) + + if configID1 == configID2 { + t.Logf("Warning: config_ids are the same, less ideal for randomness but possible.") + } + require.NotEqual(t, key1, key2, "Public keys are identical, randomness failed") +} diff --git a/x/tools/fetch/main.go b/x/tools/fetch/main.go index 3a7a91c9a..3c471572c 100644 --- a/x/tools/fetch/main.go +++ b/x/tools/fetch/main.go @@ -17,6 +17,7 @@ package main import ( "bufio" "context" + "crypto/rand" "crypto/tls" "encoding/base64" "flag" @@ -26,12 +27,14 @@ import ( "net" "net/http" "net/textproto" + "net/url" "os" "path" "strings" "time" "github.com/Jigsaw-Code/outline-sdk/x/configurl" + "github.com/Jigsaw-Code/outline-sdk/x/ech" "github.com/lmittmann/tint" "github.com/quic-go/quic-go" "github.com/quic-go/quic-go/http3" @@ -107,12 +110,16 @@ func main() { } } - url := flag.Arg(0) - if url == "" { + if flag.Arg(0) == "" { slog.Error("Need to pass the URL to fetch in the command-line") flag.Usage() os.Exit(1) } + reqURL, err := url.Parse(flag.Arg(0)) + if err != nil { + slog.Error("Invalid URL", "error", err) + os.Exit(1) + } httpClient := &http.Client{ Timeout: time.Duration(*timeoutSecFlag) * time.Second, @@ -124,13 +131,23 @@ func main() { var tlsConfig tls.Config if *echConfigFlag != "" { - // TODO(fortuna): Add support for GREASE and automatic fetching of the HTTPS RR. - echConfigBytes, err := base64.StdEncoding.DecodeString(*echConfigFlag) - if err != nil { - slog.Error("Failed to decode base64 ECH config", "error", err) - os.Exit(1) + switch *echConfigFlag { + case "grease": + echConfigBytes, err := ech.GenerateGreaseECHConfigList(rand.Reader, reqURL.Hostname()) + if err != nil { + slog.Error("Failed to decode base64 ECH config", "error", err) + os.Exit(1) + } + tlsConfig.EncryptedClientHelloConfigList = echConfigBytes + default: + // TODO(fortuna): Add support for fetching the ECH config in the HTTPS RR. + echConfigBytes, err := base64.StdEncoding.DecodeString(*echConfigFlag) + if err != nil { + slog.Error("Failed to decode base64 ECH config", "error", err) + os.Exit(1) + } + tlsConfig.EncryptedClientHelloConfigList = echConfigBytes } - tlsConfig.EncryptedClientHelloConfigList = echConfigBytes } if *tlsKeyLogFlag != "" { f, err := os.Create(*tlsKeyLogFlag) @@ -210,7 +227,7 @@ func main() { os.Exit(1) } - req, err := http.NewRequest(*methodFlag, url, nil) + req, err := http.NewRequest(*methodFlag, reqURL.String(), nil) if err != nil { slog.Error("Failed to create request", "error", err) os.Exit(1) From ded7c2e87d2a11905386c0ad345d365d33d8f986 Mon Sep 17 00:00:00 2001 From: Vinicius Fortuna Date: Fri, 29 Aug 2025 18:09:46 -0400 Subject: [PATCH 3/8] Note --- x/tools/fetch/main.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/x/tools/fetch/main.go b/x/tools/fetch/main.go index 3c471572c..94816ec99 100644 --- a/x/tools/fetch/main.go +++ b/x/tools/fetch/main.go @@ -133,6 +133,8 @@ func main() { if *echConfigFlag != "" { switch *echConfigFlag { case "grease": + // TODO: investigate ECH rejection and cert verification. + // Can we make it work with a fake domain that validates the right domain? echConfigBytes, err := ech.GenerateGreaseECHConfigList(rand.Reader, reqURL.Hostname()) if err != nil { slog.Error("Failed to decode base64 ECH config", "error", err) From 33a36f60b6dddc340bf4bdf7fc21a8593bf0bb70 Mon Sep 17 00:00:00 2001 From: Vinicius Fortuna Date: Tue, 2 Sep 2025 19:13:35 -0400 Subject: [PATCH 4/8] Use zero --- x/ech/ech_grease.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x/ech/ech_grease.go b/x/ech/ech_grease.go index 5fb5d37f6..992c1021b 100644 --- a/x/ech/ech_grease.go +++ b/x/ech/ech_grease.go @@ -64,7 +64,7 @@ func addECHConfigContents(b *cryptobyte.Builder, rand io.Reader, publicName stri } // uint8 maximum_name_length - b.AddUint8(uint8(42)) + b.AddUint8(uint8(0)) // opaque public_name<1..255> publicNameBytes := []byte(publicName) From d387d79bd7b5e323dca658a9306c5b20c57707f1 Mon Sep 17 00:00:00 2001 From: Vinicius Fortuna Date: Tue, 2 Sep 2025 19:18:44 -0400 Subject: [PATCH 5/8] Output retry config --- x/tools/fetch/main.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/x/tools/fetch/main.go b/x/tools/fetch/main.go index 94816ec99..aacdd59aa 100644 --- a/x/tools/fetch/main.go +++ b/x/tools/fetch/main.go @@ -20,6 +20,7 @@ import ( "crypto/rand" "crypto/tls" "encoding/base64" + "errors" "flag" "fmt" "io" @@ -247,7 +248,12 @@ func main() { } resp, err := httpClient.Do(req) if err != nil { - slog.Error("HTTP request failed", "error", err) + args := []any{"error", err} + echErr := new(tls.ECHRejectionError) + if errors.As(err, &echErr) { + args = append(args, "ech_retry_config", base64.StdEncoding.EncodeToString(echErr.RetryConfigList)) + } + slog.Error("HTTP request failed", args...) os.Exit(1) } defer resp.Body.Close() From aad1f58eec30dabf4026efce334e9851a98f44c3 Mon Sep 17 00:00:00 2001 From: Vinicius Fortuna Date: Tue, 2 Sep 2025 20:23:37 -0400 Subject: [PATCH 6/8] Specify the public name --- x/tools/fetch/main.go | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/x/tools/fetch/main.go b/x/tools/fetch/main.go index aacdd59aa..8488b21db 100644 --- a/x/tools/fetch/main.go +++ b/x/tools/fetch/main.go @@ -132,17 +132,27 @@ func main() { var tlsConfig tls.Config if *echConfigFlag != "" { - switch *echConfigFlag { - case "grease": - // TODO: investigate ECH rejection and cert verification. + if strings.HasPrefix(*echConfigFlag, "grease") { + publicName := reqURL.Hostname() + if len(*echConfigFlag) > 7 { + if (*echConfigFlag)[6] != ':' { + slog.Error("Invalid GREASE ECH config") + os.Exit(1) + } + publicName = (*echConfigFlag)[7:] + } // Can we make it work with a fake domain that validates the right domain? - echConfigBytes, err := ech.GenerateGreaseECHConfigList(rand.Reader, reqURL.Hostname()) + echConfigBytes, err := ech.GenerateGreaseECHConfigList(rand.Reader, publicName) if err != nil { slog.Error("Failed to decode base64 ECH config", "error", err) os.Exit(1) } tlsConfig.EncryptedClientHelloConfigList = echConfigBytes - default: + // TODO: verify the certificate based on reqURL.Hostname() instead of the public_name. + // tlsConfig.EncryptedClientHelloRejectionVerify = func(cs tls.ConnectionState) error { + // return nil + // } + } else { // TODO(fortuna): Add support for fetching the ECH config in the HTTPS RR. echConfigBytes, err := base64.StdEncoding.DecodeString(*echConfigFlag) if err != nil { From 0af4a7094121d5af5a346d2ca4992efe5e922cf6 Mon Sep 17 00:00:00 2001 From: Vinicius Fortuna Date: Wed, 3 Sep 2025 15:18:34 -0400 Subject: [PATCH 7/8] Tweak --- x/tools/fetch/main.go | 57 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 47 insertions(+), 10 deletions(-) diff --git a/x/tools/fetch/main.go b/x/tools/fetch/main.go index 8488b21db..595175406 100644 --- a/x/tools/fetch/main.go +++ b/x/tools/fetch/main.go @@ -19,6 +19,7 @@ import ( "context" "crypto/rand" "crypto/tls" + "crypto/x509" "encoding/base64" "errors" "flag" @@ -130,9 +131,13 @@ func main() { } defer httpClient.CloseIdleConnections() - var tlsConfig tls.Config + tlsConfig := tls.Config{ + ServerName: reqURL.Hostname(), + } + greaseEnabled := false if *echConfigFlag != "" { if strings.HasPrefix(*echConfigFlag, "grease") { + greaseEnabled = true publicName := reqURL.Hostname() if len(*echConfigFlag) > 7 { if (*echConfigFlag)[6] != ':' { @@ -148,10 +153,11 @@ func main() { os.Exit(1) } tlsConfig.EncryptedClientHelloConfigList = echConfigBytes - // TODO: verify the certificate based on reqURL.Hostname() instead of the public_name. - // tlsConfig.EncryptedClientHelloRejectionVerify = func(cs tls.ConnectionState) error { - // return nil - // } + tlsConfig.EncryptedClientHelloRejectionVerify = func(cs tls.ConnectionState) error { + // Ignore validation. There's no way to validate here. We will do it later in the handshake. + slog.Debug("EncryptedClientHelloRejectionVerify", "ConnectionState", cs) + return nil + } } else { // TODO(fortuna): Add support for fetching the ECH config in the HTTPS RR. echConfigBytes, err := base64.StdEncoding.DecodeString(*echConfigFlag) @@ -188,17 +194,43 @@ func main() { } return dialer.DialStream(ctx, addressToDial) } + dialTLSContext := func(ctx context.Context, network, addr string) (net.Conn, error) { + conn, err := dialContext(ctx, network, addr) + if err != nil { + return nil, err + } + tlsConn := tls.Client(conn, &tlsConfig) + err = tlsConn.HandshakeContext(ctx) + slog.Debug("tls.Conn.Handshake", "error", err, "ConnectionState", tlsConn.ConnectionState()) + if len(tlsConn.ConnectionState().PeerCertificates) >= 1 { + opts := x509.VerifyOptions{ + DNSName: reqURL.Hostname(), + Intermediates: x509.NewCertPool(), + } + for _, cert := range tlsConn.ConnectionState().PeerCertificates[1:] { + opts.Intermediates.AddCert(cert) + } + _, validationErr := tlsConn.ConnectionState().PeerCertificates[0].Verify(opts) + slog.Debug("tls.Conn.VerifyHostname", "status", validationErr) + if validationErr != nil { + return nil, validationErr + } + } + return tlsConn, err + } switch *protoFlag { case "h1": tlsConfig.NextProtos = []string{"http/1.1"} httpClient.Transport = &http.Transport{ DialContext: dialContext, + DialTLSContext: dialTLSContext, TLSClientConfig: &tlsConfig, } case "h2": tlsConfig.NextProtos = []string{"h2"} httpClient.Transport = &http.Transport{ DialContext: dialContext, + DialTLSContext: dialTLSContext, TLSClientConfig: &tlsConfig, ForceAttemptHTTP2: true, } @@ -229,7 +261,11 @@ func main() { if err != nil { return nil, err } - return quicTransport.DialEarly(ctx, udpAddr, tlsConf, quicConf) + conn, err := quicTransport.DialEarly(ctx, udpAddr, tlsConf, quicConf) + if err != nil { + slog.Debug("quicTransport.DialEarly", "ConnectionState", conn.ConnectionState().TLS) + } + return conn, err }, Logger: slog.Default(), } @@ -258,12 +294,13 @@ func main() { } resp, err := httpClient.Do(req) if err != nil { - args := []any{"error", err} echErr := new(tls.ECHRejectionError) - if errors.As(err, &echErr) { - args = append(args, "ech_retry_config", base64.StdEncoding.EncodeToString(echErr.RetryConfigList)) + echRejected := errors.As(err, &echErr) + if greaseEnabled && echRejected { + slog.Info("Ignoring ECH rejection error in ECH GREASE mode", "ech_retry_config", base64.StdEncoding.EncodeToString(echErr.RetryConfigList)) + } else { + slog.Error("HTTP request failed", "error", err) } - slog.Error("HTTP request failed", args...) os.Exit(1) } defer resp.Body.Close() From 70b95aa51ead1bf2b93cd415dbc9179b8d7da178 Mon Sep 17 00:00:00 2001 From: Vinicius Fortuna Date: Fri, 5 Sep 2025 10:38:38 -0400 Subject: [PATCH 8/8] GREASE -> fake --- x/ech/ech_grease.go | 4 ++-- x/tools/fetch/main.go | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/x/ech/ech_grease.go b/x/ech/ech_grease.go index 992c1021b..9ee0d4752 100644 --- a/x/ech/ech_grease.go +++ b/x/ech/ech_grease.go @@ -94,11 +94,11 @@ func addECHConfig(b *cryptobyte.Builder, rand io.Reader, publicName string) { }) } -// GenerateGreaseECHConfigList creates a serialized ECHConfigList containing one +// GenerateFakeECHConfigList creates a serialized ECHConfigList containing one // GREASE ECHConfig. // Client behavior: https://www.ietf.org/archive/id/draft-ietf-tls-esni-25.html#name-grease-ech // ECHConfigList format https://www.ietf.org/archive/id/draft-ietf-tls-esni-25.html#name-encrypted-clienthello-confi -func GenerateGreaseECHConfigList(rand io.Reader, publicName string) ([]byte, error) { +func GenerateFakeECHConfigList(rand io.Reader, publicName string) ([]byte, error) { var b cryptobyte.Builder // ECHConfig ECHConfigList<4..2^16-1> b.AddUint16LengthPrefixed(func(child *cryptobyte.Builder) { diff --git a/x/tools/fetch/main.go b/x/tools/fetch/main.go index 595175406..77f316c06 100644 --- a/x/tools/fetch/main.go +++ b/x/tools/fetch/main.go @@ -134,20 +134,20 @@ func main() { tlsConfig := tls.Config{ ServerName: reqURL.Hostname(), } - greaseEnabled := false + fakeECHConfig := false if *echConfigFlag != "" { - if strings.HasPrefix(*echConfigFlag, "grease") { - greaseEnabled = true + if strings.HasPrefix(*echConfigFlag, "fake") { + fakeECHConfig = true publicName := reqURL.Hostname() if len(*echConfigFlag) > 7 { if (*echConfigFlag)[6] != ':' { - slog.Error("Invalid GREASE ECH config") + slog.Error("Invalid fake ECH config") os.Exit(1) } publicName = (*echConfigFlag)[7:] } // Can we make it work with a fake domain that validates the right domain? - echConfigBytes, err := ech.GenerateGreaseECHConfigList(rand.Reader, publicName) + echConfigBytes, err := ech.GenerateFakeECHConfigList(rand.Reader, publicName) if err != nil { slog.Error("Failed to decode base64 ECH config", "error", err) os.Exit(1) @@ -296,8 +296,8 @@ func main() { if err != nil { echErr := new(tls.ECHRejectionError) echRejected := errors.As(err, &echErr) - if greaseEnabled && echRejected { - slog.Info("Ignoring ECH rejection error in ECH GREASE mode", "ech_retry_config", base64.StdEncoding.EncodeToString(echErr.RetryConfigList)) + if fakeECHConfig && echRejected { + slog.Info("Got expected ECH rejection error for fake ECH config", "ech_retry_config", base64.StdEncoding.EncodeToString(echErr.RetryConfigList)) } else { slog.Error("HTTP request failed", "error", err) }