oper: TLS client-cert fingerprint login — oper block gains an optional fp=<sha256>; password=* means cert-only. Named OperBlock struct replaces the (name,pass,level) tuple. (Password login was never broken — verified live.)

This commit is contained in:
Jean Chevronnet 2026-08-18 22:55:07 +00:00
parent 853be58d18
commit c4456cf002
6 changed files with 66 additions and 15 deletions

View file

@ -5,7 +5,9 @@
//! network = echoNet //! network = echoNet
//! bind = 127.0.0.1:6767 //! bind = 127.0.0.1:6767
//! motd = Welcome to echoIRCd //! motd = Welcome to echoIRCd
//! oper = god secret //! oper = god secret # name + password (+ optional level)
//! oper = god * fp=<sha256-cert-fp> # cert-only login (no password)
//! oper = god secret fp=<sha256-cert-fp> # password AND matching cert
//! ``` //! ```
use crate::map::HashMap; use crate::map::HashMap;
@ -29,6 +31,17 @@ pub struct LinkBlock {
pub autoconnect: bool, pub autoconnect: bool,
} }
/// An oper login: `oper = <name> <password|*> [level] [fp=<sha256-fingerprint>]`.
/// `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<String>,
}
/// Config for the `antimixedutf8` module (blocks mixed-script look-alike spam). /// Config for the `antimixedutf8` module (blocks mixed-script look-alike spam).
#[derive(Clone)] #[derive(Clone)]
pub struct AntiMixedCfg { pub struct AntiMixedCfg {
@ -70,7 +83,7 @@ pub struct Config {
pub tls_cert: Option<String>, // PEM certificate chain pub tls_cert: Option<String>, // PEM certificate chain
pub tls_key: Option<String>, // PEM private key pub tls_key: Option<String>, // PEM private key
pub motd: Vec<String>, pub motd: Vec<String>,
pub opers: Vec<(String, String, u32)>, // (name, password, operlevel) pub opers: Vec<OperBlock>, // oper logins (see OperBlock)
pub cloak_key: Option<String>, // secret key for host cloaking (+x); None = off pub cloak_key: Option<String>, // secret key for host cloaking (+x); None = off
pub sid: String, // this server's 3-char server id (S2S) pub sid: String, // this server's 3-char server id (S2S)
pub serverdesc: String, // this server's description pub serverdesc: String, // this server's description
@ -197,8 +210,24 @@ impl Config {
"oper" => { "oper" => {
let mut it = v.split_whitespace(); let mut it = v.split_whitespace();
if let (Some(n), Some(p)) = (it.next(), it.next()) { if let (Some(n), Some(p)) = (it.next(), it.next()) {
let level = it.next().and_then(|l| l.parse().ok()).unwrap_or(0); let mut b = OperBlock {
c.opers.push((n.to_string(), p.to_string(), level)); 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::<u32>() {
b.level = l;
}
}
c.opers.push(b);
} }
} }
// +G censor word: `badword = <find> [replace]` (no replace ⇒ block) // +G censor word: `badword = <find> [replace]` (no replace ⇒ block)

View file

@ -308,7 +308,7 @@ impl Command for Stats {
); );
} }
'o' => { 'o' => {
let opers: Vec<String> = s.opers.iter().map(|(n, _, _)| n.clone()).collect(); let opers: Vec<String> = s.opers.iter().map(|o| o.name.clone()).collect();
for n in opers { for n in opers {
s.numeric(uid, RPL_STATSOLINE, &format!("O * * {n} :oper")); s.numeric(uid, RPL_STATSOLINE, &format!("O * * {n} :oper"));
} }

View file

@ -123,15 +123,37 @@ impl Command for Oper {
} }
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
let (name, pass) = (params[0].clone(), params[1].clone()); let (name, pass) = (params[0].clone(), params[1].clone());
let Some((hash, level)) = s let Some(block) = s.opers.iter().find(|o| o.name == name).cloned() else {
.opers
.iter()
.find(|(n, _, _)| *n == name)
.map(|(_, p, lvl)| (p.clone(), *lvl))
else {
s.numeric(uid, ERR_PASSWDMISMATCH, ":Password incorrect"); s.numeric(uid, ERR_PASSWDMISMATCH, ":Password incorrect");
return CmdResult::Fail; 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 // 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. // (result arrives as OperAuth), so it can't freeze the server or be a DoS.
if crate::modules::password_hash::is_slow(&hash) { if crate::modules::password_hash::is_slow(&hash) {

View file

@ -25,9 +25,9 @@ pub fn handle(s: &mut Server, method: &str, _params: &str) -> Result<String, Rpc
let opers: Vec<String> = s let opers: Vec<String> = s
.opers .opers
.iter() .iter()
.map(|(name, _pass, _lvl)| { .map(|o| {
obj(&[ obj(&[
("name", qstr(name)), ("name", qstr(&o.name)),
("type", qstr("")), ("type", qstr("")),
("online", "0".into()), ("online", "0".into()),
]) ])

View file

@ -137,7 +137,7 @@ pub fn handle(s: &mut Server, action: &str, params: &str) -> Result<String, RpcE
Some(name) if !name.is_empty() => { Some(name) if !name.is_empty() => {
// apply the named oper block's level; reject an unknown name rather // apply the named oper block's level; reject an unknown name rather
// than silently opering with defaults // 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) => { Some(level) => {
s.oper_up(uid); s.oper_up(uid);
crate::modules::operlevels::set(s, uid, level); crate::modules::operlevels::set(s, uid, level);

View file

@ -130,7 +130,7 @@ pub struct Server {
pub nick_index: HashMap<String, Uid>, // lower nick -> uid pub nick_index: HashMap<String, Uid>, // lower nick -> uid
pub channels: HashMap<String, Channel>, // lower name -> channel pub channels: HashMap<String, Channel>, // lower name -> channel
pub events: VecDeque<Hook>, pub events: VecDeque<Hook>,
pub opers: Vec<(String, String, u32)>, // (name, password, operlevel) from config pub opers: Vec<crate::config::OperBlock>, // oper logins from config
pub cloak_key: Option<String>, // host-cloaking key (see modules::cloak) pub cloak_key: Option<String>, // host-cloaking key (see modules::cloak)
pub line_ctags: String, // client-only tags of the line being handled pub line_ctags: String, // client-only tags of the line being handled
// --- server-to-server (see crate::link) --- // --- server-to-server (see crate::link) ---