From eb9044ec21199211f43af7a871612fbcc5c7e4f6 Mon Sep 17 00:00:00 2001 From: Ravindra Kumar Date: Thu, 16 Jul 2026 00:30:58 +0530 Subject: [PATCH 1/6] feat(auth): env-bound subscriber hash 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 #55). The legacy input (subscriber_id alone) stays dual-accepted until an announced minor version bump. --- docs/openapi/chimely.yaml | 8 ++- server/src/auth.rs | 67 ++++++++++++++++------ server/src/openapi.rs | 8 ++- server/tests/admin.rs | 2 +- server/tests/auth.rs | 33 +++++++++-- server/tests/redteam_cross_env.rs | 92 ++++++++++++++++++++++++++++++- server/tests/sse.rs | 2 +- server/tests/support/mod.rs | 11 +++- 8 files changed, 191 insertions(+), 32 deletions(-) diff --git a/docs/openapi/chimely.yaml b/docs/openapi/chimely.yaml index 8751c3e..a0013e0 100644 --- a/docs/openapi/chimely.yaml +++ b/docs/openapi/chimely.yaml @@ -10,8 +10,12 @@ info: by the key. * **Subscriber plane** — called by `@chimely/client` (the `` widget) on behalf of one end user. Authenticated with an HMAC subscriber - hash: `hex(HMAC-SHA256(environment.subscriber_hmac_secret, subscriber_id))` - computed by the customer's backend. Mandatory in environments with + hash: `hex(HMAC-SHA256(environment.subscriber_hmac_secret, + environment_id + "\0" + subscriber_id))` computed by the customer's + backend, where `environment_id` is the environment's TypeID (`env_...`) + as shown in the admin dashboard. Hashes computed with the legacy input + (`subscriber_id` alone) are still accepted; that fallback is removed at + an announced minor version bump. Mandatory in environments with `require_subscriber_hash = true` (the production default); optional in dev environments so the quickstart works without a backend. diff --git a/server/src/auth.rs b/server/src/auth.rs index b6b7686..4e0c3ab 100644 --- a/server/src/auth.rs +++ b/server/src/auth.rs @@ -5,9 +5,11 @@ //! //! Subscriber: environment slug, customer subscriber id, and (when the //! environment requires it) `hex(HMAC-SHA256(subscriber_hmac_secret, -//! subscriber_id))`, verified against the current then the previous secret -//! slot so secret rotation never invalidates live sessions. Headers with -//! query-parameter fallbacks (EventSource cannot set headers). +//! env_typeid || 0x00 || subscriber_id))`, verified against the current then +//! the previous secret slot so secret rotation never invalidates live +//! sessions. The legacy input (`subscriber_id` alone) is still accepted +//! until the announced minor version bump. Headers with query-parameter +//! fallbacks (EventSource cannot set headers). //! //! Admin: built-in users (Argon2id passwords) with a server-side session in //! an HttpOnly, SameSite=Strict cookie scoped to /admin. Roles are @@ -378,11 +380,14 @@ impl FromRequestParts for SubscriberAuth { match &hash { Some(hash) => { - let valid = verify_subscriber_hash(&env.subscriber_hmac_secret, &external_id, hash) - || env - .subscriber_hmac_secret_previous - .as_deref() - .is_some_and(|prev| verify_subscriber_hash(prev, &external_id, hash)); + let valid = + verify_subscriber_hash(&env.subscriber_hmac_secret, env.id, &external_id, hash) + || env + .subscriber_hmac_secret_previous + .as_deref() + .is_some_and(|prev| { + verify_subscriber_hash(prev, env.id, &external_id, hash) + }); if !valid { return Err(ApiError::unauthorized("invalid subscriber hash")); } @@ -405,22 +410,52 @@ impl FromRequestParts for SubscriberAuth { } } -/// `hex(HMAC-SHA256(secret, subscriber_id))`, constant-time comparison. -pub fn verify_subscriber_hash(secret: &str, subscriber_id: &str, hash_hex: &str) -> bool { +/// The MAC input binds the hash to its environment. The environment id is +/// the public TypeID form (`env_...`), the same string the admin dashboard +/// displays next to the secret. +fn subscriber_mac_input(environment_id: Uuid, subscriber_id: &str) -> Vec { + let mut input = ids::typeid(ids::ENVIRONMENT, environment_id).into_bytes(); + input.push(0); + input.extend_from_slice(subscriber_id.as_bytes()); + input +} + +fn hmac_matches(secret: &str, message: &[u8], provided: &[u8]) -> bool { + let mut mac = + Hmac::::new_from_slice(secret.as_bytes()).expect("hmac accepts any key length"); + mac.update(message); + mac.verify_slice(provided).is_ok() +} + +/// `hex(HMAC-SHA256(secret, env_typeid || 0x00 || subscriber_id))`, +/// constant-time comparison. +pub fn verify_subscriber_hash( + secret: &str, + environment_id: Uuid, + subscriber_id: &str, + hash_hex: &str, +) -> bool { let Ok(provided) = hex::decode(hash_hex) else { return false; }; - let mut mac = - Hmac::::new_from_slice(secret.as_bytes()).expect("hmac accepts any key length"); - mac.update(subscriber_id.as_bytes()); - mac.verify_slice(&provided).is_ok() + if hmac_matches( + secret, + &subscriber_mac_input(environment_id, subscriber_id), + &provided, + ) { + return true; + } + // Legacy formula fallback (issue #55 dual-accept rollout). Hashes minted + // over `subscriber_id` alone stay valid until the announced minor version + // bump. Remove this check at that bump. + hmac_matches(secret, subscriber_id.as_bytes(), &provided) } /// Test/SDK helper: compute the subscriber hash the customer backend would. -pub fn compute_subscriber_hash(secret: &str, subscriber_id: &str) -> String { +pub fn compute_subscriber_hash(secret: &str, environment_id: Uuid, subscriber_id: &str) -> String { let mut mac = Hmac::::new_from_slice(secret.as_bytes()).expect("hmac accepts any key length"); - mac.update(subscriber_id.as_bytes()); + mac.update(&subscriber_mac_input(environment_id, subscriber_id)); hex::encode(mac.finalize().into_bytes()) } diff --git a/server/src/openapi.rs b/server/src/openapi.rs index 20dc7d5..4d0cec3 100644 --- a/server/src/openapi.rs +++ b/server/src/openapi.rs @@ -17,8 +17,12 @@ const INFO_DESCRIPTION: &str = r#"Two planes, one binary: by the key. * **Subscriber plane** — called by `@chimely/client` (the `` widget) on behalf of one end user. Authenticated with an HMAC subscriber - hash: `hex(HMAC-SHA256(environment.subscriber_hmac_secret, subscriber_id))` - computed by the customer's backend. Mandatory in environments with + hash: `hex(HMAC-SHA256(environment.subscriber_hmac_secret, + environment_id + "\0" + subscriber_id))` computed by the customer's + backend, where `environment_id` is the environment's TypeID (`env_...`) + as shown in the admin dashboard. Hashes computed with the legacy input + (`subscriber_id` alone) are still accepted; that fallback is removed at + an announced minor version bump. Mandatory in environments with `require_subscriber_hash = true` (the production default); optional in dev environments so the quickstart works without a backend. diff --git a/server/tests/admin.rs b/server/tests/admin.rs index 68cd46e..5f8339b 100644 --- a/server/tests/admin.rs +++ b/server/tests/admin.rs @@ -39,7 +39,7 @@ async fn counts_status_with_secret( subscriber: &str, secret: &str, ) -> reqwest::StatusCode { - let hash = compute_subscriber_hash(secret, subscriber); + let hash = compute_subscriber_hash(secret, app.env.id, subscriber); app.client .get(format!("{}/v1/inbox/counts", app.base)) .header("X-Chimely-Environment", app.env.slug.clone()) diff --git a/server/tests/auth.rs b/server/tests/auth.rs index f9ca77d..fb06d94 100644 --- a/server/tests/auth.rs +++ b/server/tests/auth.rs @@ -39,7 +39,7 @@ async fn get_counts_status(app: &support::TestApp, h: HeaderMap) -> u16 { #[tokio::test] async fn hash_is_mandatory_when_the_environment_requires_it() { let app = support::spawn().await; // require_subscriber_hash = true - let good = compute_subscriber_hash(&app.env.hmac_secret, SUB); + let good = compute_subscriber_hash(&app.env.hmac_secret, app.env.id, SUB); assert_eq!( get_counts_status(&app, headers(&app.env.slug, SUB, None)).await, @@ -52,7 +52,7 @@ async fn hash_is_mandatory_when_the_environment_requires_it() { "wrong hash" ); // A valid hash for a DIFFERENT subscriber id must not transfer. - let other = compute_subscriber_hash(&app.env.hmac_secret, "usr_else"); + let other = compute_subscriber_hash(&app.env.hmac_secret, app.env.id, "usr_else"); assert_eq!( get_counts_status(&app, headers(&app.env.slug, SUB, Some(&other))).await, 401 @@ -77,6 +77,27 @@ async fn hash_is_mandatory_when_the_environment_requires_it() { assert_eq!(get_counts_status(&app, h).await, 401); } +/// Dual-accept rollout for the env-bound hash formula (issue #55). A hash +/// minted with the legacy input (subscriber_id alone) still authenticates. +/// Delete this test when the legacy fallback is dropped at the announced +/// minor version bump. +#[tokio::test] +async fn legacy_subscriber_id_only_hash_still_authenticates() { + let app = support::spawn().await; + let legacy = { + use hmac::Mac; + let mut mac = hmac::Hmac::::new_from_slice(app.env.hmac_secret.as_bytes()) + .expect("hmac accepts any key length"); + mac.update(SUB.as_bytes()); + hex::encode(mac.finalize().into_bytes()) + }; + assert_eq!( + get_counts_status(&app, headers(&app.env.slug, SUB, Some(&legacy))).await, + 200, + "legacy-formula hashes must keep working until the announced removal" + ); +} + #[tokio::test] async fn dev_mode_environments_accept_missing_but_not_invalid_hashes() { let app = support::spawn_dev_mode().await; // require_subscriber_hash = false @@ -91,7 +112,7 @@ async fn dev_mode_environments_accept_missing_but_not_invalid_hashes() { get_counts_status(&app, headers(&app.env.slug, SUB, Some("deadbeef"))).await, 401 ); - let good = compute_subscriber_hash(&app.env.hmac_secret, SUB); + let good = compute_subscriber_hash(&app.env.hmac_secret, app.env.id, SUB); assert_eq!( get_counts_status(&app, headers(&app.env.slug, SUB, Some(&good))).await, 200 @@ -102,7 +123,7 @@ async fn dev_mode_environments_accept_missing_but_not_invalid_hashes() { async fn rotation_verifies_current_then_previous_secret() { let app = support::spawn().await; let old_secret = app.env.hmac_secret.clone(); - let old_hash = compute_subscriber_hash(&old_secret, SUB); + let old_hash = compute_subscriber_hash(&old_secret, app.env.id, SUB); // Rotate: new secret current, old secret in the previous slot. let new_secret = "shmac_rotated_secret"; @@ -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 @@ -149,7 +170,7 @@ async fn rotation_verifies_current_then_previous_secret() { #[tokio::test] async fn query_parameter_fallbacks_match_the_headers() { let app = support::spawn().await; - let hash = compute_subscriber_hash(&app.env.hmac_secret, SUB); + let hash = compute_subscriber_hash(&app.env.hmac_secret, app.env.id, SUB); // Pure query auth (the EventSource case) on a regular endpoint. let res = app diff --git a/server/tests/redteam_cross_env.rs b/server/tests/redteam_cross_env.rs index 2f1ff1e..4305497 100644 --- a/server/tests/redteam_cross_env.rs +++ b/server/tests/redteam_cross_env.rs @@ -132,6 +132,96 @@ async fn counter_updated_at(pool: &sqlx::PgPool, env: Uuid, subscriber: &str) -> .expect("counter updated_at") } +/// The documented subscriber hash, recomputed with the hmac crate directly +/// so the expectation is independent of the server helper. The environment +/// id is the public TypeID form, the string the admin dashboard displays. +fn env_bound_hash(secret: &str, env_typeid: &str, subscriber: &str) -> String { + use hmac::Mac; + let mut mac = hmac::Hmac::::new_from_slice(secret.as_bytes()) + .expect("hmac accepts any key length"); + mac.update(env_typeid.as_bytes()); + mac.update(&[0]); + mac.update(subscriber.as_bytes()); + hex::encode(mac.finalize().into_bytes()) +} + +fn subscriber_headers_with_hash(slug: &str, subscriber: &str, hash: &str) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + "X-Chimely-Environment", + HeaderValue::from_str(slug).unwrap(), + ); + headers.insert( + "X-Chimely-Subscriber", + HeaderValue::from_str(subscriber).unwrap(), + ); + headers.insert( + "X-Chimely-Subscriber-Hash", + HeaderValue::from_str(hash).unwrap(), + ); + headers +} + +/// Issue #55. Two environments end up sharing one secret (manual DB edit or +/// restored dump). The MAC input is env_typeid || 0x00 || subscriber_id, so +/// a hash minted for env A authenticates in env A only. Presented under env +/// B's slug it is rejected even though env B verifies with the same secret, +/// and the probe leaves no subscriber row behind in env B. +#[tokio::test] +async fn shared_secret_env_a_hash_is_rejected_by_env_b() { + let app = support::spawn().await; + let env_b = app.create_environment(true).await; + sqlx::query("UPDATE environments SET subscriber_hmac_secret = $1 WHERE id = $2") + .bind(&app.env.hmac_secret) + .bind(env_b.id) + .execute(&app.pool) + .await + .expect("give env B env A's secret"); + + let sub = "usr_dump_restore"; + let hash_a = env_bound_hash( + &app.env.hmac_secret, + &ids::typeid(ids::ENVIRONMENT, app.env.id), + sub, + ); + + // Control: the env-bound hash is a live credential in env A. + let res = app + .client + .get(format!("{}/v1/inbox/items", app.base)) + .headers(subscriber_headers_with_hash(&app.env.slug, sub, &hash_a)) + .send() + .await + .expect("env A control"); + assert_eq!(res.status(), 200, "the env-bound hash is valid in env A"); + + let res = app + .client + .get(format!("{}/v1/inbox/items", app.base)) + .headers(subscriber_headers_with_hash(&env_b.slug, sub, &hash_a)) + .send() + .await + .expect("cross-env replay with shared secret"); + assert_eq!( + res.status(), + 401, + "env B must reject env A's hash despite the shared secret" + ); + let body: serde_json::Value = res.json().await.expect("error body"); + assert_eq!(body["error"]["code"], "unauthorized"); + + let env_b_subscribers: i64 = + sqlx::query_scalar("SELECT count(*) FROM subscribers WHERE environment_id = $1") + .bind(env_b.id) + .fetch_one(&app.pool) + .await + .expect("subscriber count"); + assert_eq!( + env_b_subscribers, 0, + "a rejected replay must not create a subscriber in env B" + ); +} + /// A valid (subscriber_id, subscriber_hash) pair minted with env A's secret /// is rejected when presented under env B's slug. Auth resolves the secret /// from the presented slug, so env B can never verify env A's hash. The @@ -142,7 +232,7 @@ async fn env_a_subscriber_hash_replayed_against_env_b_is_401() { let app = support::spawn().await; let env_b = app.create_environment(true).await; let sub = "usr_replay"; - let hash_a = compute_subscriber_hash(&app.env.hmac_secret, sub); + let hash_a = compute_subscriber_hash(&app.env.hmac_secret, app.env.id, sub); // Control: the pair is a live credential in env A. let res = app diff --git a/server/tests/sse.rs b/server/tests/sse.rs index f390753..bdf0b2f 100644 --- a/server/tests/sse.rs +++ b/server/tests/sse.rs @@ -235,7 +235,7 @@ async fn subscriber_hash_is_scrubbed_from_access_logs() { .expect("install capture subscriber"); let app = support::spawn().await; - let hash = chimely::auth::compute_subscriber_hash(&app.env.hmac_secret, SUB); + let hash = chimely::auth::compute_subscriber_hash(&app.env.hmac_secret, app.env.id, SUB); let mut stream = SseStream::connect(&app, SUB, None).await; // Force the response (and its access-log line) to materialize. stream.next_frame(Duration::from_secs(2)).await; diff --git a/server/tests/support/mod.rs b/server/tests/support/mod.rs index 3e7e36b..f180b2f 100644 --- a/server/tests/support/mod.rs +++ b/server/tests/support/mod.rs @@ -386,7 +386,12 @@ impl TestApp { ); headers.insert( "X-Chimely-Subscriber-Hash", - HeaderValue::from_str(&compute_subscriber_hash(&env.hmac_secret, subscriber)).unwrap(), + HeaderValue::from_str(&compute_subscriber_hash( + &env.hmac_secret, + env.id, + subscriber, + )) + .unwrap(), ); headers } @@ -762,7 +767,7 @@ impl SseStream { subscriber: &str, last_event_id: Option<&str>, ) -> Self { - let hash = compute_subscriber_hash(&app.env.hmac_secret, subscriber); + let hash = compute_subscriber_hash(&app.env.hmac_secret, app.env.id, subscriber); let url = format!( "{base}/v1/inbox/stream?environment={}&subscriber_id={subscriber}&subscriber_hash={hash}", app.env.slug, @@ -780,7 +785,7 @@ impl SseStream { } pub async fn try_connect(app: &TestApp, subscriber: &str) -> reqwest::Response { - let hash = compute_subscriber_hash(&app.env.hmac_secret, subscriber); + let hash = compute_subscriber_hash(&app.env.hmac_secret, app.env.id, subscriber); let url = format!( "{}/v1/inbox/stream?environment={}&subscriber_id={subscriber}&subscriber_hash={hash}", app.base, app.env.slug, From 74e90130a66a57b52a0047ce9db149ec14414790 Mon Sep 17 00:00:00 2001 From: Ravindra Kumar Date: Thu, 16 Jul 2026 00:30:58 +0530 Subject: [PATCH 2/6] docs(auth): document env-bound subscriber hash New formula and env id source in auth.mdx and the nextjs example, plus the legacy-fallback removal notice. --- docs/content/docs/auth.mdx | 18 +++++++++++++++--- examples/nextjs/README.md | 4 +++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/docs/content/docs/auth.mdx b/docs/content/docs/auth.mdx index 30217bc..cf210a9 100644 --- a/docs/content/docs/auth.mdx +++ b/docs/content/docs/auth.mdx @@ -45,22 +45,34 @@ read that subscriber's inbox, your backend computes a hash and passes it to ``: ``` -subscriberHash = hex(HMAC-SHA256(environment_secret, subscriber_id)) +subscriberHash = hex(HMAC-SHA256(environment_secret, environment_id + "\0" + subscriber_id)) ``` The `environment_secret` is the environment's `subscriber_hmac_secret`, -readable in the admin dashboard (developer or admin role). Compute the hash in +readable in the admin dashboard (developer or admin role). The +`environment_id` is the environment's id (`env_...`), shown on the same +dashboard page; it is joined to the subscriber id with a NUL byte and binds +the hash to that environment, so a hash never transfers between environments +even if two environments were ever given the same secret. Compute the hash in your backend, per request: ```ts import { createHmac } from 'node:crypto'; const subscriberHash = createHmac('sha256', process.env.CHIMELY_HMAC_SECRET!) - .update(subscriberId) + .update(`${process.env.CHIMELY_ENVIRONMENT_ID}\0${subscriberId}`) .digest('hex'); // pass subscriberHash to ``` + +Hashes computed with the previous formula, +`hex(HMAC-SHA256(environment_secret, subscriber_id))`, are still accepted as +a fallback. That fallback will be dropped in an announced minor release, and +the cross-environment binding is only effective once it is gone. Switch your +backend to the environment-bound formula now. + + The secret signs every subscriber's identity for an environment. Computing the hash client-side leaks it and lets anyone read any inbox. Sign on the server diff --git a/examples/nextjs/README.md b/examples/nextjs/README.md index 67d8afa..7f5b53c 100644 --- a/examples/nextjs/README.md +++ b/examples/nextjs/README.md @@ -41,7 +41,9 @@ SSE hint and the widget refetches conditionally (ETag, mostly 304s). ## Production differences - Set `subscriberHash` on `` — `hex(HMAC-SHA256(secret, - subscriberId))`, computed by **your backend**, never in the browser. + environmentId + "\0" + subscriberId))`, computed by **your backend**, + never in the browser. The environment id is the `env_...` id from the + admin dashboard. The dev bootstrap turns the requirement off; production environments keep it on. - `CHIMELY_DEV_ENVIRONMENT` / `CHIMELY_DEV_API_KEY` are for local From 291c2d03ea995a93f82e3f1e95ebde6110ca74f8 Mon Sep 17 00:00:00 2001 From: Ravindra Kumar Date: Thu, 16 Jul 2026 01:12:09 +0530 Subject: [PATCH 3/6] docs(auth): teach env-bound hash on remaining surfaces 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. --- packages/client/README.md | 9 +++++++-- packages/client/src/types.ts | 8 +++++--- packages/react/README.md | 5 ++++- server/admin/src/routes/environment-detail.tsx | 12 ++++++++++-- 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/packages/client/README.md b/packages/client/README.md index bd90351..11c4e74 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -19,8 +19,9 @@ const client = new ChimelyClient({ serverUrl: 'https://chimely.example.com', environment: 'production', subscriberId: 'usr_123', - // Computed by YOUR backend: hex(HMAC-SHA256(environment_secret, subscriberId)). - // Never compute it in the browser. + // Computed by YOUR backend, never in the browser: + // hex(HMAC-SHA256(environment_secret, environmentId + "\0" + subscriberId)), + // where environmentId is the env_... id from the admin dashboard. subscriberHash, }); @@ -32,6 +33,10 @@ const unsubscribe = client.subscribe(() => { client.connect(); ``` +Hashes minted with the legacy input (`subscriberId` alone) are still accepted; +that fallback is removed in an announced minor release. See +[Auth and the subscriber hash](https://chimely.dev/docs/auth). + The snapshot is immutable with a new identity per change, so it plugs straight into `useSyncExternalStore` or any equality-based renderer. Mutations (`markRead`, `markUnread`, `archive`, `markAllRead`, ...) apply optimistically diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts index 31d1c3f..28f4d71 100644 --- a/packages/client/src/types.ts +++ b/packages/client/src/types.ts @@ -109,9 +109,11 @@ export interface ChimelyClientConfig { /** Customer-provided subscriber id of the current user. */ subscriberId: string; /** - * HMAC-SHA256(secret, subscriberId) hex, computed by YOUR backend. - * Required in production environments. Omittable only where the - * environment allows it (dev quickstart). + * hex(HMAC-SHA256(secret, environmentId + "\0" + subscriberId)), computed + * by YOUR backend. The environmentId is the `env_...` id from the admin + * dashboard. Legacy hashes over subscriberId alone are accepted until an + * announced minor release. Required in production environments. Omittable + * only where the environment allows it (dev quickstart). */ subscriberHash?: string; backoff?: BackoffConfig; diff --git a/packages/react/README.md b/packages/react/README.md index 00d7b20..671ec56 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -26,7 +26,10 @@ import { Inbox } from '@chimely/react'; That is a live bell with an unseen badge, a popover inbox with unread state, tabs, filters, archive, infinite scroll, per-category preferences, and SSE live updates. `subscriberHash` is `hex(HMAC-SHA256(environment_secret, -subscriberId))`, computed by **your backend**, never in the browser. See +environmentId + "\0" + subscriberId))`, computed by **your backend**, never +in the browser. The environment id is the `env_...` id from the admin +dashboard. Legacy hashes over `subscriberId` alone are accepted until an +announced minor release. See [Auth and the subscriber hash](https://chimely.dev/docs/auth). ## Hooks and composables diff --git a/server/admin/src/routes/environment-detail.tsx b/server/admin/src/routes/environment-detail.tsx index 1f1544d..fbfbf1f 100644 --- a/server/admin/src/routes/environment-detail.tsx +++ b/server/admin/src/routes/environment-detail.tsx @@ -272,11 +272,19 @@ function HmacTab({ envId }: { envId: string }) { Subscriber HMAC secret - The customer backend computes HMAC-SHA256(secret, subscriber_id) with - this. Rotation uses two slots so live widget sessions never break. + The customer backend computes{' '} + {'HMAC-SHA256(secret, environment_id + "\\0" + subscriber_id)'} with this + secret and the environment id below. Legacy hashes over{' '} + subscriber_id alone verify until an announced minor release. Rotation + uses two slots so live widget sessions never break. +
+ + +
+
From 247389c12a25e777906614d4e54e1f55f39aaa7a Mon Sep 17 00:00:00 2001 From: Ravindra Kumar Date: Thu, 16 Jul 2026 01:12:18 +0530 Subject: [PATCH 4/6] docs(auth): name the legacy window exposure 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. --- docs/content/docs/auth.mdx | 6 ++++-- server/src/auth.rs | 4 +++- server/tests/auth.rs | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/content/docs/auth.mdx b/docs/content/docs/auth.mdx index cf210a9..cec08e0 100644 --- a/docs/content/docs/auth.mdx +++ b/docs/content/docs/auth.mdx @@ -69,8 +69,10 @@ const subscriberHash = createHmac('sha256', process.env.CHIMELY_HMAC_SECRET!) Hashes computed with the previous formula, `hex(HMAC-SHA256(environment_secret, subscriber_id))`, are still accepted as a fallback. That fallback will be dropped in an announced minor release, and -the cross-environment binding is only effective once it is gone. Switch your -backend to the environment-bound formula now. +the cross-environment binding is only effective once it is gone: until then, a +legacy hash minted in one environment also verifies in any other environment +given the same secret. Switch your backend to the environment-bound formula +now. diff --git a/server/src/auth.rs b/server/src/auth.rs index 4e0c3ab..a6d8015 100644 --- a/server/src/auth.rs +++ b/server/src/auth.rs @@ -447,7 +447,9 @@ pub fn verify_subscriber_hash( } // Legacy formula fallback (issue #55 dual-accept rollout). Hashes minted // over `subscriber_id` alone stay valid until the announced minor version - // bump. Remove this check at that bump. + // bump. A legacy hash carries no environment binding, so environments + // sharing a secret accept each other's legacy hashes for the duration of + // the window. This check is removed at that bump. hmac_matches(secret, subscriber_id.as_bytes(), &provided) } diff --git a/server/tests/auth.rs b/server/tests/auth.rs index fb06d94..9217b5b 100644 --- a/server/tests/auth.rs +++ b/server/tests/auth.rs @@ -79,7 +79,7 @@ async fn hash_is_mandatory_when_the_environment_requires_it() { /// Dual-accept rollout for the env-bound hash formula (issue #55). A hash /// minted with the legacy input (subscriber_id alone) still authenticates. -/// Delete this test when the legacy fallback is dropped at the announced +/// This test is deleted when the legacy fallback is dropped at the announced /// minor version bump. #[tokio::test] async fn legacy_subscriber_id_only_hash_still_authenticates() { From 521efe0675a1d02bb48672f7d923c772656044f4 Mon Sep 17 00:00:00 2001 From: Ravindra Kumar Date: Thu, 16 Jul 2026 01:12:25 +0530 Subject: [PATCH 5/6] test(auth): pin legacy cross-env accept window 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. --- server/tests/redteam_cross_env.rs | 48 +++++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/server/tests/redteam_cross_env.rs b/server/tests/redteam_cross_env.rs index 4305497..bd4d331 100644 --- a/server/tests/redteam_cross_env.rs +++ b/server/tests/redteam_cross_env.rs @@ -164,9 +164,12 @@ fn subscriber_headers_with_hash(slug: &str, subscriber: &str, hash: &str) -> Hea /// Issue #55. Two environments end up sharing one secret (manual DB edit or /// restored dump). The MAC input is env_typeid || 0x00 || subscriber_id, so -/// a hash minted for env A authenticates in env A only. Presented under env -/// B's slug it is rejected even though env B verifies with the same secret, -/// and the probe leaves no subscriber row behind in env B. +/// an env-bound hash minted for env A authenticates in env A only. Presented +/// under env B's slug it is rejected even though env B verifies with the +/// same secret, and the probe leaves no subscriber row behind in env B. +/// Legacy-formula hashes carry no such binding during the dual-accept +/// window. shared_secret_legacy_hash_cross_authenticates_until_removal pins +/// that window. #[tokio::test] async fn shared_secret_env_a_hash_is_rejected_by_env_b() { let app = support::spawn().await; @@ -222,6 +225,45 @@ async fn shared_secret_env_a_hash_is_rejected_by_env_b() { ); } +/// Issue #55 dual-accept window. A legacy-formula hash MACs subscriber_id +/// alone and carries no environment binding, so with a shared secret it also +/// authenticates in env B. This is the accepted tradeoff of the rollout, +/// documented in the auth guide's legacy-formula callout. This test flips to +/// 401 when the fallback is dropped at the announced minor version bump. +#[tokio::test] +async fn shared_secret_legacy_hash_cross_authenticates_until_removal() { + let app = support::spawn().await; + let env_b = app.create_environment(true).await; + sqlx::query("UPDATE environments SET subscriber_hmac_secret = $1 WHERE id = $2") + .bind(&app.env.hmac_secret) + .bind(env_b.id) + .execute(&app.pool) + .await + .expect("give env B env A's secret"); + + let sub = "usr_dump_restore_legacy"; + let legacy = { + use hmac::Mac; + let mut mac = hmac::Hmac::::new_from_slice(app.env.hmac_secret.as_bytes()) + .expect("hmac accepts any key length"); + mac.update(sub.as_bytes()); + hex::encode(mac.finalize().into_bytes()) + }; + + let res = app + .client + .get(format!("{}/v1/inbox/items", app.base)) + .headers(subscriber_headers_with_hash(&env_b.slug, sub, &legacy)) + .send() + .await + .expect("legacy cross-env probe"); + assert_eq!( + res.status(), + 200, + "legacy hashes cross-authenticate while the dual-accept fallback is live" + ); +} + /// A valid (subscriber_id, subscriber_hash) pair minted with env A's secret /// is rejected when presented under env B's slug. Auth resolves the secret /// from the presented slug, so env B can never verify env A's hash. The From 931ec523d65f48ba485d57218de1f97f2d48af40 Mon Sep 17 00:00:00 2001 From: Ravindra Kumar Date: Thu, 16 Jul 2026 04:13:35 +0530 Subject: [PATCH 6/6] test(auth): cover legacy hash on previous slot 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. --- server/tests/auth.rs | 55 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 48 insertions(+), 7 deletions(-) diff --git a/server/tests/auth.rs b/server/tests/auth.rs index 9217b5b..f1672aa 100644 --- a/server/tests/auth.rs +++ b/server/tests/auth.rs @@ -77,6 +77,15 @@ async fn hash_is_mandatory_when_the_environment_requires_it() { assert_eq!(get_counts_status(&app, h).await, 401); } +/// The pre-#55 formula: HMAC over subscriber_id alone, no environment input. +fn legacy_hash(secret: &str, sub: &str) -> String { + use hmac::Mac; + let mut mac = hmac::Hmac::::new_from_slice(secret.as_bytes()) + .expect("hmac accepts any key length"); + mac.update(sub.as_bytes()); + hex::encode(mac.finalize().into_bytes()) +} + /// Dual-accept rollout for the env-bound hash formula (issue #55). A hash /// minted with the legacy input (subscriber_id alone) still authenticates. /// This test is deleted when the legacy fallback is dropped at the announced @@ -84,13 +93,7 @@ async fn hash_is_mandatory_when_the_environment_requires_it() { #[tokio::test] async fn legacy_subscriber_id_only_hash_still_authenticates() { let app = support::spawn().await; - let legacy = { - use hmac::Mac; - let mut mac = hmac::Hmac::::new_from_slice(app.env.hmac_secret.as_bytes()) - .expect("hmac accepts any key length"); - mac.update(SUB.as_bytes()); - hex::encode(mac.finalize().into_bytes()) - }; + let legacy = legacy_hash(&app.env.hmac_secret, SUB); assert_eq!( get_counts_status(&app, headers(&app.env.slug, SUB, Some(&legacy))).await, 200, @@ -98,6 +101,44 @@ async fn legacy_subscriber_id_only_hash_still_authenticates() { ); } +/// A customer still on the legacy formula rotates their secret. Their +/// pre-rotation hash must verify through the previous-slot fallback, the same +/// overlap guarantee new-formula hashes get. Deleted with the legacy fallback. +#[tokio::test] +async fn legacy_hash_survives_rotation_via_previous_slot() { + let app = support::spawn().await; + let legacy = legacy_hash(&app.env.hmac_secret, SUB); + + sqlx::query( + "UPDATE environments SET + subscriber_hmac_secret = 'shmac_rotated_secret', + subscriber_hmac_secret_previous = subscriber_hmac_secret, + subscriber_hmac_rotated_at = now() + WHERE id = $1", + ) + .bind(app.env.id) + .execute(&app.pool) + .await + .unwrap(); + + assert_eq!( + get_counts_status(&app, headers(&app.env.slug, SUB, Some(&legacy))).await, + 200, + "legacy hashes ride the rotation overlap via the previous slot" + ); + + // Rotation ends: the legacy hash dies with the previous slot. + sqlx::query("UPDATE environments SET subscriber_hmac_secret_previous = NULL WHERE id = $1") + .bind(app.env.id) + .execute(&app.pool) + .await + .unwrap(); + assert_eq!( + get_counts_status(&app, headers(&app.env.slug, SUB, Some(&legacy))).await, + 401 + ); +} + #[tokio::test] async fn dev_mode_environments_accept_missing_but_not_invalid_hashes() { let app = support::spawn_dev_mode().await; // require_subscriber_hash = false