//! jwt — a tiny, dependency-free JSON Web Token (HS256) helper shared by the //! captcha / challenge modules (recaptcha, cloudflare_challenge, cloudfire) and //! `ircv3_extjwt`. Sign and verify only what echoIRCd needs: compact JWS, HMAC- //! SHA256, base64url — all on OpenSSL (already a dependency), no `unsafe`, no crate. //! //! This is not a general JWT library: claims are passed and returned as raw JSON //! text, so callers use [`crate::http::json_str`] / [`claim_num`] to read fields. use openssl::hash::MessageDigest; use openssl::pkey::PKey; use openssl::sign::Signer; /// base64url (no padding) of arbitrary bytes. fn b64url(data: &[u8]) -> String { let std = openssl::base64::encode_block(data); std.trim_end_matches('=') .replace('+', "-") .replace('/', "_") } /// Decode base64url (no padding) back to bytes. #[allow(clippy::manual_is_multiple_of)] // is_multiple_of is unstable on our MSRV fn unb64url(s: &str) -> Option> { let mut std = s.replace('-', "+").replace('_', "/"); while std.len() % 4 != 0 { std.push('='); } openssl::base64::decode_block(&std).ok() } /// `HMAC-SHA256(secret, data)`. fn hmac(secret: &[u8], data: &[u8]) -> Option> { let key = PKey::hmac(secret).ok()?; let mut signer = Signer::new(MessageDigest::sha256(), &key).ok()?; signer.update(data).ok()?; signer.sign_to_vec().ok() } /// Sign a compact HS256 JWT with the given raw-JSON `claims` and `secret`. pub fn sign_hs256(claims_json: &str, secret: &str) -> Option { let header = b64url(br#"{"alg":"HS256","typ":"JWT"}"#); let payload = b64url(claims_json.as_bytes()); let signing_input = format!("{header}.{payload}"); let sig = b64url(&hmac(secret.as_bytes(), signing_input.as_bytes())?); Some(format!("{signing_input}.{sig}")) } /// Verify a compact HS256 JWT against `secret`; on success return the decoded /// claims as raw JSON text. Signature check is constant-time. Does NOT check /// `exp`/`iss` — the caller inspects the returned claims for those. pub fn verify_hs256(token: &str, secret: &str) -> Option { let parts: Vec<&str> = token.split('.').collect(); if parts.len() != 3 { return None; } let signing_input = format!("{}.{}", parts[0], parts[1]); let want = hmac(secret.as_bytes(), signing_input.as_bytes())?; let got = unb64url(parts[2])?; if got.len() != want.len() || !openssl::memcmp::eq(&got, &want) { return None; } let claims = unb64url(parts[1])?; String::from_utf8(claims).ok() } /// Read a numeric claim (e.g. `exp`, `iat`) from raw-JSON claims text. pub fn claim_num(claims_json: &str, key: &str) -> Option { let needle = format!("\"{key}\""); let pos = claims_json.find(&needle)?; let after = &claims_json[pos + needle.len()..]; let colon = after.find(':')?; let tail = after[colon + 1..].trim_start(); let end = tail .find(|c: char| !c.is_ascii_digit() && c != '-') .unwrap_or(tail.len()); tail[..end].parse().ok() } #[cfg(test)] mod tests { use super::*; #[test] fn sign_then_verify_roundtrips() { let claims = r#"{"iss":"echo","sub":"0AAAAA","ip":"1.2.3.4","exp":9999999999}"#; let tok = sign_hs256(claims, "topsecret").unwrap(); assert_eq!(tok.split('.').count(), 3); let got = verify_hs256(&tok, "topsecret").unwrap(); assert_eq!(got, claims); } #[test] fn wrong_secret_or_tamper_fails() { let tok = sign_hs256(r#"{"a":1}"#, "k1").unwrap(); assert!(verify_hs256(&tok, "k2").is_none()); // wrong key let mut bad = tok.clone(); bad.push('x'); // tamper the signature assert!(verify_hs256(&bad, "k1").is_none()); assert!(verify_hs256("only.two", "k1").is_none()); // malformed } #[test] fn reads_numeric_claims() { let c = r#"{"iss":"e","exp":1730000000,"iat":1729998200}"#; assert_eq!(claim_num(c, "exp"), Some(1730000000)); assert_eq!(claim_num(c, "iat"), Some(1729998200)); assert_eq!(claim_num(c, "nope"), None); } }