Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/cmd/server/wire_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (

"github.com/Wei-Shaw/sub2api/internal/config"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
Expand Down Expand Up @@ -390,6 +391,13 @@ func (u *grokCredentialHandlerUpstream) Do(req *http.Request, _ string, accountI
}, nil
}

// DoWithTLS forwards to Do: doOpenAIUpstream now always calls DoWithTLS (nil profile for
// these test accounts degrades to Do in the real httpUpstreamService, so the fake must
// mirror that instead of falling through to the embedded nil service.HTTPUpstream).
func (u *grokCredentialHandlerUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, _ *tlsfingerprint.Profile) (*http.Response, error) {
return u.Do(req, proxyURL, accountID, accountConcurrency)
}

func (u *grokCredentialHandlerUpstream) accountHits() []int64 {
u.mu.Lock()
defer u.mu.Unlock()
Expand Down
18 changes: 18 additions & 0 deletions backend/internal/handler/openai_gateway_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/config"
pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
Expand Down Expand Up @@ -1901,6 +1902,13 @@ func (u *openAIHTTPPassthroughFailoverUpstream) Do(_ *http.Request, _ string, ac
}, nil
}

// DoWithTLS forwards to Do: doOpenAIUpstream now always calls DoWithTLS (nil profile for
// these API-key test accounts degrades to Do in the real httpUpstreamService, so the fake
// must mirror that instead of falling through to the embedded nil service.HTTPUpstream).
func (u *openAIHTTPPassthroughFailoverUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, _ *tlsfingerprint.Profile) (*http.Response, error) {
return u.Do(req, proxyURL, accountID, accountConcurrency)
}

func (u *openAIHTTPPassthroughFailoverUpstream) calls() []int64 {
u.mu.Lock()
defer u.mu.Unlock()
Expand Down Expand Up @@ -1932,6 +1940,11 @@ func (u *openAIHTTPPassthroughAuthFailoverUpstream) Do(_ *http.Request, _ string
}, nil
}

// DoWithTLS forwards to Do; see openAIHTTPPassthroughFailoverUpstream.DoWithTLS for why.
func (u *openAIHTTPPassthroughAuthFailoverUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, _ *tlsfingerprint.Profile) (*http.Response, error) {
return u.Do(req, proxyURL, accountID, accountConcurrency)
}

func (u *openAIHTTPPassthroughAuthFailoverUpstream) calls() []int64 {
u.mu.Lock()
defer u.mu.Unlock()
Expand Down Expand Up @@ -1966,6 +1979,11 @@ func (u *openAIHTTPPassthroughSSERateLimitUpstream) Do(_ *http.Request, _ string
}, nil
}

// DoWithTLS forwards to Do; see openAIHTTPPassthroughFailoverUpstream.DoWithTLS for why.
func (u *openAIHTTPPassthroughSSERateLimitUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, _ *tlsfingerprint.Profile) (*http.Response, error) {
return u.Do(req, proxyURL, accountID, accountConcurrency)
}

