From fbcf0eaac7cd20d412becdc77350a48929aedd65 Mon Sep 17 00:00:00 2001 From: Jean Date: Sun, 12 Jul 2026 15:36:30 +0000 Subject: [PATCH] nickserv: email confirmation (CONFIRM) on registration When email is configured and a REGISTER includes an address, the account starts unverified and a confirmation code is emailed. CONFIRM verifies it (a federated AccountVerified event); INFO shows an unconfirmed email until then. Emailed codes now carry a purpose (reset vs confirm). --- modules/nickserv/confirm.rs | 26 +++++++++++++ modules/nickserv/info.rs | 3 +- modules/nickserv/nickserv.rs | 5 ++- modules/nickserv/resetpass.rs | 6 +-- src/engine/db.rs | 70 ++++++++++++++++++++++++++++------- src/engine/mod.rs | 50 ++++++++++++++++++++++++- 6 files changed, 140 insertions(+), 20 deletions(-) create mode 100644 modules/nickserv/confirm.rs diff --git a/modules/nickserv/confirm.rs b/modules/nickserv/confirm.rs new file mode 100644 index 0000000..99887c5 --- /dev/null +++ b/modules/nickserv/confirm.rs @@ -0,0 +1,26 @@ +use crate::engine::db::{CodeKind, Db}; +use crate::engine::service::{Sender, ServiceCtx}; + +// CONFIRM : confirm your account's email with the code you were emailed. +pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut Db) { + let Some(&code) = args.get(1) else { + ctx.notice(me, from.uid, "Syntax: CONFIRM "); + return; + }; + let Some(account) = from.account.map(str::to_string).or_else(|| db.resolve_account(from.nick).map(str::to_string)) else { + ctx.notice(me, from.uid, "You don't have an account to confirm."); + return; + }; + if db.is_verified(&account) { + ctx.notice(me, from.uid, format!("\x02{account}\x02 is already confirmed.")); + return; + } + if !db.take_code(&account, CodeKind::Confirm, code) { + ctx.notice(me, from.uid, "Invalid or expired confirmation code."); + return; + } + match db.verify_account(&account) { + Ok(()) => ctx.notice(me, from.uid, format!("\x02{account}\x02 is now confirmed. Thanks!")), + Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), + } +} diff --git a/modules/nickserv/info.rs b/modules/nickserv/info.rs index 3760e7d..79c090c 100644 --- a/modules/nickserv/info.rs +++ b/modules/nickserv/info.rs @@ -13,7 +13,8 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: ctx.notice(me, from.uid, format!(" Registered : {}", human_time(acct.ts))); if from.account == Some(acct.name.as_str()) { match &acct.email { - Some(email) => ctx.notice(me, from.uid, format!(" Email : {email}")), + Some(email) if acct.verified => ctx.notice(me, from.uid, format!(" Email : {email}")), + Some(email) => ctx.notice(me, from.uid, format!(" Email : {email} (unconfirmed)")), None => ctx.notice(me, from.uid, " Email : (none set)"), } } diff --git a/modules/nickserv/nickserv.rs b/modules/nickserv/nickserv.rs index 8f50447..8d3e3e0 100644 --- a/modules/nickserv/nickserv.rs +++ b/modules/nickserv/nickserv.rs @@ -28,6 +28,8 @@ mod ungroup; mod ghost; #[path = "resetpass.rs"] mod resetpass; +#[path = "confirm.rs"] +mod confirm; pub struct NickServ { pub uid: String, @@ -67,7 +69,8 @@ impl Service for NickServ { 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("RESETPASS") => resetpass::handle(me, from, args, ctx, db), - Some("HELP") => ctx.notice(me, from.uid, "NickServ looks after your nickname. Commands: \x02REGISTER\x02 [email], \x02IDENTIFY\x02 [account] , \x02INFO\x02 [account], \x02ALIST\x02, \x02GROUP\x02/\x02GLIST\x02/\x02UNGROUP\x02, \x02GHOST\x02 [password], \x02SET\x02 PASSWORD|EMAIL, \x02RESETPASS\x02 , \x02DROP\x02 , \x02LOGOUT\x02, \x02CERT\x02 ADD|DEL|LIST [fingerprint]."), + Some("CONFIRM") => confirm::handle(me, from, args, ctx, db), + Some("HELP") => ctx.notice(me, from.uid, "NickServ looks after your nickname. Commands: \x02REGISTER\x02 [email], \x02IDENTIFY\x02 [account] , \x02INFO\x02 [account], \x02ALIST\x02, \x02GROUP\x02/\x02GLIST\x02/\x02UNGROUP\x02, \x02GHOST\x02 [password], \x02SET\x02 PASSWORD|EMAIL, \x02RESETPASS\x02 , \x02CONFIRM\x02 , \x02DROP\x02 , \x02LOGOUT\x02, \x02CERT\x02 ADD|DEL|LIST [fingerprint]."), Some(other) => ctx.notice(me, from.uid, format!("I don't know the command \x02{other}\x02. Try \x02HELP\x02.")), None => {} } diff --git a/modules/nickserv/resetpass.rs b/modules/nickserv/resetpass.rs index edf6b99..50a0f38 100644 --- a/modules/nickserv/resetpass.rs +++ b/modules/nickserv/resetpass.rs @@ -1,4 +1,4 @@ -use crate::engine::db::Db; +use crate::engine::db::{CodeKind, Db}; use crate::engine::service::{Sender, ServiceCtx}; // RESETPASS : email a reset code to the address on file. @@ -18,7 +18,7 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: 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); + let code = db.issue_code(&canonical, CodeKind::Reset); ctx.send_email( email, format!("Password reset for {canonical}"), @@ -31,7 +31,7 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: ctx.notice(me, from.uid, format!("\x02{name}\x02 isn't registered.")); return; }; - if !db.take_reset_code(&canonical, code) { + if !db.take_code(&canonical, CodeKind::Reset, code) { ctx.notice(me, from.uid, "Invalid or expired reset code."); return; } diff --git a/src/engine/db.rs b/src/engine/db.rs index 02d3b61..128bc91 100644 --- a/src/engine/db.rs +++ b/src/engine/db.rs @@ -34,6 +34,15 @@ pub struct Account { // this account via SASL EXTERNAL. Each fingerprint maps to one account. #[serde(default)] pub certfps: Vec, + // Whether the email on file has been confirmed. Defaults true so accounts + // predating email confirmation (and those registered without email) count + // as verified. + #[serde(default = "verified_default")] + pub verified: bool, +} + +fn verified_default() -> bool { + true } // Event-sourced persistence: every change is an Event appended to a JSONL log, @@ -48,6 +57,7 @@ pub enum Event { AccountEmailSet { account: String, email: Option }, AccountPasswordSet { account: String, password_hash: String, scram256: String, scram512: String }, AccountDropped { account: String }, + AccountVerified { account: String }, NickGrouped { nick: String, account: String }, NickUngrouped { nick: String }, ChannelRegistered { name: String, founder: String, ts: u64 }, @@ -81,6 +91,7 @@ impl Event { | Event::AccountEmailSet { .. } | Event::AccountPasswordSet { .. } | Event::AccountDropped { .. } + | Event::AccountVerified { .. } | Event::NickGrouped { .. } | Event::NickUngrouped { .. } => Scope::Global, Event::ChannelRegistered { .. } @@ -445,8 +456,15 @@ pub struct Db { 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, + // Node-local, non-persisted email codes: account -> (purpose, code, expiry). + codes: HashMap, +} + +// What an emailed code authorises. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum CodeKind { + Reset, + Confirm, } fn key(name: &str) -> String { @@ -486,7 +504,7 @@ impl Db { apply(&mut accounts, &mut channels, &mut grouped, event); } tracing::info!(accounts = accounts.len(), channels = channels.len(), "account store loaded"); - Self { accounts, channels, grouped, log, scram_iterations: scram::DEFAULT_ITERATIONS, email_enabled: false, reset_codes: HashMap::new() } + Self { accounts, channels, grouped, log, scram_iterations: scram::DEFAULT_ITERATIONS, email_enabled: false, codes: HashMap::new() } } /// Fold an entry authored by another node into the store — the services-side @@ -626,6 +644,9 @@ impl Db { if self.exists(name) { return Err(RegError::Exists); } + // Unverified only when email confirmation actually applies (email is + // configured and an address was given); otherwise verified immediately. + let verified = !(self.email_enabled && email.is_some()); let account = Account { name: name.to_string(), password_hash: creds.password_hash, @@ -635,6 +656,7 @@ impl Db { scram256: Some(creds.scram256), scram512: Some(creds.scram512), certfps: Vec::new(), + verified, }; self.log.append(Event::AccountRegistered(account.clone())).map_err(|_| RegError::Internal)?; self.accounts.insert(key(name), account); @@ -689,23 +711,40 @@ impl Db { self.email_enabled } + /// Whether `account`'s email is confirmed (true for unknown/legacy accounts). + pub fn is_verified(&self, account: &str) -> bool { + self.account(account).map_or(true, |a| a.verified) + } + + /// Mark `account`'s email confirmed. + pub fn verify_account(&mut self, account: &str) -> Result<(), RegError> { + let k = key(account); + if !self.accounts.contains_key(&k) { + return Err(RegError::Internal); + } + self.log.append(Event::AccountVerified { account: account.to_string() }).map_err(|_| RegError::Internal)?; + self.accounts.get_mut(&k).unwrap().verified = true; + Ok(()) + } + 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 { + /// Issue a fresh emailed code for `account` and purpose, valid for 15 minutes. + pub fn issue_code(&mut self, account: &str, kind: CodeKind) -> String { let code = gen_code(); - self.reset_codes.insert(key(account), (code.clone(), Instant::now() + Duration::from_secs(900))); + self.codes.insert(key(account), (kind, 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 { + /// Consume a code for `account`: true if the purpose and code match and it + /// hasn't expired. + pub fn take_code(&mut self, account: &str, kind: CodeKind, 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); + match self.codes.get(&k) { + Some((ki, c, deadline)) if *ki == kind && c == code && *deadline > Instant::now() => { + self.codes.remove(&k); true } _ => false, @@ -1005,6 +1044,11 @@ fn apply(accounts: &mut HashMap, channels: &mut HashMap { + if let Some(a) = accounts.get_mut(&key(&account)) { + a.verified = true; + } + } Event::NickGrouped { nick, account } => { grouped.insert(key(&nick), account); } @@ -1146,7 +1190,7 @@ mod tests { fn account_conflict_resolves_deterministically() { let alice = |hash: &str, ts: u64, home: &str| Account { name: "alice".into(), password_hash: hash.into(), email: None, - ts, home: home.into(), scram256: None, scram512: None, certfps: vec![], + ts, home: home.into(), scram256: None, scram512: None, certfps: vec![], verified: true, }; let converge = |first: &Account, second: &Account| { let (mut acc, mut ch, mut gr) = (HashMap::new(), HashMap::new(), HashMap::new()); @@ -1251,7 +1295,7 @@ mod tests { db.register("alice", "pw", None).unwrap(); let bob = Account { name: "bob".into(), password_hash: "x".into(), email: None, - ts: 0, home: "peer".into(), scram256: None, scram512: None, certfps: vec![], + ts: 0, home: "peer".into(), scram256: None, scram512: None, certfps: vec![], verified: true, }; let entry = LogEntry { origin: "peer".into(), seq: 0, lamport: 1, event: Event::AccountRegistered(bob) }; db.ingest(entry).unwrap(); diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 01a385d..4b2a194 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -535,13 +535,27 @@ impl Engine { let Some(creds) = creds else { return reg_reply(&reply, RegOutcome::Internal, account); }; + let addr = email.clone(); let outcome = match self.db.register_prepared(account, creds, email) { Ok(()) => RegOutcome::Ok, Err(RegError::Exists) => RegOutcome::Exists, Err(RegError::Internal) => RegOutcome::Internal, }; - let out = reg_reply(&reply, outcome, account); + let ok = matches!(outcome, RegOutcome::Ok); + let mut out = reg_reply(&reply, outcome, account); self.track_accounts(&out); + // If this created an unverified account (email confirmation applies), email a code. + if ok && !self.db.is_verified(account) { + if let (Some(addr), RegReply::NickServ { agent, uid, .. }) = (addr, &reply) { + let code = self.db.issue_code(account, db::CodeKind::Confirm); + out.push(NetAction::SendEmail { + to: addr, + subject: format!("Confirm your {account} registration"), + body: format!("Welcome! Confirm your account {account} with:\n /msg NickServ CONFIRM {code}\nThe code expires in 15 minutes."), + }); + out.push(NetAction::Notice { from: agent.clone(), to: uid.clone(), text: "A confirmation code has been emailed to you. Confirm with \x02CONFIRM \x02.".to_string() }); + } + } out } @@ -1088,7 +1102,7 @@ mod tests { // An earlier claim from another node wins and takes the name over. let winner = db::Account { name: "alice".into(), password_hash: "OTHER".into(), email: None, - ts: 0, home: "peer".into(), scram256: None, scram512: None, certfps: vec![], + ts: 0, home: "peer".into(), scram256: None, scram512: None, certfps: vec![], verified: true, }; let entry = LogEntry::for_test("peer", 0, 1, db::Event::AccountRegistered(winner)); e.gossip_ingest(entry).unwrap(); @@ -1244,6 +1258,38 @@ mod tests { assert!(to_ns(&mut e, "RESETPASS alice 000000 x").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Invalid or expired")))); } + // With email configured, registering with an address creates an unverified + // account and emails a confirmation code; CONFIRM verifies it. + #[test] + fn nickserv_confirm() { + let path = std::env::temp_dir().join("fedserv-nsconfirm.jsonl"); + let _ = std::fs::remove_file(&path); + let mut db = Db::open(&path, "test"); + db.scram_iterations = 4096; + db.set_email_enabled(true); + let mut e = Engine::new(vec![Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 })], db); + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "newbie".into(), host: "h".into() }); + + // REGISTER with an email defers; complete it as the link layer would. + let reg = e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "REGISTER pw newbie@example.org".into() }); + let (account, password, email, reply) = reg.iter().find_map(|a| match a { + NetAction::DeferRegister { account, password, email, reply } => Some((account.clone(), password.clone(), email.clone(), reply.clone())), + _ => None, + }).expect("register defers"); + let out = e.complete_register(&account, Db::derive_credentials(&password, 4096), email, reply); + assert!(!e.db.is_verified("newbie"), "registering with email starts unverified"); + let body = out.iter().find_map(|a| match a { + NetAction::SendEmail { body, .. } => Some(body.clone()), + _ => None, + }).expect("a confirm code is emailed"); + let code = body.split("CONFIRM ").nth(1).unwrap().split_whitespace().next().unwrap().to_string(); + + // CONFIRM verifies (the user was auto-logged-in by register). + let out = e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: format!("CONFIRM {code}") }); + assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("now confirmed"))), "{out:?}"); + assert!(e.db.is_verified("newbie"), "confirmed after CONFIRM"); + } + // GHOST renames off a session using a nick the caller owns. #[test] fn nickserv_ghost() {