email: outbound mail subsystem and RESETPASS

Adds a config-driven mailer: a SendEmail action the link layer pipes to
a configured command (sendmail -t, msmtp, ...) off-thread. NickServ
RESETPASS <account> emails a short-lived code to the address on file;
RESETPASS <account> <code> <newpassword> completes the reset. Codes are
node-local; the password change itself federates.
This commit is contained in:
Jean Chevronnet 2026-07-12 15:28:15 +00:00
parent 020e9bd576
commit a2957ffe02
No known key found for this signature in database
12 changed files with 205 additions and 10 deletions

21
Cargo.lock generated
View file

@ -157,6 +157,16 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "fedserv" name = "fedserv"
version = "0.0.1" version = "0.0.1"
@ -542,6 +552,16 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]] [[package]]
name = "smallvec" name = "smallvec"
version = "1.15.2" version = "1.15.2"
@ -594,6 +614,7 @@ dependencies = [
"libc", "libc",
"mio", "mio",
"pin-project-lite", "pin-project-lite",
"signal-hook-registry",
"socket2", "socket2",
"tokio-macros", "tokio-macros",
"windows-sys 0.61.2", "windows-sys 0.61.2",

View file

@ -5,7 +5,7 @@ edition = "2021"
description = "Federated IRC services daemon (protocol-agnostic core, InspIRCd link first)" description = "Federated IRC services daemon (protocol-agnostic core, InspIRCd link first)"
[dependencies] [dependencies]
tokio = { version = "1", features = ["net", "io-util", "rt-multi-thread", "macros", "time", "sync"] } tokio = { version = "1", features = ["net", "io-util", "rt-multi-thread", "macros", "time", "sync", "process"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
toml = "0.8" toml = "0.8"

View file

@ -26,6 +26,8 @@ mod glist;
mod ungroup; mod ungroup;
#[path = "ghost.rs"] #[path = "ghost.rs"]
mod ghost; mod ghost;
#[path = "resetpass.rs"]
mod resetpass;
pub struct NickServ { pub struct NickServ {
pub uid: String, pub uid: String,
@ -64,7 +66,8 @@ impl Service for NickServ {
Some("GLIST") => glist::handle(me, from, ctx, db), Some("GLIST") => glist::handle(me, from, ctx, db),
Some("UNGROUP") => ungroup::handle(me, from, args, ctx, db), Some("UNGROUP") => ungroup::handle(me, from, args, ctx, db),
Some("GHOST") | Some("RECOVER") => ghost::handle(me, &self.guest_nick, &mut self.guest_seq, from, args, ctx, net, db), Some("GHOST") | Some("RECOVER") => ghost::handle(me, &self.guest_nick, &mut self.guest_seq, from, args, ctx, net, db),
Some("HELP") => ctx.notice(me, from.uid, "NickServ looks after your nickname. Commands: \x02REGISTER\x02 <password> [email], \x02IDENTIFY\x02 [account] <password>, \x02INFO\x02 [account], \x02ALIST\x02, \x02GROUP\x02/\x02GLIST\x02/\x02UNGROUP\x02, \x02GHOST\x02 <nick> [password], \x02SET\x02 PASSWORD|EMAIL, \x02DROP\x02 <password>, \x02LOGOUT\x02, \x02CERT\x02 ADD|DEL|LIST <password> [fingerprint]."), Some("RESETPASS") => resetpass::handle(me, from, args, ctx, db),
Some("HELP") => ctx.notice(me, from.uid, "NickServ looks after your nickname. Commands: \x02REGISTER\x02 <password> [email], \x02IDENTIFY\x02 [account] <password>, \x02INFO\x02 [account], \x02ALIST\x02, \x02GROUP\x02/\x02GLIST\x02/\x02UNGROUP\x02, \x02GHOST\x02 <nick> [password], \x02SET\x02 PASSWORD|EMAIL, \x02RESETPASS\x02 <account>, \x02DROP\x02 <password>, \x02LOGOUT\x02, \x02CERT\x02 ADD|DEL|LIST <password> [fingerprint]."),
Some(other) => ctx.notice(me, from.uid, format!("I don't know the command \x02{other}\x02. Try \x02HELP\x02.")), Some(other) => ctx.notice(me, from.uid, format!("I don't know the command \x02{other}\x02. Try \x02HELP\x02.")),
None => {} None => {}
} }

View file

@ -0,0 +1,42 @@
use crate::engine::db::Db;
use crate::engine::service::{Sender, ServiceCtx};
// RESETPASS <account>: email a reset code to the address on file.
// RESETPASS <account> <code> <newpassword>: complete the reset with that code.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut Db) {
if !db.email_enabled() {
ctx.notice(me, from.uid, "Password reset by email isn't available here.");
return;
}
match (args.get(1), args.get(2), args.get(3)) {
(Some(&name), None, None) => {
let Some(canonical) = db.resolve_account(name).map(str::to_string) else {
ctx.notice(me, from.uid, format!("\x02{name}\x02 isn't registered."));
return;
};
let Some(email) = db.account(&canonical).and_then(|a| a.email.clone()) else {
ctx.notice(me, from.uid, "That account has no email on file, so it can't be reset.");
return;
};
let code = db.issue_reset_code(&canonical);
ctx.send_email(
email,
format!("Password reset for {canonical}"),
format!("Your password reset code for {canonical} is: {code}\nIt expires in 15 minutes. Reset with:\n /msg NickServ RESETPASS {canonical} {code} <newpassword>"),
);
ctx.notice(me, from.uid, format!("A reset code has been emailed to the address on file for \x02{canonical}\x02."));
}
(Some(&name), Some(&code), Some(&newpass)) => {
let Some(canonical) = db.resolve_account(name).map(str::to_string) else {
ctx.notice(me, from.uid, format!("\x02{name}\x02 isn't registered."));
return;
};
if !db.take_reset_code(&canonical, code) {
ctx.notice(me, from.uid, "Invalid or expired reset code.");
return;
}
ctx.defer_password(&canonical, newpass, me, from.uid);
}
_ => ctx.notice(me, from.uid, "Syntax: RESETPASS <account> [<code> <newpassword>]"),
}
}

View file

@ -281,7 +281,7 @@ impl Protocol for InspIrcd {
} }
NetAction::Raw(s) => vec![s.clone()], NetAction::Raw(s) => vec![s.clone()],
// Internal: the link layer handles these before serialization. // Internal: the link layer handles these before serialization.
NetAction::DeferRegister { .. } | NetAction::DeferPassword { .. } => vec![], NetAction::DeferRegister { .. } | NetAction::DeferPassword { .. } | NetAction::SendEmail { .. } => vec![],
} }
} }