func (u *openAIHTTPPassthroughSSERateLimitUpstream) calls() []int64 {
u.mu.Lock()
defer u.mu.Unlock()
Expand Down
8 changes: 8 additions & 0 deletions backend/internal/handler/openai_images_failover_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (

"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
Expand Down Expand Up @@ -81,6 +82,13 @@ func (u *openAIImagesFailoverHTTPUpstream) Do(_ *http.Request, _ string, account
}, nil
}

// DoWithTLS forwards to Do: doOpenAIUpstream now always calls DoWithTLS (nil profile for
// these test accounts degrades to Do in the real httpUpstreamService, so the fake must
// mirror that instead of falling through to the embedded nil service.HTTPUpstream).
func (u *openAIImagesFailoverHTTPUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, _ *tlsfingerprint.Profile) (*http.Response, error) {
return u.Do(req, proxyURL, accountID, accountConcurrency)
}

func (u *openAIImagesFailoverHTTPUpstream) calls() []int64 {
u.mu.Lock()
defer u.mu.Unlock()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"testing"

"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
Expand Down Expand Up @@ -43,6 +44,13 @@ func (u *openAIResponsesFailoverCancelUpstream) Do(_ *http.Request, _ string, ac
}, nil
}

// DoWithTLS forwards to Do: doOpenAIUpstream now always calls DoWithTLS (nil profile for
// these test accounts degrades to Do in the real httpUpstreamService, so the fake must
// mirror that instead of falling through to the embedded nil service.HTTPUpstream).
func (u *openAIResponsesFailoverCancelUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, _ *tlsfingerprint.Profile) (*http.Response, error) {
return u.Do(req, proxyURL, accountID, accountConcurrency)
}

func (u *openAIResponsesFailoverCancelUpstream) calls() []int64 {
u.mu.Lock()
defer u.mu.Unlock()
Expand Down
2 changes: 2 additions & 0 deletions backend/internal/handler/wire.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ func ProvideGatewayHandler(
func ProvideOpenAIGatewayHandler(
gatewayService *service.OpenAIGatewayService,
pluginManager *service.PluginManager,
tlsFPProfileService *service.TLSFingerprintProfileService,
concurrencyService *service.ConcurrencyService,
billingCacheService *service.BillingCacheService,
apiKeyService *service.APIKeyService,
Expand All @@ -132,6 +133,7 @@ func ProvideOpenAIGatewayHandler(
coordinator *securityaudit.Coordinator,
) *OpenAIGatewayHandler {
gatewayService.SetPluginManager(pluginManager)
gatewayService.SetTLSFingerprintProfileService(tlsFPProfileService)
h := NewOpenAIGatewayHandler(gatewayService, concurrencyService, billingCacheService, apiKeyService,
usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, cfg)
h.securityAuditCoordinator = coordinator
Expand Down
30 changes: 30 additions & 0 deletions backend/internal/pkg/tlsfingerprint/dialer.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"encoding/base64"
"fmt"
"log/slog"
"math/rand/v2"
"net"
"net/http"
"net/url"
Expand All @@ -30,6 +31,14 @@ type Profile struct {
KeyShareGroups []uint16 // Empty uses [X25519]
PSKModes []uint16 // Empty uses [psk_dhe_ke]
Extensions []uint16 // Extension type IDs in order; empty uses default Node.js 24.x order

// RandomizeExtensionOrder, when true, shuffles the constructed extension list once per
// TLS connection (in buildClientHelloSpecFromProfile) instead of using a fixed order.
// Real rustls clients reshuffle their ClientHello extension order on every connection as
// an anti-fingerprinting measure; a Profile with a permanently fixed order is itself a
// distinguishing signal for such clients. Defaults to false so existing Profiles (e.g.
// the Node.js/Claude Code default) keep their current fixed-order behavior unchanged.
RandomizeExtensionOrder bool
}

// Dialer creates TLS connections with custom fingerprints.
Expand Down Expand Up @@ -391,6 +400,9 @@ func buildClientHelloSpecFromProfile(profile *Profile) *utls.ClientHelloSpec {
if profile != nil && len(profile.Extensions) > 0 {
extOrder = profile.Extensions
}
if profile != nil && profile.RandomizeExtensionOrder {
extOrder = shuffleExtensionOrder(extOrder)
}

// Build extensions list from the ordered IDs.
// Parametric extensions (curves, sigalgs, etc.) are populated with resolved profile values.
Expand Down Expand Up @@ -456,6 +468,24 @@ func buildClientHelloSpecFromProfile(profile *Profile) *utls.ClientHelloSpec {
}
}

// shuffleExtensionOrder returns a new slice holding a random permutation of ids. It never
// mutates ids: buildClientHelloSpecFromProfile may be called concurrently for many new TLS
// connections sharing the same package-level Profile, and shuffling its Extensions slice in
// place would both race and corrupt the base order seen by other concurrent callers.
//
// This does not need cryptographic randomness — it exists purely to keep the outbound
// ClientHello's extension order from being identical on every connection, mirroring the
// anti-fingerprinting behavior real rustls clients exhibit (see
// specs/002-codex-tls-fingerprint/research.md).
func shuffleExtensionOrder(ids []uint16) []uint16 {
shuffled := make([]uint16, len(ids))
copy(shuffled, ids)
rand.Shuffle(len(shuffled), func(i, j int) {
shuffled[i], shuffled[j] = shuffled[j], shuffled[i]
})
return shuffled
}

// toUint8s converts []uint16 to []uint8 (for utls fields that require []uint8).
func toUint8s(vals []uint16) []uint8 {
out := make([]uint8, len(vals))
Expand Down
114 changes: 114 additions & 0 deletions backend/internal/pkg/tlsfingerprint/dialer_randomize_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package tlsfingerprint

import "testing"

// sameUint16Set reports whether a and b contain the same multiset of values, ignoring order.
func sameUint16Set(a, b []uint16) bool {
if len(a) != len(b) {
return false
}
counts := make(map[uint16]int, len(a))
for _, v := range a {
counts[v]++
}
for _, v := range b {
counts[v]--
}
for _, c := range counts {
if c != 0 {
return false
}
}
return true
}

func sameUint16Order(a, b []uint16) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}

func uint16sToBytes(ids []uint16) []byte {
buf := make([]byte, len(ids)*2)
for i, id := range ids {
buf[i*2] = byte(id >> 8)
buf[i*2+1] = byte(id)
}
return buf
}

// TestShuffleExtensionOrderPreservesSetButVariesOrder 覆盖 spec User Story 2:重复打乱
// 同一组扩展类型 ID,集合必须每次不变,但排列顺序在多次采样里至少出现 2 种——镜像真实
// rustls 客户端每次连接重新打乱 ClientHello 扩展顺序的行为。
func TestShuffleExtensionOrderPreservesSetButVariesOrder(t *testing.T) {
original := []uint16{0, 5, 10, 11, 13, 23, 35, 43, 45, 51}

seenOrders := make(map[string]bool)
for i := 0; i < 20; i++ {
got := shuffleExtensionOrder(original)
if !sameUint16Set(original, got) {
t.Fatalf("iteration %d: extension set changed, want set %v got %v", i, original, got)
}
seenOrders[string(uint16sToBytes(got))] = true
}
if len(seenOrders) < 2 {
t.Fatalf("20 次采样只观察到 %d 种排列,期望 >= 2 种", len(seenOrders))
}
}

// TestShuffleExtensionOrderDoesNotMutateInput 覆盖并发安全:多个 goroutine 可能并发用
// 同一个包级 Profile 变量构造连接(每次新建 TLS 连接都会调用一次),原地打乱调用方传入的
// 切片会造成数据竞争,也会让后续调用的"打乱前基准顺序"被污染。
func TestShuffleExtensionOrderDoesNotMutateInput(t *testing.T) {
original := []uint16{0, 5, 10, 11, 13, 23, 35, 43, 45, 51}
input := append([]uint16(nil), original...)

for i := 0; i < 20; i++ {
shuffleExtensionOrder(input)
}

if !sameUint16Order(input, original) {
t.Fatalf("输入切片被就地修改:want %v got %v", original, input)
}
}

// TestBuildClientHelloSpecRandomizesExtensionOrderWhenEnabled 覆盖集成层:Profile 开启
// RandomizeExtensionOrder 后,buildClientHelloSpecFromProfile 产出的扩展数量与集合关系
// 应保持不变(打乱只影响顺序,不影响内容),且不改变传入 Profile.Extensions 本身。
func TestBuildClientHelloSpecRandomizesExtensionOrderWhenEnabled(t *testing.T) {
original := []uint16{0, 5, 10, 11, 13, 23, 35, 43, 45, 51}
profile := &Profile{
Name: "randomize-test",
Extensions: append([]uint16(nil), original...),
RandomizeExtensionOrder: true,
}

spec := buildClientHelloSpecFromProfile(profile)
if len(spec.Extensions) != len(original) {
t.Fatalf("got %d extensions, want %d", len(spec.Extensions), len(original))
}
if !sameUint16Order(profile.Extensions, original) {
t.Fatalf("profile.Extensions 被就地修改:want %v got %v", original, profile.Extensions)
}
}

// TestBuildClientHelloSpecKeepsFixedOrderWhenDisabled 覆盖既有 Profile
// (RandomizeExtensionOrder 零值 false)行为不变——这是宪法原则 IV 明确要求不能破坏的边界。
func TestBuildClientHelloSpecKeepsFixedOrderWhenDisabled(t *testing.T) {
original := []uint16{0, 5, 10, 11, 13, 23, 35, 43, 45, 51}
profile := &Profile{
Name: "fixed-order-test",
Extensions: append([]uint16(nil), original...),
}

spec := buildClientHelloSpecFromProfile(profile)
if len(spec.Extensions) != len(original) {
t.Fatalf("got %d extensions, want %d", len(spec.Extensions), len(original))
}
}
69 changes: 69 additions & 0 deletions backend/internal/service/openai_codex_tls_profile.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package service

import "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"

// codexTLSProfile 是 OpenAI Codex OAuth 出站请求的 TLS 指纹画像,字段取值逐项对应真实
// Codex CLI(reqwest 0.12 + rustls 0.23,aws_lc_rs crypto provider,编译时未启用 http2
// feature)的默认握手行为。取值依据:官方 github.com/openai/codex 仓库源码
// (codex-rs/http-client、codex-rs/utils/rustls-provider)+ 三次独立真实抓包交叉验证,
// 记录在 specs/002-codex-tls-fingerprint/research.md 与 contracts/tls-profile-values.md,
// 不是凭空指定的值。
//
// Extensions 顺序开启逐连接随机打乱(RandomizeExtensionOrder),镜像 rustls 实测的反指纹
// 行为——三次抓包里密码套件/分组/点格式三次一致,但扩展排列顺序三次均不同。
var codexTLSProfile = &tlsfingerprint.Profile{
Name: "Codex CLI (reqwest+rustls, aws_lc_rs)",
CipherSuites: []uint16{
0x1302, // TLS_AES_256_GCM_SHA384
0x1301, // TLS_AES_128_GCM_SHA256
0x1303, // TLS_CHACHA20_POLY1305_SHA256
0xc02c, // TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
0xc02b, // TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
0xcca9, // TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256
0xc030, // TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
0xc02f, // TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
0xcca8, // TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256
0x00ff, // TLS_EMPTY_RENEGOTIATION_INFO_SCSV
},
// Curves 对应 supported_groups 扩展,KeyShareGroups 对应 key_share 扩展;真实客户端
// 两者列出同一组分组,X25519MLKEM768(0x11ec)是后量子混合密钥交换,rustls 默认优先。
Curves: []uint16{0x11ec, 0x001d, 0x0017, 0x0018},
KeyShareGroups: []uint16{0x11ec, 0x001d, 0x0017, 0x0018},
PointFormats: []uint16{0}, // uncompressed
// ALPNProtocols 留空且 Extensions 不含类型 16:reqwest 编译时未启用 http2 feature,
// 从不发送 ALPN 扩展。
ALPNProtocols: nil,
Extensions: []uint16{
0, // server_name
5, // status_request
10, // supported_groups
11, // ec_point_formats
13, // signature_algorithms
23, // extended_master_secret
35, // session_ticket
43, // supported_versions
45, // psk_key_exchange_modes
51, // key_share
},
EnableGREASE: false, // rustls 不做 GREASE
// 三次真实抓包(research.md §2)显示密码套件/分组/点格式三次一致,但扩展排列顺序三次
// 均不同——镜像 rustls 的反指纹行为,逐连接重新打乱,不写死固定顺序。
RandomizeExtensionOrder: true,
}

// resolveOpenAICodexTLSProfile 决定一次 OpenAI 出站请求应使用的 TLS Profile。
//
// explicitProfile 是账号已显式配置的 TLS 指纹(由调用方通过
// TLSFingerprintProfileService.ResolveTLSProfile 解析得到;nil 表示账号未配置该项,
// 或该账号类型当前不支持配置)。account 用于在没有显式配置时判断是否属于 OpenAI Codex
// OAuth——是则自动套用 codexTLSProfile,不需要管理员逐账号手动开关;否则不启用 TLS 指纹,
// 与改动前的行为一致。
func resolveOpenAICodexTLSProfile(explicitProfile *tlsfingerprint.Profile, account *Account) *tlsfingerprint.Profile {
if explicitProfile != nil {
return explicitProfile
}
if account != nil && account.IsOpenAIOAuth() {
return codexTLSProfile
}
return nil
}
Loading
Loading