Skip to content
Open
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},
},
}
}
202 changes: 202 additions & 0 deletions internal/auth/claude/proxy_dial.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
// Package claude provides authentication functionality for Anthropic's Claude API.
// This file implements proxy tunneling (SOCKS5, HTTP/HTTPS CONNECT, env fallback)
// for establishing raw TCP connections through proxies before the TLS handshake.
package claude

import (
"bufio"
"context"
stdtls "crypto/tls"
"encoding/base64"
"fmt"
"net"
"net/http"
"net/url"
"time"

"github.com/router-for-me/CLIProxyAPI/v6/sdk/proxyutil"
log "github.com/sirupsen/logrus"
"golang.org/x/net/proxy"
)

// ProxyDialer manages proxy resolution and TCP connection establishment.
// It supports SOCKS5, HTTP CONNECT, and HTTPS CONNECT proxies, as well as
// environment-variable-based proxy configuration (HTTPS_PROXY, HTTP_PROXY, ALL_PROXY).
type ProxyDialer struct {
proxyURL *url.URL // explicit proxy URL (nil = check env per-request)
proxyMode proxyutil.Mode // inherit (use env), direct, proxy, or invalid
}

// NewProxyDialer creates a ProxyDialer from a proxy URL string.
// An empty string means inherit proxy from environment variables.
func NewProxyDialer(proxyURL string) *ProxyDialer {
d := &ProxyDialer{proxyMode: proxyutil.ModeInherit}

if proxyURL != "" {
setting, errParse := proxyutil.Parse(proxyURL)
if errParse != nil {
log.Errorf("failed to parse proxy URL %q: %v", proxyURL, errParse)
} else {
d.proxyMode = setting.Mode
d.proxyURL = setting.URL
}
}

return d
}

// resolveProxy returns the effective proxy URL for a given target host.
// For explicit proxy configuration, it returns the configured URL directly.
// For inherit mode, it delegates to http.ProxyFromEnvironment which correctly
// handles HTTPS_PROXY, HTTP_PROXY, NO_PROXY (including CIDR and wildcards).
func (d *ProxyDialer) resolveProxy(targetHost string) *url.URL {
switch d.proxyMode {
case proxyutil.ModeDirect:
return nil
case proxyutil.ModeProxy:
return d.proxyURL
default:
// ModeInherit: delegate to Go's standard proxy resolution which
// reads HTTPS_PROXY, HTTP_PROXY, ALL_PROXY and respects NO_PROXY.
req := &http.Request{URL: &url.URL{Scheme: "https", Host: targetHost}}
proxyURL, _ := http.ProxyFromEnvironment(req)
return proxyURL
}
}

// DialContext establishes a raw TCP connection to addr, tunneling through a proxy
// if one is configured. It dispatches to direct dialing, SOCKS5, or HTTP CONNECT
// based on the resolved proxy configuration.
func (d *ProxyDialer) DialContext(ctx context.Context, network, addr string) (net.Conn, error) {
host, _, err := net.SplitHostPort(addr)
if err != nil {
host = addr
}

proxyURL := d.resolveProxy(host)

switch {
case proxyURL == nil:
return (&net.Dialer{}).DialContext(ctx, "tcp", addr)
case proxyURL.Scheme == "socks5" || proxyURL.Scheme == "socks5h":
return dialViaSocks5(ctx, proxyURL, addr)
case proxyURL.Scheme == "http" || proxyURL.Scheme == "https":
return dialViaHTTPConnect(ctx, proxyURL, addr)
default:
return nil, fmt.Errorf("unsupported proxy scheme: %s", proxyURL.Scheme)
}
}