View file

@ -71,6 +71,9 @@ pub enum NetAction {
// Internal only: a password change awaiting the same off-thread derivation. // Internal only: a password change awaiting the same off-thread derivation.
// The link layer derives, then calls Engine::complete_password_change. // The link layer derives, then calls Engine::complete_password_change.
DeferPassword { account: String, password: String, agent: String, uid: String }, DeferPassword { account: String, password: String, agent: String, uid: String },
// Internal only: send an email. The link layer pipes it to the configured
// mail command off-thread; never serialized to the ircd.
SendEmail { to: String, subject: String, body: String },
} }
// How to answer a registration once its credentials have been derived. // How to answer a registration once its credentials have been derived.

View file

@ -9,6 +9,18 @@ pub struct Config {
pub gossip: Option<Gossip>, pub gossip: Option<Gossip>,
#[serde(default)] #[serde(default)]
pub peer: Vec<Peer>, pub peer: Vec<Peer>,
// Outbound email (password resets). Absent = email features are off.
#[serde(default)]
pub email: Option<Email>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct Email {
// Sender address stamped on outgoing mail.
pub from: String,
// A shell command the message is piped to on stdin, e.g. "sendmail -t" or
// "msmtp -t". Run via `sh -c`, so redirection and pipes work.
pub command: String,
} }
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]

View file

