//! HMAC-signed realtime subscriber tokens (v1.1.6, design notes ยง5). //! //! A token is a compact, URL-safe, two-part string: //! //! ```text //! . //! ``` //! //! where `payload` is the JSON [`TokenClaims`] and `signature` is //! `HMAC-SHA256(app_signing_key, base64url(payload))`. Tokens are minted //! by scripts via `pubsub::subscriber_token` (the minter lives in //! manager-core's `PubsubServiceImpl`) and verified by the SSE subscribe //! path (the verifier lives in manager-core's `RealtimeAuthority` impl). //! Both sides depend on this module so the byte-for-byte contract has a //! single home. //! //! There is no per-token revocation in v1.1.6 by design: HMAC bearers //! can't be individually revoked. Rotating an app's signing key //! invalidates every token for that app wholesale; short TTLs are the //! safety mechanism. use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::Engine as _; use hmac::{Hmac, Mac}; use serde::{Deserialize, Serialize}; use sha2::Sha256; use thiserror::Error; use crate::AppId; type HmacSha256 = Hmac; /// The signed payload. `exp` / `iat` are Unix seconds. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TokenClaims { pub app_id: AppId, pub topics: Vec, pub exp: i64, pub iat: i64, } impl TokenClaims { /// Does this token grant access to `topic`? #[must_use] pub fn allows_topic(&self, topic: &str) -> bool { self.topics.iter().any(|t| t == topic) } /// Is the token expired relative to `now_unix` (Unix seconds)? #[must_use] pub fn is_expired(&self, now_unix: i64) -> bool { now_unix >= self.exp } } #[derive(Debug, Error, PartialEq, Eq)] pub enum TokenError { #[error("token is malformed")] Malformed, #[error("token signature is invalid")] BadSignature, #[error("token has expired")] Expired, } /// Sign `claims` with `key`, producing the `payload.signature` string. #[must_use] pub fn sign(key: &[u8], claims: &TokenClaims) -> String { // `serde_json` on a fixed-field struct never fails to serialize. let payload_json = serde_json::to_vec(claims).expect("TokenClaims serialize"); let payload_b64 = URL_SAFE_NO_PAD.encode(&payload_json); let sig = mac_sign(key, payload_b64.as_bytes()); let sig_b64 = URL_SAFE_NO_PAD.encode(sig); format!("{payload_b64}.{sig_b64}") } /// Verify `token` against `key` and check expiry against `now_unix` /// (Unix seconds). Returns the decoded [`TokenClaims`] on success. /// /// Topic-scope checking (is the requested topic in the token's list?) /// is the caller's responsibility via [`TokenClaims::allows_topic`] โ€” /// this function proves authenticity + liveness only. /// /// # Errors /// /// [`TokenError::Malformed`] if the shape / base64 / JSON is wrong, /// [`TokenError::BadSignature`] if the HMAC doesn't match, or /// [`TokenError::Expired`] if `now_unix >= exp`. pub fn verify(key: &[u8], token: &str, now_unix: i64) -> Result { let (payload_b64, sig_b64) = token.split_once('.').ok_or(TokenError::Malformed)?; let provided_sig = URL_SAFE_NO_PAD .decode(sig_b64) .map_err(|_| TokenError::Malformed)?; // Constant-time verify of the MAC over the exact payload bytes. let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length"); mac.update(payload_b64.as_bytes()); mac.verify_slice(&provided_sig) .map_err(|_| TokenError::BadSignature)?; // Signature good โ†’ decode the claims and check expiry. let payload_json = URL_SAFE_NO_PAD .decode(payload_b64) .map_err(|_| TokenError::Malformed)?; let claims: TokenClaims = serde_json::from_slice(&payload_json).map_err(|_| TokenError::Malformed)?; if claims.is_expired(now_unix) { return Err(TokenError::Expired); } Ok(claims) } fn mac_sign(key: &[u8], data: &[u8]) -> Vec { let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length"); mac.update(data); mac.finalize().into_bytes().to_vec() } #[cfg(test)] mod tests { use super::*; fn claims(app: AppId, topics: &[&str], exp: i64) -> TokenClaims { TokenClaims { app_id: app, topics: topics.iter().map(|s| (*s).to_string()).collect(), iat: 1000, exp, } } #[test] fn round_trip_verifies() { let key = b"super-secret-key-bytes-0123456789"; let app = AppId::new(); let c = claims(app, &["chat.room.1", "user.notify"], 5000); let token = sign(key, &c); let got = verify(key, &token, 2000).expect("valid token verifies"); assert_eq!(got, c); assert!(got.allows_topic("chat.room.1")); assert!(!got.allows_topic("chat.room.2")); } #[test] fn tampered_payload_fails() { let key = b"super-secret-key-bytes-0123456789"; let app = AppId::new(); let token = sign(key, &claims(app, &["t"], 5000)); // Flip a character in the payload half. let (payload, sig) = token.split_once('.').unwrap(); let mut bytes = payload.as_bytes().to_vec(); bytes[0] ^= 0x01; let tampered = format!("{}.{sig}", String::from_utf8_lossy(&bytes)); assert_eq!(verify(key, &tampered, 2000), Err(TokenError::BadSignature)); } #[test] fn tampered_signature_fails() { let key = b"super-secret-key-bytes-0123456789"; let app = AppId::new(); let token = sign(key, &claims(app, &["t"], 5000)); let (payload, _sig) = token.split_once('.').unwrap(); // A valid-base64 but wrong signature. let bogus = URL_SAFE_NO_PAD.encode([0u8; 32]); let tampered = format!("{payload}.{bogus}"); assert_eq!(verify(key, &tampered, 2000), Err(TokenError::BadSignature)); } #[test] fn different_key_fails() { let app = AppId::new(); let token = sign( b"key-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", &claims(app, &["t"], 5000), ); assert_eq!( verify(b"key-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", &token, 2000), Err(TokenError::BadSignature) ); } #[test] fn expired_token_fails_at_expiry_check() { let key = b"super-secret-key-bytes-0123456789"; let app = AppId::new(); let token = sign(key, &claims(app, &["t"], 5000)); // now == exp โ†’ expired (>= boundary). assert_eq!(verify(key, &token, 5000), Err(TokenError::Expired)); assert_eq!(verify(key, &token, 9999), Err(TokenError::Expired)); } #[test] fn malformed_token_fails() { let key = b"super-secret-key-bytes-0123456789"; assert_eq!(verify(key, "no-dot-here", 0), Err(TokenError::Malformed)); assert_eq!(verify(key, "a.b.c", 0), Err(TokenError::Malformed)); } }