modules: port jwt (native HS256) + recaptcha (JWT registration gate + CAPTCHA cmd)
This commit is contained in:
parent
b0d767a809
commit
a2401a4f54
3 changed files with 301 additions and 0 deletions
109
src/modules/jwt.rs
Normal file
109
src/modules/jwt.rs
Normal file
|
|
@ -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<Vec<u8>> {
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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<String> {
|
||||||
|
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<String> {
|
||||||
|
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<i64> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,7 @@ pub mod filter;
|
||||||
pub mod flood;
|
pub mod flood;
|
||||||
pub mod hashident;
|
pub mod hashident;
|
||||||
pub mod hidewhois;
|
pub mod hidewhois;
|
||||||
|
pub mod jwt;
|
||||||
pub mod markread;
|
pub mod markread;
|
||||||
pub mod metadata;
|
pub mod metadata;
|
||||||
pub mod multiline;
|
pub mod multiline;
|
||||||
|
|
@ -25,6 +26,7 @@ pub mod network_icon;
|
||||||
pub mod password_hash;
|
pub mod password_hash;
|
||||||
pub mod profilelink;
|
pub mod profilelink;
|
||||||
pub mod realnameban;
|
pub mod realnameban;
|
||||||
|
pub mod recaptcha;
|
||||||
pub mod reputation;
|
pub mod reputation;
|
||||||
pub mod restrictcommands;
|
pub mod restrictcommands;
|
||||||
pub mod restrictmsg;
|
pub mod restrictmsg;
|
||||||
|
|
@ -57,6 +59,7 @@ pub fn default_modules() -> Vec<Box<dyn Module>> {
|
||||||
Box::new(connectban::ConnectBan),
|
Box::new(connectban::ConnectBan),
|
||||||
Box::new(securelist::SecureList),
|
Box::new(securelist::SecureList),
|
||||||
Box::new(hashident::HashIdent),
|
Box::new(hashident::HashIdent),
|
||||||
|
Box::new(recaptcha::ReCaptcha),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,5 +76,6 @@ pub fn module_commands() -> Vec<Box<dyn Command>> {
|
||||||
.chain(securitygroups::commands())
|
.chain(securitygroups::commands())
|
||||||
.chain(password_hash::commands())
|
.chain(password_hash::commands())
|
||||||
.chain(account_registration::commands())
|
.chain(account_registration::commands())
|
||||||
|
.chain(recaptcha::commands())
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
188
src/modules/recaptcha.rs
Normal file
188
src/modules/recaptcha.rs
Normal file
|
|
@ -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 <token>` 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<Uid>);
|
||||||
|
|
||||||
|
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::<Verified>()
|
||||||
|
.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::<u16>() == Ok(port))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the IP-bound challenge token for `uid`.
|
||||||
|
fn make_token(s: &Server, uid: Uid) -> Option<String> {
|
||||||
|
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<Box<dyn Command>> {
|
||||||
|
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>(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 `<token>` — 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
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue