feat(identity): Realistic fingerprint diversity & persistent device ID #1415
feat(identity): Realistic fingerprint diversity & persistent device ID #1415StarryKira wants to merge 3 commits intoWei-Shaw:mainfrom
Conversation
…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>
There was a problem hiding this comment.
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
IdentityServicefingerprint creation to restore/persistdevice_idviaAccount.Extra, and adjusted DI wiring accordingly. - Added an async “startup probe” request when a
device_idis 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))]+"...") |
There was a problem hiding this comment.
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).
| 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) | |
| } |
| // 3. 全新生成(并持久化到 DB + Redis) | ||
| // | ||
| // account 参数用于读取 Extra 中持久化的 device_id 和 fingerprint_profile_index。 | ||
| // 如果 account 为 nil,行为与旧版本一致(仅依赖 Redis 缓存)。 |
There was a problem hiding this comment.
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.
| // 如果 account 为 nil,行为与旧版本一致(仅依赖 Redis 缓存)。 | |
| // 如果 account 为 nil,则不会从 Account.Extra 读取已有信息,只依赖 Redis 和当前请求头; | |
| // 在 Redis 未命中且配置了 extraUpdater 时,仍可能将新的 device_id 持久化到 DB。 |
|
|
||
| // 保存到缓存(7天TTL,每24小时自动续期) | ||
| if err := s.cache.SetFingerprint(ctx, accountID, fp); err != nil { | ||
| s.persistDeviceID(ctx, accountID, newFP.ClientID, selectedProfileIndex, account) |
There was a problem hiding this comment.
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.
| 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) |
| } | ||
|
|
||
| // RealisticProfiles 基于真实 Claude Code 用户环境的指纹模板池。 | ||
| // 权重按真实用户分布:macOS arm64 最多,其次 Linux x64,再次 macOS x64 等。 |
There was a problem hiding this comment.
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.
| // 权重按真实用户分布:macOS arm64 最多,其次 Linux x64,再次 macOS x64 等。 | |
| // 覆盖常见的用户环境分布:macOS arm64、Linux x64、macOS x64 等。 |
| // 3. 首次 device_id 生成时,异步发送 Claude Code 启动探测 | ||
| if isNewDevice { | ||
| go s.simulateStartupProbe(context.Background(), account, fp) | ||
| } |
There was a problem hiding this comment.
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.
| } | ||
| } | ||
|
|
||
| resp, err := s.httpUpstream.Do(req, "", account.ID, account.Concurrency) |
There was a problem hiding this comment.
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.
| 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) |
| 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) |
There was a problem hiding this comment.
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.
| 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) |
| // 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 { |
There was a problem hiding this comment.
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.
…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
根据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从仅存 Redis(7 天 TTL,flush 即丢失)改为持久化到Account.Extra(DB 级别),确保同一账号的设备标识永不变化。支持管理员通过 Admin API手动导入真实 Claude Code 安装的
device_iddevice_id时,异步发送max_tokens=1 + haiku非流式请求,模拟真实 Claude Code 的启动连通性检查,在 Anthropic 服务端建立 device_id 与 OAuthtoken 的关联
Changes
claude/constants.goFingerprintProfile、RealisticProfiles(5 个模板)、SelectProfileForAccount();DefaultHeaders版本号同步到2.1.81identity_service.goGetOrCreateFingerprint改为三级优先级(Redis → DB Extra → 新生成);createFingerprintFromHeaders使用按账号选择的多样化 profile;新增persistDeviceID写回 DBgateway_service.gosimulateStartupProbe(复用ApplyFingerprint+DefaultHeaders);3 处调用点适配新签名wire.go/wire_gen.goProvideIdentityService注入AccountRepository作为AccountExtraUpdaterconstants_test.go之前 vs 之后
DefaultHeaders版本2.1.22/Linux2.1.81/MacOS(与抓包一致)Test plan
go build ./...编译通过go test -tags=unit ./internal/pkg/claude/...— profile 选择测试通过go test -tags=unit ./internal/service/...— identity service 测试通过device_id和fingerprint_profile_index写入 Account.Extra🤖 Generated with Claude Code