-
-
Notifications
You must be signed in to change notification settings - Fork 3k
fix: unify Anthropic TLS fingerprint to Bun BoringSSL #2197
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
0ac7649
10c1ef5
8461a8a
c4bc2e9
222a684
46ae697
aaed93b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,131 @@ | ||
| // Bun v1.3.x (BoringSSL) TLS ClientHello Spec for utls | ||
| // Captured from real Claude Code 2.1.71 / Bun v1.3.8 via tls.peet.ws | ||
| // | ||
| // JA3 Hash: 50027c67d7d68e24c00d233bca146d88 | ||
| // JA3: 771,4865-4866-4867-49195-49199-49196-49200-52393-52392-49161-49171-49162-49172-156-157-47-53,0-65037-23-65281-10-11-35-16-5-13-18-51-45-43-21,29-23-24,0 | ||
| // JA4: t13d1715h1_5b57614c22b0_7baf387fc6ff | ||
| // | ||
| // Key differences from Chrome: | ||
| // - No GREASE (Chrome injects random GREASE values) | ||
| // - ALPN: http/1.1 only (Chrome: h2 + http/1.1) | ||
| // - Fewer extensions (no compress_certificate, no delegated_credentials) | ||
| // - ECH extension 65037 present (BoringSSL-specific) | ||
|
|
||
| package claude | ||
|
|
||
| import ( | ||
| tls "github.com/refraction-networking/utls" | ||
| ) | ||
|
|
||
| // BunBoringSSLSpec returns a utls ClientHelloSpec that exactly matches | ||
| // Bun v1.3.x's BoringSSL TLS fingerprint, as used by Claude Code CLI. | ||
| // | ||
| // This ensures TLS fingerprint consistency between API requests and | ||
| // token refresh, matching what Anthropic sees from real Claude Code users. | ||
| func BunBoringSSLSpec() *tls.ClientHelloSpec { | ||
| return &tls.ClientHelloSpec{ | ||
| TLSVersMin: tls.VersionTLS12, | ||
| TLSVersMax: tls.VersionTLS13, | ||
| CipherSuites: []uint16{ | ||
| // TLS 1.3 suites | ||
| tls.TLS_AES_128_GCM_SHA256, // 0x1301 | ||
| tls.TLS_AES_256_GCM_SHA384, // 0x1302 | ||
| tls.TLS_CHACHA20_POLY1305_SHA256, // 0x1303 | ||
| // TLS 1.2 ECDHE suites | ||
| tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, // 0xC02B | ||
| tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, // 0xC02F | ||
| tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, // 0xC02C | ||
| tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, // 0xC030 | ||
| tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256, // 0xCCA9 | ||
| tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256, // 0xCCA8 | ||
| tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, // 0xC009 | ||
| tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, // 0xC013 | ||
| tls.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA, // 0xC00A | ||
| tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, // 0xC014 | ||
| // TLS 1.2 RSA suites | ||
| tls.TLS_RSA_WITH_AES_128_GCM_SHA256, // 0x009C | ||
| tls.TLS_RSA_WITH_AES_256_GCM_SHA384, // 0x009D | ||
| tls.TLS_RSA_WITH_AES_128_CBC_SHA, // 0x002F | ||
| tls.TLS_RSA_WITH_AES_256_CBC_SHA, // 0x0035 | ||
| }, | ||
| Extensions: []tls.TLSExtension{ | ||
| // 0 - Server Name Indication | ||
| &tls.SNIExtension{}, | ||
|
|
||
| // 65037 - Encrypted Client Hello (BoringSSL-specific) | ||
| &tls.GenericExtension{Id: 65037}, | ||
|
|
||
| // 23 - Extended Master Secret | ||
| &tls.ExtendedMasterSecretExtension{}, | ||
|
|
||
| // 65281 - Renegotiation Info | ||
| &tls.RenegotiationInfoExtension{Renegotiation: tls.RenegotiateOnceAsClient}, | ||
|
|
||
| // 10 - Supported Groups | ||
| &tls.SupportedCurvesExtension{ | ||
| Curves: []tls.CurveID{ | ||
| tls.X25519, // 29 | ||
| tls.CurveP256, // 23 | ||
| tls.CurveP384, // 24 | ||
| }, | ||
| }, | ||
|
|
||
| // 11 - EC Point Formats | ||
| &tls.SupportedPointsExtension{ | ||
| SupportedPoints: []byte{0x00}, // uncompressed | ||
| }, | ||
|
|
||
| // 35 - Session Ticket | ||
| &tls.SessionTicketExtension{}, | ||
|
|
||
| // 16 - ALPN (http/1.1 ONLY — Bun does not negotiate h2) | ||
| &tls.ALPNExtension{ | ||
| AlpnProtocols: []string{"http/1.1"}, | ||
| }, | ||
|
|
||
| // 5 - Status Request (OCSP) | ||
| &tls.StatusRequestExtension{}, | ||
|
|
||
| // 13 - Signature Algorithms | ||
| &tls.SignatureAlgorithmsExtension{ | ||
| SupportedSignatureAlgorithms: []tls.SignatureScheme{ | ||
| tls.ECDSAWithP256AndSHA256, // 0x0403 | ||
| tls.PSSWithSHA256, // 0x0804 | ||
| tls.PKCS1WithSHA256, // 0x0401 | ||
| tls.ECDSAWithP384AndSHA384, // 0x0503 | ||
| tls.PSSWithSHA384, // 0x0805 | ||
| tls.PKCS1WithSHA384, // 0x0501 | ||
| tls.PSSWithSHA512, // 0x0806 | ||
| tls.PKCS1WithSHA512, // 0x0601 | ||
| tls.PKCS1WithSHA1, // 0x0201 | ||
| }, | ||
| }, | ||
|
|
||
| // 18 - Signed Certificate Timestamp (SCT) | ||
| &tls.SCTExtension{}, | ||
|
|
||
| // 51 - Key Share (X25519) | ||
| &tls.KeyShareExtension{ | ||
| KeyShares: []tls.KeyShare{ | ||
| {Group: tls.X25519}, | ||
| }, | ||
| }, | ||
|
|
||
| // 45 - PSK Key Exchange Modes | ||
| &tls.PSKKeyExchangeModesExtension{ | ||
| Modes: []uint8{tls.PskModeDHE}, // psk_dhe_ke (1) | ||
| }, | ||
|
|
||
| // 43 - Supported Versions | ||
| &tls.SupportedVersionsExtension{ | ||
| Versions: []uint16{ | ||
| tls.VersionTLS13, // 0x0304 | ||
| tls.VersionTLS12, // 0x0303 | ||
| }, | ||
| }, | ||
|
|
||
| // 21 - Padding | ||
| &tls.UtlsPaddingExtension{GetPaddingLen: tls.BoringPaddingStyle}, | ||
| }, | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,162 +1,98 @@ | ||
| // Package claude provides authentication functionality for Anthropic's Claude API. | ||
| // This file implements a custom HTTP transport using utls to bypass TLS fingerprinting. | ||
| // This file implements a custom HTTP transport using utls to mimic Bun's BoringSSL | ||
| // TLS fingerprint, matching the real Claude Code CLI. | ||
| package claude | ||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
| "net" | ||
| "net/http" | ||
| "strings" | ||
| "sync" | ||
|
|
||
| tls "github.com/refraction-networking/utls" | ||
| "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" | ||
| "github.com/router-for-me/CLIProxyAPI/v6/sdk/proxyutil" | ||
| log "github.com/sirupsen/logrus" | ||
| "golang.org/x/net/http2" | ||
| "golang.org/x/net/proxy" | ||
| ) | ||
|
|
||
| // utlsRoundTripper implements http.RoundTripper using utls with Chrome fingerprint | ||
| // to bypass Cloudflare's TLS fingerprinting on Anthropic domains. | ||
| // utlsRoundTripper implements http.RoundTripper using utls with Bun BoringSSL | ||
| // fingerprint to match the real Claude Code CLI's TLS characteristics. | ||
| // | ||
| // It uses HTTP/1.1 (Bun's ALPN only offers http/1.1) and delegates connection | ||
| // pooling to the standard http.Transport. | ||
| type utlsRoundTripper struct { | ||
| // mu protects the connections map and pending map | ||
| mu sync.Mutex | ||
| // connections caches HTTP/2 client connections per host | ||
| connections map[string]*http2.ClientConn | ||
| // pending tracks hosts that are currently being connected to (prevents race condition) | ||
| pending map[string]*sync.Cond | ||
| // dialer is used to create network connections, supporting proxies | ||
| dialer proxy.Dialer | ||
| transport *http.Transport | ||
| dialer proxy.Dialer | ||
| } | ||
|
|
||
| // newUtlsRoundTripper creates a new utls-based round tripper with optional proxy support | ||
| func newUtlsRoundTripper(cfg *config.SDKConfig) *utlsRoundTripper { | ||
| // newUtlsRoundTripper creates a new utls-based round tripper with optional proxy support. | ||
| // The proxyURL parameter is the pre-resolved proxy URL string; an empty string means | ||
| // no proxy (direct connection). | ||
| func newUtlsRoundTripper(proxyURL string) *utlsRoundTripper { | ||
| var dialer proxy.Dialer = proxy.Direct | ||
| if cfg != nil { | ||
| proxyDialer, mode, errBuild := proxyutil.BuildDialer(cfg.ProxyURL) | ||
| if proxyURL != "" { | ||
| proxyDialer, mode, errBuild := proxyutil.BuildDialer(proxyURL) | ||
| if errBuild != nil { | ||
| log.Errorf("failed to configure proxy dialer for %q: %v", cfg.ProxyURL, errBuild) | ||
| log.Errorf("failed to configure proxy dialer for %q: %v", proxyURL, errBuild) | ||
| } else if mode != proxyutil.ModeInherit && proxyDialer != nil { | ||
|
||
| dialer = proxyDialer | ||
| } | ||
| } | ||
|
|
||
| return &utlsRoundTripper{ | ||
| connections: make(map[string]*http2.ClientConn), | ||
| pending: make(map[string]*sync.Cond), | ||
| dialer: dialer, | ||
| rt := &utlsRoundTripper{dialer: dialer} | ||
| rt.transport = &http.Transport{ | ||
| // Inject our custom TLS dial function so every connection uses | ||
| // the Bun BoringSSL fingerprint via utls. | ||
| DialTLSContext: rt.dialTLS, | ||
| // Force HTTP/1.1 — do not attempt h2 upgrade. | ||
| ForceAttemptHTTP2: false, | ||
| } | ||
| return rt | ||
| } | ||
|
|
||
| // getOrCreateConnection gets an existing connection or creates a new one. | ||
| // It uses a per-host locking mechanism to prevent multiple goroutines from | ||
| // creating connections to the same host simultaneously. | ||
| func (t *utlsRoundTripper) getOrCreateConnection(host, addr string) (*http2.ClientConn, error) { | ||
| t.mu.Lock() | ||
|
|
||
| // Check if connection exists and is usable | ||
| if h2Conn, ok := t.connections[host]; ok && h2Conn.CanTakeNewRequest() { | ||
| t.mu.Unlock() | ||
| return h2Conn, nil | ||
| } | ||
|
|
||
| // Check if another goroutine is already creating a connection | ||
| if cond, ok := t.pending[host]; ok { | ||
| // Wait for the other goroutine to finish | ||
| cond.Wait() | ||
| // Check if connection is now available | ||
| if h2Conn, ok := t.connections[host]; ok && h2Conn.CanTakeNewRequest() { | ||
| t.mu.Unlock() | ||
| return h2Conn, nil | ||
| } | ||
| // Connection still not available, we'll create one | ||
| } | ||
|
|
||
| // Mark this host as pending | ||
| cond := sync.NewCond(&t.mu) | ||
| t.pending[host] = cond | ||
| t.mu.Unlock() | ||
|
|
||
| // Create connection outside the lock | ||
| h2Conn, err := t.createConnection(host, addr) | ||
|
|
||
| t.mu.Lock() | ||
| defer t.mu.Unlock() | ||
|
|
||
| // Remove pending marker and wake up waiting goroutines | ||
| delete(t.pending, host) | ||
| cond.Broadcast() | ||
|
|
||
| // dialTLS establishes a TLS connection using utls with the Bun BoringSSL spec. | ||
| // This is called by http.Transport for every new TLS connection. | ||
| func (t *utlsRoundTripper) dialTLS(ctx context.Context, network, addr string) (net.Conn, error) { | ||
| host, _, err := net.SplitHostPort(addr) | ||
| if err != nil { | ||
| return nil, err | ||
| // addr might not have a port; use as-is for ServerName | ||
| host = addr | ||
| } | ||
|
|
||
|
Comment on lines
+52
to
56
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This strips the destination port before proxy resolution. Useful? React with 👍 / 👎. |
||
| // Store the new connection | ||
| t.connections[host] = h2Conn | ||
| return h2Conn, nil | ||
| } | ||
|
|
||
| // createConnection creates a new HTTP/2 connection with Chrome TLS fingerprint. | ||
| // Chrome's TLS fingerprint is closer to Node.js/OpenSSL (which real Claude Code uses) | ||
| // than Firefox, reducing the mismatch between TLS layer and HTTP headers. | ||
| func (t *utlsRoundTripper) createConnection(host, addr string) (*http2.ClientConn, error) { | ||
| conn, err := t.dialer.Dial("tcp", addr) | ||
| if err != nil { | ||
| return nil, err | ||
|
||
| } | ||
|
|
||
| tlsConfig := &tls.Config{ServerName: host} | ||
| tlsConn := tls.UClient(conn, tlsConfig, tls.HelloChrome_Auto) | ||
|
|
||
| if err := tlsConn.Handshake(); err != nil { | ||
| tlsConn := tls.UClient(conn, tlsConfig, tls.HelloCustom) | ||
| if err := tlsConn.ApplyPreset(BunBoringSSLSpec()); err != nil { | ||
| conn.Close() | ||
| return nil, err | ||
| return nil, fmt.Errorf("apply Bun TLS spec: %w", err) | ||
| } | ||
|
|
||
| tr := &http2.Transport{} | ||
| h2Conn, err := tr.NewClientConn(tlsConn) | ||
| if err != nil { | ||
| tlsConn.Close() | ||
| if err := tlsConn.Handshake(); err != nil { | ||
| conn.Close() | ||
| return nil, err | ||
| } | ||
|
|
||
| return h2Conn, nil | ||
| return tlsConn, nil | ||
| } | ||
|
|
||
| // RoundTrip implements http.RoundTripper | ||
| // RoundTrip implements http.RoundTripper by delegating to the underlying | ||
| // http.Transport which uses our custom TLS dial function. | ||
| func (t *utlsRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { | ||
| host := req.URL.Host | ||
| addr := host | ||
| if !strings.Contains(addr, ":") { | ||
| addr += ":443" | ||
| } | ||
|
|
||
| // Get hostname without port for TLS ServerName | ||
| hostname := req.URL.Hostname() | ||
|
|
||
| h2Conn, err := t.getOrCreateConnection(hostname, addr) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| resp, err := h2Conn.RoundTrip(req) | ||
| if err != nil { | ||
| // Connection failed, remove it from cache | ||
| t.mu.Lock() | ||
| if cached, ok := t.connections[hostname]; ok && cached == h2Conn { | ||
| delete(t.connections, hostname) | ||
| } | ||
| t.mu.Unlock() | ||
| return nil, err | ||
| } | ||
|
|
||
| return resp, nil | ||
| return t.transport.RoundTrip(req) | ||
| } | ||
|
|
||
| // NewAnthropicHttpClient creates an HTTP client that bypasses TLS fingerprinting | ||
| // for Anthropic domains by using utls with Chrome fingerprint. | ||
| // It accepts optional SDK configuration for proxy settings. | ||
| func NewAnthropicHttpClient(cfg *config.SDKConfig) *http.Client { | ||
| // NewAnthropicHttpClient creates an HTTP client that uses Bun BoringSSL TLS | ||
| // fingerprint for all connections, matching real Claude Code CLI behavior. | ||
| // | ||
| // The proxyURL parameter is the pre-resolved proxy URL (e.g. from ResolveProxyURL). | ||
| // Pass an empty string for direct connections. | ||
| func NewAnthropicHttpClient(proxyURL string) *http.Client { | ||
| return &http.Client{ | ||
| Transport: newUtlsRoundTripper(cfg), | ||
| Transport: newUtlsRoundTripper(proxyURL), | ||
| } | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The default dialer is set to
proxy.Direct, which explicitly bypasses any proxy settings from environment variables likeHTTPS_PROXY. The PR description states the intended proxy priority isauth.ProxyURL > cfg.ProxyURL > env. To align with this and respect environment variables when no other proxy is configured, the default dialer should beproxy.FromEnvironment(). This function correctly falls back to a direct connection if no proxy environment variables are set.