sub2api/backend/internal/service/gateway_attribution.go
win dab4142ab2
Some checks failed
CI / test (push) Failing after 11s
CI / golangci-lint (push) Failing after 8s
Security Scan / backend-security (push) Failing after 5s
Security Scan / frontend-security (push) Failing after 6s
feat: Claude Code 2.1.88 源码级指纹还原
基于 Claude Code 2.1.88 反编译源码,完成全面的反追踪指纹还原:

1. 版本升级 2.1.87 → 2.1.88(constants.go, identity_service.go, proxy.js)
2. 新增 6 个 beta header 常量(task-budgets, token-efficient-tools, structured-outputs, advisor, web-search)
3. 更新所有组合 beta header 字符串,加入 context-1m, redact-thinking, effort 等
4. 注入 x-anthropic-billing-header attribution block 到 system prompt 首位
   - 完整复刻 fingerprint 算法: SHA256(salt + msg[4,7,20] + version)[:3]
   - 正确省略 cch 字段(npm 版行为,非原生二进制)
5. X-Claude-Code-Session-Id: 有则同步,无则按 account 生成
6. x-client-request-id: 每请求自动生成 UUID
7. Bootstrap 预热: 模拟 GET /api/claude_cli/bootstrap(per-account, 1h cooldown)
8. 停止无条件剥离 temperature/tool_choice(与真实 CLI 行为一致)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 21:57:51 +08:00

183 lines
6.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package service
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"github.com/google/uuid"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
)
// Attribution block constants matching real Claude Code 2.1.88.
// Source: src/constants/system.ts + src/utils/fingerprint.ts
const (
// fingerprintSalt must match the hardcoded salt in the real CLI.
// Source: extracted/src/utils/fingerprint.ts:8
fingerprintSalt = "59cf53e54c78"
)
// computeAttributionFingerprint computes a 3-character hex fingerprint
// matching the algorithm in the real Claude Code CLI.
//
// Algorithm: SHA256(SALT + msg[4] + msg[7] + msg[20] + version)[:3]
// Source: extracted/src/utils/fingerprint.ts:50-63
func computeAttributionFingerprint(firstUserMessageText, cliVersion string) string {
indices := [3]int{4, 7, 20}
chars := make([]byte, 0, 3)
for _, i := range indices {
if i < len(firstUserMessageText) {
chars = append(chars, firstUserMessageText[i])
} else {
chars = append(chars, '0')
}
}
input := fmt.Sprintf("%s%s%s", fingerprintSalt, string(chars), cliVersion)
hash := sha256.Sum256([]byte(input))
return hex.EncodeToString(hash[:])[:3]
}
// extractFirstUserMessageText extracts text from the first user message in the body.
// Handles both string content and array content (text blocks).
func extractFirstUserMessageText(body []byte) string {
messages := gjson.GetBytes(body, "messages")
if !messages.Exists() || !messages.IsArray() {
return ""
}
var firstText string
messages.ForEach(func(_, msg gjson.Result) bool {
if msg.Get("role").String() != "user" {
return true // continue
}
content := msg.Get("content")
if content.Type == gjson.String {
firstText = content.String()
return false // break
}
if content.IsArray() {
content.ForEach(func(_, block gjson.Result) bool {
if block.Get("type").String() == "text" {
firstText = block.Get("text").String()
return false
}
return true
})
return false
}
return true
})
return firstText
}
// buildAttributionBlock builds the x-anthropic-billing-header attribution string
// that real Claude Code injects as the first system text block.
//
// Format: x-anthropic-billing-header: cc_version=<VERSION>.<fingerprint>; cc_entrypoint=cli; cch=00000;
// Source: extracted/src/constants/system.ts:73-95
func buildAttributionBlock(cliVersion, fingerprint string) string {
version := cliVersion + "." + fingerprint
// 注意cch 字段由 Bun 的 NATIVE_CLIENT_ATTESTATION 编译时 feature 控制。
// npm 安装版本(非原生二进制)此 feature 为 false所以不包含 cch 字段。
// 只有原生二进制安装Bun 打包)才会有 cch且其值会被 Bun 的 Zig 层替换为真实 hash。
// 我们模拟 npm 安装版本的行为:不包含 cch。
return fmt.Sprintf("x-anthropic-billing-header: cc_version=%s; cc_entrypoint=cli;", version)
}
// injectAttributionBlock prepends the x-anthropic-billing-header attribution block
// as the very first system text block in the request body.
// This must come BEFORE the "You are Claude Code" block.
//
// The real CLI injects this as system[0] with cache_control: {type: "ephemeral"}.
func injectAttributionBlock(body []byte, cliVersion string) []byte {
// Compute fingerprint from the first user message
firstMsgText := extractFirstUserMessageText(body)
fingerprint := computeAttributionFingerprint(firstMsgText, cliVersion)
attribution := buildAttributionBlock(cliVersion, fingerprint)
// Build the attribution text block as JSON
attrBlock, err := marshalAnthropicSystemTextBlock(attribution, true)
if err != nil {
logger.LegacyPrintf("service.gateway", "Warning: failed to build attribution block: %v", err)
return body
}
systemResult := gjson.GetBytes(body, "system")
// Handle the different system formats
switch {
case !systemResult.Exists() || systemResult.Type == gjson.Null:
// No system field — inject just the attribution block
newBody, err := sjson.SetRawBytes(body, "system", buildJSONArrayRaw([][]byte{attrBlock}))
if err != nil {
return body
}
return newBody
case systemResult.Type == gjson.String:
// String system — convert to array: [attribution, original]
origBlock, err := marshalAnthropicSystemTextBlock(systemResult.String(), false)
if err != nil {
return body
}
newBody, setErr := sjson.SetRawBytes(body, "system", buildJSONArrayRaw([][]byte{attrBlock, origBlock}))
if setErr != nil {
return body
}
return newBody
case systemResult.IsArray():
// Array system — check if attribution already exists, prepend if not
var items [][]byte
alreadyHasAttribution := false
systemResult.ForEach(func(_, item gjson.Result) bool {
if item.Get("type").String() == "text" {
text := item.Get("text").String()
if len(text) > 30 && text[:30] == "x-anthropic-billing-header: cc" {
alreadyHasAttribution = true
}
}
return true
})
if alreadyHasAttribution {
return body
}
items = append(items, attrBlock)
systemResult.ForEach(func(_, item gjson.Result) bool {
items = append(items, []byte(item.Raw))
return true
})
newBody, setErr := sjson.SetRawBytes(body, "system", buildJSONArrayRaw(items))
if setErr != nil {
return body
}
return newBody
default:
return body
}
}
// generateSessionIDForAccount generates a deterministic per-account session UUID
// that remains stable within a process-like timeframe.
// Uses instanceSalt + accountID to ensure uniqueness across sub2api instances.
func generateSessionIDForAccount(instanceSalt string, accountID int64) string {
// Use a per-account stable UUID (like real CLI's per-process UUID).
// We use accountID as the base — each account gets a different "session".
seed := fmt.Sprintf("session:%s:%d", instanceSalt, accountID)
hash := sha256.Sum256([]byte(seed))
sessionUUID, err := uuid.FromBytes(hash[:16])
if err != nil {
return uuid.New().String()
}
// Set UUID v4 variant
sessionUUID[6] = (sessionUUID[6] & 0x0f) | 0x40
sessionUUID[8] = (sessionUUID[8] & 0x3f) | 0x80
return sessionUUID.String()
}