feat(auth): bind subscriber hash to environment, dual-accept (alt to #79)#84
feat(auth): bind subscriber hash to environment, dual-accept (alt to #79)#84ravidsrk wants to merge 6 commits into
Conversation
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.
|
| 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
%%{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
Reviews (1): Last reviewed commit: "test(auth): pin legacy cross-env accept ..." | Re-trigger Greptile
| @@ -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 | |||
There was a problem hiding this comment.
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!
There was a problem hiding this comment.
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.
|
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. |
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 inauth.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.