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);