Skip to content
19 changes: 13 additions & 6 deletions internal/auth/claude/anthropic_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,19 +50,26 @@ type ClaudeAuth struct {
}

// NewClaudeAuth creates a new Anthropic authentication service.
// It initializes the HTTP client with a custom TLS transport that uses Firefox
// fingerprint to bypass Cloudflare's TLS fingerprinting on Anthropic domains.
// It initializes the HTTP client with a custom TLS transport that uses Bun BoringSSL
// fingerprint to match real Claude Code CLI behavior.
//
// An optional proxyURL may be provided to override the proxy in cfg.SDKConfig.
// This allows callers to pass a pre-resolved proxy URL (e.g. from ResolveProxyURL)
// so that both request and refresh paths share the same proxy priority logic.
//
// Parameters:
// - cfg: The application configuration containing proxy settings
// - proxyURL: Optional pre-resolved proxy URL that overrides cfg.SDKConfig.ProxyURL
//
// Returns:
// - *ClaudeAuth: A new Claude authentication service instance
func NewClaudeAuth(cfg *config.Config) *ClaudeAuth {
// Use custom HTTP client with Firefox TLS fingerprint to bypass
// Cloudflare's bot detection on Anthropic domains
func NewClaudeAuth(cfg *config.Config, proxyURL ...string) *ClaudeAuth {
pURL := cfg.SDKConfig.ProxyURL
if len(proxyURL) > 0 && proxyURL[0] != "" {
pURL = proxyURL[0]
}
return &ClaudeAuth{
httpClient: NewAnthropicHttpClient(&cfg.SDKConfig),
httpClient: NewAnthropicHttpClient(pURL),
}
}

Expand Down
131 changes: 131 additions & 0 deletions internal/auth/claude/bun_tls_spec.go
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},
},
}
}
182 changes: 68 additions & 114 deletions internal/auth/claude/utls_transport.go
Original file line number Diff line number Diff line change
@@ -1,162 +1,116 @@
// 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"
"time"

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 {
var dialer proxy.Dialer = proxy.Direct
if cfg != nil {
proxyDialer, mode, errBuild := proxyutil.BuildDialer(cfg.ProxyURL)
// 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 {
// Default to environment proxy (HTTPS_PROXY, etc.) so deployments that
// rely on env vars without explicit proxy-url config continue to work.
var dialer proxy.Dialer = proxy.FromEnvironment()

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restore HTTP(S)_PROXY inheritance for empty proxy-url

Fresh evidence: golang.org/x/net/proxy.FromEnvironment() only reads ALL_PROXY and NO_PROXY (go.googlesource.com/net/proxy/proxy.go#30-59,103-108), not HTTP_PROXY/HTTPS_PROXY. Switching the Claude client to this call therefore does not preserve the previous stdlib env-proxy behavior when proxy-url is unset, even though the comment says it does. Because internal/runtime/executor/proxy_helpers.go:39-41 now uses this transport for all Claude requests, deployments that rely on the documented env-proxy mode will silently bypass their proxy.

Useful? React with 👍 / 👎.

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 {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep HTTP/HTTPS proxy-url working for Claude traffic

If proxy-url is set to http://... or https://..., this path now calls proxyutil.BuildDialer(), but sdk/proxyutil/proxy.go:119-135 delegates to golang.org/x/net/proxy.FromURL, whose built-in schemes are only SOCKS (go.googlesource.com/net/proxy/proxy.go#73-100). In practice the error branch here will fire for the documented HTTP/HTTPS proxy modes from config.example.yaml:65-67, and newClaudeHTTPClient()/claude_executor.go now routes all Claude requests through this transport, so message/count-token calls will ignore the configured corporate proxy and go env/direct instead.

Useful? React with 👍 / 👎.

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
// dialTLS establishes a TLS connection using utls with the Bun BoringSSL spec.
// This is called by http.Transport for every new TLS connection.
// It respects context cancellation/deadline for both TCP dial and TLS handshake.
func (t *utlsRoundTripper) dialTLS(ctx context.Context, network, addr string) (net.Conn, error) {
host, _, err := net.SplitHostPort(addr)
if err != nil {
// addr might not have a port; use as-is for ServerName
host = addr
}

Comment on lines +52 to 56

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve port-specific NO_PROXY matches for custom HTTPS ports

This strips the destination port before proxy resolution. resolveProxy then builds https://<host> and feeds that to http.ProxyFromEnvironment, so the NO_PROXY check is evaluated as <host>:443 regardless of the real upstream port. For custom Claude base-urls on alternate HTTPS ports (for example https://gateway.example.com:8443), a bypass like NO_PROXY=gateway.example.com:8443 will stop matching and the request will be sent through the proxy unexpectedly; the previous stdlib path used the real request URL and kept that port-sensitive behavior.

Useful? React with 👍 / 👎.

// 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
// Use ContextDialer if the dialer supports it (respects cancellation/timeout);
// otherwise fall back to plain Dial.
var conn net.Conn
if cd, ok := t.dialer.(proxy.ContextDialer); ok {
conn, err = cd.DialContext(ctx, "tcp", addr)
} else {
conn, err = t.dialer.Dial("tcp", addr)
}

// 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()

if err != nil {
return nil, err
}

// 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
// Propagate context deadline to the TLS handshake so it doesn't hang
// indefinitely when the upstream is unreachable.
if deadline, ok := ctx.Deadline(); ok {
conn.SetDeadline(deadline)
defer conn.SetDeadline(time.Time{}) // clear after handshake
}

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),
}
}

Loading
Loading