mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
fix(tlsfingerprint): realign TLS+headers to Claude Code 2.1.114 baseline
Merge hai/snapshot fingerprint update into release branch, preserving our architectural optimizations (SOCKS5 ContextDialer, identity_service refactoring, slog migration, capture_fingerprint multi-file structure). TLS defaults: 52→17 ciphers, 5→3 curves (drop MLKEM768/P521), 2→1 key share (X25519 only), 3→1 point format, 26→9 sig algs. New probabilistic ECH GREASE + padding (~50% per handshake). Extension order realigned: server_name first, encrypt_then_mac removed, status_request/SCT added. Headers: UA 2.1.114, Runtime v24.3.0 (bundled Node), Timeout 600. New BetaStructuredOutputs20251215 for Haiku title-sidecar requests.
This commit is contained in:
@@ -1 +1 @@
|
||||
0.1.114.13
|
||||
0.1.114.14
|
||||
|
||||
@@ -18,6 +18,10 @@ const (
|
||||
BetaContextManagement20250627 = "context-management-2025-06-27"
|
||||
BetaPromptCachingScope20260105 = "prompt-caching-scope-2026-01-05"
|
||||
BetaAdvisorTool20260301 = "advisor-tool-2026-03-01"
|
||||
// Added 2026-04-19 from 2.1.114 haiku title-sidecar capture — haiku
|
||||
// background requests (title generation) carry this but the main sonnet
|
||||
// request does not.
|
||||
BetaStructuredOutputs20251215 = "structured-outputs-2025-12-15"
|
||||
)
|
||||
|
||||
// DroppedBetas 是转发时需要从 anthropic-beta header 中移除的 beta token 列表。
|
||||
@@ -52,13 +56,17 @@ const MessageBetaHeaderWithTools = BetaClaudeCode + "," + BetaOAuth + "," + Beta
|
||||
// CountTokensBetaHeader count_tokens 请求使用的 anthropic-beta header
|
||||
const CountTokensBetaHeader = BetaClaudeCode + "," + BetaOAuth + "," + BetaInterleavedThinking + "," + BetaTokenCounting + clientExtraBetas
|
||||
|
||||
// HaikuBetaHeader Haiku 模型使用的 anthropic-beta header(不需要 claude-code beta)
|
||||
const HaikuBetaHeader = BetaOAuth + "," + BetaInterleavedThinking
|
||||
// HaikuBetaHeader Haiku 模型(OAuth)使用的 anthropic-beta header.
|
||||
//
|
||||
// Captured 2026-04-19 from Claude Code 2.1.114 title-generation sidecar
|
||||
// request (api-key mode). Matches the variant exactly except for the oauth
|
||||
// token, which is prepended here for OAuth-credential accounts.
|
||||
const HaikuBetaHeader = BetaOAuth + "," + BetaInterleavedThinking + clientExtraBetas + "," + BetaStructuredOutputs20251215
|
||||
|
||||
// APIKeyBetaHeader API-key 账号使用的 anthropic-beta header.
|
||||
//
|
||||
// Exactly matches the real Claude Code 2.1.111 capture against x-api-key auth
|
||||
// (2.1.112 is a patch bump with no observed beta-token change):
|
||||
// Exactly matches Claude Code 2.1.114 main /v1/messages request against
|
||||
// x-api-key auth (captured 2026-04-19):
|
||||
//
|
||||
// claude-code-20250219,
|
||||
// interleaved-thinking-2025-05-14,
|
||||
@@ -70,30 +78,32 @@ const HaikuBetaHeader = BetaOAuth + "," + BetaInterleavedThinking
|
||||
// (beta is auth-type conditional; non-OAuth requests never carry it).
|
||||
const APIKeyBetaHeader = BetaClaudeCode + "," + BetaInterleavedThinking + clientExtraBetas
|
||||
|
||||
// APIKeyHaikuBetaHeader Haiku 模型在 API-key 账号下使用的 anthropic-beta header(不包含 oauth / claude-code)
|
||||
const APIKeyHaikuBetaHeader = BetaInterleavedThinking
|
||||
// APIKeyHaikuBetaHeader Haiku 模型在 API-key 账号下的 anthropic-beta header.
|
||||
//
|
||||
// Exact match to Claude Code 2.1.114 title-generation sidecar request captured
|
||||
// 2026-04-19: interleaved-thinking, context-management, prompt-caching-scope,
|
||||
// advisor-tool, structured-outputs. No claude-code and no oauth.
|
||||
const APIKeyHaikuBetaHeader = BetaInterleavedThinking + clientExtraBetas + "," + BetaStructuredOutputs20251215
|
||||
|
||||
// DefaultHeaders 是 Claude Code 客户端默认请求头。
|
||||
//
|
||||
// Values re-verified 2026-04-17 from a live capture of Claude Code 2.1.111 on
|
||||
// Node.js 24.14.1 / macOS arm64 (backend/tools/capture_fingerprint). UA bumped
|
||||
// to 2.1.112 (latest on npm as of 2026-04-17) — patch-level Claude Code releases
|
||||
// do not change Stainless package/runtime fields or the timeout. Bundled
|
||||
// @anthropic-ai/sdk is still 0.81.0 — unchanged since the 2.1.109 capture.
|
||||
// Keep these in sync with recent Claude CLI traffic to reduce the chance that
|
||||
// Claude Code-scoped OAuth credentials are rejected as "non-CLI" usage.
|
||||
// Re-verified 2026-04-19 from a live capture of Claude Code 2.1.114 on
|
||||
// macOS arm64 (backend/tools/capture_fingerprint). Critical: CC 2.1.114
|
||||
// bundles its own Node 24.3.0 runtime; the host Node version is NOT what
|
||||
// gets advertised in the X-Stainless-Runtime-Version header. Bundled
|
||||
// @anthropic-ai/sdk is 0.81.0 — unchanged since the 2.1.109 capture.
|
||||
// Keep these in sync with recent Claude CLI traffic to reduce the chance
|
||||
// that Claude Code-scoped OAuth credentials are rejected as "non-CLI" usage.
|
||||
var DefaultHeaders = map[string]string{
|
||||
"User-Agent": "claude-cli/2.1.112 (external, sdk-cli)",
|
||||
"X-Stainless-Lang": "js",
|
||||
"X-Stainless-Package-Version": "0.81.0",
|
||||
"X-Stainless-OS": "MacOS",
|
||||
"X-Stainless-Arch": "arm64",
|
||||
"X-Stainless-Runtime": "node",
|
||||
"X-Stainless-Runtime-Version": "v24.14.1",
|
||||
// 2.1.111 capture shows timeout=300; 2.1.109 was 600. Matches the
|
||||
// ~5-minute default @anthropic-ai/sdk recently moved to.
|
||||
"X-Stainless-Timeout": "300",
|
||||
"X-App": "cli",
|
||||
"User-Agent": "claude-cli/2.1.114 (external, sdk-cli)",
|
||||
"X-Stainless-Lang": "js",
|
||||
"X-Stainless-Package-Version": "0.81.0",
|
||||
"X-Stainless-OS": "MacOS",
|
||||
"X-Stainless-Arch": "arm64",
|
||||
"X-Stainless-Runtime": "node",
|
||||
"X-Stainless-Runtime-Version": "v24.3.0",
|
||||
"X-Stainless-Timeout": "600",
|
||||
"X-App": "cli",
|
||||
"Anthropic-Dangerous-Direct-Browser-Access": "true",
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
mrand "math/rand/v2"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -52,179 +53,114 @@ type SOCKS5ProxyDialer struct {
|
||||
proxyURL *url.URL
|
||||
}
|
||||
|
||||
// Default TLS fingerprint values captured from Claude Code 2.1.109 on
|
||||
// Node.js 24.14.1 / macOS arm64, pointed at a local capture server via
|
||||
// `ANTHROPIC_BASE_URL`. Capture tool source at backend/tools/capture_fingerprint.
|
||||
// Default TLS fingerprint values captured from Claude Code 2.1.114 on
|
||||
// macOS arm64 (bundled Node 24.3.0), pointed at a local capture server via
|
||||
// `ANTHROPIC_BASE_URL`. Capture tool: backend/tools/capture_fingerprint.
|
||||
// Baseline JSON: backend/tools/capture_fingerprint/baselines/claude-code-2.1.114.json.
|
||||
//
|
||||
// Capture date: 2026-04-15
|
||||
// JA3 string:
|
||||
// Capture date: 2026-04-19 (30 CC requests across 5 invocation modes)
|
||||
//
|
||||
// 771,4866-4867-4865-49199-49195-49200-49196-158-49191-103-49192-107-163-159-
|
||||
// 52393-52392-52394-49325-49311-49245-49249-49239-49235-162-49324-49310-49244-
|
||||
// 49248-49238-49234-49188-106-49187-64-49162-49172-57-56-49161-49171-51-50-
|
||||
// 157-49309-49233-156-49308-49232-61-60-53-47,
|
||||
// 65281-0-11-10-35-16-22-23-13-43-45-51,
|
||||
// 29-23-30-24-25-256-257,0-1-2
|
||||
// JA3 string (without ECH GREASE, majority variant):
|
||||
//
|
||||
// JA3 hash: d67b094811e5145139d7cea5f014309f
|
||||
// JA4: t13d5212h1 (part-a prefix — 52 ciphers, 12 extensions, http/1.1 ALPN)
|
||||
// 771,4865-4866-4867-49195-49199-49196-49200-52393-52392-49161-49171-49162-
|
||||
// 49172-156-157-47-53,
|
||||
// 0-23-65281-10-11-35-16-5-13-18-51-45-43,
|
||||
// 29-23-24,0-1-2
|
||||
//
|
||||
// Critical findings from the capture that influenced these defaults:
|
||||
// - Real Claude Code 2.1.109 advertises ONLY `http/1.1` in ALPN — it does
|
||||
// NOT offer `h2`. Earlier concerns about Go-vs-Node HTTP/2 SETTINGS
|
||||
// mismatch were moot because the real CLI does not use HTTP/2 on the
|
||||
// api.anthropic.com path at all.
|
||||
// - 52 cipher suites (not 17 — the previous hand-authored list was a
|
||||
// substantial undercount).
|
||||
// - 26 signature schemes, 8 supported groups (incl. 2 FFDHE + X448 + P521).
|
||||
// - Extension order is: renegotiation_info, server_name, ec_point_formats,
|
||||
// supported_groups, session_ticket, alpn, encrypt_then_mac(22),
|
||||
// extended_master_secret, signature_algorithms, supported_versions,
|
||||
// psk_key_exchange_modes, key_share. No ECH, no SCT, no status_request.
|
||||
// JA3 hash: dc782a9d905fdcee1223a3d4e8108bc6 (no ECH GREASE — ~67% of conns)
|
||||
// JA3 hash: d871d02cecbde59abbf8f4806134addf (with ECH GREASE — ~33% of conns)
|
||||
// JA4: t13d1713h1 / t13d1714h1 (17 ciphers, 13 or 14 extensions, http/1.1 ALPN)
|
||||
//
|
||||
// CRITICAL — this is the REVERSE of what the 2026-04-15 rewrite assumed.
|
||||
// Claude Code 2.1.112→2.1.114 simplified its TLS stack dramatically:
|
||||
// - Cipher list 52 → 17. Node dropped its "OpenSSL enable-all" suite
|
||||
// (ARIA/CCM/Brainpool/DHE legacy) in favor of a modern minimal set.
|
||||
// - Curves 8 → 3. MLKEM768 (post-quantum hybrid) is GONE. X25519 is the
|
||||
// single key share.
|
||||
// - Signature schemes 26 → 9. Brainpool TLS 1.3 and Ed25519/Ed448 gone.
|
||||
// - Extension count 12 → 13 (or 14 with ECH GREASE). encrypt_then_mac(22)
|
||||
// dropped. status_request(5), SCT(18) added. ECH GREASE appears in
|
||||
// ~33% of connections — probabilistic per-handshake.
|
||||
// - Extension order completely different — server_name is NOW first, not
|
||||
// renegotiation_info.
|
||||
//
|
||||
// If these defaults look "thin," that's the point: matching the real shape
|
||||
// means NOT overselling TLS capabilities.
|
||||
var (
|
||||
// defaultCipherSuites — 52 cipher suites in the order Node.js 24.14.1
|
||||
// OpenSSL sends them. Do NOT reorder: JA3 hash depends on order.
|
||||
// defaultCipherSuites — 17 cipher suites in the exact order Claude Code
|
||||
// 2.1.114 bundled Node 24.3.0 sends them. Do NOT reorder: JA3 hash
|
||||
// depends on order.
|
||||
defaultCipherSuites = []uint16{
|
||||
// TLS 1.3 (note: 1302 comes before 1303/1301)
|
||||
// TLS 1.3 (note: 1301 FIRST, unlike the old OpenSSL order)
|
||||
0x1301, // TLS_AES_128_GCM_SHA256
|
||||
0x1302, // TLS_AES_256_GCM_SHA384
|
||||
0x1303, // TLS_CHACHA20_POLY1305_SHA256
|
||||
0x1301, // TLS_AES_128_GCM_SHA256
|
||||
|
||||
// ECDHE + AES-GCM (RSA before ECDSA)
|
||||
0xc02f, // TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
|
||||
// ECDHE + AES-GCM (ECDSA before RSA, AES-128 before AES-256)
|
||||
0xc02b, // TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
|
||||
0xc030, // TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
|
||||
0xc02f, // TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
|
||||
0xc02c, // TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
|
||||
0xc030, // TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
|
||||
|
||||
// DHE_RSA + AES-GCM
|
||||
0x009e, // TLS_DHE_RSA_WITH_AES_128_GCM_SHA256
|
||||
|
||||
// ECDHE + AES-CBC-SHA256 (SHA256 HMAC variants)
|
||||
0xc027, // TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256
|
||||
0x0067, // TLS_DHE_RSA_WITH_AES_128_CBC_SHA256
|
||||
0xc028, // TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384
|
||||
0x006b, // TLS_DHE_RSA_WITH_AES_256_CBC_SHA256
|
||||
0x00a3, // TLS_DHE_DSS_WITH_AES_256_GCM_SHA384
|
||||
0x009f, // TLS_DHE_RSA_WITH_AES_256_GCM_SHA384
|
||||
|
||||
// ChaCha20-Poly1305 family (3 variants: ECDSA, RSA, DHE)
|
||||
// ChaCha20-Poly1305 (ECDSA before RSA)
|
||||
0xcca9, // TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256
|
||||
0xcca8, // TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256
|
||||
0xccaa, // TLS_DHE_RSA_WITH_CHACHA20_POLY1305_SHA256
|
||||
|
||||
// ECCPWD, camellia, ARIA (legacy PSK/ARIA from OpenSSL enable-all)
|
||||
0xc0ad, // TLS_ECDHE_ECDSA_WITH_AES_256_CCM
|
||||
0xc09f, // TLS_DHE_RSA_WITH_AES_256_CCM
|
||||
0xc05d, // TLS_ECDHE_ECDSA_WITH_ARIA_256_GCM_SHA384
|
||||
0xc061, // TLS_ECDHE_RSA_WITH_ARIA_256_GCM_SHA384
|
||||
0xc057, // TLS_DHE_DSS_WITH_ARIA_256_GCM_SHA384
|
||||
0xc053, // TLS_DHE_RSA_WITH_ARIA_256_GCM_SHA384
|
||||
0x00a2, // TLS_DHE_DSS_WITH_AES_128_GCM_SHA256
|
||||
0xc0ac, // TLS_ECDHE_ECDSA_WITH_AES_128_CCM
|
||||
0xc09e, // TLS_DHE_RSA_WITH_AES_128_CCM
|
||||
0xc05c, // TLS_ECDHE_ECDSA_WITH_ARIA_128_GCM_SHA256
|
||||
0xc060, // TLS_ECDHE_RSA_WITH_ARIA_128_GCM_SHA256
|
||||
0xc056, // TLS_DHE_DSS_WITH_ARIA_128_GCM_SHA256
|
||||
0xc052, // TLS_DHE_RSA_WITH_ARIA_128_GCM_SHA256
|
||||
|
||||
// ECDHE/DHE + CBC-SHA256/384 (legacy)
|
||||
0xc024, // TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384
|
||||
0x006a, // TLS_DHE_DSS_WITH_AES_256_CBC_SHA256
|
||||
0xc023, // TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256
|
||||
0x0040, // TLS_DHE_DSS_WITH_AES_128_CBC_SHA256
|
||||
|
||||
// ECDHE + AES-CBC-SHA (legacy — SHA1 HMAC)
|
||||
0xc00a, // TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA
|
||||
0xc014, // TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA
|
||||
0x0039, // TLS_DHE_RSA_WITH_AES_256_CBC_SHA
|
||||
0x0038, // TLS_DHE_DSS_WITH_AES_256_CBC_SHA
|
||||
// ECDHE + AES-CBC-SHA (legacy — SHA1 HMAC, kept for compat)
|
||||
0xc009, // TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA
|
||||
0xc013, // TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA
|
||||
0x0033, // TLS_DHE_RSA_WITH_AES_128_CBC_SHA
|
||||
0x0032, // TLS_DHE_DSS_WITH_AES_128_CBC_SHA
|
||||
0xc00a, // TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA
|
||||
0xc014, // TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA
|
||||
|
||||
// RSA key exchange + AES-GCM (non-PFS)
|
||||
0x009d, // TLS_RSA_WITH_AES_256_GCM_SHA384
|
||||
0xc09d, // TLS_RSA_WITH_AES_256_CCM
|
||||
0xc051, // TLS_RSA_WITH_ARIA_256_GCM_SHA384
|
||||
0x009c, // TLS_RSA_WITH_AES_128_GCM_SHA256
|
||||
0xc09c, // TLS_RSA_WITH_AES_128_CCM
|
||||
0xc050, // TLS_RSA_WITH_ARIA_128_GCM_SHA256
|
||||
|
||||
// RSA + AES-CBC-SHA256 (legacy)
|
||||
0x003d, // TLS_RSA_WITH_AES_256_CBC_SHA256
|
||||
0x003c, // TLS_RSA_WITH_AES_128_CBC_SHA256
|
||||
0x009d, // TLS_RSA_WITH_AES_256_GCM_SHA384
|
||||
|
||||
// RSA + AES-CBC-SHA (very legacy)
|
||||
0x0035, // TLS_RSA_WITH_AES_256_CBC_SHA
|
||||
0x002f, // TLS_RSA_WITH_AES_128_CBC_SHA
|
||||
0x0035, // TLS_RSA_WITH_AES_256_CBC_SHA
|
||||
}
|
||||
|
||||
// defaultCurves — supported groups we advertise.
|
||||
// defaultCurves — 3 supported groups Claude Code 2.1.114 advertises.
|
||||
//
|
||||
// Real Claude Code / Node.js 24 advertises 8 groups (incl. x448,
|
||||
// ffdhe2048, ffdhe3072) but utls's HelloRetryRequest path can only
|
||||
// regenerate key shares for curves in curveForCurveID (X25519, P256,
|
||||
// P384, P521). If we advertise a group utls can't handle and the
|
||||
// server picks it via HRR, handshake fails with
|
||||
// "tls: CurvePreferences includes unsupported curve".
|
||||
//
|
||||
// X25519MLKEM768 is kept because defaultKeyShareGroups always sends its
|
||||
// key share on the initial ClientHello, so servers never need to HRR
|
||||
// back to it. The randomizer must preserve this invariant.
|
||||
// The 2.1.109 era had 8 groups including post-quantum X25519MLKEM768.
|
||||
// 2.1.114 reverts to a minimal set of classical ECDHE curves only —
|
||||
// no PQ hybrid, no FFDHE, no P521, no x448. This matches what modern
|
||||
// undici (Node 24.x bundled fetch API) offers.
|
||||
defaultCurves = []utls.CurveID{
|
||||
utls.X25519MLKEM768, // 0x11ec — post-quantum hybrid
|
||||
utls.X25519, // 0x001d
|
||||
utls.CurveP256, // 0x0017 (secp256r1)
|
||||
utls.CurveP384, // 0x0018 (secp384r1)
|
||||
utls.CurveP521, // 0x0019 (secp521r1)
|
||||
utls.X25519, // 0x001d
|
||||
utls.CurveP256, // 0x0017 (secp256r1)
|
||||
utls.CurveP384, // 0x0018 (secp384r1)
|
||||
}
|
||||
|
||||
// defaultKeyShareGroups — X25519MLKEM768 + X25519, matching the real
|
||||
// Claude Code capture (the real CLI sends two key shares: a ~1216-byte
|
||||
// MLKEM payload and a 32-byte X25519 public key).
|
||||
// defaultKeyShareGroups — single X25519 key share. MLKEM768 is no
|
||||
// longer advertised as of 2.1.114 (see defaultCurves note).
|
||||
defaultKeyShareGroups = []utls.CurveID{
|
||||
utls.X25519MLKEM768,
|
||||
utls.X25519,
|
||||
}
|
||||
|
||||
// defaultPointFormats — 3 formats (uncompressed, ansiX962, compressed).
|
||||
// defaultPointFormats — Claude Code 2.1.114 advertises only
|
||||
// uncompressed. The 2.1.109 baseline listed all 3 classical formats,
|
||||
// but the bundled Node 24.3.0 TLS stack now sends just one.
|
||||
defaultPointFormats = []uint16{
|
||||
0, // uncompressed
|
||||
1, // ansiX962_compressed_prime
|
||||
2, // ansiX962_compressed_char2
|
||||
}
|
||||
|
||||
// defaultSignatureAlgorithms — 26 schemes Node.js 24.14.1 advertises,
|
||||
// in capture order. Includes Brainpool TLS 1.3 curves and several
|
||||
// legacy SHA1 entries.
|
||||
// defaultSignatureAlgorithms — 9 schemes Claude Code 2.1.114 advertises,
|
||||
// in capture order. Dramatically reduced from 2.1.109's 26-scheme list:
|
||||
// no Brainpool TLS 1.3, no Ed25519/Ed448, no experimental 0x0904/5/6,
|
||||
// no legacy DSA/SHA224 variants. Just modern RSA-PSS + ECDSA + legacy
|
||||
// RSA-PKCS1-SHA1 floor.
|
||||
defaultSignatureAlgorithms = []utls.SignatureScheme{
|
||||
0x0905, // experimental TLS 1.3 (Node.js OpenSSL)
|
||||
0x0906, // experimental TLS 1.3
|
||||
0x0904, // experimental TLS 1.3
|
||||
0x0403, // ecdsa_secp256r1_sha256
|
||||
0x0503, // ecdsa_secp384r1_sha384
|
||||
0x0603, // ecdsa_secp521r1_sha512
|
||||
0x0807, // ed25519
|
||||
0x0808, // ed448
|
||||
0x081a, // ecdsa_brainpoolP256r1tls13_sha256
|
||||
0x081b, // ecdsa_brainpoolP384r1tls13_sha384
|
||||
0x081c, // ecdsa_brainpoolP512r1tls13_sha512
|
||||
0x0809, // rsa_pss_pss_sha256
|
||||
0x080a, // rsa_pss_pss_sha384
|
||||
0x080b, // rsa_pss_pss_sha512
|
||||
0x0804, // rsa_pss_rsae_sha256
|
||||
0x0805, // rsa_pss_rsae_sha384
|
||||
0x0806, // rsa_pss_rsae_sha512
|
||||
0x0401, // rsa_pkcs1_sha256
|
||||
0x0503, // ecdsa_secp384r1_sha384
|
||||
0x0805, // rsa_pss_rsae_sha384
|
||||
0x0501, // rsa_pkcs1_sha384
|
||||
0x0806, // rsa_pss_rsae_sha512
|
||||
0x0601, // rsa_pkcs1_sha512
|
||||
0x0303, // SHA224-ECDSA (legacy)
|
||||
0x0301, // SHA224-RSA (legacy)
|
||||
0x0302, // SHA224-DSA (legacy)
|
||||
0x0402, // SHA256-DSA (legacy)
|
||||
0x0502, // SHA384-DSA (legacy)
|
||||
0x0602, // SHA512-DSA (legacy)
|
||||
0x0201, // rsa_pkcs1_sha1 (legacy floor)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -427,32 +363,67 @@ func toUTLSCurves(curves []uint16) []utls.CurveID {
|
||||
return result
|
||||
}
|
||||
|
||||
// defaultExtensionOrder — 12 extensions in the exact order Node.js 24.14.1
|
||||
// sends them (captured from live Claude Code 2.1.109).
|
||||
// defaultExtensionOrder — 13 base extensions in the exact order Claude Code
|
||||
// 2.1.114 (bundled Node 24.3.0) sends them when ECH/padding are OFF. Real
|
||||
// CC alternates between two variants (observed ratio ~55:45):
|
||||
//
|
||||
// Differences from the previous hand-authored list:
|
||||
// - Dropped: encrypted_client_hello (65037 / ECH) — real Node.js 24 does
|
||||
// NOT send ECH unless a server config is known.
|
||||
// - Dropped: status_request (5) and signed_certificate_timestamp (18) —
|
||||
// Node.js 24's default TLS extension set doesn't include them.
|
||||
// - Added: encrypt_then_mac (22) — RFC 7366, emitted by OpenSSL.
|
||||
// - Reordered: renegotiation_info is now FIRST; extended_master_secret
|
||||
// moved after encrypt_then_mac; ec_point_formats precedes supported_groups.
|
||||
// "Rich" variant (~55%): +ECH at position 1 AND +padding (21) at end.
|
||||
// 15 raw extensions, JA3 d871d02cecbde59abbf8f4806134addf,
|
||||
// JA4 t13d1714h1.
|
||||
// "Plain" variant (~45%): no ECH, no padding. 13 raw extensions,
|
||||
// JA3 dc782a9d905fdcee1223a3d4e8108bc6, JA4 t13d1713h1.
|
||||
//
|
||||
// ECH and padding appear together, not independently — probably because
|
||||
// both are emitted by the same OpenSSL "extended hello" code path in Node.
|
||||
//
|
||||
// Versus the 2026-04-15 list:
|
||||
// - server_name is FIRST (was #2 after renegotiation_info)
|
||||
// - extended_master_secret moved UP before renegotiation_info
|
||||
// - supported_groups now BEFORE ec_point_formats (was reversed)
|
||||
// - encrypt_then_mac (22) REMOVED
|
||||
// - status_request (5) and signed_certificate_timestamp (18) ADDED back
|
||||
// - key_share → psk_key_exchange_modes → supported_versions trailing triple
|
||||
//
|
||||
// Used when Profile.Extensions is empty.
|
||||
var defaultExtensionOrder = []uint16{
|
||||
65281, // renegotiation_info (0xff01)
|
||||
0, // server_name
|
||||
11, // ec_point_formats
|
||||
23, // extended_master_secret
|
||||
65281, // renegotiation_info (0xff01)
|
||||
10, // supported_groups
|
||||
11, // ec_point_formats
|
||||
35, // session_ticket
|
||||
16, // alpn
|
||||
22, // encrypt_then_mac (RFC 7366) — sent by OpenSSL/Node.js
|
||||
23, // extended_master_secret
|
||||
5, // status_request (OCSP)
|
||||
13, // signature_algorithms
|
||||
43, // supported_versions
|
||||
45, // psk_key_exchange_modes
|
||||
18, // signed_certificate_timestamp
|
||||
51, // key_share
|
||||
45, // psk_key_exchange_modes
|
||||
43, // supported_versions
|
||||
}
|
||||
|
||||
// echAndPaddingProbability is the empirically measured frequency of the
|
||||
// "rich" ClientHello variant (GREASE ECH + padding) in Claude Code 2.1.114
|
||||
// captures. Observed ratio: 22 of 40 first-round captures plus 14 of 30
|
||||
// follow-up captures = 36/70 ≈ 0.51. Per-connection independent roll.
|
||||
const echAndPaddingProbability = 0.50
|
||||
|
||||
// maybeEnrichExtensions returns extOrder unchanged (plain variant, ~50%) or
|
||||
// a copy with 0xfe0d inserted at index 1 AND 21 (padding) appended at end
|
||||
// (rich variant, ~50%). Called per-handshake so each connection rolls
|
||||
// independently — matches the per-connection randomness observed in real CC.
|
||||
func maybeEnrichExtensions(extOrder []uint16) []uint16 {
|
||||
if mrand.Float64() >= echAndPaddingProbability {
|
||||
return extOrder
|
||||
}
|
||||
if len(extOrder) < 1 || extOrder[0] != 0 {
|
||||
return extOrder
|
||||
}
|
||||
out := make([]uint16, 0, len(extOrder)+2)
|
||||
out = append(out, extOrder[0]) // server_name
|
||||
out = append(out, 0xfe0d) // encrypted_client_hello (GREASE)
|
||||
out = append(out, extOrder[1:]...) // 23, 65281, ..., 43
|
||||
out = append(out, 21) // padding (RFC 7685)
|
||||
return out
|
||||
}
|
||||
|
||||
// isGREASEValue checks if a uint16 value matches the TLS GREASE pattern (0x?a?a).
|
||||
@@ -522,9 +493,11 @@ func buildClientHelloSpecFromProfile(profile *Profile) *utls.ClientHelloSpec {
|
||||
}
|
||||
|
||||
// Determine extension order
|
||||
extOrder := defaultExtensionOrder
|
||||
var extOrder []uint16
|
||||
if profile != nil && len(profile.Extensions) > 0 {
|
||||
extOrder = profile.Extensions
|
||||
} else {
|
||||
extOrder = maybeEnrichExtensions(defaultExtensionOrder)
|
||||
}
|
||||
|
||||
// Build extensions list from the ordered IDs.
|
||||
@@ -567,11 +540,12 @@ func buildClientHelloSpecFromProfile(profile *Profile) *utls.ClientHelloSpec {
|
||||
// Send GREASE ECH with random payload — mimics Node.js behavior when no real ECHConfig is available.
|
||||
// An empty GenericExtension causes "error decoding message" from servers that validate ECH format.
|
||||
extensions = append(extensions, &utls.GREASEEncryptedClientHelloExtension{})
|
||||
case 21: // padding (RFC 7685) — BoringPaddingStyle pads CH to ≥512 bytes
|
||||
extensions = append(extensions, &utls.UtlsPaddingExtension{GetPaddingLen: utls.BoringPaddingStyle})
|
||||
case 0xff01: // renegotiation_info
|
||||
extensions = append(extensions, &utls.RenegotiationInfoExtension{})
|
||||
default:
|
||||
// Unknown extension — send as GenericExtension (type ID + empty data).
|
||||
// This covers encrypt_then_mac(22) and any future extensions.
|
||||
extensions = append(extensions, &utls.GenericExtension{Id: id})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ func TestDialerAgainstCaptureServer(t *testing.T) {
|
||||
}
|
||||
effectiveKeyShare := tc.profile.KeyShareGroups
|
||||
if len(effectiveKeyShare) == 0 {
|
||||
effectiveKeyShare = []uint16{4588, 29} // X25519MLKEM768 + X25519 (Claude Code CLI default)
|
||||
effectiveKeyShare = []uint16{29} // X25519 only (Claude Code 2.1.114 default)
|
||||
}
|
||||
effectivePSKModes := tc.profile.PSKModes
|
||||
if len(effectivePSKModes) == 0 {
|
||||
@@ -361,8 +361,8 @@ func TestBuildClientHelloSpecNewFields(t *testing.T) {
|
||||
t.Errorf("default versions: got %v, want 2 entries", e.Versions)
|
||||
}
|
||||
case *utls.KeyShareExtension:
|
||||
if len(e.KeyShares) != 2 {
|
||||
t.Errorf("default key shares: got %d, want 2", len(e.KeyShares))
|
||||
if len(e.KeyShares) != 1 {
|
||||
t.Errorf("default key shares: got %d, want 1", len(e.KeyShares))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,8 +40,8 @@ func skipIfExternalServiceUnavailable(t *testing.T, err error) {
|
||||
|
||||
// TestJA3Fingerprint verifies the JA3/JA4 fingerprint matches expected value.
|
||||
// This test uses tls.peet.ws to verify the fingerprint.
|
||||
// Expected JA3 hash: 44f88fca027f27bab4bb08d4af15f23e (Node.js 24.x)
|
||||
// Expected JA4: t13d1714h1_5b57614c22b0_7baf387fc6ff
|
||||
// Expected JA3 hash: 048900f5ae64cc2a49a44389a5406191 (Claude Code 2.1.114 baseline)
|
||||
// Expected JA4 cipher hash: b262b3658495
|
||||
func TestJA3Fingerprint(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
|
||||
@@ -163,10 +163,12 @@ func skipNetworkTest(t *testing.T) {
|
||||
|
||||
// TestDialerWithProfile tests that different profiles produce different fingerprints.
|
||||
func TestDialerWithProfile(t *testing.T) {
|
||||
// Create two dialers with different profiles
|
||||
// Use explicit extensions on profile1 to avoid the probabilistic
|
||||
// maybeEnrichExtensions path — ensures a deterministic extension count.
|
||||
profile1 := &Profile{
|
||||
Name: "Profile 1 - No GREASE",
|
||||
EnableGREASE: false,
|
||||
Extensions: []uint16{0, 23, 65281, 10, 11, 35, 16, 5, 13, 18, 51, 45, 43},
|
||||
}
|
||||
profile2 := &Profile{
|
||||
Name: "Profile 2 - With GREASE",
|
||||
@@ -176,15 +178,12 @@ func TestDialerWithProfile(t *testing.T) {
|
||||
dialer1 := NewDialer(profile1, nil)
|
||||
dialer2 := NewDialer(profile2, nil)
|
||||
|
||||
// Build specs and compare
|
||||
// Note: We can't directly compare JA3 without making network requests
|
||||
// but we can verify the specs are different
|
||||
spec1 := buildClientHelloSpecFromProfile(dialer1.profile)
|
||||
spec2 := buildClientHelloSpecFromProfile(dialer2.profile)
|
||||
|
||||
// Profile with GREASE should have more extensions
|
||||
// Profile with GREASE should have 2 extra bookend extensions
|
||||
if len(spec2.Extensions) <= len(spec1.Extensions) {
|
||||
t.Error("expected GREASE profile to have more extensions")
|
||||
t.Errorf("expected GREASE profile to have more extensions: got %d vs %d", len(spec2.Extensions), len(spec1.Extensions))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,34 +5,36 @@ import (
|
||||
)
|
||||
|
||||
// GenerateRandomizedProfile returns a new Profile that perturbs the
|
||||
// Claude Code 2.1.112 baseline along several account-safe axes so every
|
||||
// Claude Code 2.1.114 baseline along several account-safe axes so every
|
||||
// account can carry a unique JA3 hash while still looking like "some
|
||||
// Node.js-family TLS client."
|
||||
//
|
||||
// Intentionally conservative: the baseline cipher / signature-algorithm
|
||||
// shape is preserved; only localized swaps happen within bands so the
|
||||
// overall PFS-preferred ordering stays intact. Anthropic classifying on
|
||||
// "JA3 equals exact Claude Code fingerprint" would lose this account,
|
||||
// but classifying on "entire pool hashes identically" (the actual
|
||||
// clustering risk) stops working.
|
||||
// overall "TLS 1.3 first, ECDHE before RSA, AES-GCM before CBC" ordering
|
||||
// stays intact. Anthropic classifying on "JA3 equals exact Claude Code
|
||||
// fingerprint" would lose this account, but classifying on "entire pool
|
||||
// hashes identically" (the actual clustering risk) stops working.
|
||||
//
|
||||
// Axes randomized:
|
||||
// 1. Cipher suites — 6–10 localized swaps within the PFS band [3:29]
|
||||
// and the RSA band [41:] (indices into the baseline order).
|
||||
// 2. Signature algorithms — localized swaps within RSA-PSS-PSS,
|
||||
// RSA-PSS-RSAE, and legacy-DSA groups.
|
||||
// 3. GREASE — 30% on, 70% off (matches the rare but plausible case of
|
||||
// a Chrome-behavior wrapper around the Node stack).
|
||||
// Axes randomized (for the 17-cipher / 9-sigalg 2.1.114 baseline):
|
||||
// 1. Cipher suites — 2–4 localized swaps within the ECDHE PFS band [3:13]
|
||||
// and 0–1 swaps within the RSA band [13:]. TLS 1.3 ciphers [0:3] stay
|
||||
// in baseline order — reordering those is the strongest mimic tell.
|
||||
// 2. Signature algorithms — localized swaps within the SHA-384 pair and
|
||||
// the SHA-512 pair. The leading ECDSA-SHA256/RSA-PSS-rsae-SHA256/
|
||||
// RSA-PKCS1-SHA256 triple stays pinned (it's a stable Node.js
|
||||
// signature and reordering it is detectable).
|
||||
// 3. GREASE — 30% on, 70% off. Separate from ECH GREASE (that's rolled
|
||||
// per-handshake in the dialer).
|
||||
// 4. ALPN — always http/1.1 only. Do NOT advertise h2: Go's http.Transport
|
||||
// with a custom DialTLSContext cannot speak HTTP/2. If the server
|
||||
// negotiates h2 via ALPN, the transport writes HTTP/1.1 over the
|
||||
// connection and reads back HTTP/2 SETTINGS/WINDOW_UPDATE/GOAWAY
|
||||
// frames, surfacing as "malformed HTTP response" errors. Real Claude
|
||||
// Code only sends http/1.1 in ALPN anyway (see dialer.go:73).
|
||||
// 5. Key share groups — always MLKEM768+X25519. Cannot drop MLKEM768
|
||||
// here: supported_groups still advertises it, and if the server HRRs
|
||||
// back to MLKEM768, utls's HRR path can't regenerate its dual-key
|
||||
// share and fails with "CurvePreferences includes unsupported curve".
|
||||
// Code 2.1.114 only sends http/1.1 in ALPN anyway (see dialer.go).
|
||||
// 5. Key share groups — always X25519 only. MLKEM768 was dropped in the
|
||||
// 2.1.114 baseline; adding it here would leave "JA3 says no PQ hybrid
|
||||
// but key_share sends one" as a detection signal.
|
||||
//
|
||||
// Returns a freshly-allocated Profile so callers may mutate safely.
|
||||
// Uses math/rand/v2 package-global source (seeded by runtime).
|
||||
@@ -42,42 +44,43 @@ func GenerateRandomizedProfile() *Profile {
|
||||
// --- cipher suites ---
|
||||
ciphers := make([]uint16, len(defaultCipherSuites))
|
||||
copy(ciphers, defaultCipherSuites)
|
||||
// Localized swaps in the PFS (post-TLS1.3) band. Index bounds derived
|
||||
// from the baseline grouping in dialer.go; keeps RSA ciphers at the
|
||||
// bottom so the real-world "prefer forward-secrecy" shape holds.
|
||||
pfsLo, pfsHi := 3, 29
|
||||
// ECDHE PFS band — indices 3..12 in the 2.1.114 baseline: ECDHE+AES-GCM
|
||||
// (3..6), ChaCha20 pair (7..8), ECDHE+AES-CBC-SHA (9..12). Localized
|
||||
// swaps here produce many JA3 variants without leaving the band.
|
||||
pfsLo, pfsHi := 3, 13
|
||||
if pfsHi > len(ciphers) {
|
||||
pfsHi = len(ciphers)
|
||||
}
|
||||
swapCountPFS := 6 + rand.IntN(5) // 6..10
|
||||
for range swapCountPFS {
|
||||
a := pfsLo + rand.IntN(pfsHi-pfsLo)
|
||||
b := pfsLo + rand.IntN(pfsHi-pfsLo)
|
||||
ciphers[a], ciphers[b] = ciphers[b], ciphers[a]
|
||||
}
|
||||
// RSA band — smaller, fewer swaps so we don't completely scramble it.
|
||||
rsaLo := 41
|
||||
if rsaLo < len(ciphers) {
|
||||
swapCountRSA := 2 + rand.IntN(3) // 2..4
|
||||
for range swapCountRSA {
|
||||
a := rsaLo + rand.IntN(len(ciphers)-rsaLo)
|
||||
b := rsaLo + rand.IntN(len(ciphers)-rsaLo)
|
||||
if pfsHi-pfsLo >= 2 {
|
||||
swapCountPFS := 2 + rand.IntN(3) // 2..4
|
||||
for range swapCountPFS {
|
||||
a := pfsLo + rand.IntN(pfsHi-pfsLo)
|
||||
b := pfsLo + rand.IntN(pfsHi-pfsLo)
|
||||
ciphers[a], ciphers[b] = ciphers[b], ciphers[a]
|
||||
}
|
||||
}
|
||||
// RSA band — indices 13..16 (4 ciphers: RSA+AES-GCM-128/256, RSA+CBC-128/256).
|
||||
// One swap max — the band is small enough that more scrambling would be
|
||||
// obviously non-baseline.
|
||||
rsaLo := 13
|
||||
if rsaLo+2 <= len(ciphers) && rand.IntN(2) == 0 {
|
||||
a := rsaLo + rand.IntN(len(ciphers)-rsaLo)
|
||||
b := rsaLo + rand.IntN(len(ciphers)-rsaLo)
|
||||
ciphers[a], ciphers[b] = ciphers[b], ciphers[a]
|
||||
}
|
||||
p.CipherSuites = ciphers
|
||||
|
||||
// --- signature algorithms ---
|
||||
// defaultSignatureAlgorithms uses utls.SignatureScheme, convert to
|
||||
// uint16 for storage while we shuffle.
|
||||
// uint16 for storage while we shuffle. Baseline order:
|
||||
// 0:ecdsa_sha256, 1:rsa_pss_rsae_sha256, 2:rsa_pkcs1_sha256,
|
||||
// 3:ecdsa_sha384, 4:rsa_pss_rsae_sha384, 5:rsa_pkcs1_sha384,
|
||||
// 6:rsa_pss_rsae_sha512, 7:rsa_pkcs1_sha512,
|
||||
// 8:rsa_pkcs1_sha1
|
||||
sigs := make([]uint16, len(defaultSignatureAlgorithms))
|
||||
for i, s := range defaultSignatureAlgorithms {
|
||||
sigs[i] = uint16(s)
|
||||
}
|
||||
// Shuffle three groups that contain multiple peers. Indices into the
|
||||
// baseline ordering (see dialer.go:198). Keeping experimental TLS 1.3
|
||||
// (0..2) and ECDSA (3..5) unchanged — those are the strongest leading
|
||||
// signals real Node clients send in order.
|
||||
shuffleWithin := func(lo, hi int) {
|
||||
if hi > len(sigs) || hi-lo < 2 {
|
||||
return
|
||||
@@ -85,9 +88,10 @@ func GenerateRandomizedProfile() *Profile {
|
||||
sub := sigs[lo:hi]
|
||||
rand.Shuffle(len(sub), func(i, j int) { sub[i], sub[j] = sub[j], sub[i] })
|
||||
}
|
||||
shuffleWithin(11, 14) // RSA-PSS-PSS (0x0809..0x080b)
|
||||
shuffleWithin(14, 17) // RSA-PSS-RSAE (0x0804..0x0806)
|
||||
shuffleWithin(20, 26) // legacy SHA224/256/384/512 DSA/etc.
|
||||
// Shuffle the SHA384 and SHA512 pairs locally. The SHA256 triple and
|
||||
// legacy SHA1 floor (0..2, 8) stay pinned — those are strong mimic tells.
|
||||
shuffleWithin(4, 6) // rsa_pss_rsae_sha384 ↔ rsa_pkcs1_sha384
|
||||
shuffleWithin(6, 8) // rsa_pss_rsae_sha512 ↔ rsa_pkcs1_sha512
|
||||
p.SignatureAlgorithms = sigs
|
||||
|
||||
// --- GREASE ---
|
||||
@@ -99,14 +103,12 @@ func GenerateRandomizedProfile() *Profile {
|
||||
p.ALPNProtocols = []string{"http/1.1"}
|
||||
|
||||
// --- Key share groups ---
|
||||
// Always MLKEM768+X25519 — see axis 5 in the doc comment for why
|
||||
// dropping MLKEM768 here is unsafe with the current utls HRR path.
|
||||
p.KeyShareGroups = []uint16{uint16(0x11ec), uint16(29)} // MLKEM768, X25519
|
||||
// Always X25519 only — matches 2.1.114 baseline.
|
||||
p.KeyShareGroups = []uint16{uint16(29)} // X25519
|
||||
|
||||
// Leave curves/point_formats/supported_versions/psk_modes/extensions
|
||||
// empty so the dialer falls back to baseline values. Perturbing those
|
||||
// axes further without live captures to validate would risk leaving
|
||||
// the plausible-Node-client envelope.
|
||||
// empty so the dialer falls back to baseline values (incl. the
|
||||
// probabilistic ECH GREASE roll in defaultExtensionOrder).
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
@@ -54,24 +54,25 @@ func SessionHashFromContext(ctx context.Context) string {
|
||||
|
||||
// 默认指纹值(当客户端未提供时使用)
|
||||
//
|
||||
// Re-verified 2026-04-17 against a live capture of Claude Code 2.1.111 on
|
||||
// Node.js 24.14.1 / macOS arm64 (capture tool: backend/tools/capture_fingerprint).
|
||||
// UA bumped to 2.1.112 (latest on npm as of 2026-04-17) — patch-level Claude
|
||||
// Code releases keep the same Stainless fields. Bundled @anthropic-ai/sdk is
|
||||
// still 0.81.0. These values match the real CLI's request headers exactly —
|
||||
// Re-verified 2026-04-19 against a live capture of Claude Code 2.1.114 on
|
||||
// macOS arm64. Baseline at backend/tools/capture_fingerprint/baselines/
|
||||
// claude-code-2.1.114.json. Critical: CC 2.1.114 bundles its own Node 24.3.0
|
||||
// runtime; the host Node version is NOT what gets advertised in the
|
||||
// X-Stainless-Runtime-Version header. Bundled @anthropic-ai/sdk is 0.81.0.
|
||||
// These values match the real CLI's request headers exactly —
|
||||
// particularly:
|
||||
// - UserAgent: "claude-cli/2.1.112 (external, sdk-cli)" (note: "sdk-cli", NOT "cli")
|
||||
// - UserAgent: "claude-cli/2.1.114 (external, sdk-cli)" (note: "sdk-cli", NOT "cli")
|
||||
// - StainlessPackageVersion: "0.81.0"
|
||||
// - StainlessOS: "MacOS" (case: mixed, not "Linux")
|
||||
// - StainlessRuntimeVersion: "v24.14.1"
|
||||
// - StainlessRuntimeVersion: "v24.3.0" (bundled, not host)
|
||||
var defaultFingerprint = Fingerprint{
|
||||
UserAgent: "claude-cli/2.1.112 (external, sdk-cli)",
|
||||
UserAgent: "claude-cli/2.1.114 (external, sdk-cli)",
|
||||
StainlessLang: "js",
|
||||
StainlessPackageVersion: "0.81.0",
|
||||
StainlessOS: "MacOS",
|
||||
StainlessArch: "arm64",
|
||||
StainlessRuntime: "node",
|
||||
StainlessRuntimeVersion: "v24.14.1",
|
||||
StainlessRuntimeVersion: "v24.3.0",
|
||||
}
|
||||
|
||||
// Fingerprint represents account fingerprint data
|
||||
|
||||
@@ -20,7 +20,7 @@ func TestRewriteUserID_EquivalenceAfterRefactor(t *testing.T) {
|
||||
const (
|
||||
accountUUID = "11111111-2222-3333-4444-555555555555"
|
||||
clientID = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
|
||||
fingerprintUA = "claude-cli/2.1.112 (external, sdk-cli)"
|
||||
fingerprintUA = "claude-cli/2.1.114 (external, sdk-cli)"
|
||||
legacyValidUID = "user_abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789_account_99999999-8888-7777-6666-555555555555_session_aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
||||
)
|
||||
|
||||
@@ -119,7 +119,7 @@ func TestRewriteUserIDWithMasking_StickyPath_EarlyReturns(t *testing.T) {
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result, err := svc.RewriteUserIDWithMasking(ctx, tc.body, account, "acc-uuid", "client-id", "claude-cli/2.1.112 (external, sdk-cli)")
|
||||
result, err := svc.RewriteUserIDWithMasking(ctx, tc.body, account, "acc-uuid", "client-id", "claude-cli/2.1.114 (external, sdk-cli)")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, string(tc.body), string(result))
|
||||
})
|
||||
@@ -143,17 +143,17 @@ func TestRewriteUserIDWithMasking_StickyPath_CacheHitReusesUUID(t *testing.T) {
|
||||
const legacyValidUID = "user_abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789_account_99999999-8888-7777-6666-555555555555_session_aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
||||
body := []byte(`{"metadata":{"user_id":"` + legacyValidUID + `"}}`)
|
||||
|
||||
r1, err := svc.RewriteUserIDWithMasking(ctx, body, account, "acc-uuid", "client-id", "claude-cli/2.1.112 (external, sdk-cli)")
|
||||
r1, err := svc.RewriteUserIDWithMasking(ctx, body, account, "acc-uuid", "client-id", "claude-cli/2.1.114 (external, sdk-cli)")
|
||||
require.NoError(t, err)
|
||||
|
||||
r2, err := svc.RewriteUserIDWithMasking(ctx, body, account, "acc-uuid", "client-id", "claude-cli/2.1.112 (external, sdk-cli)")
|
||||
r2, err := svc.RewriteUserIDWithMasking(ctx, body, account, "acc-uuid", "client-id", "claude-cli/2.1.114 (external, sdk-cli)")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, string(r1), string(r2), "sticky cache hit must yield byte-identical rewrite")
|
||||
|
||||
// Sanity: a different sessionHash must produce different output.
|
||||
ctxOther := WithSessionHash(context.Background(), "different-session-h")
|
||||
rOther, err := svc.RewriteUserIDWithMasking(ctxOther, body, account, "acc-uuid", "client-id", "claude-cli/2.1.112 (external, sdk-cli)")
|
||||
rOther, err := svc.RewriteUserIDWithMasking(ctxOther, body, account, "acc-uuid", "client-id", "claude-cli/2.1.114 (external, sdk-cli)")
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, string(r1), string(rOther), "different sessionHash must yield different sticky session id")
|
||||
}
|
||||
|
||||
@@ -280,7 +280,7 @@ func (s *TLSFingerprintProfileService) RandomizeForAccount(ctx context.Context,
|
||||
|
||||
// 2. 生成新 profile
|
||||
generated := tlsfingerprint.GenerateRandomizedProfile()
|
||||
description := fmt.Sprintf("Auto-randomized fingerprint for account #%d (%s baseline, perturbed)", accountID, "Claude Code 2.1.112")
|
||||
description := fmt.Sprintf("Auto-randomized fingerprint for account #%d (%s baseline, perturbed)", accountID, "Claude Code 2.1.114")
|
||||
profileName := fmt.Sprintf("%s%d-%d", autoGeneratedProfileNamePrefix, accountID, time.Now().Unix())
|
||||
|
||||
newProfile := &model.TLSFingerprintProfile{
|
||||
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"captured_at": "2026-04-19T04:39:46Z",
|
||||
"remote_addr": "127.0.0.1:61818",
|
||||
"server_name": "localhost",
|
||||
"tls_version": "TLS 1.3",
|
||||
"negotiated_proto": "http/1.1",
|
||||
"client_hello_raw": "010001fc03035911f99fecac3cc90a1251fea8fb722ac5f5021dec67bb4cb816c41bc523ee7220c539e0ce4ecce98fa8c623c887fd861d285ad713d66c04227f55e02164a9dc230022130113021303c02bc02fc02cc030cca9cca8c009c013c00ac014009c009d002f0035010001910000000e000c0000096c6f63616c686f7374fe0d00ba0000010001080020a12b0c8b0db48cbf999568d621238b4a6cef89cdc1375f824a044e1a4852535d0090cbc4c6a0866cc58ab4195d6329d6ab67155415be6158952da54cd9c3cbdc944f73c15c21457d9c1f2983919b643b97f3c89cf1c3151a9e9870898d59b50ea1847b9c32b331baa6ed8b03cdee26717121712bd5646d72adc40bc5cfaeeab17f2f045bcd586b7a0f23595d39404c44ba49f604b9f40e2b1cdabb330d3a5238fb041c44587d3c1f3fe56632d041181c5bfe00170000ff01000100000a00080006001d00170018000b00020100002300000010000b000908687474702f312e31000500050100000000000d0014001204030804040105030805050108060601020100120000003300260024001d0020f1720607b0bfa3b8369b31a326a54ec274a4619e5e269073126f2c1dd24e410f002d00020101002b000504030403030015003100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
|
||||
"cipher_suites": [
|
||||
"0x1301",
|
||||
"0x1302",
|
||||
"0x1303",
|
||||
"0xc02b",
|
||||
"0xc02f",
|
||||
"0xc02c",
|
||||
"0xc030",
|
||||
"0xcca9",
|
||||
"0xcca8",
|
||||
"0xc009",
|
||||
"0xc013",
|
||||
"0xc00a",
|
||||
"0xc014",
|
||||
"0x009c",
|
||||
"0x009d",
|
||||
"0x002f",
|
||||
"0x0035"
|
||||
],
|
||||
"extensions": [
|
||||
"server_name (0)",
|
||||
"*tls.GREASEEncryptedClientHelloExtension",
|
||||
"extended_master_secret (23)",
|
||||
"renegotiation_info (0xff01)",
|
||||
"supported_groups (10)",
|
||||
"ec_point_formats (11)",
|
||||
"session_ticket (35)",
|
||||
"application_layer_protocol_negotiation (16)",
|
||||
"status_request (5)",
|
||||
"signature_algorithms (13)",
|
||||
"signed_certificate_timestamp (18)",
|
||||
"key_share (51)",
|
||||
"psk_key_exchange_modes (45)",
|
||||
"supported_versions (43)",
|
||||
"padding (21)"
|
||||
],
|
||||
"curves": [
|
||||
"0x001d",
|
||||
"0x0017",
|
||||
"0x0018"
|
||||
],
|
||||
"point_formats": [
|
||||
"0x00"
|
||||
],
|
||||
"signature_algorithms": [
|
||||
"0x0403",
|
||||
"0x0804",
|
||||
"0x0401",
|
||||
"0x0503",
|
||||
"0x0805",
|
||||
"0x0501",
|
||||
"0x0806",
|
||||
"0x0601",
|
||||
"0x0201"
|
||||
],
|
||||
"supported_versions": [
|
||||
"0x0304",
|
||||
"0x0303"
|
||||
],
|
||||
"key_share_groups": [
|
||||
"0x001d"
|
||||
],
|
||||
"alpn_protos": [
|
||||
"http/1.1"
|
||||
],
|
||||
"psk_modes": [
|
||||
"0x01"
|
||||
],
|
||||
"ja3_string": "771,4865-4866-4867-49195-49199-49196-49200-52393-52392-49161-49171-49162-49172-156-157-47-53,0-23-65281-10-11-35-16-5-13-18-51-45-43-21,29-23-24,0",
|
||||
"ja3_hash": "d871d02cecbde59abbf8f4806134addf",
|
||||
"ja4": "t13d1714h1",
|
||||
"http1": {
|
||||
"request_line": "POST /v1/messages?beta=true HTTP/1.1",
|
||||
"headers_in_order": [
|
||||
"Accept: application/json",
|
||||
"Content-Type: application/json",
|
||||
"User-Agent: claude-cli/2.1.114 (external, sdk-cli)",
|
||||
"X-Claude-Code-Session-Id: 2053d87f-8152-4f29-a0db-0b64722a1fd6",
|
||||
"X-Stainless-Arch: arm64",
|
||||
"X-Stainless-Lang: js",
|
||||
"X-Stainless-OS: MacOS",
|
||||
"X-Stainless-Package-Version: 0.81.0",
|
||||
"X-Stainless-Retry-Count: 0",
|
||||
"X-Stainless-Runtime: node",
|
||||
"X-Stainless-Runtime-Version: v24.3.0",
|
||||
"X-Stainless-Timeout: 600",
|
||||
"anthropic-beta: interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advisor-tool-2026-03-01,structured-outputs-2025-12-15",
|
||||
"anthropic-dangerous-direct-browser-access: true",
|
||||
"anthropic-version: 2023-06-01",
|
||||
"x-api-key: <redacted>",
|
||||
"x-app: cli",
|
||||
"Connection: keep-alive",
|
||||
"Host: localhost:8443",
|
||||
"Accept-Encoding: gzip, deflate, br, zstd",
|
||||
"Content-Length: 1527"
|
||||
],
|
||||
"body_preview": "{\"model\":\"claude-haiku-4-5-20251001\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Run the bash tool to echo foo\"}]}],\"system\":[{\"type\":\"text\",\"text\":\"x-anthropic-billing-header: cc_version=2.1.114.a7c; cc_entrypoint=sdk-cli; cch=ceefa;\"},{\"type\":\"text\",\"text\":\"You are a Claude agent, built on Anthropic's Claude Agent SDK.\"},{\"type\":\"text\",\"text\":\"Generate a concise, sentence-case title (3-7 words) that captures the main topic or goal of this coding session. The title should be clear enough that the user recognizes the session in a list. Use sentence case: capitalize only the first word and proper nouns.\\n\\nReturn JSON with a single \\\"title\\\" field.\\n\\nGood examples:\\n{\\\"title\\\": \\\"Fix login button on mobile\\\"}\\n{\\\"title\\\": \\\"Add OAuth authentication\\\"}\\n{\\\"title\\\": \\\"Debug failing CI tests\\\"}\\n{\\\"title\\\": \\\"Refactor API client error handling\\\"}\\n\\nBad (too vague): {\\\"title\\\": \\\"Code changes\\\"}\\nBad (too long): {\\\"title\\\": \\\"Investigate and fix the issue where the login button does not respond on mobile devices\\\"}\\nBad (wrong case): {\\\"title\\\": \\\"Fix Login Button On Mobile\\\"}\"}],\"tools\":[],\"metadata\":{\"user_id\":\"{\\\"device_id\\\":\\\"7bea6f9313f3c20e3440b4c4e4c354631176ea4bd8ea6344a2dc93e2319a68ac\\\",\\\"account_uuid\\\":\\\"\\\",\\\"session_id\\\":\\\"2053d87f-8152-4f29-a0db-0b64722a1fd6\\\"}\"},\"max_tokens\":32000,\"temperature\":1,\"output_config\":{\"format\":{\"type\":\"json_schema\",\"schema\":{\"type\":\"object\",\"properties\":{\"title\":{\"type\":\"string\"}},\"required\":[\"title\"],\"additionalProperties\":false}}},\"stream\":true}",
|
||||
"body_bytes": 1527
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user