Skip to content

feat(auth): bind subscriber hash to environment, dual-accept (alt to #79)#84

Closed
ravidsrk wants to merge 6 commits into
dodopayments:mainfrom
ravidsrk:ravidsrk/cs-55-hmac-env-id
Closed

feat(auth): bind subscriber hash to environment, dual-accept (alt to #79)#84
ravidsrk wants to merge 6 commits into
dodopayments:mainfrom
ravidsrk:ravidsrk/cs-55-hmac-env-id

Conversation

@ravidsrk

Copy link
Copy Markdown

This PR is an alternative implementation of #55, opened alongside #79 (thanks @soufian3hm).

The subscriber HMAC input becomes env_typeid || 0x00 || subscriber_id. Rollout is dual-accept: the new formula is verified first, the legacy formula falls back, and the fallback is marked for removal at an announced minor bump. The shared-secret cross-environment window that dual-accept opens is named at the fallback and in auth.mdx, and pinned by a red-team test computing the expected hash independently (HMAC crate directly, not the production helper — tautology guard).

Two things my review of #79 flagged that this PR handles: every customer-facing surface that teaches the formula is migrated (client README/JSDoc, react README, admin panel, examples), and since the docs reference an environment id that no admin page displayed, the admin panel gains an Environment ID copy field so customers can actually compute the new hash.

ravidsrk added 5 commits July 16, 2026 00:30
The subscriber MAC input is now env_typeid || 0x00 || subscriber_id,
so a hash never transfers between environments even if two
environments are given the same secret (issue dodopayments#55). The legacy input
(subscriber_id alone) stays dual-accepted until an announced minor
version bump.
New formula and env id source in auth.mdx and the nextjs example,
plus the legacy-fallback removal notice.
Customers following the SDK readmes, the client JSDoc, or the admin
HMAC panel kept minting legacy hashes and would break at the fallback
removal. Every formula copy now teaches env_id + NUL + subscriber_id
with the dual-accept note, and the admin panel shows the environment
id beside the secret it feeds.
During the dual-accept window a legacy hash carries no environment
binding, so environments sharing a secret accept each other's legacy
hashes. That accepted tradeoff (FORK 55-A) is now stated at the
fallback and in the auth guide callout. Removal notes reworded as
declarative sentences per comment style.
The redteam suite proved env binding for new-formula hashes but was
silent on the intended transitional hole. A shared-secret legacy hash
now pins 200 in env B, to flip to 401 at the fallback removal, and the
existing test's doc comment scopes its claim to env-bound hashes.
@ravidsrk

Copy link
Copy Markdown
Author

@claude review this PR. @codex review this PR.

@greptile-apps

greptile-apps Bot commented Jul 15, 2026

Copy link
Copy Markdown

Greptile Summary

This PR hardens subscriber authentication by binding the HMAC input to the environment: HMAC-SHA256(secret, env_typeid || 0x00 || subscriber_id) instead of just subscriber_id, preventing a hash minted for one environment from validating in another even when two environments share a secret. A dual-accept rollout accepts legacy hashes as a fallback until an announced minor-version bump, with the cross-environment exposure window explicitly documented in the auth guide, the OpenAPI spec, and a red-team test that pins the known tradeoff.

  • Core formula change (server/src/auth.rs): subscriber_mac_input prepends the TypeID string and a NUL separator; hmac_matches is extracted for reuse; verify_subscriber_hash tries the new formula first, then the legacy fallback, for both the current and previous secret slots.
  • Red-team tests (redteam_cross_env.rs): shared_secret_env_a_hash_is_rejected_by_env_b proves the new-formula hash is env-scoped even with a shared secret; shared_secret_legacy_hash_cross_authenticates_until_removal explicitly pins the dual-accept window risk.
  • Customer-facing migration (docs, READMEs, JSDoc, admin panel): every surface that teaches the formula is updated, and the admin dashboard now shows a copyable Environment ID so customers can compute the new hash without guessing the TypeID format.

Confidence Score: 4/5

Safe to merge. The formula change is correctly implemented, constant-time comparisons are preserved, and the dual-accept window behaves as documented.

The auth core is well-constructed: the new subscriber_mac_input function uses a fixed-length TypeID prefix so there is no separator-injection ambiguity, hmac_matches is constant-time via verify_slice, and all four combinations of (new/legacy formula) × (current/previous secret) are reachable. The red-team suite independently verifies the formula without delegating to the production helper. The only gap is that the rotation test exercises new-formula hashes against the previous slot but has no case for a legacy hash surviving rotation into the previous slot.

server/tests/auth.rs — the rotation_verifies_current_then_previous_secret test would benefit from a legacy-hash variant to cover the full dual-accept × rotation matrix.

Important Files Changed

Filename Overview
server/src/auth.rs Introduces subscriber_mac_input (TypeID + NUL + subscriber_id) and dual-accept via hmac_matches helper. New formula verified first; legacy fallback documented and correctly not env-bound. Hex decode and constant-time comparison logic are correct.
server/tests/auth.rs All existing call sites updated to pass env.id; new legacy_subscriber_id_only_hash_still_authenticates test added. Rotation test exercises new-formula + previous-slot. Legacy-formula + previous-slot combination is not covered.
server/tests/redteam_cross_env.rs Two new red-team tests added: shared-secret env-bound hash is rejected by env B (correct), and shared-secret legacy hash cross-authenticates until removal (expected dual-accept tradeoff, explicitly documented). Independent env_bound_hash helper avoids tautology with production helper.
server/admin/src/routes/environment-detail.tsx Adds Environment ID CopyField to the HMAC tab. detail.id is the TypeID string (env_...) confirmed by admin.rs serialization, matching the HMAC formula exactly.
docs/content/docs/auth.mdx Formula updated to include environment_id + NUL separator; legacy-accept callout added with clear cross-environment warning; Node.js code sample updated correctly.
server/tests/support/mod.rs All three compute_subscriber_hash call sites updated with env.id; no omissions.
server/tests/sse.rs Single call site updated; SSE hash-scrubbing log test unaffected by formula change.
server/tests/admin.rs Single call site updated to pass env.id; admin test unchanged in intent.
packages/client/src/types.ts JSDoc for subscriberHash updated with new formula and legacy-acceptance note.
docs/openapi/chimely.yaml Regenerated from server/src/openapi.rs; content matches the Rust source.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Incoming request\nwith subscriber_hash] --> B[hex::decode hash_hex]
    B -->|invalid hex| Z[return false]
    B -->|OK| C[Try new formula:\nHMAC current_secret\nenv_typeid + NUL + subscriber_id]
    C -->|matches| Y[return true]
    C -->|no match| D[Try legacy fallback:\nHMAC current_secret\nsubscriber_id only]
    D -->|matches| Y
    D -->|no match| E{previous secret\nexists?}
    E -->|No| Z2[return false]
    E -->|Yes| F[Try new formula:\nHMAC prev_secret\nenv_typeid + NUL + subscriber_id]
    F -->|matches| Y
    F -->|no match| G[Try legacy fallback:\nHMAC prev_secret\nsubscriber_id only]
    G -->|matches| Y
    G -->|no match| Z2
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[Incoming request\nwith subscriber_hash] --> B[hex::decode hash_hex]
    B -->|invalid hex| Z[return false]
    B -->|OK| C[Try new formula:\nHMAC current_secret\nenv_typeid + NUL + subscriber_id]
    C -->|matches| Y[return true]
    C -->|no match| D[Try legacy fallback:\nHMAC current_secret\nsubscriber_id only]
    D -->|matches| Y
    D -->|no match| E{previous secret\nexists?}
    E -->|No| Z2[return false]
    E -->|Yes| F[Try new formula:\nHMAC prev_secret\nenv_typeid + NUL + subscriber_id]
    F -->|matches| Y
    F -->|no match| G[Try legacy fallback:\nHMAC prev_secret\nsubscriber_id only]
    G -->|matches| Y
    G -->|no match| Z2
Loading

Fix All in Claude Code

Reviews (1): Last reviewed commit: "test(auth): pin legacy cross-env accept ..." | Re-trigger Greptile

Comment thread server/tests/auth.rs
Comment on lines 123 to 151
@@ -124,7 +145,7 @@ async fn rotation_verifies_current_then_previous_secret() {
get_counts_status(&app, headers(&app.env.slug, SUB, Some(&old_hash))).await,
200
);
let new_hash = compute_subscriber_hash(new_secret, SUB);
let new_hash = compute_subscriber_hash(new_secret, app.env.id, SUB);
assert_eq!(
get_counts_status(&app, headers(&app.env.slug, SUB, Some(&new_hash))).await,
200

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Missing coverage: legacy hash + rotated-to-previous-slot combination

The rotation test was updated to compute hashes with the new formula via compute_subscriber_hash. A customer who has not yet migrated to the new formula will hold a legacy hash (HMAC(old_secret, subscriber_id)). After that customer rotates their secret, their pre-rotation legacy hash must still verify via the previous-slot fallback. The code supports it (both formulas are tried for both secret slots), but this specific combination — legacy formula against the subscriber_hmac_secret_previous slot — has no test. If verify_subscriber_hash is later modified to only apply the legacy fallback against the current slot, the regression would go undetected.

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!

Fix in Claude Code

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 931ec52. Added legacy_hash_survives_rotation_via_previous_slot: a legacy-formula hash (HMAC over subscriber_id alone) minted with the pre-rotation secret must still authenticate after rotation via the previous-slot fallback, and must die once subscriber_hmac_secret_previous is cleared. Negative control verified: scoping the previous-slot check to the env-bound formula only turns exactly this test red while the existing legacy and rotation tests stay green, so the combination is now pinned. Also extracted the inline legacy-HMAC block into a shared legacy_hash helper since two tests now need it.

A legacy-formula hash minted before rotation must keep verifying
through the previous-slot fallback. Pins the one formula-by-slot
combination no existing test exercised.
@that-ambuj

Copy link
Copy Markdown
Member

@ravidsrk could you help me understand how a parallel reimplementation of someone else's open PR is a better use of everyone's time than simply leaving your suggestions on #79? The additions are fine, but I'd rather not maintain two competing PRs for the same issue.

@ravidsrk

Copy link
Copy Markdown
Author

Fair point, and sorry for the extra load. The implementation happened independently from #55 before I found #79; once I saw the overlap I left the transferable findings as a comment on #79 and opened this as a cherry-pickable alternative — but you're right that either way it stands as a second competing PR. Closing in favor of #79. Everything actionable is already in the #79 comment, and the branch stays up if any of the tests or doc migrations are worth lifting.

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