diff --git a/x/ech/ech_grease.go b/x/ech/ech_grease.go new file mode 100644 index 00000000..9ee0d475 --- /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(0)) + + // 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 + } + }) +} + +// 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 GenerateFakeECHConfigList(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 00000000..b23c0be7 --- /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 3a7a91c9..77f316c0 100644 --- a/x/tools/fetch/main.go +++ b/x/tools/fetch/main.go @@ -17,8 +17,11 @@ package main import ( "bufio" "context" + "crypto/rand" "crypto/tls" + "crypto/x509" "encoding/base64" + "errors" "flag" "fmt" "io" @@ -26,12 +29,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 +112,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, @@ -122,15 +131,42 @@ func main() { } defer httpClient.CloseIdleConnections() - var tlsConfig tls.Config + tlsConfig := tls.Config{ + ServerName: reqURL.Hostname(), + } + fakeECHConfig := false 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) + if strings.HasPrefix(*echConfigFlag, "fake") { + fakeECHConfig = true + publicName := reqURL.Hostname() + if len(*echConfigFlag) > 7 { + if (*echConfigFlag)[6] != ':' { + 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.GenerateFakeECHConfigList(rand.Reader, publicName) + if err != nil { + slog.Error("Failed to decode base64 ECH config", "error", err) + os.Exit(1) + } + tlsConfig.EncryptedClientHelloConfigList = echConfigBytes + 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) + 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) @@ -158,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, } @@ -199,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(), } @@ -210,7 +276,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) @@ -228,7 +294,13 @@ func main() { } resp, err := httpClient.Do(req) if err != nil { - slog.Error("HTTP request failed", "error", err) + echErr := new(tls.ECHRejectionError) + echRejected := errors.As(err, &echErr) + 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) + } os.Exit(1) } defer resp.Body.Close()