diff --git a/src/modules/extjwt.rs b/src/modules/extjwt.rs new file mode 100644 index 0000000..bfe0a59 --- /dev/null +++ b/src/modules/extjwt.rs @@ -0,0 +1,152 @@ +//! ircv3_extjwt — the `EXTJWT` command: hand a client a short-lived, server-signed +//! JWT it can present to an *external* service (a web app, file host, …) to prove +//! "this IRC user, with these modes, in this channel, right now". The service +//! trusts the token because it shares the HS256 secret. Uses the native +//! [`crate::modules::jwt`] signer. +//! +//! `EXTJWT *| []` → one or more +//! `: EXTJWT [*] ` lines (a `*` param before the +//! chunk means more chunks follow). Claims: `exp, iss, sub`(nick)`, account, +//! umodes[]`, and for a channel target `channel, cmodes[]`. +//! +//! Config: `extjwt_secret` (+ `extjwt_duration`, default 30s); optional named +//! services via `extjwt_service = [duration]`. Off with no secret. +//! +//! Behaviour reference: InspIRCd's `m_ircv3_extjwt`. Original native Rust. + +use crate::command::{CmdResult, Command}; +use crate::modules::jwt; +use crate::numeric::ERR_NOSUCHCHANNEL; +use crate::server::{now, Server}; +use crate::Uid; + +/// Longest token chunk per EXTJWT line (keeps the whole line well under 512). +const CHUNK: usize = 200; + +/// Resolve `(secret, duration)` for a service name (`*` = the default service). +fn service(s: &Server, name: &str) -> Option<(String, u64)> { + if name == "*" { + let secret = s.conf("extjwt_secret").filter(|v| !v.is_empty())?; + return Some((secret.to_string(), s.conf_num("extjwt_duration", 30u64))); + } + for line in s.conf_all("extjwt_service") { + let mut it = line.split_whitespace(); + if it.next().is_some_and(|n| n.eq_ignore_ascii_case(name)) { + let secret = it.next()?.to_string(); + let dur = it.next().and_then(|d| d.parse().ok()).unwrap_or(30); + return Some((secret, dur)); + } + } + None +} + +/// JSON array literal of single-char strings, e.g. `["i","o"]`. +fn json_arr(chars: impl IntoIterator) -> String { + let items: Vec = chars.into_iter().map(|c| format!("\"{c}\"")).collect(); + format!("[{}]", items.join(",")) +} + +pub fn commands() -> Vec> { + vec![Box::new(ExtJwt)] +} + +struct ExtJwt; +impl Command for ExtJwt { + fn name(&self) -> &'static str { + "EXTJWT" + } + fn min_params(&self) -> usize { + 1 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + let target = params[0].clone(); + let svc_name = params.get(1).cloned().unwrap_or_else(|| "*".to_string()); + let Some((secret, duration)) = service(s, &svc_name) else { + s.fail( + uid, + "EXTJWT", + "NO_SUCH_SERVICE", + &format!("No such JWT service: {svc_name}"), + ); + return CmdResult::Fail; + }; + let Some(u) = s.users.get(&uid) else { + return CmdResult::Fail; + }; + let (nick, account) = (u.nick.clone(), u.account.clone().unwrap_or_default()); + let umodes = json_arr(u.flags.umodes().chars().filter(|c| c.is_ascii_alphabetic())); + + // channel target: verify membership and collect the user's status modes + let mut chan_claims = String::new(); + if target != "*" { + let key = target.to_ascii_lowercase(); + let Some(ch) = s.channels.get(&key) else { + s.numeric( + uid, + ERR_NOSUCHCHANNEL, + &format!("{target} :No such channel"), + ); + return CmdResult::Fail; + }; + let member = ch.members.get(&uid); + let cmodes = member + .map(|m| { + let mut v = Vec::new(); + if m.owner { + v.push('q'); + } + if m.admin { + v.push('a'); + } + if m.op { + v.push('o'); + } + if m.halfop { + v.push('h'); + } + if m.voice { + v.push('v'); + } + v + }) + .unwrap_or_default(); + chan_claims = format!(",\"channel\":\"{target}\",\"cmodes\":{}", json_arr(cmodes)); + } + + let claims = format!( + "{{\"exp\":{},\"iss\":\"{}\",\"sub\":\"{}\",\"account\":\"{}\",\"umodes\":{}{}}}", + now() + duration, + s.name, + nick, + account, + umodes, + chan_claims + ); + let Some(token) = jwt::sign_hs256(&claims, &secret) else { + s.fail( + uid, + "EXTJWT", + "UNSPECIFIED_ERROR", + "Failed to create token.", + ); + return CmdResult::Fail; + }; + + // send the token, chunked, with a `*` continuation marker on all but the last + let bytes = token.as_bytes(); + let mut i = 0; + while i < bytes.len() { + let end = (i + CHUNK).min(bytes.len()); + let chunk = &token[i..end]; + let more = end < bytes.len(); + let line = if more { + format!(":{} EXTJWT {target} {svc_name} * {chunk}", s.name) + } else { + format!(":{} EXTJWT {target} {svc_name} {chunk}", s.name) + }; + s.send(uid, line); + i = end; + } + CmdResult::Ok + } +} diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 8f94bc9..756ef50 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -15,6 +15,7 @@ pub mod connectban; pub mod connflood; pub mod denychans; pub mod dnsbl; +pub mod extjwt; pub mod filter; pub mod flood; pub mod hashident; @@ -80,5 +81,6 @@ pub fn module_commands() -> Vec> { .chain(account_registration::commands()) .chain(recaptcha::commands()) .chain(cloudflare_challenge::commands()) + .chain(extjwt::commands()) .collect() }