package service import ( "crypto/sha256" "encoding/hex" "fmt" "regexp" "sync" "time" "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.89. // 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=.; cc_entrypoint=cli; cch=00000; // Source: extracted/src/constants/system.ts:73-95 func buildAttributionBlock(cliVersion, fingerprint string) string { version := cliVersion + "." + fingerprint // 2.1.89 起 cch=00000 出现在所有安装模式(含 npm 版),不再只限于原生二进制。 // 原生二进制由 Bun 的 Zig 层在运行时将 00000 替换为真实 attestation hash; // 普通安装版保持 00000 占位符不变。 return fmt.Sprintf("x-anthropic-billing-header: cc_version=%s; cc_entrypoint=cli; cch=00000;", 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 } } // cliSessionEntry holds a cached session UUID with an expiration time. type cliSessionEntry struct { id string expiresAt time.Time } // cliSessionCache stores per-account session UUIDs that rotate on a TTL. // Real CLI creates a new random UUID per process invocation; we approximate // this by rotating every 30-60 minutes (jittered per account). var ( cliSessionCache = make(map[int64]cliSessionEntry) cliSessionCacheMu sync.Mutex ) // sessionTTLBase is the base TTL for session ID rotation. const sessionTTLBase = 30 * time.Minute // generateSessionIDForAccount returns a per-account session UUID that rotates // periodically. Each account gets a random TTL jitter (0-30 min on top of // the 30 min base) so accounts don't all rotate simultaneously. func generateSessionIDForAccount(instanceSalt string, accountID int64) string { cliSessionCacheMu.Lock() defer cliSessionCacheMu.Unlock() now := time.Now() if entry, ok := cliSessionCache[accountID]; ok && now.Before(entry.expiresAt) { return entry.id } // Compute per-account jitter from a hash so the same account always gets // the same jitter within a process (avoids re-rolling on every rotation). jitterSeed := fmt.Sprintf("jitter:%s:%d", instanceSalt, accountID) h := sha256.Sum256([]byte(jitterSeed)) jitterMinutes := int(h[0]) % 31 // 0-30 minutes ttl := sessionTTLBase + time.Duration(jitterMinutes)*time.Minute newID := uuid.New().String() cliSessionCache[accountID] = cliSessionEntry{ id: newID, expiresAt: now.Add(ttl), } return newID } // reUserHome matches /Users// or /home// path segments. // Captures the prefix (/Users/ or /home/) so we can preserve it while replacing the username. var reUserHome = regexp.MustCompile(`(/(Users|home)/)[^/\s"']+/`) // reEnvLine matches lines of the form "Key: value" for the environment block // fields injected by Claude Code's CLAUDE.md / sysprompt machinery. var reEnvLine = regexp.MustCompile(`(?m)^(Platform|Shell|OS Version|Working directory):.*$`) // canonicalEnvValues maps environment block keys to their canonical replacements. // Values mirror cc-gateway's prompt_env config and represent a stock macOS dev machine. var canonicalEnvValues = map[string]string{ "Platform": "Platform: darwin", "Shell": "Shell: zsh", "OS Version": "OS Version: Darwin 24.4.0", "Working directory": "Working directory: /Users/user/project", } // NormalizeSystemPromptEnv rewrites environment-specific fields in a system // prompt text block to canonical values, preventing real machine fingerprinting. // // Handles two classes of leakage (matching cc-gateway rewriter.ts:rewritePromptText): // 1. "Platform: Windows / Linux / Darwin 25.x" → canonical darwin/zsh/Darwin 24.4.0 // 2. "/Users/alice/" or "/home/bob/" → "/Users/user/" // // Only called on system prompt text blocks, never on user message content. func NormalizeSystemPromptEnv(text string) string { // Replace env-info lines with canonical values text = reEnvLine.ReplaceAllStringFunc(text, func(line string) string { for key, canonical := range canonicalEnvValues { if len(line) >= len(key) && line[:len(key)] == key { return canonical } } return line }) // Redact real usernames in home directory paths // e.g. /Users/alice/project -> /Users/user/project text = reUserHome.ReplaceAllString(text, "${1}user/") return text }