Skip to content

feat: compute cch via xxHash64 of full request body#192

Open
Arkptz wants to merge 7 commits intogriffinmartin:mainfrom
Arkptz:feat/cch-xxhash64
Open

feat: compute cch via xxHash64 of full request body#192
Arkptz wants to merge 7 commits intogriffinmartin:mainfrom
Arkptz:feat/cch-xxhash64

Conversation

@Arkptz
Copy link
Copy Markdown

@Arkptz Arkptz commented Apr 14, 2026

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:

  1. Billing header is built with a cch=00000 placeholder
  2. The full request body is serialized to JSON
  3. xxHash64(bodyBytes, seed) & 0xFFFFF → 5-char hex
  4. The placeholder is replaced with the computed hash

The 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:

  • Pure TypeScript xxHash64 implementation (zero dependencies)
  • ccVersion bump from 2.1.90 to 2.1.97
  • Removal of dead computeCch() function and its export
  • Developer scripts for cch verification (extract-cch) and prompt testing (test-prompt)

Related issue

Related to #188, #190 — correct cch computation is a prerequisite for reliable billing validation.

Testing

  • All existing tests pass (assertions updated for new cch behavior)
  • New xxHash64 test suite: spec vectors, determinism, seed consistency (8 tests)
  • xxHash64 output verified against Python reference implementation
  • extract-cch.ts intercept proxy confirms our hash matches real Claude CLI output
  • make all passes locally

Checklist

  • PR title follows Conventional Commits (feat:, fix:, docs:, chore:, etc.)
  • make all passes locally (runs lint, build, and test)
  • Tests added or updated where applicable
  • README or docs updated where applicable

Copy link
Copy Markdown
Owner

@griffinmartin griffinmartin left a comment

Choose a reason for hiding this comment

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

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: main now uses a prefixName(...) 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 literal cch=00000, the wrong location is patched and the request fails validation while the log still records a plausible-looking cch.
  • If the placeholder is missing entirely (e.g. an upstream caller bypasses buildBillingHeaderValue, or a future transform strips the billing header), replace is a silent no-op. The body goes out without a real cch but log("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.tscomputeCch is three lines, costs nothing, and external consumers may exist.
  • Otherwise, relabel the commit as feat!: with a BREAKING 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)

  • buildBillingHeaderValue now produces a template (cch=00000;) rather than a final header. The doc comment you added is sufficient; if you're already touching the file, a Template suffix in the name would make it clearer at call sites, but optional.
  • scripts/extract-cch.ts uses hardcoded /tmp/... paths and has a benign race between setTimeout(..., 3000) and clearTimeout(timer) on child close. Dev-only script, not worth blocking on.

Summary of required actions

  1. Rebase onto main (resolve transforms.ts conflict against prefixName).
  2. Bump ccVersion to current.
  3. Re-run scripts/extract-cch.ts against Claude Code 2.1.112 and confirm seed validity in a comment on this PR.
  4. Anchor and validate the cch placeholder replacement; fail loudly if missing or duplicated.
  5. Restore computeCch export OR relabel commit as feat!:.
  6. Add at least one fixture-based cch regression vector.
  7. Add at least one short-input xxhash spec vector.
  8. 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants