Skip to content

feat(identity): Realistic fingerprint diversity & persistent device ID #1415

Open
StarryKira wants to merge 3 commits intoWei-Shaw:mainfrom
StarryKira:feat/realistic-fingerprint-management
Open

feat(identity): Realistic fingerprint diversity & persistent device ID #1415
StarryKira wants to merge 3 commits intoWei-Shaw:mainfrom
StarryKira:feat/realistic-fingerprint-management

Conversation

@StarryKira
Copy link
Copy Markdown
Contributor

@StarryKira StarryKira commented Mar 31, 2026

根据Claude Code 泄漏的2.1.88源代码正向开发

Summary

  • 多样化指纹模板池:替换原来所有账号共用的单一硬编码指纹(Linux arm64, cli/2.1.22),新增 5 种真实 Claude Code 用户环境 profile(macOS arm64/x64, Linux x64/arm64,
    Windows x64),每个账号基于 SHA256(accountID) 确定性选择,不同账号呈现不同的 OS/Arch/CLI 版本/SDK 版本组合
  • Device ID 持久化device_id 从仅存 Redis(7 天 TTL,flush 即丢失)改为持久化到 Account.Extra(DB 级别),确保同一账号的设备标识永不变化。支持管理员通过 Admin API
    手动导入真实 Claude Code 安装的 device_id
  • 启动探测模拟:首次生成 device_id 时,异步发送 max_tokens=1 + haiku 非流式请求,模拟真实 Claude Code 的启动连通性检查,在 Anthropic 服务端建立 device_id 与 OAuth
    token 的关联

Changes

文件 修改
claude/constants.go 新增 FingerprintProfileRealisticProfiles(5 个模板)、SelectProfileForAccount()DefaultHeaders 版本号同步到 2.1.81
identity_service.go GetOrCreateFingerprint 改为三级优先级(Redis → DB Extra → 新生成);createFingerprintFromHeaders 使用按账号选择的多样化 profile;新增
persistDeviceID 写回 DB
gateway_service.go 新增 simulateStartupProbe(复用 ApplyFingerprint + DefaultHeaders);3 处调用点适配新签名
wire.go / wire_gen.go ProvideIdentityService 注入 AccountRepository 作为 AccountExtraUpdater
constants_test.go 新增:profile 选择确定性、多样性、锁定索引、输出有效性测试

之前 vs 之后

维度 之前 之后
指纹多样性 所有账号同一指纹 5 种 OS/Arch 组合 × 多版本变体
Device ID 持久性 Redis 7 天 TTL DB 持久化(永不丢失)
Device ID 来源 随机生成 支持导入真实值 / 自动生成
启动行为 无探测 首次自动发送 haiku 探测
DefaultHeaders 版本 2.1.22 / Linux 2.1.81 / MacOS(与抓包一致)

Test plan

  • go build ./... 编译通过
  • go test -tags=unit ./internal/pkg/claude/... — profile 选择测试通过
  • go test -tags=unit ./internal/service/... — identity service 测试通过
  • 手动验证:创建新 OAuth 账号 → 确认 device_idfingerprint_profile_index 写入 Account.Extra
  • 手动验证:Redis flush 后请求 → 确认 device_id 从 DB 恢复而非重新生成
  • 手动验证:不同账号的上游请求 headers 呈现不同的 OS/Arch/版本组合

🤖 Generated with Claude Code

…tence

Replace the single hardcoded fingerprint (Linux arm64, cli/2.1.22) with a
pool of 5 realistic profiles (macOS arm64/x64, Linux x64/arm64, Windows x64)
selected deterministically per account via SHA256(accountID).

Device IDs are now persisted to Account.Extra in the database instead of
relying solely on Redis 7-day TTL, preventing identity changes on cache flush.
Admins can also import real device_id values from actual Claude Code installs.

On first device_id generation, an async startup probe (max_tokens=1 + haiku)
is sent to Anthropic to simulate Claude Code's connectivity check, associating
the device_id with the OAuth token on the server side.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 31, 2026 16:23
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR enhances the Claude OAuth “identity” layer by making client fingerprints more realistic per account and by persisting a stable device_id beyond Redis, with an added one-time startup probe to better mimic Claude Code behavior.

Changes:

  • Added a deterministic “realistic” fingerprint profile pool (OS/arch/CLI/SDK/runtime versions) and selection logic keyed by accountID.
  • Updated IdentityService fingerprint creation to restore/persist device_id via Account.Extra, and adjusted DI wiring accordingly.
  • Added an async “startup probe” request when a device_id is first generated.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
