diff --git a/internal/auth/claude/utls_transport.go b/internal/auth/claude/utls_transport.go index d756de15..1df71f1e 100644 --- a/internal/auth/claude/utls_transport.go +++ b/internal/auth/claude/utls_transport.go @@ -6,6 +6,7 @@ import ( "net" "net/http" "strings" + "sync" "time" tls "github.com/refraction-networking/utls" @@ -60,6 +61,48 @@ func claudeOAuthRequestHeaderOrder(method, requestTarget string) []string { return claudeOAuthRefreshHeaderOrder } +// claudeOAuthSessionCacheCapacity bounds one proxy's TLS session cache. The +// OAuth control plane only talks to platform.claude.com and api.anthropic.com, +// so a small cache covers every reachable server. +const claudeOAuthSessionCacheCapacity = 8 + +// claudeOAuthSessionCaches keys one session cache per effective proxy URL. +// +// ClaudeAuth is constructed per operation (every refresh and every executor +// profile check builds a new one), so a cache owned by the round tripper would +// always start empty and never resume. Keying on the proxy instead matches the +// inference plane, where the whole round tripper is cached per proxy, and keeps +// resumption from crossing proxy boundaries. TLS sessions are scoped to a +// server rather than a credential, and connections are already pooled per proxy +// on the inference plane, so this adds no new cross-credential linkage. + +var claudeOAuthSessionCaches sync.Map + +func claudeOAuthSessionCache(proxyURL string) tls.ClientSessionCache { + if cached, ok := claudeOAuthSessionCaches.Load(proxyURL); ok { + return cached.(tls.ClientSessionCache) + } + created := tls.NewLRUClientSessionCache(claudeOAuthSessionCacheCapacity) + actual, _ := claudeOAuthSessionCaches.LoadOrStore(proxyURL, created) + return actual.(tls.ClientSessionCache) +} + +// newClaudeOAuthTLSConfig builds the uTLS config for one control-plane dial. +// +// OmitEmptyPsk keeps the pre_shared_key extension silent until a session is +// actually cached, so the first ClientHello is byte-identical to the captured +// native handshake. PreferSkipResumptionOnNilExtension is defense in depth: for +// HelloCustom specs uTLS panics when it wants to resume but the spec lacks the +// matching extension, and this degrades that into a skipped resumption. +func newClaudeOAuthTLSConfig(host string, sessionCache tls.ClientSessionCache) *tls.Config { + return &tls.Config{ + ServerName: host, + ClientSessionCache: sessionCache, + OmitEmptyPsk: true, + PreferSkipResumptionOnNilExtension: true, + } +} + // claudeOAuthTLSClientHelloSpec reproduces the compact Node/OpenSSL profile // Claude Code 2.1.220 uses for Axios OAuth control-plane requests. Unlike the // inference profile, it advertises no ALPN extension and therefore uses @@ -109,6 +152,9 @@ func claudeOAuthTLSClientHelloSpec() *tls.ClientHelloSpec { &tls.KeyShareExtension{KeyShares: []tls.KeyShare{{Group: tls.X25519}}}, &tls.PSKKeyExchangeModesExtension{Modes: []uint8{tls.PskModeDHE}}, &tls.SupportedVersionsExtension{Versions: []uint16{tls.VersionTLS13, tls.VersionTLS12}}, + // pre_shared_key MUST be the final extension (RFC 8446 4.2.11). It + // contributes zero bytes until a cached session exists. + &tls.UtlsPreSharedKeyExtension{}, }, } } @@ -117,13 +163,19 @@ func claudeOAuthTLSClientHelloSpec() *tls.ClientHelloSpec { // profile while retaining net/http proxy, cancellation, response parsing and // connection lifecycle semantics. type utlsRoundTripper struct { - dialer proxy.Dialer - transport *http.Transport + dialer proxy.Dialer + // sessionCache is shared by every transport built for the same proxy, so + // short-lived ClaudeAuth instances can still resume, while resumption never + // crosses proxy boundaries. + sessionCache tls.ClientSessionCache + transport *http.Transport } func newUtlsRoundTripper(cfg *config.SDKConfig) *utlsRoundTripper { var dialer proxy.Dialer = proxy.Direct + var proxyURL string if cfg != nil { + proxyURL = cfg.ProxyURL proxyDialer, mode, errBuild := proxyutil.BuildDialer(cfg.ProxyURL) if errBuild != nil { log.Errorf("failed to configure proxy dialer for %q: %v", proxyutil.Redact(cfg.ProxyURL), errBuild) @@ -132,7 +184,10 @@ func newUtlsRoundTripper(cfg *config.SDKConfig) *utlsRoundTripper { } } - roundTripper := &utlsRoundTripper{dialer: dialer} + roundTripper := &utlsRoundTripper{ + dialer: dialer, + sessionCache: claudeOAuthSessionCache(proxyURL), + } roundTripper.transport = &http.Transport{ ForceAttemptHTTP2: false, DialTLSContext: roundTripper.dialTLSContext, @@ -161,7 +216,7 @@ func (t *utlsRoundTripper) dialTLSContext(ctx context.Context, network, addr str } return nil, fmt.Errorf("claude oauth tls: split upstream address: %w", errSplit) } - tlsConn := tls.UClient(conn, &tls.Config{ServerName: host}, tls.HelloCustom) + tlsConn := tls.UClient(conn, newClaudeOAuthTLSConfig(host, t.sessionCache), tls.HelloCustom) if errPreset := tlsConn.ApplyPreset(claudeOAuthTLSClientHelloSpec()); errPreset != nil { if errClose := tlsConn.Close(); errClose != nil { log.Debugf("claude oauth tls: close connection after preset failure: %v", errClose) diff --git a/internal/auth/claude/utls_transport_test.go b/internal/auth/claude/utls_transport_test.go index 5d7ebd0d..cda48f6e 100644 --- a/internal/auth/claude/utls_transport_test.go +++ b/internal/auth/claude/utls_transport_test.go @@ -15,6 +15,7 @@ import ( "time" tls "github.com/refraction-networking/utls" + sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" ) type claudeTestDialer struct { @@ -85,6 +86,65 @@ func TestClaudeOAuthTLSClientHelloSpecMatchesNative220Capture(t *testing.T) { } } +func TestClaudeOAuthTLSResumptionIsWireSafe(t *testing.T) { + t.Parallel() + + // RFC 8446 4.2.11 requires pre_shared_key to be the final extension. + spec := claudeOAuthTLSClientHelloSpec() + last := spec.Extensions[len(spec.Extensions)-1] + if _, ok := last.(*tls.UtlsPreSharedKeyExtension); !ok { + t.Fatalf("last OAuth extension = %T, want *tls.UtlsPreSharedKeyExtension", last) + } + + // Without OmitEmptyPsk uTLS refuses to marshal an empty PSK, and without + // PreferSkipResumptionOnNilExtension a HelloCustom resumption attempt panics. + cfg := newClaudeOAuthTLSConfig("api.anthropic.com", tls.NewLRUClientSessionCache(claudeOAuthSessionCacheCapacity)) + if cfg.ServerName != "api.anthropic.com" { + t.Fatalf("ServerName = %q, want api.anthropic.com", cfg.ServerName) + } + if cfg.ClientSessionCache == nil { + t.Fatal("ClientSessionCache = nil, want a session cache so resumption is possible") + } + if !cfg.OmitEmptyPsk { + t.Fatal("OmitEmptyPsk = false, want true so an unresumed ClientHello stays byte-identical") + } + if !cfg.PreferSkipResumptionOnNilExtension { + t.Fatal("PreferSkipResumptionOnNilExtension = false, want true to avoid a HelloCustom resumption panic") + } + + // ClaudeAuth is rebuilt for every refresh and every executor profile check, so + // the cache must be keyed on the proxy rather than owned by the transport; + // otherwise every dial starts with an empty cache and never resumes. + first := newUtlsRoundTripper(&sdkconfig.SDKConfig{ProxyURL: "http://127.0.0.1:9"}) + second := newUtlsRoundTripper(&sdkconfig.SDKConfig{ProxyURL: "http://127.0.0.1:9"}) + if first.sessionCache == nil || second.sessionCache == nil { + t.Fatal("round tripper session cache = nil, want a shared per-proxy cache") + } + if first.sessionCache != second.sessionCache { + t.Fatal("same-proxy transports have different session caches, so resumption can never hit") + } + + // Resumption must not cross proxy boundaries. + other := newUtlsRoundTripper(&sdkconfig.SDKConfig{ProxyURL: "http://127.0.0.1:10"}) + if first.sessionCache == other.sessionCache { + t.Fatal("different proxies share a session cache, want per-proxy isolation") + } + + // Same check through the real entry point: two ClaudeAuth values built the way + // refresh and the executor profile check build them must still share a cache. + cacheOf := func(service *ClaudeAuth) tls.ClientSessionCache { + t.Helper() + transport, ok := service.httpClient.Transport.(*utlsRoundTripper) + if !ok { + t.Fatalf("ClaudeAuth transport type = %T, want *utlsRoundTripper", service.httpClient.Transport) + } + return transport.sessionCache + } + if cacheOf(NewClaudeAuthWithProxyURL(nil, "http://127.0.0.1:11")) != cacheOf(NewClaudeAuthWithProxyURL(nil, "http://127.0.0.1:11")) { + t.Fatal("per-operation ClaudeAuth instances do not share a session cache, so refresh can never resume") + } +} + func TestClaudeOAuthRequestHeaderOrderMatchesNative220Capture(t *testing.T) { t.Parallel() @@ -131,6 +191,12 @@ func claudeOAuthExtensionTypes(t *testing.T, extensions []tls.TLSExtension) []ui result = append(result, 45) case *tls.SupportedVersionsExtension: result = append(result, 43) + case *tls.UtlsPreSharedKeyExtension: + // pre_shared_key contributes zero bytes until a session is cached, so + // it never appears in the fresh ClientHello the native capture covers + // and must stay out of the JA3 extension list. The record length + // assertion in the caller proves the byte neutrality. + continue default: t.Fatalf("unexpected OAuth TLS extension %T", extension) } @@ -173,7 +239,9 @@ func captureClaudeOAuthClientHello(t *testing.T) []byte { t.Errorf("close server connection: %v", errClose) } }) - tlsConn := tls.UClient(clientConn, &tls.Config{ServerName: "api.anthropic.com"}, tls.HelloCustom) + // Use the production config so the captured bytes reflect the real dial path. + cfg := newClaudeOAuthTLSConfig("api.anthropic.com", tls.NewLRUClientSessionCache(claudeOAuthSessionCacheCapacity)) + tlsConn := tls.UClient(clientConn, cfg, tls.HelloCustom) if errPreset := tlsConn.ApplyPreset(claudeOAuthTLSClientHelloSpec()); errPreset != nil { t.Fatal(errPreset) } diff --git a/internal/runtime/executor/helps/utls_client.go b/internal/runtime/executor/helps/utls_client.go index 529f0bed..03067a17 100644 --- a/internal/runtime/executor/helps/utls_client.go +++ b/internal/runtime/executor/helps/utls_client.go @@ -131,6 +131,25 @@ func (t *utlsRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) return resp, nil } +// claudeCodeSessionCacheCapacity bounds the per-transport TLS session cache for +// the Anthropic inference plane. +const claudeCodeSessionCacheCapacity = 32 + +// newClaudeCodeTLSConfig builds the uTLS config for one inference-plane dial. +// +// OmitEmptyPsk keeps the pre_shared_key extension silent until a session is +// cached, so an unresumed ClientHello stays byte-identical to the captured +// native handshake. PreferSkipResumptionOnNilExtension turns uTLS's HelloCustom +// "resume without the matching extension" panic into a skipped resumption. +func newClaudeCodeTLSConfig(host string, sessionCache tls.ClientSessionCache) *tls.Config { + return &tls.Config{ + ServerName: host, + ClientSessionCache: sessionCache, + OmitEmptyPsk: true, + PreferSkipResumptionOnNilExtension: true, + } +} + // claudeCodeTLSClientHelloSpec reproduces the deterministic Node/OpenSSL // ClientHello emitted by Claude Code 2.1.220 on macOS arm64. Keep this spec in // sync with a fresh native capture whenever the advertised Claude Code version @@ -182,6 +201,9 @@ func claudeCodeTLSClientHelloSpec() *tls.ClientHelloSpec { &tls.PSKKeyExchangeModesExtension{Modes: []uint8{tls.PskModeDHE}}, &tls.SupportedVersionsExtension{Versions: []uint16{tls.VersionTLS13, tls.VersionTLS12}}, &tls.UtlsPaddingExtension{GetPaddingLen: tls.BoringPaddingStyle}, + // pre_shared_key MUST be the final extension (RFC 8446 4.2.11), after + // padding. It contributes zero bytes until a cached session exists. + &tls.UtlsPreSharedKeyExtension{}, }, } } @@ -260,6 +282,9 @@ func cachedClaudeCodeRoundTripper(proxyURL string) http.RoundTripper { } func newClaudeCodeRoundTripper(proxyURL string) http.RoundTripper { + // The cache is scoped to this round tripper, which is already keyed by proxy, + // so resumption never crosses proxy boundaries. + sessionCache := tls.NewLRUClientSessionCache(claudeCodeSessionCacheCapacity) var dialer proxy.Dialer = proxy.Direct if proxyURL != "" { proxyDialer, mode, errBuild := proxyutil.BuildDialer(proxyURL) @@ -293,7 +318,7 @@ func newClaudeCodeRoundTripper(proxyURL string) http.RoundTripper { } return nil, fmt.Errorf("claude tls: split upstream address: %w", errSplit) } - tlsConn := tls.UClient(conn, &tls.Config{ServerName: host}, tls.HelloCustom) + tlsConn := tls.UClient(conn, newClaudeCodeTLSConfig(host, sessionCache), tls.HelloCustom) if errPreset := tlsConn.ApplyPreset(claudeCodeTLSClientHelloSpec()); errPreset != nil { if errClose := tlsConn.Close(); errClose != nil { log.Debugf("claude tls: close connection after preset failure: %v", errClose) diff --git a/internal/runtime/executor/helps/utls_client_resumption_test.go b/internal/runtime/executor/helps/utls_client_resumption_test.go new file mode 100644 index 00000000..a7a8cb21 --- /dev/null +++ b/internal/runtime/executor/helps/utls_client_resumption_test.go @@ -0,0 +1,136 @@ +package helps + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + gotls "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "errors" + "io" + "math/big" + "net" + "testing" + "time" + + tls "github.com/refraction-networking/utls" +) + +// newResumptionTestCertificate mints a short-lived self-signed leaf for the +// loopback TLS server used by the resumption test. +func newResumptionTestCertificate(t *testing.T) gotls.Certificate { + t.Helper() + key, errKey := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if errKey != nil { + t.Fatalf("generate test key: %v", errKey) + } + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "api.anthropic.com"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + DNSNames: []string{"api.anthropic.com"}, + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + IsCA: true, + } + der, errCreate := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if errCreate != nil { + t.Fatalf("create test certificate: %v", errCreate) + } + leaf, errParse := x509.ParseCertificate(der) + if errParse != nil { + t.Fatalf("parse test certificate: %v", errParse) + } + return gotls.Certificate{Certificate: [][]byte{der}, PrivateKey: key, Leaf: leaf} +} + +// TestClaudeCodeTLSSessionResumptionCompletesHandshake proves the Claude Code +// inference ClientHello can actually resume: the spec places pre_shared_key +// after the padding extension, so a malformed ordering or padding interaction +// would surface here as a handshake failure rather than a silent regression. +func TestClaudeCodeTLSSessionResumptionCompletesHandshake(t *testing.T) { + certificate := newResumptionTestCertificate(t) + roots := x509.NewCertPool() + roots.AddCert(certificate.Leaf) + + listener, errListen := net.Listen("tcp", "127.0.0.1:0") + if errListen != nil { + t.Fatalf("listen: %v", errListen) + } + t.Cleanup(func() { + if errClose := listener.Close(); errClose != nil && !errors.Is(errClose, net.ErrClosed) { + t.Errorf("close listener: %v", errClose) + } + }) + + serverConfig := &gotls.Config{ + Certificates: []gotls.Certificate{certificate}, + MinVersion: gotls.VersionTLS13, + } + go func() { + for { + raw, errAccept := listener.Accept() + if errAccept != nil { + return + } + go func(conn net.Conn) { + server := gotls.Server(conn, serverConfig) + if errHandshake := server.Handshake(); errHandshake != nil { + _ = conn.Close() + return + } + // The greeting flushes the post-handshake NewSessionTicket + // messages the client needs in order to resume. + _, _ = server.Write([]byte("ok\n")) + _, _ = server.Read(make([]byte, 8)) + _ = server.Close() + }(raw) + } + }() + + sessionCache := tls.NewLRUClientSessionCache(claudeCodeSessionCacheCapacity) + dial := func(round int) (resumed bool, helloLength int) { + raw, errDial := net.Dial("tcp", listener.Addr().String()) + if errDial != nil { + t.Fatalf("round %d dial: %v", round, errDial) + } + defer func() { + if errClose := raw.Close(); errClose != nil && !errors.Is(errClose, net.ErrClosed) { + t.Errorf("round %d close: %v", round, errClose) + } + }() + + config := newClaudeCodeTLSConfig("api.anthropic.com", sessionCache) + config.RootCAs = roots + conn := tls.UClient(raw, config, tls.HelloCustom) + if errPreset := conn.ApplyPreset(claudeCodeTLSClientHelloSpec()); errPreset != nil { + t.Fatalf("round %d apply preset: %v", round, errPreset) + } + if errHandshake := conn.Handshake(); errHandshake != nil { + t.Fatalf("round %d handshake: %v", round, errHandshake) + } + helloLength = len(conn.HandshakeState.Hello.Raw) + if _, errRead := conn.Read(make([]byte, 8)); errRead != nil && !errors.Is(errRead, io.EOF) { + t.Fatalf("round %d read: %v", round, errRead) + } + _, _ = conn.Write([]byte("bye\n")) + return conn.ConnectionState().DidResume, helloLength + } + + firstResumed, firstLength := dial(1) + if firstResumed { + t.Fatal("first handshake reported resumption without a cached session") + } + secondResumed, secondLength := dial(2) + if !secondResumed { + t.Fatal("second handshake did not resume, so the session cache is not effective") + } + + // The padding extension absorbs the pre_shared_key bytes, so a resumed + // ClientHello keeps the same BoringSSL padding boundary as a fresh one. + if firstLength != secondLength { + t.Fatalf("resumed ClientHello length = %d, want %d to match the fresh handshake", secondLength, firstLength) + } +} diff --git a/internal/runtime/executor/helps/utls_client_test.go b/internal/runtime/executor/helps/utls_client_test.go index a1b8270c..08321872 100644 --- a/internal/runtime/executor/helps/utls_client_test.go +++ b/internal/runtime/executor/helps/utls_client_test.go @@ -119,6 +119,34 @@ func TestClaudeCodeTLSClientHelloSpecMatches220Capture(t *testing.T) { } } +func TestClaudeCodeTLSResumptionIsWireSafe(t *testing.T) { + t.Parallel() + + // RFC 8446 4.2.11 requires pre_shared_key to be the final extension, after + // the padding extension. + spec := claudeCodeTLSClientHelloSpec() + last := spec.Extensions[len(spec.Extensions)-1] + if _, ok := last.(*tls.UtlsPreSharedKeyExtension); !ok { + t.Fatalf("last inference extension = %T, want *tls.UtlsPreSharedKeyExtension", last) + } + if _, ok := spec.Extensions[len(spec.Extensions)-2].(*tls.UtlsPaddingExtension); !ok { + t.Fatalf("extension before pre_shared_key = %T, want *tls.UtlsPaddingExtension", spec.Extensions[len(spec.Extensions)-2]) + } + + // Without OmitEmptyPsk uTLS refuses to marshal an empty PSK, and without + // PreferSkipResumptionOnNilExtension a HelloCustom resumption attempt panics. + cfg := newClaudeCodeTLSConfig("api.anthropic.com", tls.NewLRUClientSessionCache(claudeCodeSessionCacheCapacity)) + if cfg.ClientSessionCache == nil { + t.Fatal("ClientSessionCache = nil, want a session cache so resumption is possible") + } + if !cfg.OmitEmptyPsk { + t.Fatal("OmitEmptyPsk = false, want true so an unresumed ClientHello stays byte-identical") + } + if !cfg.PreferSkipResumptionOnNilExtension { + t.Fatal("PreferSkipResumptionOnNilExtension = false, want true to avoid a HelloCustom resumption panic") + } +} + func TestClaudeCodeRequestHeaderOrderMatchesNative220Capture(t *testing.T) { t.Parallel() @@ -277,7 +305,10 @@ func captureClaudeCodeClientHello(t *testing.T) []byte { t.Errorf("close server pipe: %v", errClose) } }) - tlsConn := tls.UClient(clientConn, &tls.Config{ServerName: "api.anthropic.com"}, tls.HelloCustom) + // Use the production config so the captured bytes reflect the real dial path, + // including the resumption settings. + cfg := newClaudeCodeTLSConfig("api.anthropic.com", tls.NewLRUClientSessionCache(claudeCodeSessionCacheCapacity)) + tlsConn := tls.UClient(clientConn, cfg, tls.HelloCustom) if errPreset := tlsConn.ApplyPreset(claudeCodeTLSClientHelloSpec()); errPreset != nil { t.Fatal(errPreset) }