// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! HS256 JSON Web Tokens for the API. //! //! We hand-roll the (small, well-specified) HS256 construction rather than pull //! in a JWT library, because the mainstream crates depend on `ring`, whose //! license set falls outside this project's allow-list. The surface we need is //! tiny: base64url of a fixed header and a JSON payload, an HMAC-SHA256 over the //! two, and constant-time verification of the same. //! //! Signature and expiry are all this module proves. **Revocation is not checked //! here** — a valid signature only means the token is authentic and unexpired. //! Callers MUST additionally confirm the `jti` row in `api_tokens` has //! `revoked_at IS NULL`; that database check is what makes revocation real. use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use hmac::{Hmac, Mac}; use rand_core::{OsRng, RngCore}; use serde::{Deserialize, Serialize}; use sha2::Sha256; use crate::AuthError; type HmacSha256 = Hmac; /// Generate a fresh instance signing secret: 32 bytes of OS randomness, base64url /// encoded. Written to `auth.secret_file` on first run and reused thereafter; it /// signs both API tokens and session-cookie material. #[must_use] pub fn new_secret() -> String { let mut bytes = [0u8; 32]; OsRng.fill_bytes(&mut bytes); URL_SAFE_NO_PAD.encode(bytes) } /// The fixed JOSE header for every token we mint: `{"alg":"HS256","typ":"JWT"}`. /// Pre-encoded so minting never re-serializes it, and matched exactly on verify /// so a token advertising any other algorithm is rejected out of hand (closing /// the classic "alg" confusion attack). const HEADER_B64: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"; /// The claims carried by an API token. Field names are the JWT wire names. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Claims { /// Subject: the owning user's id (`users.id`). pub sub: String, /// JWT id: the `api_tokens` row id, checked against `revoked_at` on every /// request to enforce revocation. pub jti: String, /// Comma-separated scope list (see [`crate::scope`]). pub scopes: String, /// Issued-at, Unix seconds. pub iat: i64, /// Expiry, Unix seconds. A token is rejected once `exp <= now`. pub exp: i64, } /// Mint a signed HS256 token for `claims`, signed with the instance `secret`. /// /// # Errors /// /// Returns [`AuthError::Hash`] if the claims cannot be serialized (in practice /// only an allocation failure), reusing that variant to avoid widening the error /// enum for an all-but-impossible case. pub fn mint_jwt(secret: &str, claims: &Claims) -> Result { let payload_json = serde_json::to_vec(claims).map_err(|e| AuthError::Hash(format!("claims: {e}")))?; let payload_b64 = URL_SAFE_NO_PAD.encode(payload_json); let signing_input = format!("{HEADER_B64}.{payload_b64}"); let signature = sign(secret, signing_input.as_bytes()); Ok(format!("{signing_input}.{signature}")) } /// Verify a token's signature and expiry against `secret` and the current time /// `now` (Unix seconds), returning its claims on success. /// /// Every failure — wrong shape, unexpected algorithm, bad signature, malformed /// payload, or expiry — collapses to [`AuthError::InvalidToken`] so nothing about /// *why* a token was rejected reaches the caller. /// /// # Errors /// /// Returns [`AuthError::InvalidToken`] on any verification failure. pub fn verify_jwt(secret: &str, token: &str, now: i64) -> Result { let mut parts = token.split('.'); let (Some(header_b64), Some(payload_b64), Some(signature_b64), None) = (parts.next(), parts.next(), parts.next(), parts.next()) else { return Err(AuthError::InvalidToken); }; // Reject anything not signed with our fixed HS256 header before spending an // HMAC on it. if header_b64 != HEADER_B64 { return Err(AuthError::InvalidToken); } // Constant-time signature check via the MAC's own verifier. let provided = URL_SAFE_NO_PAD .decode(signature_b64) .map_err(|_| AuthError::InvalidToken)?; let signing_input = format!("{header_b64}.{payload_b64}"); let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).map_err(|_| AuthError::InvalidToken)?; mac.update(signing_input.as_bytes()); mac.verify_slice(&provided) .map_err(|_| AuthError::InvalidToken)?; // Signature is good; now decode and check the claims. let payload = URL_SAFE_NO_PAD .decode(payload_b64) .map_err(|_| AuthError::InvalidToken)?; let claims: Claims = serde_json::from_slice(&payload).map_err(|_| AuthError::InvalidToken)?; if claims.exp <= now { return Err(AuthError::InvalidToken); } Ok(claims) } /// Compute the base64url HMAC-SHA256 signature of `message` under `secret`. fn sign(secret: &str, message: &[u8]) -> String { // `new_from_slice` only fails for key sizes HMAC cannot accept, which is // none — every byte length is valid — so this cannot error in practice. let Ok(mut mac) = HmacSha256::new_from_slice(secret.as_bytes()) else { return String::new(); }; mac.update(message); URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()) } #[cfg(test)] mod tests { #![allow(clippy::unwrap_used)] use super::*; fn claims(exp: i64) -> Claims { Claims { sub: "01useruseruseruseruseruser".to_string(), jti: "01tokentokentokentokentoken".to_string(), scopes: "repo:read,repo:write".to_string(), iat: 1_000, exp, } } #[test] fn round_trips_a_valid_token() { let token = mint_jwt("s3cr3t", &claims(2_000)).unwrap(); // Three base64url segments. assert_eq!(token.split('.').count(), 3); let decoded = verify_jwt("s3cr3t", &token, 1_500).unwrap(); assert_eq!(decoded, claims(2_000)); } #[test] fn header_is_the_canonical_hs256_header() { let token = mint_jwt("s", &claims(2_000)).unwrap(); let header = token.split('.').next().unwrap(); let json = URL_SAFE_NO_PAD.decode(header).unwrap(); assert_eq!(&json, br#"{"alg":"HS256","typ":"JWT"}"#); } #[test] fn wrong_secret_is_rejected() { let token = mint_jwt("right", &claims(2_000)).unwrap(); assert!(matches!( verify_jwt("wrong", &token, 1_500), Err(AuthError::InvalidToken) )); } #[test] fn expired_token_is_rejected() { let token = mint_jwt("s", &claims(2_000)).unwrap(); // At exactly exp the token is already expired (exp <= now). assert!(matches!( verify_jwt("s", &token, 2_000), Err(AuthError::InvalidToken) )); assert!(verify_jwt("s", &token, 1_999).is_ok()); } #[test] fn tampered_payload_is_rejected() { let token = mint_jwt("s", &claims(2_000)).unwrap(); let mut parts: Vec<&str> = token.split('.').collect(); // Re-encode a payload that escalates scopes; the signature no longer fits. let forged = Claims { scopes: "admin".to_string(), ..claims(2_000) }; let forged_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&forged).unwrap()); parts[1] = &forged_b64; let tampered = parts.join("."); assert!(matches!( verify_jwt("s", &tampered, 1_500), Err(AuthError::InvalidToken) )); } #[test] fn malformed_shapes_are_rejected() { for bad in ["", "onlyonepart", "two.parts", "a.b.c.d", "..", "x.y.z"] { assert!( matches!(verify_jwt("s", bad, 0), Err(AuthError::InvalidToken)), "should reject {bad:?}" ); } } #[test] fn algorithm_confusion_is_rejected() { // A token whose header claims "alg":"none" but is otherwise well-formed // must not verify, even with an empty signature. let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims(2_000)).unwrap()); let none_header = URL_SAFE_NO_PAD.encode(br#"{"alg":"none","typ":"JWT"}"#); let forged = format!("{none_header}.{payload}."); assert!(matches!( verify_jwt("s", &forged, 1_500), Err(AuthError::InvalidToken) )); } }