@ -1,9 +1,9 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::io::Write; use std::io::Write;
use std::path::PathBuf; use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use argon2::password_hash::rand_core::OsRng; use argon2::password_hash::rand_core::{OsRng, RngCore};
use argon2::password_hash::SaltString; use argon2::password_hash::SaltString;
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier}; use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -443,6 +443,10 @@ pub struct Db {
log: EventLog, log: EventLog,
// PBKDF2 cost baked into new SCRAM verifiers; lowered by tests. // PBKDF2 cost baked into new SCRAM verifiers; lowered by tests.
pub(crate) scram_iterations: u32, pub(crate) scram_iterations: u32,
// Whether outbound email is configured, so email features can gate themselves.
email_enabled: bool,
// Node-local, non-persisted password-reset codes: account -> (code, expiry).
reset_codes: HashMap<String, (String, Instant)>,
} }
fn key(name: &str) -> String { fn key(name: &str) -> String {
@ -482,7 +486,7 @@ impl Db {
apply(&mut accounts, &mut channels, &mut grouped, event); apply(&mut accounts, &mut channels, &mut grouped, event);
} }
tracing::info!(accounts = accounts.len(), channels = channels.len(), "account store loaded"); tracing::info!(accounts = accounts.len(), channels = channels.len(), "account store loaded");
Self { accounts, channels, grouped, log, scram_iterations: scram::DEFAULT_ITERATIONS } Self { accounts, channels, grouped, log, scram_iterations: scram::DEFAULT_ITERATIONS, email_enabled: false, reset_codes: HashMap::new() }
} }
/// Fold an entry authored by another node into the store — the services-side /// Fold an entry authored by another node into the store — the services-side
@ -680,6 +684,34 @@ impl Db {
self.accounts.get(&key(account)).map_or(&[], |a| a.certfps.as_slice()) self.accounts.get(&key(account)).map_or(&[], |a| a.certfps.as_slice())
} }
/// Whether outbound email is configured.
pub fn email_enabled(&self) -> bool {
self.email_enabled
}
pub fn set_email_enabled(&mut self, on: bool) {
self.email_enabled = on;
}
/// Issue a fresh password-reset code for `account`, valid for 15 minutes.
pub fn issue_reset_code(&mut self, account: &str) -> String {
let code = gen_code();
self.reset_codes.insert(key(account), (code.clone(), Instant::now() + Duration::from_secs(900)));
code
}
/// Consume a reset code for `account`: true if it matches and hasn't expired.
pub fn take_reset_code(&mut self, account: &str, code: &str) -> bool {
let k = key(account);
match self.reset_codes.get(&k) {
Some((c, deadline)) if c == code && *deadline > Instant::now() => {
self.reset_codes.remove(&k);
true
}
_ => false,
}
}
/// Set (or clear) `account`'s email. /// Set (or clear) `account`'s email.
pub fn set_email(&mut self, account: &str, email: Option<String>) -> Result<(), RegError> { pub fn set_email(&mut self, account: &str, email: Option<String>) -> Result<(), RegError> {
let k = key(account); let k = key(account);
@ -1062,6 +1094,13 @@ fn glob_match(pattern: &str, text: &str) -> bool {
pi == p.len() pi == p.len()
} }
// A random 6-digit code for email verification / password reset.
fn gen_code() -> String {
let mut b = [0u8; 4];
OsRng.fill_bytes(&mut b);
format!("{:06}", u32::from_le_bytes(b) % 1_000_000)
}
fn hash_password(password: &str) -> Option<String> { fn hash_password(password: &str) -> Option<String> {
let salt = SaltString::generate(&mut OsRng); let salt = SaltString::generate(&mut OsRng);
Argon2::default().hash_password(password.as_bytes(), &salt).ok().map(|h| h.to_string()) Argon2::default().hash_password(password.as_bytes(), &salt).ok().map(|h| h.to_string())

View file

@ -1210,6 +1210,40 @@ mod tests {
assert!(notice(&to_ns(&mut e, "000AAAAAC", "IDENTIFY sesame"), "isn't registered")); assert!(notice(&to_ns(&mut e, "000AAAAAC", "IDENTIFY sesame"), "isn't registered"));
} }
// RESETPASS emails a code, and that code + a new password completes the reset;
// the new password then authenticates and the old one no longer does.
#[test]
fn nickserv_resetpass() {
let mut e = engine_with("nsreset", "alice", "sesame");
e.db.set_email_enabled(true);
e.db.set_email("alice", Some("alice@example.org".into())).unwrap();
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "someone".into(), host: "h".into() });
let to_ns = |e: &mut Engine, text: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: text.into() });
// Request: a code is emailed to the address on file.
let out = to_ns(&mut e, "RESETPASS alice");
let (to, body) = out.iter().find_map(|a| match a {
NetAction::SendEmail { to, body, .. } => Some((to.clone(), body.clone())),
_ => None,
}).expect("a reset code is emailed");
assert_eq!(to, "alice@example.org");
let code = body.split("is: ").nth(1).unwrap().split_whitespace().next().unwrap().to_string();
// Complete: the code + a new password defers the change.
let out = to_ns(&mut e, &format!("RESETPASS alice {code} brandnew"));
let (account, password, agent, uid) = out.iter().find_map(|a| match a {
NetAction::DeferPassword { account, password, agent, uid } => Some((account.clone(), password.clone(), agent.clone(), uid.clone())),
_ => None,
}).expect("a valid code defers the new password");
assert_eq!(password, "brandnew");
e.complete_password_change(&account, Db::derive_credentials(&password, 4096), &agent, &uid);
assert!(e.db.authenticate("alice", "brandnew").is_some(), "new password works");
assert!(e.db.authenticate("alice", "sesame").is_none(), "old password rejected");
// A used/wrong code is refused.
assert!(to_ns(&mut e, "RESETPASS alice 000000 x").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Invalid or expired"))));
}
// GHOST renames off a session using a nick the caller owns. // GHOST renames off a session using a nick the caller owns.
#[test] #[test]
fn nickserv_ghost() { fn nickserv_ghost() {

View file

@ -57,6 +57,11 @@ impl ServiceCtx {
}); });
} }
// Send an email (the link layer pipes it to the configured mail command).
pub fn send_email(&mut self, to: impl Into<String>, subject: impl Into<String>, body: impl Into<String>) {
self.actions.push(NetAction::SendEmail { to: to.into(), subject: subject.into(), body: body.into() });
}
// Hand a password change to the engine to finish: its derivation runs off the // Hand a password change to the engine to finish: its derivation runs off the
// reactor, then the engine commits it and notices `uid`, sourced from `agent`. // reactor, then the engine commits it and notices `uid`, sourced from `agent`.
pub fn defer_password(&mut self, account: impl Into<String>, password: impl Into<String>, agent: impl Into<String>, uid: impl Into<String>) { pub fn defer_password(&mut self, account: impl Into<String>, password: impl Into<String>, agent: impl Into<String>, uid: impl Into<String>) {

View file

@ -12,7 +12,7 @@ use crate::proto::{NetAction, Protocol};
// One uplink session: connect, handshake + burst, then translate lines forever. // One uplink session: connect, handshake + burst, then translate lines forever.
// The engine is shared with the gossip layer, so it is locked per operation and // The engine is shared with the gossip layer, so it is locked per operation and
// never held across the registration key-stretching await. // never held across the registration key-stretching await.
pub async fn run(mut proto: Box<dyn Protocol>, engine: Arc<Mutex<Engine>>, addr: &str, mut irc_rx: mpsc::UnboundedReceiver<NetAction>) -> Result<()> { pub async fn run(mut proto: Box<dyn Protocol>, engine: Arc<Mutex<Engine>>, addr: &str, mut irc_rx: mpsc::UnboundedReceiver<NetAction>, email: Option<crate::config::Email>) -> Result<()> {
let stream = TcpStream::connect(addr).await?; let stream = TcpStream::connect(addr).await?;
let (read, mut write) = stream.into_split(); let (read, mut write) = stream.into_split();
let mut lines = BufReader::new(read).lines(); let mut lines = BufReader::new(read).lines();
@ -66,6 +66,10 @@ pub async fn run(mut proto: Box<dyn Protocol>, engine: Arc<Mutex<Engine>>, addr:
action => vec![action], action => vec![action],
}; };
for act in outs { for act in outs {
if let NetAction::SendEmail { to, subject, body } = act {
dispatch_email(&email, to, subject, body);
continue;
}
for out in proto.serialize(&act) { for out in proto.serialize(&act) {
send(&mut write, &out).await?; send(&mut write, &out).await?;
} }
@ -76,17 +80,48 @@ pub async fn run(mut proto: Box<dyn Protocol>, engine: Arc<Mutex<Engine>>, addr:
// A services-initiated action (e.g. a forced logout after a lost // A services-initiated action (e.g. a forced logout after a lost
// registration conflict), pushed by the engine from the gossip path. // registration conflict), pushed by the engine from the gossip path.
Some(action) = irc_rx.recv() => { Some(action) = irc_rx.recv() => {
if let NetAction::SendEmail { to, subject, body } = action {
dispatch_email(&email, to, subject, body);
} else {
for out in proto.serialize(&action) { for out in proto.serialize(&action) {
send(&mut write, &out).await?; send(&mut write, &out).await?;
} }
} }
} }
} }
}
tracing::warn!("uplink closed the connection"); tracing::warn!("uplink closed the connection");
Ok(()) Ok(())
} }
// Fire off an email if email is configured: pipe an RFC822 message to the mail
// command on a spawned task, so a slow MTA never stalls the link.
fn dispatch_email(email: &Option<crate::config::Email>, to: String, subject: String, body: String) {
let Some(email) = email.clone() else {
return tracing::warn!(%to, "email requested but not configured");
};
tokio::spawn(async move {
let msg = format!("From: {}\r\nTo: {to}\r\nSubject: {subject}\r\n\r\n{body}\r\n", email.from);
let child = tokio::process::Command::new("sh")
.arg("-c")
.arg(&email.command)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
let mut child = match child {
Ok(c) => c,
Err(e) => return tracing::warn!(%e, "mail command failed to start"),
};
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(msg.as_bytes()).await;
let _ = stdin.shutdown().await;
}
let _ = child.wait().await;
});
}
async fn send(write: &mut (impl AsyncWriteExt + Unpin), line: &str) -> Result<()> { async fn send(write: &mut (impl AsyncWriteExt + Unpin), line: &str) -> Result<()> {
tracing::debug!(dir = ">>", %line); tracing::debug!(dir = ">>", %line);
write.write_all(line.as_bytes()).await?; write.write_all(line.as_bytes()).await?;

View file

@ -55,6 +55,7 @@ async fn main() -> Result<()> {
let mut db = engine::db::Db::open("fedserv.db.jsonl", &cfg.server.sid); let mut db = engine::db::Db::open("fedserv.db.jsonl", &cfg.server.sid);
db.scram_iterations = cfg.server.scram_iterations; db.scram_iterations = cfg.server.scram_iterations;
db.set_outbound(gossip_tx.clone()); db.set_outbound(gossip_tx.clone());
db.set_email_enabled(cfg.email.is_some());
let engine = Arc::new(Mutex::new(Engine::new(services, db))); let engine = Arc::new(Mutex::new(Engine::new(services, db)));
// Channel for services-initiated actions to reach the uplink (drained by the link loop). // Channel for services-initiated actions to reach the uplink (drained by the link loop).
@ -81,5 +82,5 @@ async fn main() -> Result<()> {
let addr = format!("{}:{}", cfg.uplink.host, cfg.uplink.port); let addr = format!("{}:{}", cfg.uplink.host, cfg.uplink.port);
tracing::info!(server = %cfg.server.name, %addr, "linking to uplink"); tracing::info!(server = %cfg.server.name, %addr, "linking to uplink");
link::run(proto, engine, &addr, irc_rx).await link::run(proto, engine, &addr, irc_rx, cfg.email.clone()).await
} }