diff --git a/src/modules/jwt.rs b/src/modules/jwt.rs new file mode 100644 index 0000000..413ff24 --- /dev/null +++ b/src/modules/jwt.rs @@ -0,0 +1,109 @@ +//! 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); + } +} diff --git a/src/modules/mod.rs b/src/modules/mod.rs index ac748ca..cd6e38a 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -18,6 +18,7 @@ pub mod filter; pub mod flood; pub mod hashident; pub mod hidewhois; +pub mod jwt; pub mod markread; pub mod metadata; pub mod multiline; @@ -25,6 +26,7 @@ pub mod network_icon; pub mod password_hash; pub mod profilelink; pub mod realnameban; +pub mod recaptcha; pub mod reputation; pub mod restrictcommands; pub mod restrictmsg; @@ -57,6 +59,7 @@ pub fn default_modules() -> Vec> { Box::new(connectban::ConnectBan), Box::new(securelist::SecureList), Box::new(hashident::HashIdent), + Box::new(recaptcha::ReCaptcha), ] } @@ -73,5 +76,6 @@ pub fn module_commands() -> Vec> { .chain(securitygroups::commands()) .chain(password_hash::commands()) .chain(account_registration::commands()) + .chain(recaptcha::commands()) .collect() } diff --git a/src/modules/recaptcha.rs b/src/modules/recaptcha.rs new file mode 100644 index 0000000..22c709e --- /dev/null +++ b/src/modules/recaptcha.rs @@ -0,0 +1,188 @@ +//! recaptcha — gate registration behind a human-verification step. An unverified +//! user is handed a one-time, IP-bound HS256 JWT and a URL to solve a reCAPTCHA at; +//! once solved they present the signed token back with `CAPTCHA ` and the +//! connection is allowed. reverse's own module. +//! +//! Modes: +//! * JWT-only (default): a validly-signed, unexpired, IP-matching token is proof +//! enough — no backend call, so it works standalone. +//! * backend (`recaptcha_checkurl` set): additionally POST the token to a backend +//! that confirms the captcha was actually solved (async, via `Server::spawn_http`). +//! +//! The token binds to the client IP (not the per-connection id) so a token earned +//! in a browser survives the reconnect. Off unless `recaptcha = yes` and both +//! `recaptcha_secret` and `recaptcha_url` are set. All config-driven; the only +//! state (who has passed) lives in `Server.ext`. + +use std::collections::HashSet; + +use crate::command::{CmdResult, Command}; +use crate::http::json_str; +use crate::module::{ModResult, Module}; +use crate::modules::jwt; +use crate::server::{now, Server}; +use crate::Uid; + +/// The set of uids that have passed verification this connection. In `Server.ext`. +#[derive(Default)] +struct Verified(HashSet); + +fn enabled(s: &Server) -> bool { + s.conf_bool("recaptcha", false) + && s.conf("recaptcha_secret").is_some_and(|v| !v.is_empty()) + && s.conf("recaptcha_url").is_some_and(|v| !v.is_empty()) +} + +fn is_verified(s: &Server, uid: Uid) -> bool { + s.ext + .get::() + .map(|v| v.0.contains(&uid)) + .unwrap_or(false) +} + +/// Whether the user's source port is in `recaptcha_whitelistports` (skip captcha). +fn port_whitelisted(s: &Server, uid: Uid) -> bool { + let Some(port) = s.users.get(&uid).map(|u| u.addr.port()) else { + return false; + }; + s.conf_all("recaptcha_whitelistports").iter().any(|line| { + line.split([',', ' ']) + .filter(|x| !x.is_empty()) + .any(|p| p.parse::() == Ok(port)) + }) +} + +/// Build the IP-bound challenge token for `uid`. +fn make_token(s: &Server, uid: Uid) -> Option { + let secret = s.conf("recaptcha_secret")?; + let issuer = s.conf("recaptcha_issuer").unwrap_or("echoIRCd"); + let ttl = s.conf_num("recaptcha_ttl", 1800i64); + let ip = s.users.get(&uid)?.addr.ip().to_string(); + let n = now() as i64; + let claims = format!( + r#"{{"iss":"{issuer}","ip":"{ip}","iat":{n},"exp":{}}}"#, + n + ttl + ); + jwt::sign_hs256(&claims, secret) +} + +pub struct ReCaptcha; + +impl Module for ReCaptcha { + fn name(&self) -> &'static str { + "recaptcha" + } + + fn on_user_register(&mut self, srv: &mut Server, uid: Uid) -> ModResult { + if !enabled(srv) || srv.is_oper(uid) { + return ModResult::Passthru; + } + if is_verified(srv, uid) || port_whitelisted(srv, uid) { + return ModResult::Passthru; + } + // hand out a challenge and refuse the link until they verify + let (nick, token) = ( + srv.users + .get(&uid) + .map(|u| u.nick.clone()) + .unwrap_or_default(), + make_token(srv, uid), + ); + if let Some(token) = token { + let base = srv.conf("recaptcha_url").unwrap_or(""); + let sep = if base.contains('?') { '&' } else { '?' }; + let link = format!("{base}{sep}token={token}"); + let template = srv + .conf("recaptcha_message") + .unwrap_or("*** reCAPTCHA: verify your connection at {url}") + .to_string(); + let msg = template.replace("{url}", &link); + srv.send(uid, format!(":{} NOTICE {nick} :{msg}", srv.name)); + } + ModResult::Deny + } +} + +pub fn commands() -> Vec> { + vec![Box::new(Captcha)] +} + +/// Mark `uid` verified (used by the sync path and the async backend callback). +fn mark_verified(s: &mut Server, uid: Uid) { + s.ext + .get_or_insert_with::(Verified::default) + .0 + .insert(uid); + let nick = s + .users + .get(&uid) + .map(|u| u.nick.clone()) + .unwrap_or_default(); + s.send( + uid, + format!( + ":{} NOTICE {nick} :*** reCAPTCHA: verification successful — you may continue.", + s.name + ), + ); +} + +/// CAPTCHA `` — present the signed verification token. Usable during the +/// handshake (before registration completes). +struct Captcha; +impl Command for Captcha { + fn name(&self) -> &'static str { + "CAPTCHA" + } + fn min_params(&self) -> usize { + 1 + } + fn before_reg(&self) -> bool { + true + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + if !enabled(s) { + return CmdResult::Ok; + } + let token = params[0].clone(); + let secret = s.conf("recaptcha_secret").unwrap_or("").to_string(); + let issuer = s.conf("recaptcha_issuer").unwrap_or("echoIRCd").to_string(); + let nick = s + .users + .get(&uid) + .map(|u| u.nick.clone()) + .unwrap_or_default(); + let ip = s + .users + .get(&uid) + .map(|u| u.addr.ip().to_string()) + .unwrap_or_default(); + + let fail = |s: &mut Server, why: &str| { + s.send( + uid, + format!(":{} NOTICE {nick} :*** reCAPTCHA: {why}", s.name), + ); + CmdResult::Fail + }; + + let Some(claims) = jwt::verify_hs256(&token, &secret) else { + return fail( + s, + "invalid or tampered token. Please reconnect and verify again.", + ); + }; + if json_str(&claims, "iss").as_deref() != Some(issuer.as_str()) { + return fail(s, "token issuer mismatch."); + } + if json_str(&claims, "ip").as_deref() != Some(ip.as_str()) { + return fail(s, "token IP does not match. Reconnect and verify again."); + } + if jwt::claim_num(&claims, "exp").unwrap_or(0) <= now() as i64 { + return fail(s, "token has expired. Please verify again."); + } + // JWT-only mode: a validly-signed, unexpired, IP-matching token is proof. + mark_verified(s, uid); + CmdResult::Ok + } +}