Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions docs/content/docs/auth.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -45,22 +45,32 @@ read that subscriber's inbox, your backend computes a hash and passes it to
`<Inbox />`:

```
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 <Inbox subscriberHash={...} />
```

<Callout title="Migrating from the unbound form">
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.
</Callout>

<Callout type="error" title="Never sign in the browser">
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
Expand Down
1 change: 1 addition & 0 deletions docs/content/docs/self-hosting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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)) |

<Callout type="warn" title="Dev-only">
`CHIMELY_DEV_ENVIRONMENT` and `CHIMELY_DEV_API_KEY` seed a quickstart
Expand Down
13 changes: 9 additions & 4 deletions docs/openapi/chimely.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,15 @@ info:
by the key.
* **Subscriber plane** — called by `@chimely/client` (the `<Inbox />`
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`):
Expand Down
76 changes: 65 additions & 11 deletions server/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -378,11 +381,19 @@ impl FromRequestParts<AppState> 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"));
}
Expand All @@ -405,25 +416,68 @@ impl FromRequestParts<AppState> 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::<Sha256>::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::<Sha256>::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::<Sha256>::new_from_slice(secret.as_bytes()).expect("hmac accepts any key length");
mac.update(subscriber_id.as_bytes());
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::<Sha256>::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,
Expand Down
13 changes: 13 additions & 0 deletions server/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 `{:?}`.
Expand Down Expand Up @@ -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()
}
}
Expand Down Expand Up @@ -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,
)?,
})
}
}
Expand Down Expand Up @@ -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!(
Expand Down
13 changes: 9 additions & 4 deletions server/src/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,15 @@ const INFO_DESCRIPTION: &str = r#"Two planes, one binary:
by the key.
* **Subscriber plane** — called by `@chimely/client` (the `<Inbox />`
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`):
Expand Down
1 change: 1 addition & 0 deletions server/tests/chaos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
137 changes: 137 additions & 0 deletions server/tests/redteam_hmac_env_binding.rs
Original file line number Diff line number Diff line change
@@ -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
);
}
3 changes: 3 additions & 0 deletions server/tests/support/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down