diff --git a/docs/content/docs/auth.mdx b/docs/content/docs/auth.mdx
index 30217bc..cec08e0 100644
--- a/docs/content/docs/auth.mdx
+++ b/docs/content/docs/auth.mdx
@@ -45,22 +45,36 @@ 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: 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.
+
+
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/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/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
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.
+
+
+
+
+
diff --git a/server/src/auth.rs b/server/src/auth.rs
index b6b7686..a6d8015 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,54 @@ 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. 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)
}
/// 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..f1672aa 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,68 @@ 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
+/// minor version bump.
+#[tokio::test]
+async fn legacy_subscriber_id_only_hash_still_authenticates() {
+ let app = support::spawn().await;
+ let legacy = legacy_hash(&app.env.hmac_secret, SUB);
+ 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"
+ );
+}
+
+/// 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
@@ -91,7 +153,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 +164,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 +186,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 +211,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..bd4d331 100644
--- a/server/tests/redteam_cross_env.rs
+++ b/server/tests/redteam_cross_env.rs
@@ -132,6 +132,138 @@ 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
+/// 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;
+ 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"
+ );
+}
+
+/// 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
@@ -142,7 +274,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,