Skip to content

feat(auth): bind the subscriber hash to its environment#79

Open
soufian3hm wants to merge 1 commit into
dodopayments:mainfrom
soufian3hm:feat/hmac-env-binding
Open

feat(auth): bind the subscriber hash to its environment#79
soufian3hm wants to merge 1 commit into
dodopayments:mainfrom
soufian3hm:feat/hmac-env-binding

Conversation

@soufian3hm

Copy link
Copy Markdown
Contributor

Closes #55.

The subscriber hash was hex(HMAC-SHA256(secret, subscriber_id)) -- the environment was not part of the MAC input, so isolation rested entirely on per-environment server-minted secrets. If two environments ever shared a secret (manual DB edit, restored dump), a hash would transfer between them.

What

  • The hash becomes hex(HMAC-SHA256(secret, env_typeid || 0x00 || subscriber_id)), following the formula sketched in the issue. env_typeid is the environment's env_… TypeID -- immutable, and customer-visible in the same dashboard view as the secret. Verification checks the bound form against both secret slots, so it composes with the two-slot rotation.
  • Migration/overlap story: the legacy unbound form stays accepted while CHIMELY_SUBSCRIBER_HASH_LEGACY_ACCEPT is true, which is the default -- this PR changes nothing for deployed backends. Operators flip it to false once their backends compute the bound form; the project can flip the default at a scheduled minor. Both comparisons stay constant-time.
  • Docs updated: auth.mdx (formula, Node example, migration callout), the OpenAPI info.description, and the self-hosting env table. packages/client/src/generated/api.d.ts regenerated from the yaml -- byte-identical, since types don't embed the info description.

Decision points (flagging explicitly)

  • TypeID vs raw UUID vs slug as the MAC input: I used the TypeID because it is the immutable, customer-facing form of the environment id (slugs read nicer but renaming one would invalidate every hash; raw UUIDs are not surfaced to customers). Happy to switch representation before this merges if you prefer -- it is one line in compute/verify plus docs.
  • Default true for legacy accept keeps this merge-safe now and makes the strict flip a scheduled decision rather than a breaking surprise.

Tests

New redteam_hmac_env_binding suite:

  • bound form authenticates; legacy stays accepted by default
  • legacy rejected in strict mode, bound still accepted
  • the audit threat staged directly: two environments sharing a secret (via UPDATE), a hash minted for A is 401 in B under strict mode
  • rotation interplay: a bound hash computed with the old secret verifies against the previous slot

Verification

Full CI (fmt, clippy -D warnings, nextest against real Postgres/Redis, cargo-deny, docker build) green on my fork before opening this PR. No SQL changes, so the .sqlx cache is untouched.

From the launch security audit (dodopayments#55): the MAC input was only the
subscriber id, so isolation rested entirely on per-environment
secrets. If two environments ever shared one (manual DB edit,
restored dump), a hash would transfer between them.

The hash becomes hex(HMAC-SHA256(secret, env_typeid || 0x00 ||
subscriber_id)), checked against both secret slots so it composes
with rotation. The legacy unbound form stays accepted while
CHIMELY_SUBSCRIBER_HASH_LEGACY_ACCEPT is true (the default), giving
deployed backends a migration window. Docs updated to the bound form.

Closes dodopayments#55
@greptile-apps

greptile-apps Bot commented Jul 15, 2026

Copy link
Copy Markdown

Greptile Summary

This PR binds subscriber hashes to their environment. The main changes are:

  • Adds the environment TypeID to the subscriber HMAC input.
  • Keeps legacy unbound hashes behind a migration flag.
  • Documents the new formula and self-hosting flag.
  • Adds tests for strict mode, shared-secret isolation, and secret rotation.

Confidence Score: 5/5

This looks safe to merge.

  • No blocking issues found in the changed code.

Important Files Changed

Filename Overview
server/src/auth.rs Adds environment-bound subscriber hash verification with optional legacy acceptance.
server/src/config.rs Adds the legacy-accept config flag and default parsing.
server/tests/redteam_hmac_env_binding.rs Adds coverage for bound hashes, strict mode, shared-secret isolation, and rotation.
docs/content/docs/auth.mdx Documents the new subscriber hash formula and migration path.
docs/content/docs/self-hosting.mdx Documents the new self-hosting environment variable.
docs/openapi/chimely.yaml Updates the OpenAPI subscriber-plane auth description.
server/src/openapi.rs Keeps the code-first OpenAPI description aligned with the docs.

Reviews (1): Last reviewed commit: "feat(auth): bind the subscriber hash to ..." | Re-trigger Greptile

@ravidsrk

Copy link
Copy Markdown

Independently implemented this against the same issue and hit a few edge cases while testing that may apply here, since the core approach (same MAC input, same 0x00 separator, legacy accept avoiding the deploy-time break) matches what we converged on.

  1. Four more customer-facing surfaces teach the unbound formula and aren't in this diff. Everything a customer reads outside auth.mdx still tells them to compute the old form:

    • packages/client/README.md:22: // Computed by YOUR backend: hex(HMAC-SHA256(environment_secret, subscriberId)).
    • packages/client/src/types.ts:112, the subscriberHash JSDoc: HMAC-SHA256(secret, subscriberId) hex, computed by YOUR backend.
    • packages/react/README.md:28: `subscriberHash` is `hex(HMAC-SHA256(environment_secret, subscriberId))`
    • server/admin/src/routes/environment-detail.tsx:275, the card description directly above the secret CopyField: The customer backend computes <code>HMAC-SHA256(secret, subscriber_id)</code> with this.
    • examples/nextjs/README.md:43 teaches the unbound form too.

    Concrete scenario: a customer integrating from the SDK README or the admin panel rather than auth.mdx keeps minting unbound hashes, never gains the binding, and 401s the moment CHIMELY_SUBSCRIBER_HASH_LEGACY_ACCEPT flips to false. When we migrated only the docs site at first, these stragglers were the thing that bit us.

  2. The docs say the env TypeID is "readable in the admin dashboard", and the compute_subscriber_hash_env_bound doc comment says "as shown in the admin dashboard, next to the secret", but no admin page currently renders it. environment-detail.tsx:44 shows the slug in the header ({env.data?.slug ?? envId}), the HMAC tab CopyFields only the secret, and the environments list renders slugs; the TypeID exists only in the URL path. Since the slug sits in monospace right above the Subscriber HMAC tab, the natural reading of "env id, next to the secret" is the slug, and HMAC(secret, slug || 0x00 || subscriber_id) matches neither form, so it 401s even with legacy accept on. We ended up adding an Environment ID CopyField beside the secret in the HMAC tab so the docs point at something that actually exists on the page.

  3. In redteam_hmac_env_binding.rs the expected hashes come from chimely::auth::compute_subscriber_hash_env_bound, which lives in the same module as verify_subscriber_hash. The two build the MAC input separately today, but a later refactor that changes the input in both (dropping the separator, or swapping TypeID for slug/raw UUID per your open decision point) would keep the suite green while every backend computing the documented formula starts failing. Building the expected hash with the hmac crate directly inside the test, mirroring the auth.mdx Node snippet, pins the documented byte sequence instead. Our cross-env shared-secret test does it that way; happy to share it if useful.

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.

Include environment id in the subscriber HMAC input

3 participants