From 8dd38febbdd77955692b99c545dfd830be2f8bba Mon Sep 17 00:00:00 2001 From: Adam Magued Date: Sat, 17 Oct 2026 22:29:32 +0300 Subject: [PATCH] fix: resolve TLS error decoding message for ECH extension Replace the placeholder CustomECHExtension with utls.BoringGREASEECH for extension 0xfe0d, and omit it when disableGrease is active. Update TLS 1.3 KeyShareExtension to provide X25519MLKEM768 alongside X25519, matching the order in SupportedCurvesExtension and preventing server HelloRetryRequest failures. Add comprehensive unit tests verifying handshake, ECH extension generation, and curve order. Signed-off-by: Adam Magued --- cycletls/extensions.go | 42 ++--------- cycletls/tests/unit/issue409_test.go | 102 +++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 36 deletions(-) create mode 100644 cycletls/tests/unit/issue409_test.go diff --git a/cycletls/extensions.go b/cycletls/extensions.go index 222ad08d..d47d4975 100644 --- a/cycletls/extensions.go +++ b/cycletls/extensions.go @@ -103,38 +103,6 @@ func (c *CustomApplicationSettingsExtension) GetPreservedID() uint16 { return c.OriginalID } -// CustomECHExtension preserves the original extension ID for Encrypted Client Hello -type CustomECHExtension struct { - *utls.GenericExtension - OriginalID uint16 -} - -// NewCustomECHExtension creates a new ECH extension with preserved ID -func NewCustomECHExtension(extID uint16) *CustomECHExtension { - // ECH extension data - using a simple placeholder for now - // In a full implementation, this would contain actual ECH configuration - data := []byte{ - 0x00, 0x00, // config_id - 0x00, 0x00, // kem_id placeholder - 0x00, 0x00, // public_key length - // public_key would follow - 0x00, 0x00, // cipher_suites length - // cipher_suites would follow - } - - return &CustomECHExtension{ - GenericExtension: &utls.GenericExtension{ - Id: extID, - Data: data, - }, - OriginalID: extID, - } -} - -// GetPreservedID returns the original extension ID -func (c *CustomECHExtension) GetPreservedID() uint16 { - return c.OriginalID -} // CustomCompressCertificateExtension preserves the original extension ID type CustomCompressCertificateExtension struct { @@ -305,7 +273,7 @@ func CreateExtensionFromID(extID uint16, tlsVersion uint16, components *JA4RComp // Add post-quantum curves if supported if tlsVersion == utls.VersionTLS13 { // Add X25519MLKEM768 if supported by uTLS version - curves = append([]utls.CurveID{utls.X25519}, curves...) + curves = append([]utls.CurveID{utls.X25519MLKEM768}, curves...) } return &utls.SupportedCurvesExtension{Curves: curves} case 0x000b: // EC Point Formats @@ -396,10 +364,9 @@ func CreateExtensionFromID(extID uint16, tlsVersion uint16, components *JA4RComp case 0x0033: // Key Share if tlsVersion == utls.VersionTLS13 { keyShares := []utls.KeyShare{ + {Group: utls.X25519MLKEM768}, {Group: utls.X25519}, - {Group: utls.CurveP256}, } - // Add post-quantum key share if supported return &utls.KeyShareExtension{ KeyShares: keyShares, } @@ -412,7 +379,10 @@ func CreateExtensionFromID(extID uint16, tlsVersion uint16, components *JA4RComp case 0x6399: // X25519Kyber768Draft00 (Post-Quantum) - 25497 return NewCustomPostQuantumExtension(extID, 0x6399) case 0xfe0d: // Encrypted Client Hello (ECH) - 65037 - return NewCustomECHExtension(extID) + if disableGrease { + return nil + } + return utls.BoringGREASEECH() case 0xff01: // Renegotiation Info - 65281 return &utls.RenegotiationInfoExtension{ Renegotiation: utls.RenegotiateOnceAsClient, diff --git a/cycletls/tests/unit/issue409_test.go b/cycletls/tests/unit/issue409_test.go new file mode 100644 index 00000000..7975cb59 --- /dev/null +++ b/cycletls/tests/unit/issue409_test.go @@ -0,0 +1,102 @@ +package unit + +import ( + "crypto/tls" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Danny-Dasilva/CycleTLS/cycletls" + utls "github.com/refraction-networking/utls" +) + +func TestIssue409_JA4RHandshake(t *testing.T) { + ts := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + })) + ts.TLS = &tls.Config{ + MinVersion: tls.VersionTLS12, + MaxVersion: tls.VersionTLS13, + } + ts.StartTLS() + defer ts.Close() + + client := cycletls.Init() + defer client.Close() + + ja4r := "t13d1516h2_002f,0035,009c,009d,1301,1302,1303,c013,c014,c02b,c02c,c02f,c030,cca8,cca9_0000,0005,000a,000b,000d,0012,0017,001b,0023,002b,002d,0033,44cd,fe0d,ff01_0403,0804,0401,0503,0805,0501,0806,0601" + + resp, err := client.Do(ts.URL, cycletls.Options{ + Ja4r: ja4r, + UserAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36", + InsecureSkipVerify: true, + }, "GET") + + if err != nil { + t.Fatalf("client.Do failed: %v", err) + } + if resp.Status != 200 { + t.Fatalf("expected status 200, got %d, body: %s", resp.Status, resp.Body) + } +} + +func TestIssue409_CreateExtensionECH(t *testing.T) { + // When GREASE is enabled, 0xfe0d should return valid BoringGREASEECH extension + ext := cycletls.CreateExtensionFromID(0xfe0d, utls.VersionTLS13, nil, false, "example.com") + if ext == nil { + t.Fatal("expected non-nil extension for 0xfe0d when disableGrease is false") + } + if _, ok := ext.(*utls.GREASEEncryptedClientHelloExtension); !ok { + t.Fatalf("expected *utls.GREASEEncryptedClientHelloExtension, got %T", ext) + } + + // When GREASE is disabled, 0xfe0d should return nil + extDisabled := cycletls.CreateExtensionFromID(0xfe0d, utls.VersionTLS13, nil, true, "example.com") + if extDisabled != nil { + t.Fatalf("expected nil extension for 0xfe0d when disableGrease is true, got %T", extDisabled) + } +} + +func TestIssue409_SupportedCurvesNoDuplicate(t *testing.T) { + ext := cycletls.CreateExtensionFromID(0x000a, utls.VersionTLS13, nil, false, "example.com") + if ext == nil { + t.Fatal("expected non-nil SupportedCurves extension") + } + curvesExt, ok := ext.(*utls.SupportedCurvesExtension) + if !ok { + t.Fatalf("expected *utls.SupportedCurvesExtension, got %T", ext) + } + + seen := make(map[utls.CurveID]bool) + for _, c := range curvesExt.Curves { + if seen[c] { + t.Fatalf("duplicate curve %v found in SupportedCurvesExtension: %v", c, curvesExt.Curves) + } + seen[c] = true + } + + if len(curvesExt.Curves) == 0 || curvesExt.Curves[0] != utls.X25519MLKEM768 { + t.Fatalf("expected first curve to be X25519MLKEM768 in TLS 1.3, got %v", curvesExt.Curves) + } +} + +func TestIssue409_KeyShare(t *testing.T) { + ext := cycletls.CreateExtensionFromID(0x0033, utls.VersionTLS13, nil, false, "example.com") + if ext == nil { + t.Fatal("expected non-nil KeyShare extension") + } + ksExt, ok := ext.(*utls.KeyShareExtension) + if !ok { + t.Fatalf("expected *utls.KeyShareExtension, got %T", ext) + } + if len(ksExt.KeyShares) < 2 { + t.Fatalf("expected at least 2 key shares, got %d", len(ksExt.KeyShares)) + } + if ksExt.KeyShares[0].Group != utls.X25519MLKEM768 { + t.Fatalf("expected first key share to be X25519MLKEM768, got %v", ksExt.KeyShares[0].Group) + } + if ksExt.KeyShares[1].Group != utls.X25519 { + t.Fatalf("expected second key share to be X25519, got %v", ksExt.KeyShares[1].Group) + } +}