diff --git a/docs/content/docs/auth.mdx b/docs/content/docs/auth.mdx
index 30217bc..a42aa89 100644
--- a/docs/content/docs/auth.mdx
+++ b/docs/content/docs/auth.mdx
@@ -45,22 +45,32 @@ 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, env_typeid || 0x00 || 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
-your backend, per request:
+The `environment_secret` is the environment's `subscriber_hmac_secret` and
+`env_typeid` is the environment's `env_…` TypeID, both readable in the admin
+dashboard (developer or admin role). Including the environment id binds the
+hash to its environment, so it can never authenticate anywhere else. 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_ENV_ID}\0${subscriberId}`)
.digest('hex');
// pass subscriberHash to
```
+
+Backends deployed before the environment binding compute
+`hex(HMAC-SHA256(environment_secret, subscriber_id))`. That form stays
+accepted while `CHIMELY_SUBSCRIBER_HASH_LEGACY_ACCEPT` is `true` (the
+default). Update your backends to the bound form at your own pace, then set
+the variable to `false` to require the binding.
+
+
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/content/docs/self-hosting.mdx b/docs/content/docs/self-hosting.mdx
index 714dd3b..fa1a9a5 100644
--- a/docs/content/docs/self-hosting.mdx
+++ b/docs/content/docs/self-hosting.mdx
@@ -90,6 +90,7 @@ Every knob is an environment variable with a production default. Only
| `CHIMELY_ADMIN_TLS_TERMINATED` | `false` | Set `true` when TLS terminates upstream |
| `CHIMELY_SHUTDOWN_GRACE_MS` | `5000` | `/readyz` 503 window before the listener closes |
| `CHIMELY_SHUTDOWN_DRAIN_DEADLINE_MS` | `30000` | In-flight job drain budget |
+| `CHIMELY_SUBSCRIBER_HASH_LEGACY_ACCEPT` | `true` | Accept subscriber hashes without the environment binding (see [Auth](/docs/auth)) |
`CHIMELY_DEV_ENVIRONMENT` and `CHIMELY_DEV_API_KEY` seed a quickstart
diff --git a/docs/openapi/chimely.yaml b/docs/openapi/chimely.yaml
index 8751c3e..9c58767 100644
--- a/docs/openapi/chimely.yaml
+++ b/docs/openapi/chimely.yaml
@@ -10,10 +10,15 @@ 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
- `require_subscriber_hash = true` (the production default); optional in
- dev environments so the quickstart works without a backend.
+ hash computed by the customer's backend:
+ `hex(HMAC-SHA256(environment.subscriber_hmac_secret, env_typeid || 0x00 || subscriber_id))`
+ where `env_typeid` is the environment's `env_…` TypeID. The legacy form
+ without the environment binding,
+ `hex(HMAC-SHA256(environment.subscriber_hmac_secret, subscriber_id))`,
+ stays accepted while `CHIMELY_SUBSCRIBER_HASH_LEGACY_ACCEPT` is true (the
+ default). Mandatory in environments with `require_subscriber_hash = true`
+ (the production default); optional in dev environments so the quickstart
+ works without a backend.
Subscriber-plane scoping travels in headers (or query parameters where
headers are impossible, i.e. `EventSource`):
diff --git a/server/src/auth.rs b/server/src/auth.rs
index b6b7686..453aa5e 100644
--- a/server/src/auth.rs
+++ b/server/src/auth.rs
@@ -5,9 +5,12 @@
//!
//! 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 unbound form `hex(HMAC-SHA256(secret,
+//! subscriber_id))` stays accepted while
+//! `CHIMELY_SUBSCRIBER_HASH_LEGACY_ACCEPT` is true (the default). 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 +381,19 @@ 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 env_typeid = ids::typeid(ids::ENVIRONMENT, env.id);
+ let accept_legacy = state.cfg.subscriber_hash_legacy_accept;
+ let valid = verify_subscriber_hash(
+ &env.subscriber_hmac_secret,
+ &env_typeid,
+ &external_id,
+ hash,
+ accept_legacy,
+ ) || env.subscriber_hmac_secret_previous.as_deref().is_some_and(
+ |prev| {
+ verify_subscriber_hash(prev, &env_typeid, &external_id, hash, accept_legacy)
+ },
+ );
if !valid {
return Err(ApiError::unauthorized("invalid subscriber hash"));
}
@@ -405,18 +416,45 @@ 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 {
+/// Verify a subscriber hash against one secret slot.
+///
+/// The environment-bound form `hex(HMAC-SHA256(secret, env_typeid || 0x00 ||
+/// subscriber_id))` is checked first. Isolation otherwise rests entirely on
+/// per-environment secrets. If two environments ever shared one (manual DB
+/// edit, restored dump), an unbound hash would transfer between them.
+///
+/// `accept_legacy` also admits the original `hex(HMAC-SHA256(secret,
+/// subscriber_id))` so deployed customer backends keep working through the
+/// changeover. Both comparisons are constant-time.
+pub fn verify_subscriber_hash(
+ secret: &str,
+ env_typeid: &str,
+ subscriber_id: &str,
+ hash_hex: &str,
+ accept_legacy: bool,
+) -> 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(env_typeid.as_bytes());
+ mac.update(&[0]);
+ mac.update(subscriber_id.as_bytes());
+ if mac.verify_slice(&provided).is_ok() {
+ return true;
+ }
+ if !accept_legacy {
+ 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()
}
-/// Test/SDK helper: compute the subscriber hash the customer backend would.
+/// Test/SDK helper: compute the legacy subscriber hash the customer backend
+/// would. Superseded by [`compute_subscriber_hash_env_bound`].
pub fn compute_subscriber_hash(secret: &str, subscriber_id: &str) -> String {
let mut mac =
Hmac::::new_from_slice(secret.as_bytes()).expect("hmac accepts any key length");
@@ -424,6 +462,22 @@ pub fn compute_subscriber_hash(secret: &str, subscriber_id: &str) -> String {
hex::encode(mac.finalize().into_bytes())
}
+/// Test/SDK helper: compute the environment-bound subscriber hash.
+/// `env_typeid` is the environment's `env_…` TypeID as shown in the admin
+/// dashboard, next to the secret.
+pub fn compute_subscriber_hash_env_bound(
+ secret: &str,
+ env_typeid: &str,
+ subscriber_id: &str,
+) -> String {
+ let mut mac =
+ 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_id.as_bytes());
+ hex::encode(mac.finalize().into_bytes())
+}
+
/// Lazy subscriber + counters upsert. Returns (internal id, created_at).
pub async fn ensure_subscriber(
pool: &sqlx::PgPool,
diff --git a/server/src/config.rs b/server/src/config.rs
index 9a1ecea..b5b02a6 100644
--- a/server/src/config.rs
+++ b/server/src/config.rs
@@ -78,6 +78,10 @@ pub struct Config {
/// In-flight job drain budget after claiming stops. Past it the worker
/// is aborted. At-least-once semantics make the rollback safe.
pub shutdown_drain_deadline: Duration,
+ /// Accept the legacy subscriber hash form that omits the environment
+ /// binding. On by default so deployed customer backends keep working.
+ /// Flip to false once every backend computes the environment-bound form.
+ pub subscriber_hash_legacy_accept: bool,
}
/// Manual impl so credentials can never reach logs through `{:?}`.
@@ -130,6 +134,10 @@ impl std::fmt::Debug for Config {
.field("subscriber_rate_burst", &self.subscriber_rate_burst)
.field("shutdown_readiness_grace", &self.shutdown_readiness_grace)
.field("shutdown_drain_deadline", &self.shutdown_drain_deadline)
+ .field(
+ "subscriber_hash_legacy_accept",
+ &self.subscriber_hash_legacy_accept,
+ )
.finish()
}
}
@@ -197,6 +205,10 @@ impl Config {
"CHIMELY_SHUTDOWN_DRAIN_DEADLINE_MS",
30_000,
)?),
+ subscriber_hash_legacy_accept: parse_var(
+ "CHIMELY_SUBSCRIBER_HASH_LEGACY_ACCEPT",
+ true,
+ )?,
})
}
}
@@ -249,6 +261,7 @@ mod tests {
subscriber_rate_burst: 0.0,
shutdown_readiness_grace: Duration::from_millis(1),
shutdown_drain_deadline: Duration::from_millis(1),
+ subscriber_hash_legacy_accept: true,
};
let out = format!("{cfg:?}");
assert!(
diff --git a/server/src/openapi.rs b/server/src/openapi.rs
index 20dc7d5..f72cdc8 100644
--- a/server/src/openapi.rs
+++ b/server/src/openapi.rs
@@ -17,10 +17,15 @@ 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
- `require_subscriber_hash = true` (the production default); optional in
- dev environments so the quickstart works without a backend.
+ hash computed by the customer's backend:
+ `hex(HMAC-SHA256(environment.subscriber_hmac_secret, env_typeid || 0x00 || subscriber_id))`
+ where `env_typeid` is the environment's `env_…` TypeID. The legacy form
+ without the environment binding,
+ `hex(HMAC-SHA256(environment.subscriber_hmac_secret, subscriber_id))`,
+ stays accepted while `CHIMELY_SUBSCRIBER_HASH_LEGACY_ACCEPT` is true (the
+ default). Mandatory in environments with `require_subscriber_hash = true`
+ (the production default); optional in dev environments so the quickstart
+ works without a backend.
Subscriber-plane scoping travels in headers (or query parameters where
headers are impossible, i.e. `EventSource`):
diff --git a/server/tests/chaos.rs b/server/tests/chaos.rs
index 91dd700..a56c741 100644
--- a/server/tests/chaos.rs
+++ b/server/tests/chaos.rs
@@ -527,6 +527,7 @@ async fn sustained_jobs_churn_stays_bounded_under_autovacuum() {
subscriber_rate_burst: 0.0,
shutdown_readiness_grace: Duration::from_millis(100),
shutdown_drain_deadline: Duration::from_secs(5),
+ subscriber_hash_legacy_accept: true,
};
let cfg = std::sync::Arc::new(cfg);
let pubsub = chimely::pubsub::build(None, &pool).await.unwrap();
diff --git a/server/tests/redteam_hmac_env_binding.rs b/server/tests/redteam_hmac_env_binding.rs
new file mode 100644
index 0000000..a9f4e8d
--- /dev/null
+++ b/server/tests/redteam_hmac_env_binding.rs
@@ -0,0 +1,137 @@
+//! The subscriber hash binds to its environment. Isolation otherwise rests
+//! entirely on per-environment server-minted secrets. If two environments
+//! ever shared one (manual DB edit, restored dump), an unbound hash would
+//! transfer between them. The environment-bound form includes the env
+//! TypeID in the MAC input, so a hash minted for one environment is invalid
+//! in every other. The legacy unbound form stays accepted while
+//! subscriber_hash_legacy_accept is true (the default).
+
+mod support;
+
+use chimely::auth::{compute_subscriber_hash, compute_subscriber_hash_env_bound};
+use chimely::ids;
+use reqwest::header::{HeaderMap, HeaderValue};
+
+fn subscriber_headers(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
+}
+
+async fn counts_status(app: &support::TestApp, slug: &str, subscriber: &str, hash: &str) -> u16 {
+ app.client
+ .get(format!("{}/v1/inbox/counts", app.base))
+ .headers(subscriber_headers(slug, subscriber, hash))
+ .send()
+ .await
+ .expect("counts request")
+ .status()
+ .as_u16()
+}
+
+#[tokio::test]
+async fn env_bound_hash_authenticates_and_legacy_stays_accepted() {
+ let app = support::spawn().await;
+ let env_typeid = ids::typeid(ids::ENVIRONMENT, app.env.id);
+
+ let bound = compute_subscriber_hash_env_bound(&app.env.hmac_secret, &env_typeid, "usr_b");
+ assert_eq!(
+ counts_status(&app, &app.env.slug, "usr_b", &bound).await,
+ 200
+ );
+
+ let legacy = compute_subscriber_hash(&app.env.hmac_secret, "usr_b");
+ assert_eq!(
+ counts_status(&app, &app.env.slug, "usr_b", &legacy).await,
+ 200,
+ "the legacy form must keep working through the changeover"
+ );
+}
+
+#[tokio::test]
+async fn legacy_hash_is_rejected_in_strict_mode() {
+ let app = support::spawn_configured(false, |cfg| {
+ cfg.subscriber_hash_legacy_accept = false;
+ })
+ .await;
+
+ let legacy = compute_subscriber_hash(&app.env.hmac_secret, "usr_s");
+ assert_eq!(
+ counts_status(&app, &app.env.slug, "usr_s", &legacy).await,
+ 401
+ );
+
+ let env_typeid = ids::typeid(ids::ENVIRONMENT, app.env.id);
+ let bound = compute_subscriber_hash_env_bound(&app.env.hmac_secret, &env_typeid, "usr_s");
+ assert_eq!(
+ counts_status(&app, &app.env.slug, "usr_s", &bound).await,
+ 200
+ );
+}
+
+/// The audit threat: two environments sharing one secret. Server-minted
+/// secrets make this unreachable normally, so it is staged with a direct
+/// UPDATE. A bound hash minted for environment A must not authenticate in B.
+#[tokio::test]
+async fn bound_hash_does_not_transfer_between_environments_sharing_a_secret() {
+ let app = support::spawn_configured(false, |cfg| {
+ cfg.subscriber_hash_legacy_accept = false;
+ })
+ .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("share the secret");
+
+ let env_a_typeid = ids::typeid(ids::ENVIRONMENT, app.env.id);
+ let bound_for_a =
+ compute_subscriber_hash_env_bound(&app.env.hmac_secret, &env_a_typeid, "usr_x");
+ assert_eq!(
+ counts_status(&app, &app.env.slug, "usr_x", &bound_for_a).await,
+ 200
+ );
+ assert_eq!(
+ counts_status(&app, &env_b.slug, "usr_x", &bound_for_a).await,
+ 401,
+ "a hash minted for environment A must not authenticate in B"
+ );
+}
+
+/// Rotation interplay: a bound hash computed with the old secret verifies
+/// against the previous slot, so formula adoption and secret rotation
+/// compose without invalidating live sessions.
+#[tokio::test]
+async fn bound_hash_verifies_against_the_previous_secret_slot() {
+ let app = support::spawn().await;
+ sqlx::query(
+ "UPDATE environments
+ SET subscriber_hmac_secret_previous = subscriber_hmac_secret,
+ subscriber_hmac_secret = 'fresh-secret-after-rotation'
+ WHERE id = $1",
+ )
+ .bind(app.env.id)
+ .execute(&app.pool)
+ .await
+ .expect("stage a rotation");
+
+ let env_typeid = ids::typeid(ids::ENVIRONMENT, app.env.id);
+ let bound_old = compute_subscriber_hash_env_bound(&app.env.hmac_secret, &env_typeid, "usr_r");
+ assert_eq!(
+ counts_status(&app, &app.env.slug, "usr_r", &bound_old).await,
+ 200
+ );
+}
diff --git a/server/tests/support/mod.rs b/server/tests/support/mod.rs
index 3e7e36b..6131c98 100644
--- a/server/tests/support/mod.rs
+++ b/server/tests/support/mod.rs
@@ -149,6 +149,9 @@ async fn spawn_inner(
subscriber_rate_burst: 0.0,
shutdown_readiness_grace: Duration::from_millis(150),
shutdown_drain_deadline: Duration::from_secs(5),
+ // Legacy on matches the production default. Binding tests flip it
+ // via spawn_configured.
+ subscriber_hash_legacy_accept: true,
};
configure(&mut cfg);
let cfg = Arc::new(cfg);