From 6cfb7898df32ef30b02e9c2216536a5b30913201 Mon Sep 17 00:00:00 2001 From: haruka <1628615876@qq.com> Date: Fri, 19 Jun 2026 08:01:29 -0700 Subject: [PATCH] fix(claude-mimicry): drop the cch sign to match new Claude Code CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recent Claude Code CLI versions no longer emit the cch=... signature field in their x-anthropic-billing-header system block (issue #3358). sub2api still injected cch=00000 when mimicking Claude Code for OAuth accounts and optionally signed it, so mimicked requests now diverge from real CLI traffic — the opposite of what the mimicry is for. - buildBillingAttributionText emits the block without the cch=00000 segment; cc_version + cc_entrypoint=cli are kept (detection and Anthropic's first-party signal rely on the block, not on cch). - Retire signing: remove the two enableCCH signBillingHeaderCCH call sites in buildUpstreamRequest / buildCountTokensRequest and delete the now-dead signBillingHeaderCCH, cchPlaceholderRe, cchSeed, xxHash64Seeded helpers. - enable_cch_signing is now a documented no-op (kept for backward compat). - Drop the obsolete signing tests (TestSignBillingHeaderCCH, TestXXHash64Seeded, TestSanitizeMustBeBeforeCCHSigning_HashConsistency) and update the prompt test to assert the injected block no longer carries cch=. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/internal/service/domain_constants.go | 4 +- .../internal/service/gateway_billing_block.go | 12 ++- .../service/gateway_billing_header.go | 25 ----- .../service/gateway_billing_header_test.go | 98 ------------------- .../gateway_context_management_test.go | 63 ------------ .../internal/service/gateway_prompt_test.go | 3 +- backend/internal/service/gateway_service.go | 24 ++--- backend/internal/service/settings_view.go | 2 +- 8 files changed, 21 insertions(+), 210 deletions(-) diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 3be2df7eae..3908c00abd 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -421,7 +421,9 @@ const ( SettingKeyEnableFingerprintUnification = "enable_fingerprint_unification" // SettingKeyEnableMetadataPassthrough 是否透传客户端原始 metadata.user_id(默认 false) SettingKeyEnableMetadataPassthrough = "enable_metadata_passthrough" - // SettingKeyEnableCCHSigning 是否对 billing header 中的 cch 进行 xxHash64 签名(默认 false) + // SettingKeyEnableCCHSigning 已废弃(no-op):新版 Claude Code CLI 已取消 cch 签名字段, + // 网关随之不再注入/签名 cch(见 buildBillingAttributionText)。保留该 key 仅为向后兼容, + // 开关不再产生任何效果。 SettingKeyEnableCCHSigning = "enable_cch_signing" // SettingKeyEnableClaudeOAuthSystemPromptInjection 是否对 Claude OAuth mimic 路径注入 Claude Code system blocks(默认 true) SettingKeyEnableClaudeOAuthSystemPromptInjection = "enable_claude_oauth_system_prompt_injection" diff --git a/backend/internal/service/gateway_billing_block.go b/backend/internal/service/gateway_billing_block.go index 0a5128835a..98f98bc044 100644 --- a/backend/internal/service/gateway_billing_block.go +++ b/backend/internal/service/gateway_billing_block.go @@ -72,12 +72,14 @@ func extractFirstUserText(body []byte) string { // buildBillingAttributionText 构造 system 数组的 billing attribution 文本。 // -// 形态严格对齐真实 Claude Code CLI: +// 形态对齐真实 Claude Code CLI: // -// x-anthropic-billing-header: cc_version=2.1.161.{fp}; cc_entrypoint=cli; cch=00000; +// x-anthropic-billing-header: cc_version=2.1.161.{fp}; cc_entrypoint=cli; // -// cch=00000 是签名占位符,由 signBillingHeaderCCH 在 buildUpstreamRequest 阶段 -// 替换为基于完整 body 的 xxhash64 5 位十六进制摘要。 +// 注意:新版 Claude Code CLI 已不再发送 cch=... 签名字段(见 issue #3358)。我们 +// 随之去掉了 cch 段——继续注入它反而会让伪装请求偏离真实 CLI 流量。cc_version + +// cc_entrypoint=cli 仍保留:它们是客户端识别(claude_code_validator)与 Anthropic +// 第一方判定都依赖的稳定信号。 // // 此 block 不带 cache_control(与真实 CLI 一致;cache breakpoint 由后续的 // Claude Code prompt block 承担)。 @@ -87,7 +89,7 @@ func buildBillingAttributionText(body []byte, cliVersion string) (string, error) } fp := computeClaudeCodeFingerprint(body, cliVersion) return fmt.Sprintf( - "x-anthropic-billing-header: cc_version=%s.%s; cc_entrypoint=cli; cch=00000;", + "x-anthropic-billing-header: cc_version=%s.%s; cc_entrypoint=cli;", cliVersion, fp, ), nil } diff --git a/backend/internal/service/gateway_billing_header.go b/backend/internal/service/gateway_billing_header.go index 91fbfd8fdd..5dd0474780 100644 --- a/backend/internal/service/gateway_billing_header.go +++ b/backend/internal/service/gateway_billing_header.go @@ -5,7 +5,6 @@ import ( "regexp" "strings" - "github.com/cespare/xxhash/v2" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) @@ -14,12 +13,6 @@ import ( // the trailing message-derived suffix (e.g. ".c02") if present. var ccVersionInBillingRe = regexp.MustCompile(`cc_version=\d+\.\d+\.\d+`) -// cchPlaceholderRe matches the cch=00000 placeholder in billing header text, -// scoped to x-anthropic-billing-header to avoid touching user content. -var cchPlaceholderRe = regexp.MustCompile(`(x-anthropic-billing-header:[^"]*?\bcch=)(00000)(;)`) - -const cchSeed uint64 = 0x6E52736AC806831E - // syncBillingHeaderVersion rewrites cc_version in x-anthropic-billing-header // system text blocks to match the version extracted from userAgent. // Only touches system array blocks whose text starts with "x-anthropic-billing-header". @@ -53,21 +46,3 @@ func syncBillingHeaderVersion(body []byte, userAgent string) []byte { return body } - -// signBillingHeaderCCH computes the xxHash64-based CCH signature for the request -// body and replaces the cch=00000 placeholder with the computed 5-hex-char hash. -// The body must contain the placeholder when this function is called. -func signBillingHeaderCCH(body []byte) []byte { - if !cchPlaceholderRe.Match(body) { - return body - } - cch := fmt.Sprintf("%05x", xxHash64Seeded(body, cchSeed)&0xFFFFF) - return cchPlaceholderRe.ReplaceAll(body, []byte("${1}"+cch+"${3}")) -} - -// xxHash64Seeded computes xxHash64 of data with a custom seed. -func xxHash64Seeded(data []byte, seed uint64) uint64 { - d := xxhash.NewWithSeed(seed) - _, _ = d.Write(data) - return d.Sum64() -} diff --git a/backend/internal/service/gateway_billing_header_test.go b/backend/internal/service/gateway_billing_header_test.go index ffc4091c63..0d5930996a 100644 --- a/backend/internal/service/gateway_billing_header_test.go +++ b/backend/internal/service/gateway_billing_header_test.go @@ -1,13 +1,9 @@ package service import ( - "fmt" "testing" - "github.com/cespare/xxhash/v2" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/tidwall/gjson" ) func TestSyncBillingHeaderVersion(t *testing.T) { @@ -69,97 +65,3 @@ func TestSyncBillingHeaderVersion(t *testing.T) { }) } } - -func TestSignBillingHeaderCCH(t *testing.T) { - t.Run("replaces placeholder with hash", func(t *testing.T) { - body := []byte(`{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.63.a43; cc_entrypoint=cli; cch=00000;"}],"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`) - result := signBillingHeaderCCH(body) - - // Should not have the placeholder anymore - assert.NotContains(t, string(result), "cch=00000") - - // Should have a 5 hex-char cch value - billingText := gjson.GetBytes(result, "system.0.text").String() - require.Contains(t, billingText, "cch=") - assert.Regexp(t, `cch=[0-9a-f]{5};`, billingText) - }) - - t.Run("no placeholder - body unchanged", func(t *testing.T) { - body := []byte(`{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.63; cc_entrypoint=cli; cch=abcde;"}],"messages":[]}`) - result := signBillingHeaderCCH(body) - assert.Equal(t, string(body), string(result)) - }) - - t.Run("no billing header - body unchanged", func(t *testing.T) { - body := []byte(`{"system":[{"type":"text","text":"You are Claude Code."}],"messages":[]}`) - result := signBillingHeaderCCH(body) - assert.Equal(t, string(body), string(result)) - }) - - t.Run("cch=00000 in user content is not touched", func(t *testing.T) { - body := []byte(`{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.63; cc_entrypoint=cli; cch=00000;"}],"messages":[{"role":"user","content":[{"type":"text","text":"keep literal cch=00000 in this message"}]}]}`) - result := signBillingHeaderCCH(body) - - // Billing header should be signed - billingText := gjson.GetBytes(result, "system.0.text").String() - assert.NotContains(t, billingText, "cch=00000") - - // User message should keep its literal cch=00000 - userText := gjson.GetBytes(result, "messages.0.content.0.text").String() - assert.Contains(t, userText, "cch=00000") - }) - - t.Run("signing is deterministic", func(t *testing.T) { - body := []byte(`{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.63; cc_entrypoint=cli; cch=00000;"}],"messages":[{"role":"user","content":"hi"}]}`) - r1 := signBillingHeaderCCH(body) - body2 := []byte(`{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.63; cc_entrypoint=cli; cch=00000;"}],"messages":[{"role":"user","content":"hi"}]}`) - r2 := signBillingHeaderCCH(body2) - assert.Equal(t, string(r1), string(r2)) - }) - - t.Run("matches reference algorithm", func(t *testing.T) { - // Verify: signBillingHeaderCCH(body) produces cch = xxHash64(body_with_placeholder, seed) & 0xFFFFF - body := []byte(`{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.63.a43; cc_entrypoint=cli; cch=00000;"}],"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`) - expectedCCH := fmt.Sprintf("%05x", xxHash64Seeded(body, cchSeed)&0xFFFFF) - - result := signBillingHeaderCCH(body) - billingText := gjson.GetBytes(result, "system.0.text").String() - assert.Contains(t, billingText, "cch="+expectedCCH+";") - }) -} - -func TestXXHash64Seeded(t *testing.T) { - t.Run("matches cespare/xxhash for seed 0", func(t *testing.T) { - inputs := []string{"", "a", "hello world", "The quick brown fox jumps over the lazy dog"} - for _, s := range inputs { - data := []byte(s) - expected := xxhash.Sum64(data) - got := xxHash64Seeded(data, 0) - assert.Equal(t, expected, got, "mismatch for input %q", s) - } - }) - - t.Run("large input matches cespare", func(t *testing.T) { - data := make([]byte, 256) - for i := range data { - data[i] = byte(i) - } - expected := xxhash.Sum64(data) - got := xxHash64Seeded(data, 0) - assert.Equal(t, expected, got) - }) - - t.Run("deterministic with custom seed", func(t *testing.T) { - data := []byte("hello world") - h1 := xxHash64Seeded(data, cchSeed) - h2 := xxHash64Seeded(data, cchSeed) - assert.Equal(t, h1, h2) - }) - - t.Run("different seeds produce different results", func(t *testing.T) { - data := []byte("test data for hashing") - h1 := xxHash64Seeded(data, 0) - h2 := xxHash64Seeded(data, cchSeed) - assert.NotEqual(t, h1, h2) - }) -} diff --git a/backend/internal/service/gateway_context_management_test.go b/backend/internal/service/gateway_context_management_test.go index 51b12809e7..df23974ee0 100644 --- a/backend/internal/service/gateway_context_management_test.go +++ b/backend/internal/service/gateway_context_management_test.go @@ -7,7 +7,6 @@ import ( "io" "net/http" "net/http/httptest" - "regexp" "strings" "testing" @@ -503,68 +502,6 @@ func TestBuildUpstreamRequest_OAuthTransparentHaikuWithRealCCBeta_PreservesField "回归保护:真 CC + haiku + 客户端带 beta token 时,clear_thinking_20251015 功能不能静默失效") } -// CCH 顺序语义测试:sanitize 必须在 signBillingHeaderCCH 之前, -// 否则签名的 hash 与最终发送的 body 不一致,被 Anthropic 判 third-party。 -// -// 该测试不走 buildUpstreamRequest 完整路径(需要 mock SettingService 成本高), -// 而是直接验证两个顺序产生的 cch 不同,证明二者不可交换。 -// 测试名本身是语义约束的文档化 marker。 -func TestSanitizeMustBeBeforeCCHSigning_HashConsistency(t *testing.T) { - // 构造 body:含 context_management + cch=00000 占位符 - body := []byte(`{"model":"claude-haiku-4-5","context_management":{"edits":[{"type":"clear_thinking_20251015"}]},"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.92; cch=00000;"}],"messages":[]}`) - - // 最终发送场景:final beta 不含 context-management beta → sanitize 会 strip - finalBeta := "oauth-2025-04-20,interleaved-thinking-2025-05-14" - - extractCCH := func(t *testing.T, b []byte) string { - t.Helper() - m := regexp.MustCompile(`\bcch=([0-9a-fA-F]{5})\b`).FindSubmatch(b) - require.NotNil(t, m, "body 里找不到 cch=<5hex> :%s", string(b)) - return string(m[1]) - } - - // === 正确顺序:sanitize → signBillingHeaderCCH === - // 1. strip context_management - sanitizedFirst, changed := sanitizeAnthropicBodyForBetaTokens(body, finalBeta) - require.True(t, changed) - require.False(t, gjson.GetBytes(sanitizedFirst, "context_management").Exists()) - // 2. 基于“strip 后的 body”算 hash - correctFinal := signBillingHeaderCCH(sanitizedFirst) - correctCCH := extractCCH(t, correctFinal) - require.NotEqual(t, "00000", correctCCH, "placeholder 应被替换") - - // === 错误顺序:signBillingHeaderCCH → sanitize(未来 regression 场景)=== - // 1. 先基于“含 context_management 的 body”算 hash → cch=H_with - signedFirst := signBillingHeaderCCH(body) - wrongCCH := extractCCH(t, signedFirst) - require.NotEqual(t, "00000", wrongCCH) - // 2. 后 strip context_management → body 变化但 cch 仍是 H_with - wrongFinal, _ := sanitizeAnthropicBodyForBetaTokens(signedFirst, finalBeta) - wrongFinalCCH := extractCCH(t, wrongFinal) - - // === 关键断言 === - // 上游验证逻辑:将 outgoing body 的 cch 还原为 00000、重算 hash、与 cch 字段比较。 - // 模拟上游验证:用发送 body 算出“期望的 cch”,与发送 body 里的 cch 字段比。 - recomputeExpected := func(b []byte, currentCCH string) string { - t.Helper() - // 把 cch= 还原为 cch=00000 - re := regexp.MustCompile(`(\bcch=)` + currentCCH + `(\b)`) - restored := re.ReplaceAll(b, []byte("${1}00000${2}")) - return extractCCH(t, signBillingHeaderCCH(restored)) - } - - // 正确顺序:发送 body 的 cch == 重算 hash → 上游验证过 - require.Equal(t, correctCCH, recomputeExpected(correctFinal, correctCCH), - "正确顺序:final body 里的 cch 与重算 hash 一致 → 上游验证通过") - - // 错误顺序:发送 body 的 cch 是“含 ctx 算的”,但最终 body 不含 ctx → 重算 hash 不同 - require.NotEqual(t, wrongFinalCCH, recomputeExpected(wrongFinal, wrongFinalCCH), - "错误顺序:final body 里的 cch 是基于含 ctx 的 body 算的,"+ - "但发送 body 已 strip ctx → 上游重算 hash 与 cch 不一致 → 被判 third-party。"+ - "这是 buildUpstreamRequest / buildCountTokensRequest 里 sanitize 必须在 "+ - "signBillingHeaderCCH 之前的原因。") -} - // count_tokens 主路径 E2E 集成测试 func TestBuildCountTokensRequest_OAuthMimicHaiku_PreservesContextManagementEndToEnd(t *testing.T) { // count_tokens 路径下 mimic 不按 haiku 排除,始终注入 BetaContextManagement diff --git a/backend/internal/service/gateway_prompt_test.go b/backend/internal/service/gateway_prompt_test.go index ed702e3d06..bbdb8a1458 100644 --- a/backend/internal/service/gateway_prompt_test.go +++ b/backend/internal/service/gateway_prompt_test.go @@ -417,7 +417,8 @@ func TestRewriteSystemForNonClaudeCode(t *testing.T) { require.Contains(t, billingBlock["text"], "x-anthropic-billing-header:") require.Contains(t, billingBlock["text"], "cc_version=") require.Contains(t, billingBlock["text"], "cc_entrypoint=cli") - require.Contains(t, billingBlock["text"], "cch=00000") + // 新版 CLI 已取消 cch=... 签名字段,注入的 billing block 不应再带 cch。 + require.NotContains(t, billingBlock["text"], "cch=") systemBlock, ok := systemArr[1].(map[string]any) require.True(t, ok) diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index bd579fb063..eea5bfaa2e 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -4444,7 +4444,7 @@ func rewriteSystemForNonClaudeCodeWithPromptBlocks(body []byte, system any, expa } // 2. 构造 system 数组,对齐真实 Claude Code CLI 的 3-block 形态: - // [0] billing attribution block(cc_version={cliVer}.{fp}; cc_entrypoint=cli; cch=00000;) + // [0] billing attribution block(cc_version={cliVer}.{fp}; cc_entrypoint=cli;) // [1] "You are Claude Code..." 身份前缀 block(默认不带 cache_control) // [2] 工具无关的通用提示词扩充 block(带 cache_control 作为稳定缓存断点) // @@ -4452,9 +4452,9 @@ func rewriteSystemForNonClaudeCodeWithPromptBlocks(body []byte, system any, expa // 区别于真实 CLI。这里注入 claudeCodeSystemPromptExpansion(中性段落)把形态做到 // 接近真实,同时不注入会污染被代理用户行为的工具专属指令。 // - // billing block 的 cch=00000 是占位符,会被 buildUpstreamRequest 里的 - // signBillingHeaderCCH 替换成 xxhash64 签名。缺失 billing block 的系统 payload - // 是 Anthropic 判定第三方的关键信号之一(真实 CLI 每个请求都带)。 + // 缺失 billing block 的系统 payload 是 Anthropic 判定第三方的关键信号之一 + // (真实 CLI 每个请求都带)。新版 CLI 已取消 cch=... 签名字段,故 block 不再注入 + // cch(见 buildBillingAttributionText)。 systemBlocks, blockErr := buildClaudeOAuthSystemPromptBlocksJSON(body, expansionPrompt, blocksConfig) if blockErr != nil { logger.LegacyPrintf("service.gateway", "Warning: failed to build configured Claude OAuth system blocks: %v", blockErr) @@ -6679,9 +6679,9 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex // OAuth账号:应用统一指纹和metadata重写(受设置开关控制) var fingerprint *Fingerprint - enableFP, enableMPT, enableCCH := true, false, false + enableFP, enableMPT := true, false if s.settingService != nil { - enableFP, enableMPT, enableCCH = s.settingService.GetGatewayForwardingSettings(ctx) + enableFP, enableMPT, _ = s.settingService.GetGatewayForwardingSettings(ctx) } if account.IsOAuth() && s.identityService != nil { // 1. 获取或创建指纹(包含随机生成的ClientID) @@ -6735,11 +6735,6 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex body = sanitized } - // CCH 签名:将 cch=00000 占位符替换为 xxHash64 签名(需在所有 body 修改之后) - if enableCCH { - body = signBillingHeaderCCH(body) - } - req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body)) if err != nil { return nil, nil, err @@ -10243,9 +10238,9 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con // OAuth 账号:应用统一指纹和重写 userID(受设置开关控制) // 如果启用了会话ID伪装,会在重写后替换 session 部分为固定值 - ctEnableFP, ctEnableMPT, ctEnableCCH := true, false, false + ctEnableFP, ctEnableMPT := true, false if s.settingService != nil { - ctEnableFP, ctEnableMPT, ctEnableCCH = s.settingService.GetGatewayForwardingSettings(ctx) + ctEnableFP, ctEnableMPT, _ = s.settingService.GetGatewayForwardingSettings(ctx) } var ctFingerprint *Fingerprint if account.IsOAuth() && s.identityService != nil { @@ -10280,9 +10275,6 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con body = sanitized } - if ctEnableCCH { - body = signBillingHeaderCCH(body) - } body = sanitizeCountTokensRequestBody(body) req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body)) diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index fc4f27b281..932647dd54 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -192,7 +192,7 @@ type SystemSettings struct { // Gateway forwarding behavior EnableFingerprintUnification bool // 是否统一 OAuth 账号的指纹头(默认 true) EnableMetadataPassthrough bool // 是否透传客户端原始 metadata(默认 false) - EnableCCHSigning bool // 是否对 billing header cch 进行签名(默认 false) + EnableCCHSigning bool // 已废弃 no-op:新版 CLI 取消 cch 签名后网关不再注入/签名 cch,开关无效果 EnableClaudeOAuthSystemPromptInjection bool // 是否对 Claude OAuth mimic 路径注入 Claude Code system blocks(默认 true) ClaudeOAuthSystemPrompt string // Claude OAuth mimic 路径注入的通用扩展 system prompt;空值使用内置默认 ClaudeOAuthSystemPromptBlocks string // Claude OAuth mimic 路径注入的 system blocks JSON 配置;空值使用内置默认