diff --git a/src/config.rs b/src/config.rs index 63e9790..d73ba45 100644 --- a/src/config.rs +++ b/src/config.rs @@ -5,7 +5,9 @@ //! network = echoNet //! bind = 127.0.0.1:6767 //! motd = Welcome to echoIRCd -//! oper = god secret +//! oper = god secret # name + password (+ optional level) +//! oper = god * fp= # cert-only login (no password) +//! oper = god secret fp= # password AND matching cert //! ``` use crate::map::HashMap; @@ -29,6 +31,17 @@ pub struct LinkBlock { pub autoconnect: bool, } +/// An oper login: `oper = [level] [fp=]`. +/// `password = *` means no password is checked (cert-only login); a `fp=` token +/// requires the user's TLS client-certificate SHA-256 fingerprint to match. +#[derive(Clone, Default)] +pub struct OperBlock { + pub name: String, + pub password: String, + pub level: u32, + pub fingerprint: Option, +} + /// Config for the `antimixedutf8` module (blocks mixed-script look-alike spam). #[derive(Clone)] pub struct AntiMixedCfg { @@ -70,7 +83,7 @@ pub struct Config { pub tls_cert: Option, // PEM certificate chain pub tls_key: Option, // PEM private key pub motd: Vec, - pub opers: Vec<(String, String, u32)>, // (name, password, operlevel) + pub opers: Vec, // oper logins (see OperBlock) pub cloak_key: Option, // secret key for host cloaking (+x); None = off pub sid: String, // this server's 3-char server id (S2S) pub serverdesc: String, // this server's description @@ -197,8 +210,24 @@ impl Config { "oper" => { let mut it = v.split_whitespace(); if let (Some(n), Some(p)) = (it.next(), it.next()) { - let level = it.next().and_then(|l| l.parse().ok()).unwrap_or(0); - c.opers.push((n.to_string(), p.to_string(), level)); + let mut b = OperBlock { + name: n.to_string(), + password: p.to_string(), + level: 0, + fingerprint: None, + }; + // trailing tokens (any order): a number is the operlevel, a + // `fp=`/`certfp=` token is the required TLS cert fingerprint. + for tok in it { + if let Some(fp) = + tok.strip_prefix("fp=").or_else(|| tok.strip_prefix("certfp=")) + { + b.fingerprint = Some(fp.to_ascii_lowercase()); + } else if let Ok(l) = tok.parse::() { + b.level = l; + } + } + c.opers.push(b); } } // +G censor word: `badword = [replace]` (no replace ⇒ block) diff --git a/src/coremods/core_extra.rs b/src/coremods/core_extra.rs index 77c486f..d5f863e 100644 --- a/src/coremods/core_extra.rs +++ b/src/coremods/core_extra.rs @@ -308,7 +308,7 @@ impl Command for Stats { ); } 'o' => { - let opers: Vec = s.opers.iter().map(|(n, _, _)| n.clone()).collect(); + let opers: Vec = s.opers.iter().map(|o| o.name.clone()).collect(); for n in opers { s.numeric(uid, RPL_STATSOLINE, &format!("O * * {n} :oper")); } diff --git a/src/coremods/core_oper.rs b/src/coremods/core_oper.rs index 40b3b2b..750a976 100644 --- a/src/coremods/core_oper.rs +++ b/src/coremods/core_oper.rs @@ -123,15 +123,37 @@ impl Command for Oper { } fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { let (name, pass) = (params[0].clone(), params[1].clone()); - let Some((hash, level)) = s - .opers - .iter() - .find(|(n, _, _)| *n == name) - .map(|(_, p, lvl)| (p.clone(), *lvl)) - else { + let Some(block) = s.opers.iter().find(|o| o.name == name).cloned() else { s.numeric(uid, ERR_PASSWDMISMATCH, ":Password incorrect"); return CmdResult::Fail; }; + let (hash, level) = (block.password.clone(), block.level); + // fingerprint login: the block demands a specific TLS client-cert SHA-256 + // fingerprint, so the user must be on a matching certificate. + if let Some(want_fp) = &block.fingerprint { + let user_fp = s.users.get(&uid).and_then(|u| u.certfp.clone()); + if !user_fp + .as_deref() + .is_some_and(|f| f.eq_ignore_ascii_case(want_fp)) + { + s.snotice_c( + 'o', + &format!("Failed OPER for {name}: certificate fingerprint mismatch"), + ); + s.numeric( + uid, + ERR_PASSWDMISMATCH, + ":Password incorrect (a matching TLS client certificate is required)", + ); + return CmdResult::Fail; + } + } + // `password = *` means cert-only: the fingerprint above is the whole check. + if hash == "*" { + s.oper_up(uid); + crate::modules::operlevels::set(s, uid, level); + return CmdResult::Ok; + } // a KDF password (bcrypt / pbkdf2) is slow — verify it off the core thread // (result arrives as OperAuth), so it can't freeze the server or be a DoS. if crate::modules::password_hash::is_slow(&hash) { diff --git a/src/modules/rpc/stats.rs b/src/modules/rpc/stats.rs index 85b1504..39a76d8 100644 --- a/src/modules/rpc/stats.rs +++ b/src/modules/rpc/stats.rs @@ -25,9 +25,9 @@ pub fn handle(s: &mut Server, method: &str, _params: &str) -> Result = s .opers .iter() - .map(|(name, _pass, _lvl)| { + .map(|o| { obj(&[ - ("name", qstr(name)), + ("name", qstr(&o.name)), ("type", qstr("")), ("online", "0".into()), ]) diff --git a/src/modules/rpc/user.rs b/src/modules/rpc/user.rs index b69ec4b..1085d97 100644 --- a/src/modules/rpc/user.rs +++ b/src/modules/rpc/user.rs @@ -137,7 +137,7 @@ pub fn handle(s: &mut Server, action: &str, params: &str) -> Result { // apply the named oper block's level; reject an unknown name rather // than silently opering with defaults - match s.opers.iter().find(|o| o.0 == name).map(|o| o.2) { + match s.opers.iter().find(|o| o.name == name).map(|o| o.level) { Some(level) => { s.oper_up(uid); crate::modules::operlevels::set(s, uid, level); diff --git a/src/server.rs b/src/server.rs index 129b271..8041eb6 100644 --- a/src/server.rs +++ b/src/server.rs @@ -130,7 +130,7 @@ pub struct Server { pub nick_index: HashMap, // lower nick -> uid pub channels: HashMap, // lower name -> channel pub events: VecDeque, - pub opers: Vec<(String, String, u32)>, // (name, password, operlevel) from config + pub opers: Vec, // oper logins from config pub cloak_key: Option, // host-cloaking key (see modules::cloak) pub line_ctags: String, // client-only tags of the line being handled // --- server-to-server (see crate::link) ---