diff --git a/src/coremods/core_oper.rs b/src/coremods/core_oper.rs index 015af98..10b159a 100644 --- a/src/coremods/core_oper.rs +++ b/src/coremods/core_oper.rs @@ -121,7 +121,10 @@ impl Command for Oper { } fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { let (name, pass) = (¶ms[0], ¶ms[1]); - if s.opers.iter().any(|(n, p)| n == name && p == pass) { + if s.opers + .iter() + .any(|(n, p)| n == name && crate::modules::password_hash::verify(p, pass)) + { s.oper_up(uid); CmdResult::Ok } else { diff --git a/src/modules/hashident.rs b/src/modules/hashident.rs new file mode 100644 index 0000000..1315d09 --- /dev/null +++ b/src/modules/hashident.rs @@ -0,0 +1,83 @@ +//! hashident — replace a user's ident with a stable, opaque 12-character token +//! derived from their IP, so the username field leaks nothing (no `~guest`, no +//! probed identd name) yet stays constant per address. reverse's own module. +//! +//! The token is the first 6 bytes of `HMAC-SHA256(hashident_key, ip)`, hex-encoded +//! (12 chars). Off unless `hashident = yes` and a `hashident_key` secret is set — +//! without the key it does nothing (the key is what makes the mapping unforgeable). +//! Applied once, right after the user finishes connecting; all config-driven. + +use openssl::hash::MessageDigest; +use openssl::pkey::PKey; +use openssl::sign::Signer; + +use crate::module::Module; +use crate::server::Server; +use crate::Uid; + +/// `HMAC-SHA256(key, data)` via OpenSSL, or `None` on any OpenSSL error. +fn hmac_sha256(key: &[u8], data: &[u8]) -> Option> { + let pkey = PKey::hmac(key).ok()?; + let mut signer = Signer::new(MessageDigest::sha256(), &pkey).ok()?; + signer.update(data).ok()?; + signer.sign_to_vec().ok() +} + +/// The 12-char hashed ident for `ip` under `key`, or `None` if HMAC fails. +fn hashed_ident(key: &str, ip: &str) -> Option { + let mac = hmac_sha256(key.as_bytes(), ip.as_bytes())?; + let mut out = String::with_capacity(12); + for b in mac.iter().take(6) { + out.push(char::from_digit((b >> 4) as u32, 16).unwrap()); + out.push(char::from_digit((b & 0xf) as u32, 16).unwrap()); + } + Some(out) +} + +pub struct HashIdent; + +impl Module for HashIdent { + fn name(&self) -> &'static str { + "hashident" + } + + fn on_user_connect(&mut self, srv: &mut Server, uid: Uid) { + if !srv.conf_bool("hashident", false) { + return; + } + let Some(key) = srv.conf("hashident_key").filter(|k| !k.is_empty()) else { + return; // no secret configured → do nothing (fail safe) + }; + let key = key.to_string(); + let ip = match srv.users.get(&uid) { + Some(u) => u.addr.ip().to_string(), + None => return, + }; + if let Some(ident) = hashed_ident(&key, &ip) { + if let Some(u) = srv.users.get_mut(&uid) { + u.ident = ident; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ident_is_12_hex_chars_and_stable() { + let a = hashed_ident("secretkey", "203.0.113.7").unwrap(); + let b = hashed_ident("secretkey", "203.0.113.7").unwrap(); + assert_eq!(a, b); // same ip+key → same ident + assert_eq!(a.len(), 12); + assert!(a.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn different_key_or_ip_changes_it() { + let base = hashed_ident("k1", "203.0.113.7").unwrap(); + assert_ne!(base, hashed_ident("k2", "203.0.113.7").unwrap()); + assert_ne!(base, hashed_ident("k1", "203.0.113.8").unwrap()); + } +} diff --git a/src/modules/mod.rs b/src/modules/mod.rs index e6700e1..921866d 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -15,11 +15,13 @@ pub mod denychans; pub mod dnsbl; pub mod filter; pub mod flood; +pub mod hashident; pub mod hidewhois; pub mod markread; pub mod metadata; pub mod multiline; pub mod network_icon; +pub mod password_hash; pub mod profilelink; pub mod realnameban; pub mod reputation; @@ -53,6 +55,7 @@ pub fn default_modules() -> Vec> { Box::new(blockamsg::BlockAmsg), Box::new(connectban::ConnectBan), Box::new(securelist::SecureList), + Box::new(hashident::HashIdent), ] } @@ -67,5 +70,6 @@ pub fn module_commands() -> Vec> { .chain(chathistory::commands()) .chain(reputation::commands()) .chain(securitygroups::commands()) + .chain(password_hash::commands()) .collect() } diff --git a/src/modules/password_hash.rs b/src/modules/password_hash.rs new file mode 100644 index 0000000..e7d5099 --- /dev/null +++ b/src/modules/password_hash.rs @@ -0,0 +1,209 @@ +//! password_hash — hashed `` passwords plus a `/MKPASSWD` helper to make +//! them. This is echoIRCd's answer to InspIRCd's hash-provider family (md5, sha1, +//! sha2, pbkdf2): one module, backed entirely by OpenSSL (already a dependency), +//! that both verifies a stored hash against a supplied password and generates new +//! hashes for the config. +//! +//! A stored password is either plaintext (no recognised prefix — backward +//! compatible) or `":"`: +//! * `md5:` `sha1:` `sha256:` `sha512:` — a plain hex digest of the password +//! * `pbkdf2:::` — PBKDF2-HMAC-SHA256, salted +//! +//! Comparisons are constant-time (`openssl::memcmp`). Everything is self-contained +//! here; the OPER handler just calls [`verify`]. + +use openssl::hash::{hash, MessageDigest}; +use openssl::pkcs5::pbkdf2_hmac; +use openssl::rand::rand_bytes; + +use crate::command::{CmdResult, Command}; +use crate::numeric::ERR_NOPRIVILEGES; +use crate::server::Server; +use crate::Uid; + +/// The OpenSSL digest for a named simple algorithm. +fn digest_for(algo: &str) -> Option { + match algo.to_ascii_lowercase().as_str() { + "md5" => Some(MessageDigest::md5()), + "sha1" => Some(MessageDigest::sha1()), + "sha256" | "sha2" | "sha-256" => Some(MessageDigest::sha256()), + "sha512" | "sha-512" => Some(MessageDigest::sha512()), + _ => None, + } +} + +/// Lowercase hex of a byte slice. +fn hex(bytes: &[u8]) -> String { + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + s.push(char::from_digit((b >> 4) as u32, 16).unwrap()); + s.push(char::from_digit((b & 0xf) as u32, 16).unwrap()); + } + s +} + +/// Decode a lowercase/uppercase hex string to bytes (None on bad input). +#[allow(clippy::manual_is_multiple_of)] // is_multiple_of is unstable on our MSRV +fn unhex(s: &str) -> Option> { + if s.len() % 2 != 0 { + return None; + } + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok()) + .collect() +} + +/// Constant-time equality (guards against timing attacks on the compare). +/// `openssl::memcmp::eq` requires equal-length inputs, so short-circuit first. +fn ct_eq(a: &[u8], b: &[u8]) -> bool { + a.len() == b.len() && openssl::memcmp::eq(a, b) +} + +/// PBKDF2-HMAC-SHA256 of `pass` with `salt` and `iters`, `len` bytes out. +fn pbkdf2(pass: &str, salt: &[u8], iters: usize, len: usize) -> Option> { + let mut out = vec![0u8; len]; + pbkdf2_hmac( + pass.as_bytes(), + salt, + iters, + MessageDigest::sha256(), + &mut out, + ) + .ok()?; + Some(out) +} + +/// Verify `plaintext` against a `stored` credential. Plaintext (no known prefix) +/// falls back to a constant-time string compare, so existing configs keep working. +pub fn verify(stored: &str, plaintext: &str) -> bool { + // pbkdf2::: + if let Some(rest) = stored.strip_prefix("pbkdf2:") { + let parts: Vec<&str> = rest.splitn(3, ':').collect(); + if parts.len() == 3 { + if let (Ok(iters), Some(salt), Some(want)) = + (parts[0].parse::(), unhex(parts[1]), unhex(parts[2])) + { + if let Some(got) = pbkdf2(plaintext, &salt, iters, want.len()) { + return ct_eq(&got, &want); + } + } + } + return false; + } + // : + if let Some((algo, want_hex)) = stored.split_once(':') { + if let Some(md) = digest_for(algo) { + if let (Ok(got), Some(want)) = (hash(md, plaintext.as_bytes()), unhex(want_hex)) { + return ct_eq(&got, &want); + } + return false; + } + } + // plaintext + ct_eq(stored.as_bytes(), plaintext.as_bytes()) +} + +/// Produce a stored-credential string for `algo` over `plaintext`. For pbkdf2 a +/// fresh 16-byte salt and 60000 iterations are used. +fn make(algo: &str, plaintext: &str) -> Option { + if algo.eq_ignore_ascii_case("pbkdf2") { + let mut salt = [0u8; 16]; + rand_bytes(&mut salt).ok()?; + let iters = 60000usize; + let out = pbkdf2(plaintext, &salt, iters, 32)?; + return Some(format!("pbkdf2:{iters}:{}:{}", hex(&salt), hex(&out))); + } + let md = digest_for(algo)?; + let d = hash(md, plaintext.as_bytes()).ok()?; + Some(format!("{}:{}", algo.to_ascii_lowercase(), hex(&d))) +} + +pub fn commands() -> Vec> { + vec![Box::new(MkPasswd)] +} + +/// MKPASSWD — `MKPASSWD `. Oper-only; returns a config-ready +/// hashed credential. Algorithms: md5, sha1, sha256, sha512, pbkdf2. +struct MkPasswd; +impl Command for MkPasswd { + fn name(&self) -> &'static str { + "MKPASSWD" + } + fn min_params(&self) -> usize { + 2 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + if !s.is_oper(uid) { + s.numeric( + uid, + ERR_NOPRIVILEGES, + ":Permission Denied- You're not an IRC operator", + ); + return CmdResult::Fail; + } + let (algo, pass) = (¶ms[0], ¶ms[1]); + let nick = s + .users + .get(&uid) + .map(|u| u.nick.clone()) + .unwrap_or_default(); + match make(algo, pass) { + Some(hashed) => { + s.send( + uid, + format!( + ":{} NOTICE {nick} :{algo} hashed password: {hashed}", + s.name + ), + ); + CmdResult::Ok + } + None => { + s.send( + uid, + format!( + ":{} NOTICE {nick} :Unknown hash '{algo}' (try md5, sha1, sha256, sha512, pbkdf2)", + s.name + ), + ); + CmdResult::Fail + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plaintext_roundtrips() { + assert!(verify("hunter2", "hunter2")); + assert!(!verify("hunter2", "wrong")); + } + + #[test] + fn simple_digests_roundtrip() { + for algo in ["md5", "sha1", "sha256", "sha512"] { + let stored = make(algo, "s3cret").unwrap(); + assert!(verify(&stored, "s3cret"), "{algo} should verify"); + assert!(!verify(&stored, "nope"), "{algo} should reject wrong"); + } + } + + #[test] + fn pbkdf2_roundtrips() { + let stored = make("pbkdf2", "correct horse").unwrap(); + assert!(stored.starts_with("pbkdf2:60000:")); + assert!(verify(&stored, "correct horse")); + assert!(!verify(&stored, "battery staple")); + } + + #[test] + fn known_sha256_vector() { + // sha256("abc") + let h = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"; + assert!(verify(&format!("sha256:{h}"), "abc")); + } +}