From 004e277178e01fd4aefbc2551d29cae2e1ee2b4d Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Mon, 6 Apr 2026 01:01:55 +0800 Subject: [PATCH 1/6] feat(billing): add cch attestation for OAuth requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compute and inject the billing attribution header (fingerprint + cch body hash) for OAuth-authenticated API requests. This matches Claude Code's native client attestation protocol, enabling subscription-tier rate limits and fast mode access. The fingerprint is SHA-256(salt + 3 chars from first user message + version), truncated to 3 hex chars. The cch is xxHash64(serialized body with placeholder, seed) masked to 5 hex chars, replacing the cch=00000 placeholder via str::replacen. Only the first occurrence is replaced — struct field ordering ensures system serializes before messages to avoid collision with tool results containing the literal placeholder. Unlike Claude Code's Bun runtime (which mutates strings in-place and can poison prompt cache via global cch= substitution in historical tool results), our implementation creates a fresh String per request and never modifies the conversation history. --- CLAUDE.md | 3 +- Cargo.lock | 79 ++++++++++ Cargo.toml | 2 + crates/oxide-code/Cargo.toml | 2 + crates/oxide-code/src/client.rs | 1 + crates/oxide-code/src/client/anthropic.rs | 92 ++++++++++-- crates/oxide-code/src/client/billing.rs | 172 ++++++++++++++++++++++ 7 files changed, 341 insertions(+), 10 deletions(-) create mode 100644 crates/oxide-code/src/client/billing.rs diff --git a/CLAUDE.md b/CLAUDE.md index a5c6d1ba..e2317b5a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,7 +25,8 @@ ox # Start an interactive session . ├── client.rs # Client module root ├── client/ -│ └── anthropic.rs # Anthropic Messages API streaming client +│ ├── anthropic.rs # Anthropic Messages API streaming client +│ └── billing.rs # Billing attribution header (fingerprint, cch attestation) ├── config.rs # Configuration loading (env vars, model, base URL) ├── config/ │ └── oauth.rs # Claude Code OAuth credentials (macOS Keychain + file), token refresh, file locking diff --git a/Cargo.lock b/Cargo.lock index d0619106..fcabc025 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -85,6 +85,15 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bstr" version = "1.12.1" @@ -191,6 +200,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -216,6 +234,16 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "deranged" version = "0.5.8" @@ -225,6 +253,16 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dirs" version = "6.0.0" @@ -376,6 +414,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -817,11 +865,13 @@ dependencies = [ "security-framework", "serde", "serde_json", + "sha2", "tempfile", "time", "tokio", "tracing", "tracing-subscriber", + "xxhash-rust", ] [[package]] @@ -1216,6 +1266,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1548,6 +1609,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1584,6 +1651,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "walkdir" version = "2.5.0" @@ -1898,6 +1971,12 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "xxhash-rust" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" + [[package]] name = "yoke" version = "0.8.1" diff --git a/Cargo.toml b/Cargo.toml index 1b93c1d9..eff68084 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ reqwest = { version = "0.12", default-features = false, features = [ security-framework = "3" serde = { version = "1", features = ["derive"] } serde_json = "1" +sha2 = "0.10" tempfile = "3" time = { version = "0.3", features = ["local-offset"] } tokio = { version = "1", features = [ @@ -45,6 +46,7 @@ tokio = { version = "1", features = [ "time", ] } tracing = "0.1" +xxhash-rust = { version = "0.8", features = ["xxh64"] } tracing-subscriber = { version = "0.3", default-features = false, features = [ "ansi", "env-filter", diff --git a/crates/oxide-code/Cargo.toml b/crates/oxide-code/Cargo.toml index 11841267..0b73ab41 100644 --- a/crates/oxide-code/Cargo.toml +++ b/crates/oxide-code/Cargo.toml @@ -23,10 +23,12 @@ regex.workspace = true reqwest.workspace = true serde.workspace = true serde_json.workspace = true +sha2.workspace = true time.workspace = true tokio.workspace = true tracing.workspace = true tracing-subscriber.workspace = true +xxhash-rust.workspace = true [target.'cfg(target_os = "macos")'.dependencies] security-framework.workspace = true diff --git a/crates/oxide-code/src/client.rs b/crates/oxide-code/src/client.rs index e5299972..aa06db7f 100644 --- a/crates/oxide-code/src/client.rs +++ b/crates/oxide-code/src/client.rs @@ -1 +1,2 @@ pub mod anthropic; +mod billing; diff --git a/crates/oxide-code/src/client/anthropic.rs b/crates/oxide-code/src/client/anthropic.rs index 685664ef..89c7f795 100644 --- a/crates/oxide-code/src/client/anthropic.rs +++ b/crates/oxide-code/src/client/anthropic.rs @@ -4,8 +4,9 @@ use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue, USER_ use serde::{Deserialize, Serialize}; use tokio::sync::mpsc; +use super::billing; use crate::config::{Auth, Config, ThinkingConfig}; -use crate::message::Message; +use crate::message::{ContentBlock, Message, Role}; use crate::tool::ToolDefinition; const API_VERSION: &str = "2023-06-01"; @@ -28,13 +29,17 @@ const SYSTEM_PROMPT_PREFIX: &str = "You are Claude Code, Anthropic's official CL struct CreateMessageRequest<'a> { model: &'a str, max_tokens: u32, - messages: &'a [Message], + /// Serialized before `messages` so the billing header's `cch=00000` + /// placeholder appears first in the JSON, making [`billing::inject_cch`]'s + /// single-occurrence replacement safe even when tool results contain the + /// literal placeholder string. system: Vec>, stream: bool, #[serde(skip_serializing_if = "Option::is_none")] tools: Option<&'a [ToolDefinition]>, #[serde(skip_serializing_if = "Option::is_none")] thinking: Option<&'a ThinkingConfig>, + messages: &'a [Message], } /// A text block in the system prompt array. The Anthropic API accepts `system` @@ -240,10 +245,28 @@ impl Client { system: Option<&str>, tools: &[ToolDefinition], ) -> Result>> { - let mut system_blocks = vec![SystemBlock { + let billing_header = if matches!(self.config.auth, Auth::OAuth(_)) { + let first_text = first_user_text(messages); + let fingerprint = billing::compute_fingerprint(first_text, CLAUDE_CLI_VERSION); + Some(billing::build_billing_header( + CLAUDE_CLI_VERSION, + &fingerprint, + )) + } else { + None + }; + + let mut system_blocks = Vec::new(); + if let Some(ref header) = billing_header { + system_blocks.push(SystemBlock { + r#type: "text", + text: header, + }); + } + system_blocks.push(SystemBlock { r#type: "text", text: SYSTEM_PROMPT_PREFIX, - }]; + }); if let Some(s) = system { system_blocks.push(SystemBlock { r#type: "text", @@ -252,22 +275,26 @@ impl Client { } let url = format!("{}/v1/messages", self.config.base_url); - let body = serde_json::to_value(CreateMessageRequest { + let mut body = serde_json::to_string(&CreateMessageRequest { model: &self.config.model, max_tokens: self.config.max_tokens, - messages, system: system_blocks, stream: true, tools: (!tools.is_empty()).then_some(tools), thinking: self.config.thinking.as_ref(), + messages, }) .context("failed to serialize request")?; + if billing_header.is_some() { + body = billing::inject_cch(&body); + } + let (tx, rx) = mpsc::channel(64); let http = self.http.clone(); tokio::spawn(async move { - let result = stream_sse(&http, &url, &body, &tx).await; + let result = stream_sse(&http, &url, body, &tx).await; if let Err(e) = result { _ = tx.send(Err(e)).await; } @@ -277,13 +304,27 @@ impl Client { } } +/// Extract the text of the first user message for fingerprint computation. +fn first_user_text(messages: &[Message]) -> &str { + messages + .iter() + .find(|m| m.role == Role::User) + .into_iter() + .flat_map(|m| &m.content) + .find_map(|b| match b { + ContentBlock::Text { text } => Some(text.as_str()), + _ => None, + }) + .unwrap_or("") +} + async fn stream_sse( http: &reqwest::Client, url: &str, - body: &serde_json::Value, + body: String, tx: &mpsc::Sender>, ) -> Result<()> { - let response = http.post(url).json(body).send().await?; + let response = http.post(url).body(body).send().await?; let status = response.status(); if !status.is_success() { @@ -509,4 +550,37 @@ mod tests { let frame = "data: {not valid json}"; assert!(parse_sse_frame(frame).is_err()); } + + // ── first_user_text ── + + #[test] + fn first_user_text_extracts_from_first_user_message() { + let messages = vec![Message::user("hello world"), Message::assistant("hi")]; + assert_eq!(first_user_text(&messages), "hello world"); + } + + #[test] + fn first_user_text_returns_empty_for_no_user_messages() { + let messages = vec![Message::assistant("hi")]; + assert_eq!(first_user_text(&messages), ""); + } + + #[test] + fn first_user_text_returns_empty_for_empty_messages() { + let messages: Vec = vec![]; + assert_eq!(first_user_text(&messages), ""); + } + + #[test] + fn first_user_text_returns_empty_when_first_user_has_no_text() { + let messages = vec![Message { + role: Role::User, + content: vec![ContentBlock::ToolResult { + tool_use_id: "id".to_owned(), + content: "result".to_owned(), + is_error: false, + }], + }]; + assert_eq!(first_user_text(&messages), ""); + } } diff --git a/crates/oxide-code/src/client/billing.rs b/crates/oxide-code/src/client/billing.rs new file mode 100644 index 00000000..22fb90ac --- /dev/null +++ b/crates/oxide-code/src/client/billing.rs @@ -0,0 +1,172 @@ +use sha2::{Digest, Sha256}; +use xxhash_rust::xxh64; + +/// Salt for the version fingerprint (hardcoded in Claude Code JS source). +const FINGERPRINT_SALT: &str = "59cf53e54c78"; + +/// Character indices extracted from the first user message for fingerprinting. +const FINGERPRINT_INDICES: [usize; 3] = [4, 7, 20]; + +/// xxHash64 seed for the cch body hash (extracted from the Bun binary). +const CCH_SEED: u64 = 0x6E52_736A_C806_831E; + +/// Placeholder written into the billing header before the real hash is computed. +const CCH_PLACEHOLDER: &str = "cch=00000"; + +// ── Public API ── + +/// Compute the 3-character hex fingerprint suffix for `cc_version`. +/// +/// `SHA-256(salt + chars_at_indices + version)`, take first 3 hex chars. +/// Character extraction uses Unicode scalar indexing, which matches +/// JavaScript's UTF-16 code-unit indexing for all BMP characters. +pub(super) fn compute_fingerprint(first_user_message: &str, version: &str) -> String { + let chars: String = FINGERPRINT_INDICES + .iter() + .map(|&i| first_user_message.chars().nth(i).unwrap_or('0')) + .collect(); + + let input = format!("{FINGERPRINT_SALT}{chars}{version}"); + let hash = Sha256::digest(input.as_bytes()); + format!("{:02x}{:02x}", hash[0], hash[1])[..3].to_string() +} + +/// Build the billing attribution header with a `cch=00000` placeholder. +/// +/// The placeholder is later replaced by [`inject_cch`] with the computed hash. +pub(super) fn build_billing_header(version: &str, fingerprint: &str) -> String { + format!( + "x-anthropic-billing-header: \ + cc_version={version}.{fingerprint}; \ + cc_entrypoint=cli; \ + cch=00000;" + ) +} + +/// Compute xxHash64 of the request body and replace the `cch` placeholder. +/// +/// The hash is computed over the body *with* the placeholder in place, +/// then the placeholder is replaced with the 5-char hex result. Only the +/// first occurrence is replaced — field ordering in +/// [`super::anthropic::CreateMessageRequest`] ensures `system` is serialized +/// before `messages`, so the billing header's placeholder comes first. +pub(super) fn inject_cch(body: &str) -> String { + debug_assert!( + body.contains(CCH_PLACEHOLDER), + "cch placeholder not found in request body" + ); + + let hash = xxh64::xxh64(body.as_bytes(), CCH_SEED); + let cch = format!("{:05x}", hash & 0xFFFFF); + body.replacen(CCH_PLACEHOLDER, &format!("cch={cch}"), 1) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── compute_fingerprint ── + + /// Verified via `echo -n "59cf53e54c78'li2.1.37" | shasum -a 256` → "9e71…". + /// Chars at indices [4, 7, 20] of "Say 'hello'…" are `'`, `l`, `i`. + #[test] + fn compute_fingerprint_known_vector() { + let fp = compute_fingerprint("Say 'hello' and nothing else.", "2.1.37"); + assert_eq!(fp, "9e7"); + } + + #[test] + fn compute_fingerprint_short_message_pads_with_zero() { + // "Hi" (len 2): all fingerprint indices (4, 7, 20) are out of bounds, + // so chars default to '0'. Same result as an empty message. + let short = compute_fingerprint("Hi", "2.1.87"); + let empty = compute_fingerprint("", "2.1.87"); + assert_eq!(short, empty, "both should pad all positions with '0'"); + } + + #[test] + fn compute_fingerprint_partial_bounds() { + // "Hello" (len 5): index 4 = 'o', indices 7 and 20 out of bounds. + let fp = compute_fingerprint("Hello", "2.1.87"); + let fp_all_zero = compute_fingerprint("", "2.1.87"); + assert_eq!(fp.len(), 3); + assert!(fp.chars().all(|c| c.is_ascii_hexdigit())); + assert_ne!( + fp, fp_all_zero, + "partial in-bounds should differ from all-zero" + ); + } + + #[test] + fn compute_fingerprint_varies_with_version() { + let fp1 = compute_fingerprint("hello world", "2.1.37"); + let fp2 = compute_fingerprint("hello world", "2.1.87"); + assert_ne!( + fp1, fp2, + "different versions should produce different fingerprints" + ); + } + + // ── build_billing_header ── + + #[test] + fn build_billing_header_format() { + let header = build_billing_header("2.1.87", "abc"); + assert_eq!( + header, + "x-anthropic-billing-header: cc_version=2.1.87.abc; cc_entrypoint=cli; cch=00000;" + ); + } + + // ── inject_cch ── + + #[test] + fn inject_cch_replaces_placeholder() { + let body = r#"{"system":[{"type":"text","text":"cch=00000;"}],"messages":[]}"#; + let result = inject_cch(body); + + assert!( + !result.contains(CCH_PLACEHOLDER), + "placeholder should be replaced" + ); + + let hash = xxh64::xxh64(body.as_bytes(), CCH_SEED); + let expected = format!("{:05x}", hash & 0xFFFFF); + assert!( + result.contains(&format!("cch={expected}")), + "result should contain cch={expected}, got: {result}" + ); + } + + #[test] + fn inject_cch_deterministic() { + let body = r#"{"system":[{"type":"text","text":"cch=00000;"}],"messages":[]}"#; + assert_eq!(inject_cch(body), inject_cch(body)); + } + + #[test] + fn inject_cch_replaces_only_first_occurrence() { + // system (with placeholder) is before messages — matches our struct field order. + let body = r#"{"system":[{"type":"text","text":"cch=00000;"}],"messages":[{"role":"user","content":[{"type":"text","text":"cch=00000"}]}]}"#; + let result = inject_cch(body); + + assert_eq!( + result.matches("cch=00000").count(), + 1, + "only the second occurrence (in messages) should remain" + ); + } + + #[test] + fn inject_cch_produces_five_hex_chars() { + let body = r#"{"system":[{"type":"text","text":"cch=00000;"}]}"#; + let result = inject_cch(body); + + let cch_start = result.find("cch=").expect("cch= not found") + 4; + let cch_value = &result[cch_start..cch_start + 5]; + assert!( + cch_value.chars().all(|c| c.is_ascii_hexdigit()), + "cch should be 5 hex chars, got: {cch_value}" + ); + } +} From 3405a3e59b1fade391d3a1fb88fa5e7367308d6e Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Mon, 6 Apr 2026 01:03:24 +0800 Subject: [PATCH 2/6] docs(research): document reverse-engineered cch mechanism Replace the earlier "tamper-proof / unreplicable" characterization with the actual algorithm, constants, and known bugs based on the a10k.co reverse engineering writeup. --- docs/research/anthropic-api.md | 42 ++++++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/docs/research/anthropic-api.md b/docs/research/anthropic-api.md index 9beed454..3b0949ad 100644 --- a/docs/research/anthropic-api.md +++ b/docs/research/anthropic-api.md @@ -77,25 +77,51 @@ The API validates that the **first non-attribution text block** matches one of t **Critical**: Concatenating the prefix into the prompt body as a single string causes the API to reject OAuth requests with 429, even though the same prefix content is present. The block-level separation is what the server checks. -### 3. Attribution header (optional, recommended) +### 3. Attribution header Claude Code prepends an attribution header as the very first system block: ```json -{"type": "text", "text": "x-anthropic-billing-header: cc_version=2.1.87.a3f; cc_entrypoint=cli;"} +{"type": "text", "text": "x-anthropic-billing-header: cc_version=2.1.87.a3f; cc_entrypoint=cli; cch=1b4e2;"} ``` -Format: `x-anthropic-billing-header: cc_version=.; cc_entrypoint=;` +Format: `x-anthropic-billing-header: cc_version=.; cc_entrypoint=; cch=;` -The fingerprint is a 3-character hex value computed per request: +#### Fingerprint (3-char version suffix) + +A 3-character hex value derived from conversation content: 1. Extract characters at indices `[4, 7, 20]` from the first user message text (use `"0"` if index is out of bounds). 2. Compute `SHA256(SALT + chars + VERSION)`, take the first 3 hex characters. -3. Salt: `59cf53e54c78` (hardcoded, must match server). +3. Salt: `59cf53e54c78` (hardcoded in `claude-code/src/utils/fingerprint.ts`, must match server). The entrypoint is `cli` for interactive sessions. -When `NATIVE_CLIENT_ATTESTATION` is enabled (compile-time feature flag in Bun), the header also includes a `cch=00000` placeholder that Bun's native HTTP stack (Zig) overwrites with a computed attestation token before sending. This is a tamper-proof mechanism that third-party tools cannot replicate. +#### cch (5-char request integrity hash) + +The `cch` field is a request integrity hash used for feature gating (fast mode) and billing attribution. It was reverse-engineered from Anthropic's custom Bun binary in February 2026 ([a10k.co writeup](https://a10k.co/b/reverse-engineering-claude-code-cch.html)). + +How it works: + +1. The JavaScript layer writes a `cch=00000` placeholder into the billing header (controlled by `feature('NATIVE_CLIENT_ATTESTATION')` compile-time flag). +2. The request body is serialized to JSON with the placeholder in place. +3. Bun's native HTTP stack (compiled Zig, `bun-anthropic/src/http/Attestation.zig`) intercepts the `fetch()` call, detects the placeholder, and computes `xxHash64(body_bytes, seed) & 0xFFFFF`. +4. The five `0` characters are overwritten in-place with the 5-char hex result before sending. + +Constants: + +- **Seed**: `0x6E52736AC806831E` (64-bit, embedded in the binary's data section). +- **Mask**: `& 0xFFFFF` (20 bits → 5 hex chars, zero-padded). + +The hash covers the entire serialized body (messages, tools, metadata, model, thinking config). The only safe post-hash modification is to non-billing system blocks, which the server excludes from its verification. + +**JSON key ordering matters**: `system` must be serialized before `messages` so the placeholder in the billing header appears first in the JSON. If tool results contain the literal `cch=00000`, serializing `messages` first would cause the replacement to hit the wrong occurrence. + +#### Known bug: cch substitution breaks prompt cache + +The Bun binary performs a global find-and-replace of `cch=` values across the entire serialized request body, including historical tool results. If any tool result in the conversation contains a `cch=XXXXX` string (e.g., from reading proxy logs or session JSONL files), the substitution rewrites those historical bytes on every turn, changing the conversation prefix and permanently invalidating the prompt cache. This wastes 30-50K+ tokens per turn and never self-heals. Tracked as [anthropics/claude-code#40652](https://github.com/anthropics/claude-code/issues/40652), partially mitigated in v2.1.90-91. + +oxide-code avoids this entirely: we serialize with `serde_json`, replace only the first occurrence via `str::replacen`, and never mutate historical message content. ### 4. Client identity headers @@ -124,7 +150,9 @@ As of April 4, 2026, Anthropic enforces that OAuth subscription credits (Pro / M - **API key** (`ANTHROPIC_API_KEY`) with standard per-token billing. - **Extra Usage** billing enabled on the account, which allows OAuth but bills per-token beyond the subscription. -The native client attestation (`cch` in the attribution header) is the primary technical enforcement mechanism. Third-party tools cannot compute the attestation token since it requires Anthropic's custom Bun binary. Without valid attestation, subscription-tier rate limits are not applied. +The `cch` hash is the primary technical enforcement mechanism. The algorithm (xxHash64, non-cryptographic) and constants are publicly known. No additional protections exist: no TLS fingerprinting, binary attestation, pre-registration handshake, replay detection, or connection association. Anthropic could escalate enforcement at any time — the current scheme is billing plumbing, not a security boundary. + +oxide-code computes valid `cch` hashes for OAuth requests. The fingerprint salt and xxHash64 seed are version-specific constants; they may change with Claude Code releases. ## API Version From 32861e72bd8e4e7bb61eae1f72a1b5bf2b843d31 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Mon, 6 Apr 2026 01:15:42 +0800 Subject: [PATCH 3/6] style(CLAUDE): align crate structure comments to column 33 --- CLAUDE.md | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e2317b5a..fe03ec2e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,43 +7,43 @@ oxide-code is a terminal-based AI coding assistant written in Rust, inspired by ### CLI ```bash -ox # Start an interactive session +ox # Start an interactive session ``` ### Project Layout ```text . -├── crates/oxide-code/ # Main binary crate -├── docs/ # Roadmap and research notes -└── target/ # Build output +├── crates/oxide-code/ # Main binary crate +├── docs/ # Roadmap and research notes +└── target/ # Build output ``` ### Crate Structure (`crates/oxide-code/src/`) ```text . -├── client.rs # Client module root +├── client.rs # Client module root ├── client/ -│ ├── anthropic.rs # Anthropic Messages API streaming client -│ └── billing.rs # Billing attribution header (fingerprint, cch attestation) -├── config.rs # Configuration loading (env vars, model, base URL) +│ ├── anthropic.rs # Anthropic Messages API streaming client +│ └── billing.rs # Billing attribution header (fingerprint, cch attestation) +├── config.rs # Configuration loading (env vars, model, base URL) ├── config/ -│ └── oauth.rs # Claude Code OAuth credentials (macOS Keychain + file), token refresh, file locking -├── main.rs # CLI entry point, agent loop, async REPL -├── message.rs # Conversation message types -├── prompt.rs # System prompt builder (section assembly, static content) +│ └── oauth.rs # Claude Code OAuth credentials (macOS Keychain + file), token refresh, file locking +├── main.rs # CLI entry point, agent loop, async REPL +├── message.rs # Conversation message types +├── prompt.rs # System prompt builder (section assembly, static content) ├── prompt/ -│ ├── environment.rs # Runtime environment detection (platform, git, date) -│ └── instructions.rs # Instruction file discovery and loading (CLAUDE.md, AGENTS.md) -├── tool.rs # Tool trait, registry, definitions +│ ├── environment.rs # Runtime environment detection (platform, git, date) +│ └── instructions.rs # Instruction file discovery and loading (CLAUDE.md, AGENTS.md) +├── tool.rs # Tool trait, registry, definitions └── tool/ - ├── bash.rs # Shell command execution with timeout - ├── edit.rs # Exact string replacement in files - ├── glob.rs # File pattern matching (glob) - ├── grep.rs # Content search via regex - ├── read.rs # File reading with line numbers and pagination - └── write.rs # File writing with directory creation + ├── bash.rs # Shell command execution with timeout + ├── edit.rs # Exact string replacement in files + ├── glob.rs # File pattern matching (glob) + ├── grep.rs # Content search via regex + ├── read.rs # File reading with line numbers and pagination + └── write.rs # File writing with directory creation ``` ## Coding Conventions From 03adc347540b0e2979b2fe85b2e4bdcbba0b8ccb Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Mon, 6 Apr 2026 01:24:03 +0800 Subject: [PATCH 4/6] fix(billing): address review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix workspace Cargo.toml: move xxhash-rust after tracing-subscriber to restore alphabetical dependency order. - DRY: use CCH_PLACEHOLDER constant in build_billing_header instead of hardcoding the literal. - Reorder tests to match convention: happy path → variants → edge cases. --- Cargo.toml | 2 +- crates/oxide-code/src/client/billing.rs | 48 ++++++++++++------------- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index eff68084..654b1e41 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,12 +46,12 @@ tokio = { version = "1", features = [ "time", ] } tracing = "0.1" -xxhash-rust = { version = "0.8", features = ["xxh64"] } tracing-subscriber = { version = "0.3", default-features = false, features = [ "ansi", "env-filter", "fmt", ] } +xxhash-rust = { version = "0.8", features = ["xxh64"] } [profile.release] lto = true diff --git a/crates/oxide-code/src/client/billing.rs b/crates/oxide-code/src/client/billing.rs index 22fb90ac..01798165 100644 --- a/crates/oxide-code/src/client/billing.rs +++ b/crates/oxide-code/src/client/billing.rs @@ -39,7 +39,7 @@ pub(super) fn build_billing_header(version: &str, fingerprint: &str) -> String { "x-anthropic-billing-header: \ cc_version={version}.{fingerprint}; \ cc_entrypoint=cli; \ - cch=00000;" + {CCH_PLACEHOLDER};" ) } @@ -75,6 +75,16 @@ mod tests { assert_eq!(fp, "9e7"); } + #[test] + fn compute_fingerprint_varies_with_version() { + let fp1 = compute_fingerprint("hello world", "2.1.37"); + let fp2 = compute_fingerprint("hello world", "2.1.87"); + assert_ne!( + fp1, fp2, + "different versions should produce different fingerprints" + ); + } + #[test] fn compute_fingerprint_short_message_pads_with_zero() { // "Hi" (len 2): all fingerprint indices (4, 7, 20) are out of bounds, @@ -97,16 +107,6 @@ mod tests { ); } - #[test] - fn compute_fingerprint_varies_with_version() { - let fp1 = compute_fingerprint("hello world", "2.1.37"); - let fp2 = compute_fingerprint("hello world", "2.1.87"); - assert_ne!( - fp1, fp2, - "different versions should produce different fingerprints" - ); - } - // ── build_billing_header ── #[test] @@ -144,19 +144,6 @@ mod tests { assert_eq!(inject_cch(body), inject_cch(body)); } - #[test] - fn inject_cch_replaces_only_first_occurrence() { - // system (with placeholder) is before messages — matches our struct field order. - let body = r#"{"system":[{"type":"text","text":"cch=00000;"}],"messages":[{"role":"user","content":[{"type":"text","text":"cch=00000"}]}]}"#; - let result = inject_cch(body); - - assert_eq!( - result.matches("cch=00000").count(), - 1, - "only the second occurrence (in messages) should remain" - ); - } - #[test] fn inject_cch_produces_five_hex_chars() { let body = r#"{"system":[{"type":"text","text":"cch=00000;"}]}"#; @@ -169,4 +156,17 @@ mod tests { "cch should be 5 hex chars, got: {cch_value}" ); } + + #[test] + fn inject_cch_replaces_only_first_occurrence() { + // system (with placeholder) is before messages — matches our struct field order. + let body = r#"{"system":[{"type":"text","text":"cch=00000;"}],"messages":[{"role":"user","content":[{"type":"text","text":"cch=00000"}]}]}"#; + let result = inject_cch(body); + + assert_eq!( + result.matches("cch=00000").count(), + 1, + "only the second occurrence (in messages) should remain" + ); + } } From e4201e83b0cb5a38b1e569f946736cee10a31af4 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Mon, 6 Apr 2026 01:29:12 +0800 Subject: [PATCH 5/6] chore(cspell): add 'xxhash' to custom dictionary --- .cspell/words.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/.cspell/words.txt b/.cspell/words.txt index d9b6cecc..83e24951 100644 --- a/.cspell/words.txt +++ b/.cspell/words.txt @@ -23,3 +23,4 @@ thiserror tokio tracing venv +xxhash From f355cf10b63c5315ad29c813e7a87f5ff4cbbf20 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Mon, 6 Apr 2026 01:30:39 +0800 Subject: [PATCH 6/6] chore(cspell): add unknown words to word list --- .cspell/words.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.cspell/words.txt b/.cspell/words.txt index 83e24951..c813851e 100644 --- a/.cspell/words.txt +++ b/.cspell/words.txt @@ -1,4 +1,5 @@ anthropic +anthropics anyhow claudemd clippy @@ -15,6 +16,7 @@ RAII ratatui replacen reqwest +rfind rustls serde strum