Add projects
This commit is contained in:
parent
2d3a9ad623
commit
8b607dd700
1802 changed files with 503346 additions and 2 deletions
284
backend/internal/auth/claude/utls_transport_test.go
Normal file
284
backend/internal/auth/claude/utls_transport_test.go
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
package claude
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
tls "github.com/refraction-networking/utls"
|
||||
sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
|
||||
)
|
||||
|
||||
type claudeTestDialer struct {
|
||||
conn net.Conn
|
||||
}
|
||||
|
||||
func (d claudeTestDialer) Dial(_, _ string) (net.Conn, error) {
|
||||
return d.conn, nil
|
||||
}
|
||||
|
||||
func TestUtlsRoundTripperBoundsTLSHandshake(t *testing.T) {
|
||||
clientConn, serverConn := net.Pipe()
|
||||
defer func() {
|
||||
if errClose := serverConn.Close(); errClose != nil {
|
||||
t.Errorf("server connection close returned error: %v", errClose)
|
||||
}
|
||||
}()
|
||||
|
||||
transport := &utlsRoundTripper{dialer: claudeTestDialer{conn: clientConn}}
|
||||
ctx := context.WithValue(context.Background(), claudeRefreshHandshakeTimeoutContextKey{}, 20*time.Millisecond)
|
||||
startedAt := time.Now()
|
||||
_, err := transport.dialTLSContext(ctx, "tcp", "example.com:443")
|
||||
if err == nil {
|
||||
t.Fatal("expected TLS handshake timeout")
|
||||
}
|
||||
var netErr net.Error
|
||||
if !errors.As(err, &netErr) || !netErr.Timeout() {
|
||||
t.Fatalf("error = %v, want timeout error", err)
|
||||
}
|
||||
if elapsed := time.Since(startedAt); elapsed > time.Second {
|
||||
t.Fatalf("TLS handshake took %s, want less than one second", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeOAuthTLSClientHelloSpecMatchesNative220Capture(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const wantJA3 = "771,4865-4866-4867-49195-49199-49196-49200-52393-52392-49161-49171-49162-49172-156-157-47-53,0-23-65281-10-11-35-13-51-45-43,29-23-24,0"
|
||||
const wantJA3MD5 = "203503b7023848ab87b9836c336b8e81"
|
||||
wantCipherSuites := []uint16{4865, 4866, 4867, 49195, 49199, 49196, 49200, 52393, 52392, 49161, 49171, 49162, 49172, 156, 157, 47, 53}
|
||||
wantExtensions := []uint16{0, 23, 65281, 10, 11, 35, 13, 51, 45, 43}
|
||||
|
||||
spec := claudeOAuthTLSClientHelloSpec()
|
||||
if !reflect.DeepEqual(spec.CipherSuites, wantCipherSuites) {
|
||||
t.Fatalf("cipher suites = %v, want %v", spec.CipherSuites, wantCipherSuites)
|
||||
}
|
||||
extensionTypes := claudeOAuthExtensionTypes(t, spec.Extensions)
|
||||
if !reflect.DeepEqual(extensionTypes, wantExtensions) {
|
||||
t.Fatalf("extension types = %v, want %v", extensionTypes, wantExtensions)
|
||||
}
|
||||
curves := spec.Extensions[3].(*tls.SupportedCurvesExtension).Curves
|
||||
points := spec.Extensions[4].(*tls.SupportedPointsExtension).SupportedPoints
|
||||
actualJA3 := "771," + joinClaudeOAuthUint16(spec.CipherSuites) + "," + joinClaudeOAuthUint16(extensionTypes) + "," + joinClaudeOAuthCurves(curves) + "," + joinClaudeOAuthUint8(points)
|
||||
if actualJA3 != wantJA3 {
|
||||
t.Fatalf("JA3 = %q, want %q", actualJA3, wantJA3)
|
||||
}
|
||||
if strings.Contains(actualJA3, "-16-") {
|
||||
t.Fatal("OAuth JA3 unexpectedly contains ALPN extension 16")
|
||||
}
|
||||
hash := md5.Sum([]byte(actualJA3)) // #nosec G401 -- JA3 requires MD5.
|
||||
if got := hex.EncodeToString(hash[:]); got != wantJA3MD5 {
|
||||
t.Fatalf("JA3 MD5 = %s, want %s", got, wantJA3MD5)
|
||||
}
|
||||
|
||||
record := captureClaudeOAuthClientHello(t)
|
||||
if got := len(record) - 9; got != 245 {
|
||||
t.Fatalf("ClientHello length = %d, want 245", got)
|
||||
}
|
||||
}
|
||||
|
||||
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 TestClaudeOAuthSessionCacheBoundsProxyCardinality(t *testing.T) {
|
||||
firstProxy := "http://127.0.0.1:31000"
|
||||
first := claudeOAuthSessionCache(firstProxy)
|
||||
for index := 1; index <= claudeOAuthProxySessionCacheCapacity; index++ {
|
||||
claudeOAuthSessionCache("http://127.0.0.1:" + strconv.Itoa(31000+index))
|
||||
}
|
||||
if got := claudeOAuthSessionCaches.Len(); got > claudeOAuthProxySessionCacheCapacity {
|
||||
t.Fatalf("OAuth session caches = %d, want at most %d", got, claudeOAuthProxySessionCacheCapacity)
|
||||
}
|
||||
if recreated := claudeOAuthSessionCache(firstProxy); recreated == first {
|
||||
t.Fatal("least recently used OAuth proxy session cache was not evicted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeOAuthRequestHeaderOrderMatchesNative220Capture(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantRefresh := []string{"Accept", "Content-Type", "User-Agent", "Content-Length", "Accept-Encoding", "Host", "Connection"}
|
||||
wantProfile := []string{"Accept", "Content-Type", "Authorization", "Cache-Control", "User-Agent", "Accept-Encoding", "Host", "Connection"}
|
||||
if got := claudeOAuthRequestHeaderOrder("POST", "/v1/oauth/token"); !reflect.DeepEqual(got, wantRefresh) {
|
||||
t.Fatalf("refresh header order = %v, want %v", got, wantRefresh)
|
||||
}
|
||||
if got := claudeOAuthRequestHeaderOrder("GET", "/api/oauth/profile"); !reflect.DeepEqual(got, wantProfile) {
|
||||
t.Fatalf("profile header order = %v, want %v", got, wantProfile)
|
||||
}
|
||||
// The claude_cli roles companion lookup uses the same authenticated Axios GET shape.
|
||||
if got := claudeOAuthRequestHeaderOrder("GET", "/api/oauth/claude_cli/roles"); !reflect.DeepEqual(got, wantProfile) {
|
||||
t.Fatalf("roles header order = %v, want %v", got, wantProfile)
|
||||
}
|
||||
// The authorization-code exchange is a POST and keeps the JSON-body order.
|
||||
if got := claudeOAuthRequestHeaderOrder("POST", "/api/oauth/profile"); !reflect.DeepEqual(got, wantRefresh) {
|
||||
t.Fatalf("non-GET profile target header order = %v, want %v", got, wantRefresh)
|
||||
}
|
||||
}
|
||||
|
||||
func claudeOAuthExtensionTypes(t *testing.T, extensions []tls.TLSExtension) []uint16 {
|
||||
t.Helper()
|
||||
result := make([]uint16, 0, len(extensions))
|
||||
for _, extension := range extensions {
|
||||
switch extension.(type) {
|
||||
case *tls.SNIExtension:
|
||||
result = append(result, 0)
|
||||
case *tls.ExtendedMasterSecretExtension:
|
||||
result = append(result, 23)
|
||||
case *tls.RenegotiationInfoExtension:
|
||||
result = append(result, 65281)
|
||||
case *tls.SupportedCurvesExtension:
|
||||
result = append(result, 10)
|
||||
case *tls.SupportedPointsExtension:
|
||||
result = append(result, 11)
|
||||
case *tls.SessionTicketExtension:
|
||||
result = append(result, 35)
|
||||
case *tls.SignatureAlgorithmsExtension:
|
||||
result = append(result, 13)
|
||||
case *tls.KeyShareExtension:
|
||||
result = append(result, 51)
|
||||
case *tls.PSKKeyExchangeModesExtension:
|
||||
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)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func joinClaudeOAuthUint16(values []uint16) string {
|
||||
parts := make([]string, len(values))
|
||||
for index, value := range values {
|
||||
parts[index] = strconv.Itoa(int(value))
|
||||
}
|
||||
return strings.Join(parts, "-")
|
||||
}
|
||||
|
||||
func joinClaudeOAuthCurves(values []tls.CurveID) string {
|
||||
parts := make([]string, len(values))
|
||||
for index, value := range values {
|
||||
parts[index] = strconv.Itoa(int(value))
|
||||
}
|
||||
return strings.Join(parts, "-")
|
||||
}
|
||||
|
||||
func joinClaudeOAuthUint8(values []uint8) string {
|
||||
parts := make([]string, len(values))
|
||||
for index, value := range values {
|
||||
parts[index] = strconv.Itoa(int(value))
|
||||
}
|
||||
return strings.Join(parts, "-")
|
||||
}
|
||||
|
||||
func captureClaudeOAuthClientHello(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
clientConn, serverConn := net.Pipe()
|
||||
t.Cleanup(func() {
|
||||
if errClose := clientConn.Close(); errClose != nil && !errors.Is(errClose, net.ErrClosed) {
|
||||
t.Errorf("close client connection: %v", errClose)
|
||||
}
|
||||
if errClose := serverConn.Close(); errClose != nil && !errors.Is(errClose, net.ErrClosed) {
|
||||
t.Errorf("close server connection: %v", errClose)
|
||||
}
|
||||
})
|
||||
// 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)
|
||||
}
|
||||
handshakeDone := make(chan error, 1)
|
||||
go func() { handshakeDone <- tlsConn.Handshake() }()
|
||||
if errDeadline := serverConn.SetReadDeadline(time.Now().Add(5 * time.Second)); errDeadline != nil {
|
||||
t.Fatal(errDeadline)
|
||||
}
|
||||
header := make([]byte, 5)
|
||||
if _, errRead := io.ReadFull(serverConn, header); errRead != nil {
|
||||
t.Fatal(errRead)
|
||||
}
|
||||
payload := make([]byte, int(binary.BigEndian.Uint16(header[3:5])))
|
||||
if _, errRead := io.ReadFull(serverConn, payload); errRead != nil {
|
||||
t.Fatal(errRead)
|
||||
}
|
||||
if errClose := serverConn.Close(); errClose != nil {
|
||||
t.Fatal(errClose)
|
||||
}
|
||||
select {
|
||||
case <-handshakeDone:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("OAuth uTLS handshake did not exit")
|
||||
}
|
||||
return append(header, payload...)
|
||||
}
|
||||
Loading…
Reference in a new issue