// dialViaSocks5 establishes a TCP connection through a SOCKS5 proxy.
func dialViaSocks5(ctx context.Context, proxyURL *url.URL, targetAddr string) (net.Conn, error) {
var auth *proxy.Auth
if proxyURL.User != nil {
username := proxyURL.User.Username()
password, _ := proxyURL.User.Password()
auth = &proxy.Auth{User: username, Password: password}
}
dialer, err := proxy.SOCKS5("tcp", proxyURL.Host, auth, proxy.Direct)
if err != nil {
Comment on lines +98 to +99

Choose a reason for hiding this comment

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

P2 Badge Preserve SOCKS5 default-port handling

For SOCKS5 proxies that omit an explicit port, this new path now passes proxyURL.Host straight to proxy.SOCKS5. The previous Claude transport went through sdk/proxyutil/proxy.goproxy.FromURL, which fills in the standard :1080 default, but proxy.SOCKS5 expects host:port and will fail on values like socks5://proxy.example.com (or inherited ALL_PROXY=socks5://proxy.example.com). This regresses any deployment that relies on the default SOCKS5 port instead of spelling out :1080 in the URL.

Useful? React with 👍 / 👎.

return nil, fmt.Errorf("create SOCKS5 dialer: %w", err)
}
if cd, ok := dialer.(proxy.ContextDialer); ok {
return cd.DialContext(ctx, "tcp", targetAddr)
}
return dialer.Dial("tcp", targetAddr)
}

// dialViaHTTPConnect establishes a TCP tunnel through an HTTP proxy using CONNECT.
// The proxy connection itself is plain TCP (for http:// proxies) or TLS (for https://).
func dialViaHTTPConnect(ctx context.Context, proxyURL *url.URL, targetAddr string) (net.Conn, error) {
proxyAddr := proxyURL.Host
// Ensure the proxy address has a port; default to 80/443 based on scheme.
if _, _, err := net.SplitHostPort(proxyAddr); err != nil {
if proxyURL.Scheme == "https" {
proxyAddr = net.JoinHostPort(proxyAddr, "443")
} else {
proxyAddr = net.JoinHostPort(proxyAddr, "80")
}
}

// Connect to the proxy.
conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", proxyAddr)
if err != nil {
return nil, fmt.Errorf("dial proxy %s: %w", proxyAddr, err)
}

// HTTPS proxies require a TLS handshake with the proxy itself before
// sending the CONNECT request. We use standard crypto/tls here (not utls)
// because this is the proxy connection — fingerprint mimicry is only
// needed for the final connection to api.anthropic.com.
if proxyURL.Scheme == "https" {
proxyHost := proxyURL.Hostname()
tlsConn := stdtls.Client(conn, &stdtls.Config{ServerName: proxyHost})
if err := tlsConn.HandshakeContext(ctx); err != nil {
conn.Close()
return nil, fmt.Errorf("TLS to proxy %s: %w", proxyAddr, err)
}
conn = tlsConn
}

// Propagate context deadline to the CONNECT handshake so it cannot hang
// indefinitely if the proxy accepts TCP but never responds.
if deadline, ok := ctx.Deadline(); ok {
conn.SetDeadline(deadline)
defer conn.SetDeadline(time.Time{})
}

// Send CONNECT request.
hdr := make(http.Header)
hdr.Set("Host", targetAddr)
if proxyURL.User != nil {
username := proxyURL.User.Username()
password, _ := proxyURL.User.Password()
cred := base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
hdr.Set("Proxy-Authorization", "Basic "+cred)
}
connectReq := &http.Request{
Method: "CONNECT",
URL: &url.URL{Opaque: targetAddr},
Host: targetAddr,
Header: hdr,
}
if err := connectReq.Write(conn); err != nil {
conn.Close()
return nil, fmt.Errorf("write CONNECT: %w", err)
}

// Read CONNECT response. Use a bufio.Reader, then check for buffered
// bytes to avoid data loss if the proxy sent anything beyond the header.
br := bufio.NewReader(conn)
resp, err := http.ReadResponse(br, connectReq)
if err != nil {
conn.Close()
return nil, fmt.Errorf("read CONNECT response: %w", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
conn.Close()
return nil, fmt.Errorf("CONNECT to %s via %s: %s", targetAddr, proxyAddr, resp.Status)
}

// If the bufio.Reader consumed extra bytes beyond the HTTP response,
// wrap the connection so those bytes are read first.
if br.Buffered() > 0 {
return &bufferedConn{Conn: conn, br: br}, nil
}
return conn, nil
}

// bufferedConn wraps a net.Conn with a bufio.Reader to drain any bytes
// that were buffered during the HTTP CONNECT handshake.
type bufferedConn struct {
net.Conn
br *bufio.Reader
}

func (c *bufferedConn) Read(p []byte) (int, error) {
if c.br.Buffered() > 0 {
return c.br.Read(p)
}
return c.Conn.Read(p)
}
Loading
Loading