Skip to content
Closed
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: 17 additions & 3 deletions docs/content/docs/auth.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -45,22 +45,36 @@ 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, 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 <Inbox subscriberHash={...} />
```

<Callout type="warn" title="Legacy formula: accepted for now, removal announced ahead">
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.
</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
8 changes: 6 additions & 2 deletions docs/openapi/chimely.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,12 @@ 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
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.

Expand Down
4 changes: 3 additions & 1 deletion examples/nextjs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ SSE hint and the widget refetches conditionally (ETag, mostly 304s).
## Production differences

- Set `subscriberHash` on `<Inbox />` — `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
Expand Down
9 changes: 7 additions & 2 deletions packages/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});

Expand All @@ -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
Expand Down
8 changes: 5 additions & 3 deletions packages/client/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 4 additions & 1 deletion packages/react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 10 additions & 2 deletions server/admin/src/routes/environment-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -272,11 +272,19 @@ function HmacTab({ envId }: { envId: string }) {
<KeyRound className="size-4 text-primary" /> Subscriber HMAC secret
</CardTitle>
<CardDescription>
The customer backend computes <code>HMAC-SHA256(secret, subscriber_id)</code> with
this. Rotation uses two slots so live widget sessions never break.
The customer backend computes{' '}
<code>{'HMAC-SHA256(secret, environment_id + "\\0" + subscriber_id)'}</code> with this
secret and the environment id below. Legacy hashes over{' '}
<code>subscriber_id</code> alone verify until an announced minor release. Rotation
uses two slots so live widget sessions never break.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-5">
<div className="flex flex-col gap-1.5">
<Label>Environment ID</Label>
<CopyField value={detail.id} />
</div>

<div className="flex flex-col gap-1.5">
<Label>Current secret</Label>
<CopyField value={detail.subscriber_hmac_secret ?? ''} maskable />
Expand Down
69 changes: 53 additions & 16 deletions server/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -378,11 +380,14 @@ 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 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"));
}
Expand All @@ -405,22 +410,54 @@ 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 {
/// 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<u8> {
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::<Sha256>::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::<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()
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::<Sha256>::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())
}

Expand Down
8 changes: 6 additions & 2 deletions server/src/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,12 @@ 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
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.

Expand Down
2 changes: 1 addition & 1 deletion server/tests/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
74 changes: 68 additions & 6 deletions server/tests/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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::<sha2::Sha256>::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
Expand All @@ -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
Expand All @@ -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";
Expand All @@ -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
Comment on lines 164 to 192

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing coverage: legacy hash + rotated-to-previous-slot combination

The rotation test was updated to compute hashes with the new formula via compute_subscriber_hash. A customer who has not yet migrated to the new formula will hold a legacy hash (HMAC(old_secret, subscriber_id)). After that customer rotates their secret, their pre-rotation legacy hash must still verify via the previous-slot fallback. The code supports it (both formulas are tried for both secret slots), but this specific combination — legacy formula against the subscriber_hmac_secret_previous slot — has no test. If verify_subscriber_hash is later modified to only apply the legacy fallback against the current slot, the regression would go undetected.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 931ec52. Added legacy_hash_survives_rotation_via_previous_slot: a legacy-formula hash (HMAC over subscriber_id alone) minted with the pre-rotation secret must still authenticate after rotation via the previous-slot fallback, and must die once subscriber_hmac_secret_previous is cleared. Negative control verified: scoping the previous-slot check to the env-bound formula only turns exactly this test red while the existing legacy and rotation tests stay green, so the combination is now pinned. Also extracted the inline legacy-HMAC block into a shared legacy_hash helper since two tests now need it.

Expand All @@ -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
Expand Down
Loading