feat: compute cch via xxHash64 of full request body#192
Conversation
Replace hardcoded cch=fa690 SHA-256 test vectors with structural checks (5-char hex, not placeholder) since cch is now computed via xxHash64. Add xxhash64.ts to SOURCE_FILES in index.test.ts so temp-dir copies include the new module.
griffinmartin
left a comment
There was a problem hiding this comment.
Thanks for the careful reverse-engineering work — the xxHash64 implementation is clean, the seed-extraction story is convincing, and tests pass. Before merge, a few things must be addressed.
Must-fix
1. Rebase onto current main — branch is stale
mergeStateStatus: DIRTY,mergeable: CONFLICTING.- Conflict source is
src/transforms.ts:mainnow uses aprefixName(...)helper (PascalCase tool prefixing, PR #197) at lines 11/231/248; this PR still uses${TOOL_PREFIX}${tool.name}directly. - Releases 1.5.0 → 1.5.3 have shipped since this PR was opened.
2. Update ccVersion to current
main is at ccVersion: "2.1.112" (src/model-config.ts:13). This PR proposes 2.1.97, which is already two CC releases behind. Bump to whatever ships at merge time.
3. Re-verify CCH_SEED against Claude Code 2.1.112
The PR description states the seed is constant across v2.1.37 → v2.1.97. main is now 2.1.112. Please re-run scripts/extract-cch.ts against the current Claude Code release and confirm 0x6E52736AC806831En still produces matching hashes before merge. Reverse-engineered constants tied to a vendor binary need re-confirmation on every version bump.
4. Harden the placeholder replacement (src/transforms.ts:248)
const final = serialized.replace("cch=00000", `cch=${cch}`)Two real failure modes:
String.prototype.replace(string, …)only replaces the first occurrence. If user-supplied content (a prompt, a moved system entry, a tool result) ever contains the literalcch=00000, the wrong location is patched and the request fails validation while the log still records a plausible-lookingcch.- If the placeholder is missing entirely (e.g. an upstream caller bypasses
buildBillingHeaderValue, or a future transform strips the billing header),replaceis a silent no-op. The body goes out without a real cch butlog("transform_cch", { cch, … })still reports a value — false confidence.
Suggested fix: anchor on the trailing ; (cch=00000;) which is structurally part of the billing header format, and assert exactly one replacement occurred. Pseudo:
const PLACEHOLDER = "cch=00000;"
const idx = serialized.indexOf(PLACEHOLDER)
if (idx === -1 || serialized.indexOf(PLACEHOLDER, idx + 1) !== -1) {
log("transform_cch_placeholder_missing_or_duplicate", { ... })
return serialized // or throw, depending on policy
}
const final = serialized.slice(0, idx) + `cch=${cch};` + serialized.slice(idx + PLACEHOLDER.length)5. Decide on the computeCch export removal
This package is published (opencode-claude-auth@1.5.3, dist/index.d.ts). computeCch is exported from src/index.ts:51 on main and removed in this PR. Two acceptable resolutions:
- (preferred) Restore as a thin back-compat shim in
signing.ts/index.ts—computeCchis three lines, costs nothing, and external consumers may exist. - Otherwise, relabel the commit as
feat!:with aBREAKING CHANGE:footer per Conventional Commits, and let release-please bump the major.
The current feat: title with a silent public-API removal is the wrong combination.
Should-fix
6. Add a regression vector pinning CCH_SEED
src/xxhash64.test.ts covers algorithm correctness against the empty/seed-0 spec vector and computeCchHash's format/determinism — but nothing pins (body, expected_cch) against CCH_SEED. If the seed is ever changed by accident, the only thing that catches it is the manual scripts/extract-cch.ts (which requires the Claude CLI installed).
Add at least one fixture, e.g. capture a known body with the placeholder and assert the resulting cch matches a hard-coded value.
7. Strengthen xxhash64 spec coverage
The empty-input/seed-0 vector exercises only the avalanche + length-add paths. Add at least one of the standard xxHash test corpus vectors that cover:
- short non-empty input (<8 bytes)
- input crossing the 32-byte stripe boundary
This protects future maintainers against subtle off-by-one or stripe-loop regressions.
8. Mark cch as the last mutation in transformBody
At the end of transformBody (src/transforms.ts ~line 244):
// IMPORTANT: cch must be computed last. Any mutation of `parsed` or
// `serialized` after this point desyncs the hash from the body that
// will be sent on the wire.Cheap insurance; the function is long enough that someone will eventually want to add a step at the bottom.
Nits (non-blocking)
buildBillingHeaderValuenow produces a template (cch=00000;) rather than a final header. The doc comment you added is sufficient; if you're already touching the file, aTemplatesuffix in the name would make it clearer at call sites, but optional.scripts/extract-cch.tsuses hardcoded/tmp/...paths and has a benign race betweensetTimeout(..., 3000)andclearTimeout(timer)on child close. Dev-only script, not worth blocking on.
Summary of required actions
- Rebase onto
main(resolvetransforms.tsconflict againstprefixName). - Bump
ccVersionto current. - Re-run
scripts/extract-cch.tsagainst Claude Code 2.1.112 and confirm seed validity in a comment on this PR. - Anchor and validate the cch placeholder replacement; fail loudly if missing or duplicated.
- Restore
computeCchexport OR relabel commit asfeat!:. - Add at least one fixture-based cch regression vector.
- Add at least one short-input xxhash spec vector.
- Add the "must remain last" comment near the cch step.
The core change is good and matches my read of the Bun binary's behavior. Once items 1–5 land I'm happy to approve.
Greptile SummaryThis PR replaces the old SHA-256-based
Confidence Score: 3/5The core xxHash64 implementation is correct and the signing refactor is clean, but the string-replacement step in transforms.ts has a position-dependent correctness issue that warrants a fix before merging. The Files Needing Attention: src/transforms.ts (the replace call at line 250) and src/xxhash64.test.ts (missing a pinned expected value for computeCchHash)
|
| Filename | Overview |
|---|---|
| src/transforms.ts | Adds xxHash64-based cch computation after full body serialization; uses string.replace() with a plain string that targets only the first occurrence, which may hit user message content before the billing header if JSON property order puts messages first |
| src/xxhash64.ts | New pure-TypeScript xxHash64 implementation using BigInt; primes, accumulator lanes, merge, and avalanche steps match the spec; verified against the known empty-input spec vector |
| src/xxhash64.test.ts | Tests verify spec vector, determinism, and format of computeCchHash, but no test pins a known expected output for the CCH_SEED hash value, leaving seed correctness unverifiable from the test suite alone |
| src/signing.ts | Removes computeCch (SHA-256 path) and makes buildBillingHeaderValue always emit cch=00000 placeholder; clean and correct change |
| src/transforms.test.ts | Updated cch assertions from specific hash values to format-only checks (non-placeholder 5-char hex); weaker regression coverage but tests still fail if placeholder is not replaced |
| src/model-config.ts | Bumps ccVersion from 2.1.90 to 2.1.97; straightforward version string update |
Sequence Diagram
sequenceDiagram
participant Client
participant transformBody
participant buildBillingHeaderValue
participant computeCchHash
participant API as Anthropic API
Client->>transformBody: POST body (JSON string)
transformBody->>buildBillingHeaderValue: messages, version, entrypoint
buildBillingHeaderValue-->>transformBody: "billing header with cch=00000 placeholder"
transformBody->>transformBody: inject into system[0], relocate non-core entries
transformBody->>transformBody: JSON.stringify parsed object
transformBody->>computeCchHash: TextEncoder bytes of serialized JSON
computeCchHash->>computeCchHash: xxhash64(bytes, CCH_SEED) AND 0xFFFFF
computeCchHash-->>transformBody: 5-char hex cch value
transformBody->>transformBody: "replace first cch=00000 with computed cch"
transformBody-->>Client: final body with real cch
Client->>API: POST with computed cch in billing header
Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 3
src/transforms.ts:250
**String replacement targets first occurrence regardless of position**
`String.prototype.replace` with a plain string argument replaces only the *first* occurrence of `"cch=00000"` in the serialized JSON. JSON property order follows insertion order, so if the original request body serializes `messages` before `system` (e.g., the client sent them in that order), and a user message contains the literal text `"cch=00000"`, the replacement hits the user-message content instead of the billing header — leaving `system[0].text` with the placeholder and silently mutating the user's message.
A targeted replacement that anchors to the billing header prefix avoids the ordering dependency, e.g. anchoring to the billing header key before replacing the value.
No test currently exercises a request where `messages` precedes `system` in JSON order and user content contains `"cch=00000"`.
### Issue 2 of 3
src/xxhash64.test.ts:40-74
**No known-value end-to-end test vector for `computeCchHash`**
The `xxhash64` tests include the spec vector for empty input with seed 0, which validates correctness of the core hash function. However, all `computeCchHash` tests only assert format (`/^[0-9a-f]{5}$/`), determinism, or self-consistency (computing via both paths with the same `CCH_SEED`). None assert against a *known expected hash value* from the actual Claude CLI intercept. If `CCH_SEED` contains a transcription error, all these tests still pass — the format check only confirms a 5-char hex string is produced. Adding one test that pins a specific `computeCchHash` output (from the `extract-cch.ts` probe results or Python reference) would close this gap.
### Issue 3 of 3
src/xxhash64.ts:123-124
The comment says "v2.1.37" but the PR description notes the seed was verified across v2.1.37 → v2.1.97 and the version is being bumped to 2.1.97. Updating the comment to reflect the verified range makes it easier to know when re-verification is needed.
```suggestion
/** The seed baked into Claude Code's custom Bun binary (verified v2.1.37–v2.1.97) */
export const CCH_SEED = 0x6e52736ac806831en
```
Reviews (1): Last reviewed commit: "style: fix formatting" | Re-trigger Greptile
| const serialized = JSON.stringify(parsed) | ||
| const encoder = new TextEncoder() | ||
| const cch = computeCchHash(encoder.encode(serialized)) | ||
| const final = serialized.replace("cch=00000", `cch=${cch}`) |
There was a problem hiding this comment.
String replacement targets first occurrence regardless of position
String.prototype.replace with a plain string argument replaces only the first occurrence of "cch=00000" in the serialized JSON. JSON property order follows insertion order, so if the original request body serializes messages before system (e.g., the client sent them in that order), and a user message contains the literal text "cch=00000", the replacement hits the user-message content instead of the billing header — leaving system[0].text with the placeholder and silently mutating the user's message.
A targeted replacement that anchors to the billing header prefix avoids the ordering dependency, e.g. anchoring to the billing header key before replacing the value.
No test currently exercises a request where messages precedes system in JSON order and user content contains "cch=00000".
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/transforms.ts
Line: 250
Comment:
**String replacement targets first occurrence regardless of position**
`String.prototype.replace` with a plain string argument replaces only the *first* occurrence of `"cch=00000"` in the serialized JSON. JSON property order follows insertion order, so if the original request body serializes `messages` before `system` (e.g., the client sent them in that order), and a user message contains the literal text `"cch=00000"`, the replacement hits the user-message content instead of the billing header — leaving `system[0].text` with the placeholder and silently mutating the user's message.
A targeted replacement that anchors to the billing header prefix avoids the ordering dependency, e.g. anchoring to the billing header key before replacing the value.
No test currently exercises a request where `messages` precedes `system` in JSON order and user content contains `"cch=00000"`.
How can I resolve this? If you propose a fix, please make it concise.| describe("computeCchHash", () => { | ||
| it("returns a 5-char lowercase hex string", () => { | ||
| const body = new TextEncoder().encode('{"prompt":"hello","cch":"00000"}') | ||
| const result = computeCchHash(body) | ||
| assert.match(result, /^[0-9a-f]{5}$/, "must be 5-char hex") | ||
| }) | ||
|
|
||
| it("is deterministic (same input produces same output)", () => { | ||
| const body = new TextEncoder().encode('{"prompt":"hello","cch":"00000"}') | ||
| const r1 = computeCchHash(body) | ||
| const r2 = computeCchHash(body) | ||
| assert.equal(r1, r2, "same input must produce same hash") | ||
| }) | ||
|
|
||
| it("uses CCH_SEED consistently", () => { | ||
| const body = new TextEncoder().encode('{"model":"claude","cch":"00000"}') | ||
| const fromComputeCch = computeCchHash(body) | ||
| // Manually compute using xxhash64 with CCH_SEED | ||
| const hash = xxhash64(body, CCH_SEED) | ||
| const expected = (hash & 0xfffffn).toString(16).padStart(5, "0") | ||
| assert.equal( | ||
| fromComputeCch, | ||
| expected, | ||
| "computeCchHash must use CCH_SEED internally", | ||
| ) | ||
| }) | ||
|
|
||
| it("produces different hashes for different inputs", () => { | ||
| const body1 = new TextEncoder().encode('{"a":1,"cch":"00000"}') | ||
| const body2 = new TextEncoder().encode('{"b":2,"cch":"00000"}') | ||
| const r1 = computeCchHash(body1) | ||
| const r2 = computeCchHash(body2) | ||
| assert.notEqual(r1, r2, "different inputs should produce different hashes") | ||
| }) | ||
| }) |
There was a problem hiding this comment.
No known-value end-to-end test vector for
computeCchHash
The xxhash64 tests include the spec vector for empty input with seed 0, which validates correctness of the core hash function. However, all computeCchHash tests only assert format (/^[0-9a-f]{5}$/), determinism, or self-consistency (computing via both paths with the same CCH_SEED). None assert against a known expected hash value from the actual Claude CLI intercept. If CCH_SEED contains a transcription error, all these tests still pass — the format check only confirms a 5-char hex string is produced. Adding one test that pins a specific computeCchHash output (from the extract-cch.ts probe results or Python reference) would close this gap.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/xxhash64.test.ts
Line: 40-74
Comment:
**No known-value end-to-end test vector for `computeCchHash`**
The `xxhash64` tests include the spec vector for empty input with seed 0, which validates correctness of the core hash function. However, all `computeCchHash` tests only assert format (`/^[0-9a-f]{5}$/`), determinism, or self-consistency (computing via both paths with the same `CCH_SEED`). None assert against a *known expected hash value* from the actual Claude CLI intercept. If `CCH_SEED` contains a transcription error, all these tests still pass — the format check only confirms a 5-char hex string is produced. Adding one test that pins a specific `computeCchHash` output (from the `extract-cch.ts` probe results or Python reference) would close this gap.
How can I resolve this? If you propose a fix, please make it concise.| /** The seed baked into Claude Code's custom Bun binary (v2.1.37) */ | ||
| export const CCH_SEED = 0x6e52736ac806831en |
There was a problem hiding this comment.
The comment says "v2.1.37" but the PR description notes the seed was verified across v2.1.37 → v2.1.97 and the version is being bumped to 2.1.97. Updating the comment to reflect the verified range makes it easier to know when re-verification is needed.
| /** The seed baked into Claude Code's custom Bun binary (v2.1.37) */ | |
| export const CCH_SEED = 0x6e52736ac806831en | |
| /** The seed baked into Claude Code's custom Bun binary (verified v2.1.37–v2.1.97) */ | |
| export const CCH_SEED = 0x6e52736ac806831en |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/xxhash64.ts
Line: 123-124
Comment:
The comment says "v2.1.37" but the PR description notes the seed was verified across v2.1.37 → v2.1.97 and the version is being bumped to 2.1.97. Updating the comment to reflect the verified range makes it easier to know when re-verification is needed.
```suggestion
/** The seed baked into Claude Code's custom Bun binary (verified v2.1.37–v2.1.97) */
export const CCH_SEED = 0x6e52736ac806831en
```
How can I resolve this? If you propose a fix, please make it concise.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Summary
Replace SHA-256-based cch computation with xxHash64 of the full serialized request body, matching Claude Code's actual signing mechanism.
The previous implementation used
SHA-256(firstUserMessageText)[:5], but analysis of Claude Code's custom Bun binary reveals the actual algorithm:cch=00000placeholderxxHash64(bodyBytes, seed) & 0xFFFFF→ 5-char hexThe seed (
0x6E52736AC806831E) was extracted from Claude Code's Bun runtime and verified against the Python reference implementation across all input lengths. It has remained constant across versions (v2.1.37 → v2.1.97).Also includes:
ccVersionbump from2.1.90to2.1.97computeCch()function and its exportextract-cch) and prompt testing (test-prompt)Related issue
Related to #188, #190 — correct cch computation is a prerequisite for reliable billing validation.
Testing
extract-cch.tsintercept proxy confirms our hash matches real Claude CLI outputmake allpasses locallyChecklist
feat:,fix:,docs:,chore:, etc.)make allpasses locally (runs lint, build, and test)