backend/internal/service/wire.go Adds ProvideIdentityService provider and updates Wire set to inject AccountRepository.
backend/internal/service/identity_service.go Implements Redis→DB Extra→generate flow; adds DB persistence for device_id and profile index; uses per-account profile selection.
backend/internal/service/identity_service_order_test.go Updates constructor calls for new NewIdentityService signature.
backend/internal/service/gateway_service.go Updates fingerprint call sites and adds async simulateStartupProbe request logic.
backend/internal/pkg/claude/constants.go Updates default headers, adds fingerprint profile pool + deterministic selector.
backend/internal/pkg/claude/constants_test.go Adds tests for deterministic selection / diversity / locked index / output validity.
backend/cmd/server/wire_gen.go Switches app wiring to use ProvideIdentityService.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

if persistedDeviceID != "" {
newFP.ClientID = persistedDeviceID
isNewDevice = false
logger.LegacyPrintf("service.identity", "Restored device_id from DB for account %d: %s", accountID, persistedDeviceID[:min(16, len(persistedDeviceID))]+"...")
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When Redis is a miss and device_id is restored from Account.Extra, fingerprint_profile_index is never written back if it was missing (e.g. admin-imported device_id). That means the selected profile can silently change later if RealisticProfiles is reordered/expanded. Consider persisting the computed selectedProfileIndex whenever the DB has device_id but no fingerprint_profile_index (and extraUpdater is available).

Suggested change
logger.LegacyPrintf("service.identity", "Restored device_id from DB for account %d: %s", accountID, persistedDeviceID[:min(16, len(persistedDeviceID))]+"...")
logger.LegacyPrintf("service.identity", "Restored device_id from DB for account %d: %s", accountID, persistedDeviceID[:min(16, len(persistedDeviceID))]+"...")
// If the account already has a device_id but no persisted fingerprint_profile_index,
// persist the computed selectedProfileIndex so future cache misses remain stable.
if profileIndex == -1 && account != nil {
s.persistDeviceID(ctx, accountID, newFP.ClientID, selectedProfileIndex, account)
}

Copilot uses AI. Check for mistakes.
// 3. 全新生成(并持久化到 DB + Redis)
//
// account 参数用于读取 Extra 中持久化的 device_id 和 fingerprint_profile_index。
// 如果 account 为 nil,行为与旧版本一致(仅依赖 Redis 缓存)。
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring says passing account=nil keeps the old behavior (only Redis), but on a Redis miss this code still generates a new device_id and calls persistDeviceID (writing to DB if extraUpdater is configured). Either update the comment to match the behavior, or gate DB persistence on account != nil so account=nil truly stays cache-only.

Suggested change
// 如果 account 为 nil,行为与旧版本一致(仅依赖 Redis 缓存)。
// 如果 account 为 nil,则不会从 Account.Extra 读取已有信息,只依赖 Redis 和当前请求头;
// 在 Redis 未命中且配置了 extraUpdater 时,仍可能将新的 device_id 持久化到 DB。

Copilot uses AI. Check for mistakes.

// 保存到缓存(7天TTL,每24小时自动续期)
if err := s.cache.SetFingerprint(ctx, accountID, fp); err != nil {
s.persistDeviceID(ctx, accountID, newFP.ClientID, selectedProfileIndex, account)
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

persistDeviceID is invoked with the request ctx. If the client disconnects / context is canceled, the DB write can fail and the account may generate a new device_id on the next cache miss (breaking the intended stability). Consider persisting with a small context.WithTimeout(context.Background(), ...) (or otherwise decouple persistence from the request context) and handling any error deterministically.

Suggested change
s.persistDeviceID(ctx, accountID, newFP.ClientID, selectedProfileIndex, account)
persistCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
s.persistDeviceID(persistCtx, accountID, newFP.ClientID, selectedProfileIndex, account)

Copilot uses AI. Check for mistakes.
}

// RealisticProfiles 基于真实 Claude Code 用户环境的指纹模板池。
// 权重按真实用户分布:macOS arm64 最多,其次 Linux x64,再次 macOS x64 等。
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment says the profile pool is "weighted" by real user distribution, but SelectProfileForAccount currently selects seed % len(RealisticProfiles) (uniform). Either implement weighted selection or adjust the comment to avoid misleading future maintainers.

Suggested change
// 权重按真实用户分布:macOS arm64 最多,其次 Linux x64,再次 macOS x64 等。
// 覆盖常见的用户环境分布:macOS arm64Linux x64macOS x64 等。

Copilot uses AI. Check for mistakes.
Comment on lines +5745 to +5748
// 3. 首次 device_id 生成时,异步发送 Claude Code 启动探测
if isNewDevice {
go s.simulateStartupProbe(context.Background(), account, fp)
}
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spawning a goroutine per new device with context.Background() has no deadline and no backpressure; under load this can create unbounded goroutines and hung outbound requests. Consider using a bounded worker/queue (or per-account dedupe) and context.WithTimeout for the probe request.

Copilot uses AI. Check for mistakes.
}
}

