// Package tlsfingerprint provides TLS fingerprint simulation for HTTP clients. // // Integration tests for verifying TLS fingerprint correctness. // These tests make actual network requests and should be run manually. // // Run with: go test -v ./internal/pkg/tlsfingerprint/... // Run integration tests: go test -v -run TestJA3 ./internal/pkg/tlsfingerprint/... package tlsfingerprint import ( "context" "encoding/json" "io" "net/http" "net/url" "strings" "testing" "time" ) // FingerprintResponse represents the response from tls.peet.ws/api/all. type FingerprintResponse struct { IP string `json:"ip"` TLS TLSInfo `json:"tls"` HTTP2 any `json:"http2"` } // TLSInfo contains TLS fingerprint details. type TLSInfo struct { JA3 string `json:"ja3"` JA3Hash string `json:"ja3_hash"` JA4 string `json:"ja4"` PeetPrint string `json:"peetprint"` PeetPrintHash string `json:"peetprint_hash"` ClientRandom string `json:"client_random"` SessionID string `json:"session_id"` } // TestDialerBasicConnection tests that the dialer can establish TLS connections. func TestDialerBasicConnection(t *testing.T) { if testing.Short() { t.Skip("skipping network test in short mode") } // Create a dialer with default profile profile := &Profile{ Name: "Test Profile", EnableGREASE: false, } dialer := NewDialer(profile, nil) // Create HTTP client with custom TLS dialer client := &http.Client{ Transport: &http.Transport{ DialTLSContext: dialer.DialTLSContext, }, Timeout: 30 * time.Second, } // Make a request to a known HTTPS endpoint resp, err := client.Get("https://www.google.com") if err != nil { t.Fatalf("failed to connect: %v", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { t.Errorf("expected status 200, got %d", resp.StatusCode) } } // TestJA3Fingerprint verifies the JA3/JA4 fingerprint matches expected value. // This test uses tls.peet.ws to verify the fingerprint. // Expected JA3 hash: 1a28e69016765d92e3b381168d68922c (Claude CLI / Node.js 20.x) // Expected JA4: t13d5911h1_a33745022dd6_1f22a2ca17c4 (d=domain) or t13i5911h1_... (i=IP) func TestJA3Fingerprint(t *testing.T) { // Skip if network is unavailable or if running in short mode if testing.Short() { t.Skip("skipping integration test in short mode") } profile := &Profile{ Name: "Claude CLI Test", EnableGREASE: false, } dialer := NewDialer(profile, nil) client := &http.Client{ Transport: &http.Transport{ DialTLSContext: dialer.DialTLSContext, }, Timeout: 30 * time.Second, } // Use tls.peet.ws fingerprint detection API ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, "GET", "https://tls.peet.ws/api/all", nil) if err != nil { t.Fatalf("failed to create request: %v", err) } req.Header.Set("User-Agent", "Claude Code/2.0.0 Node.js/20.0.0") resp, err := client.Do(req) if err != nil { t.Fatalf("failed to get fingerprint: %v", err) } defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(resp.Body) if err != nil { t.Fatalf("failed to read response: %v", err) } var fpResp FingerprintResponse if err := json.Unmarshal(body, &fpResp); err != nil { t.Logf("Response body: %s", string(body)) t.Fatalf("failed to parse fingerprint response: %v", err) } // Log all fingerprint information t.Logf("JA3: %s", fpResp.TLS.JA3) t.Logf("JA3 Hash: %s", fpResp.TLS.JA3Hash) t.Logf("JA4: %s", fpResp.TLS.JA4) t.Logf("PeetPrint: %s", fpResp.TLS.PeetPrint) t.Logf("PeetPrint Hash: %s", fpResp.TLS.PeetPrintHash) // Verify JA3 hash matches expected value expectedJA3Hash := "1a28e69016765d92e3b381168d68922c" if fpResp.TLS.JA3Hash == expectedJA3Hash { t.Logf("✓ JA3 hash matches expected value: %s", expectedJA3Hash) } else { t.Errorf("✗ JA3 hash mismatch: got %s, expected %s", fpResp.TLS.JA3Hash, expectedJA3Hash) } // Verify JA4 fingerprint // JA4 format: t[version][sni][cipher_count][ext_count][alpn]_[cipher_hash]_[ext_hash] // Expected: t13d5910h1 (d=domain) or t13i5910h1 (i=IP) // The suffix _a33745022dd6_1f22a2ca17c4 should match expectedJA4Suffix := "_a33745022dd6_1f22a2ca17c4" if strings.HasSuffix(fpResp.TLS.JA4, expectedJA4Suffix) { t.Logf("✓ JA4 suffix matches expected value: %s", expectedJA4Suffix) } else { t.Errorf("✗ JA4 suffix mismatch: got %s, expected suffix %s", fpResp.TLS.JA4, expectedJA4Suffix) } // Verify JA4 prefix (t13d5911h1 or t13i5911h1) // d = domain (SNI present), i = IP (no SNI) // Since we connect to tls.peet.ws (domain), we expect 'd' expectedJA4Prefix := "t13d5911h1" if strings.HasPrefix(fpResp.TLS.JA4, expectedJA4Prefix) { t.Logf("✓ JA4 prefix matches: %s (t13=TLS1.3, d=domain, 59=ciphers, 11=extensions, h1=HTTP/1.1)", expectedJA4Prefix) } else { // Also accept 'i' variant for IP connections altPrefix := "t13i5911h1" if strings.HasPrefix(fpResp.TLS.JA4, altPrefix) { t.Logf("✓ JA4 prefix matches (IP variant): %s", altPrefix) } else { t.Errorf("✗ JA4 prefix mismatch: got %s, expected %s or %s", fpResp.TLS.JA4, expectedJA4Prefix, altPrefix) } } // Verify JA3 contains expected cipher suites (TLS 1.3 ciphers at the beginning) if strings.Contains(fpResp.TLS.JA3, "4866-4867-4865") { t.Logf("✓ JA3 contains expected TLS 1.3 cipher suites") } else { t.Logf("Warning: JA3 does not contain expected TLS 1.3 cipher suites") } // Verify extension list (should be 11 extensions including SNI) // Expected: 0-11-10-35-16-22-23-13-43-45-51 expectedExtensions := "0-11-10-35-16-22-23-13-43-45-51" if strings.Contains(fpResp.TLS.JA3, expectedExtensions) { t.Logf("✓ JA3 contains expected extension list: %s", expectedExtensions) } else { t.Logf("Warning: JA3 extension list may differ") } } // TestDialerWithProfile tests that different profiles produce different fingerprints. func TestDialerWithProfile(t *testing.T) { // Create two dialers with different profiles profile1 := &Profile{ Name: "Profile 1 - No GREASE", EnableGREASE: false, } profile2 := &Profile{ Name: "Profile 2 - With GREASE", EnableGREASE: true, } dialer1 := NewDialer(profile1, nil) dialer2 := NewDialer(profile2, nil) // Build specs and compare // Note: We can't directly compare JA3 without making network requests // but we can verify the specs are different spec1 := dialer1.buildClientHelloSpec() spec2 := dialer2.buildClientHelloSpec() // Profile with GREASE should have more extensions if len(spec2.Extensions) <= len(spec1.Extensions) { t.Error("expected GREASE profile to have more extensions") } } // TestHTTPProxyDialerBasic tests HTTP proxy dialer creation. // Note: This is a unit test - actual proxy testing requires a proxy server. func TestHTTPProxyDialerBasic(t *testing.T) { profile := &Profile{ Name: "Test Profile", EnableGREASE: false, } // Test that dialer is created without panic proxyURL := mustParseURL("http://proxy.example.com:8080") dialer := NewHTTPProxyDialer(profile, proxyURL) if dialer == nil { t.Fatal("expected dialer to be created") } if dialer.profile != profile { t.Error("expected profile to be set") } if dialer.proxyURL != proxyURL { t.Error("expected proxyURL to be set") } } // TestSOCKS5ProxyDialerBasic tests SOCKS5 proxy dialer creation. // Note: This is a unit test - actual proxy testing requires a proxy server. func TestSOCKS5ProxyDialerBasic(t *testing.T) { profile := &Profile{ Name: "Test Profile", EnableGREASE: false, } // Test that dialer is created without panic proxyURL := mustParseURL("socks5://proxy.example.com:1080") dialer := NewSOCKS5ProxyDialer(profile, proxyURL) if dialer == nil { t.Fatal("expected dialer to be created") } if dialer.profile != profile { t.Error("expected profile to be set") } if dialer.proxyURL != proxyURL { t.Error("expected proxyURL to be set") } } // TestBuildClientHelloSpec tests ClientHello spec construction. func TestBuildClientHelloSpec(t *testing.T) { // Test with nil profile (should use defaults) spec := buildClientHelloSpecFromProfile(nil) if len(spec.CipherSuites) == 0 { t.Error("expected cipher suites to be set") } if len(spec.Extensions) == 0 { t.Error("expected extensions to be set") } // Verify default cipher suites are used if len(spec.CipherSuites) != len(defaultCipherSuites) { t.Errorf("expected %d cipher suites, got %d", len(defaultCipherSuites), len(spec.CipherSuites)) } // Test with custom profile customProfile := &Profile{ Name: "Custom", EnableGREASE: false, CipherSuites: []uint16{0x1301, 0x1302}, } spec = buildClientHelloSpecFromProfile(customProfile) if len(spec.CipherSuites) != 2 { t.Errorf("expected 2 cipher suites, got %d", len(spec.CipherSuites)) } } // TestToUTLSCurves tests curve ID conversion. func TestToUTLSCurves(t *testing.T) { input := []uint16{0x001d, 0x0017, 0x0018} result := toUTLSCurves(input) if len(result) != len(input) { t.Errorf("expected %d curves, got %d", len(input), len(result)) } for i, curve := range result { if uint16(curve) != input[i] { t.Errorf("curve %d: expected 0x%04x, got 0x%04x", i, input[i], uint16(curve)) } } } // Helper function to parse URL without error handling. func mustParseURL(rawURL string) *url.URL { u, err := url.Parse(rawURL) if err != nil { panic(err) } return u } // TestProfileExpectation defines expected fingerprint values for a profile. type TestProfileExpectation struct { Profile *Profile ExpectedJA3 string // Expected JA3 hash (empty = don't check) ExpectedJA4 string // Expected full JA4 (empty = don't check) JA4CipherHash string // Expected JA4 cipher hash - the stable middle part (empty = don't check) } // TestAllProfiles tests multiple TLS fingerprint profiles against tls.peet.ws. // Run with: go test -v -run TestAllProfiles ./internal/pkg/tlsfingerprint/... func TestAllProfiles(t *testing.T) { if testing.Short() { t.Skip("skipping integration test in short mode") } // Define all profiles to test with their expected fingerprints // These profiles are from config.yaml gateway.tls_fingerprint.profiles profiles := []TestProfileExpectation{ { // Linux x64 Node.js v22.17.1 // Expected JA3 Hash: 1a28e69016765d92e3b381168d68922c // Expected JA4: t13d5911h1_a33745022dd6_1f22a2ca17c4 Profile: &Profile{ Name: "linux_x64_node_v22171", EnableGREASE: false, CipherSuites: []uint16{4866, 4867, 4865, 49199, 49195, 49200, 49196, 158, 49191, 103, 49192, 107, 163, 159, 52393, 52392, 52394, 49327, 49325, 49315, 49311, 49245, 49249, 49239, 49235, 162, 49326, 49324, 49314, 49310, 49244, 49248, 49238, 49234, 49188, 106, 49187, 64, 49162, 49172, 57, 56, 49161, 49171, 51, 50, 157, 49313, 49309, 49233, 156, 49312, 49308, 49232, 61, 60, 53, 47, 255}, Curves: []uint16{29, 23, 30, 25, 24, 256, 257, 258, 259, 260}, PointFormats: []uint8{0, 1, 2}, }, JA4CipherHash: "a33745022dd6", // stable part }, { // MacOS arm64 Node.js v22.18.0 // Expected JA3 Hash: 70cb5ca646080902703ffda87036a5ea // Expected JA4: t13d5912h1_a33745022dd6_dbd39dd1d406 Profile: &Profile{ Name: "macos_arm64_node_v22180", EnableGREASE: false, CipherSuites: []uint16{4866, 4867, 4865, 49199, 49195, 49200, 49196, 158, 49191, 103, 49192, 107, 163, 159, 52393, 52392, 52394, 49327, 49325, 49315, 49311, 49245, 49249, 49239, 49235, 162, 49326, 49324, 49314, 49310, 49244, 49248, 49238, 49234, 49188, 106, 49187, 64, 49162, 49172, 57, 56, 49161, 49171, 51, 50, 157, 49313, 49309, 49233, 156, 49312, 49308, 49232, 61, 60, 53, 47, 255}, Curves: []uint16{29, 23, 30, 25, 24, 256, 257, 258, 259, 260}, PointFormats: []uint8{0, 1, 2}, }, JA4CipherHash: "a33745022dd6", // stable part (same cipher suites) }, } for _, tc := range profiles { tc := tc // capture range variable t.Run(tc.Profile.Name, func(t *testing.T) { fp := fetchFingerprint(t, tc.Profile) if fp == nil { return // fetchFingerprint already called t.Fatal } t.Logf("Profile: %s", tc.Profile.Name) t.Logf(" JA3: %s", fp.JA3) t.Logf(" JA3 Hash: %s", fp.JA3Hash) t.Logf(" JA4: %s", fp.JA4) t.Logf(" PeetPrint: %s", fp.PeetPrint) t.Logf(" PeetPrintHash: %s", fp.PeetPrintHash) // Verify expectations if tc.ExpectedJA3 != "" { if fp.JA3Hash == tc.ExpectedJA3 { t.Logf(" ✓ JA3 hash matches: %s", tc.ExpectedJA3) } else { t.Errorf(" ✗ JA3 hash mismatch: got %s, expected %s", fp.JA3Hash, tc.ExpectedJA3) } } if tc.ExpectedJA4 != "" { if fp.JA4 == tc.ExpectedJA4 { t.Logf(" ✓ JA4 matches: %s", tc.ExpectedJA4) } else { t.Errorf(" ✗ JA4 mismatch: got %s, expected %s", fp.JA4, tc.ExpectedJA4) } } // Check JA4 cipher hash (stable middle part) // JA4 format: prefix_cipherHash_extHash if tc.JA4CipherHash != "" { if strings.Contains(fp.JA4, "_"+tc.JA4CipherHash+"_") { t.Logf(" ✓ JA4 cipher hash matches: %s", tc.JA4CipherHash) } else { t.Errorf(" ✗ JA4 cipher hash mismatch: got %s, expected cipher hash %s", fp.JA4, tc.JA4CipherHash) } } }) } } // fetchFingerprint makes a request to tls.peet.ws and returns the TLS fingerprint info. func fetchFingerprint(t *testing.T, profile *Profile) *TLSInfo { t.Helper() dialer := NewDialer(profile, nil) client := &http.Client{ Transport: &http.Transport{ DialTLSContext: dialer.DialTLSContext, }, Timeout: 30 * time.Second, } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, "GET", "https://tls.peet.ws/api/all", nil) if err != nil { t.Fatalf("failed to create request: %v", err) return nil } req.Header.Set("User-Agent", "Claude Code/2.0.0 Node.js/20.0.0") resp, err := client.Do(req) if err != nil { t.Fatalf("failed to get fingerprint: %v", err) return nil } defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(resp.Body) if err != nil { t.Fatalf("failed to read response: %v", err) return nil } var fpResp FingerprintResponse if err := json.Unmarshal(body, &fpResp); err != nil { t.Logf("Response body: %s", string(body)) t.Fatalf("failed to parse fingerprint response: %v", err) return nil } return &fpResp.TLS }