308 lines
9.3 KiB
Go
308 lines
9.3 KiB
Go
// 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
|
|
}
|