resp, err := s.httpUpstream.Do(req, "", account.ID, account.Concurrency)
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The startup probe bypasses the normal forwarding path by calling httpUpstream.Do(req, "", ...) (no proxyURL) and not using DoWithTLS / resolved TLS fingerprint profile. This can fail in environments that require proxies and can behave differently from normal upstream requests; consider reusing the same proxyURL + TLS profile selection used in Forward/buildUpstreamRequest.

Suggested change
resp, err := s.httpUpstream.Do(req, "", account.ID, account.Concurrency)
// Respect proxy configuration for the startup probe to match normal upstream requests.
proxyURL := os.Getenv("HTTPS_PROXY")
if proxyURL == "" {
proxyURL = os.Getenv("HTTP_PROXY")
}
resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency)

Copilot uses AI. Check for mistakes.
version := ExtractCLIVersion(fp.UserAgent)
metadataUserID := FormatMetadataUserID(fp.ClientID, accountUUID, sessionID, version)

probeBody := fmt.Sprintf(`{"model":"claude-haiku-4-5-20251001","max_tokens":1,"messages":[{"role":"user","content":"hi"}],"metadata":{"user_id":%q}}`, metadataUserID)
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The probe hardcodes the Haiku model ID string. To avoid drift with the rest of the gateway’s model normalization/override logic, consider referencing the existing model mapping helpers/constants (e.g. claude.NormalizeModelID("claude-haiku-4-5") or a dedicated constant) instead of an inline literal.

Suggested change
probeBody := fmt.Sprintf(`{"model":"claude-haiku-4-5-20251001","max_tokens":1,"messages":[{"role":"user","content":"hi"}],"metadata":{"user_id":%q}}`, metadataUserID)
modelID := claude.NormalizeModelID("claude-haiku-4-5")
probeBody := fmt.Sprintf(`{"model":%q,"max_tokens":1,"messages":[{"role":"user","content":"hi"}],"metadata":{"user_id":%q}}`, modelID, metadataUserID)

Copilot uses AI. Check for mistakes.
Comment on lines +6252 to +6256
// simulateStartupProbe 模拟 Claude Code 启动时的连通性探测请求。
// 真实 Claude Code 在启动时发送 max_tokens=1 + haiku 的非流式请求来验证 API 连接。
// 此方法在新 device_id 首次生成后异步调用,让 Anthropic 服务端关联 device_id 与 OAuth token。
func (s *GatewayService) simulateStartupProbe(ctx context.Context, account *Account, fp *Fingerprint) {
if account == nil || fp == nil {
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

simulateStartupProbe introduces new outbound behavior (extra upstream request on first device_id) but there doesn’t appear to be any test coverage exercising it (no references found outside this file). Consider adding a unit test with an HTTPUpstream stub to assert it is triggered only when isNewDevice is true and that the probe request has the expected URL/headers/body.

Copilot uses AI. Check for mistakes.
StarryKira and others added 2 commits April 1, 2026 00:31
…tence

Replace the single hardcoded fingerprint (Linux arm64, cli/2.1.22) with a
pool of 5 realistic profiles (macOS arm64/x64, Linux x64/arm64, Windows x64)
selected deterministically per account via SHA256(accountID).

Device IDs are now persisted to Account.Extra in the database instead of
relying solely on Redis 7-day TTL, preventing identity changes on cache flush.
Admins can also import real device_id values from actual Claude Code installs.

On first device_id generation, an async startup probe (max_tokens=1 + haiku)
is sent to Anthropic to simulate Claude Code's connectivity check, associating
the device_id with the OAuth token on the server side.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ement' into feat/realistic-fingerprint-management

# Conflicts:
#	backend/internal/service/gateway_service.go
#	backend/internal/service/identity_service.go
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants