diff --git a/modules/nickserv/drop.rs b/modules/nickserv/drop.rs new file mode 100644 index 0000000..a632d87 --- /dev/null +++ b/modules/nickserv/drop.rs @@ -0,0 +1,33 @@ +use crate::engine::db::Db; +use crate::engine::service::{Sender, ServiceCtx}; +use crate::engine::state::Network; + +// DROP : delete your account. Re-authenticates as confirmation, releases +// and drops the channels you found, and logs you out. +pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &Network, db: &mut Db) { + let Some(account) = from.account else { + ctx.notice(me, from.uid, "You need to be logged in. Identify to NickServ first."); + return; + }; + let Some(&password) = args.get(1) else { + ctx.notice(me, from.uid, "Syntax: DROP "); + return; + }; + if db.authenticate(account, password).is_none() { + ctx.notice(me, from.uid, "Invalid password."); + return; + } + let channels = db.channels_owned_by(account); + for chan in &channels { + let _ = db.drop_channel(chan); + ctx.channel_mode("", chan, "-r"); // server-sourced: release the registered mode + } + let _ = db.drop_account(account); + for uid in net.uids_logged_into(account) { + ctx.logout(&uid); + } + ctx.notice(me, from.uid, format!("Your account \x02{account}\x02 has been dropped.")); + if !channels.is_empty() { + ctx.notice(me, from.uid, format!("Channels released: {}.", channels.join(", "))); + } +} diff --git a/modules/nickserv/nickserv.rs b/modules/nickserv/nickserv.rs index f786533..547e63c 100644 --- a/modules/nickserv/nickserv.rs +++ b/modules/nickserv/nickserv.rs @@ -14,6 +14,10 @@ mod cert; mod info; #[path = "alist.rs"] mod alist; +#[path = "set.rs"] +mod set; +#[path = "drop.rs"] +mod drop; pub struct NickServ { pub uid: String, @@ -37,7 +41,7 @@ impl Service for NickServ { true } - fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, _net: &Network, db: &mut Db) { + fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &Network, db: &mut Db) { let me = self.uid.as_str(); match args.first().map(|s| s.to_ascii_uppercase()).as_deref() { Some("REGISTER") => register::handle(me, from, args, ctx), @@ -46,7 +50,9 @@ impl Service for NickServ { Some("CERT") => cert::handle(me, from, args, ctx, db), Some("INFO") => info::handle(me, from, args, ctx, db), Some("ALIST") => alist::handle(me, from, 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, \x02LOGOUT\x02, \x02CERT\x02 ADD|DEL|LIST [fingerprint]."), + Some("SET") => set::handle(me, from, args, ctx, db), + Some("DROP") => drop::handle(me, from, args, ctx, net, 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, \x02SET\x02 PASSWORD|EMAIL, \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/set.rs b/modules/nickserv/set.rs new file mode 100644 index 0000000..4afab84 --- /dev/null +++ b/modules/nickserv/set.rs @@ -0,0 +1,30 @@ +use crate::engine::db::Db; +use crate::engine::service::{Sender, ServiceCtx}; + +// SET PASSWORD | SET EMAIL [address]: change your account settings. +pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut Db) { + let Some(account) = from.account else { + ctx.notice(me, from.uid, "You need to be logged in. Identify to NickServ first."); + return; + }; + match args.get(1).map(|s| s.to_ascii_uppercase()).as_deref() { + Some("PASSWORD") | Some("PASS") => { + let Some(&password) = args.get(2) else { + ctx.notice(me, from.uid, "Syntax: SET PASSWORD "); + return; + }; + // The link layer derives the new password off-thread, then commits it. + ctx.defer_password(account, password, me, from.uid); + } + Some("EMAIL") => { + let email = args.get(2).map(|s| s.to_string()); + let cleared = email.is_none(); + match db.set_email(account, email) { + Ok(()) if cleared => ctx.notice(me, from.uid, format!("Email for \x02{account}\x02 cleared.")), + Ok(()) => ctx.notice(me, from.uid, format!("Email for \x02{account}\x02 updated.")), + Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), + } + } + _ => ctx.notice(me, from.uid, "Syntax: SET PASSWORD | SET EMAIL [address]"), + } +} diff --git a/modules/protocol/inspircd.rs b/modules/protocol/inspircd.rs index d2b4495..7a2f7bf 100644 --- a/modules/protocol/inspircd.rs +++ b/modules/protocol/inspircd.rs @@ -280,8 +280,8 @@ impl Protocol for InspIrcd { vec![format!(":{} INVITE {} {} 1 0", from, uid, channel)] } NetAction::Raw(s) => vec![s.clone()], - // Internal: the link layer handles this before serialization. - NetAction::DeferRegister { .. } => vec![], + // Internal: the link layer handles these before serialization. + NetAction::DeferRegister { .. } | NetAction::DeferPassword { .. } => vec![], } } diff --git a/modules/protocol/mod.rs b/modules/protocol/mod.rs index ed294a3..e9c6d76 100644 --- a/modules/protocol/mod.rs +++ b/modules/protocol/mod.rs @@ -68,6 +68,9 @@ pub enum NetAction { // still needs its (expensive) key derivation. The link layer runs the // derivation off-thread, then calls Engine::complete_register. DeferRegister { account: String, password: String, email: Option, reply: RegReply }, + // Internal only: a password change awaiting the same off-thread derivation. + // The link layer derives, then calls Engine::complete_password_change. + DeferPassword { account: String, password: String, agent: String, uid: String }, } // How to answer a registration once its credentials have been derived. diff --git a/src/engine/db.rs b/src/engine/db.rs index bc770d2..3e13b6e 100644 --- a/src/engine/db.rs +++ b/src/engine/db.rs @@ -45,6 +45,9 @@ pub enum Event { AccountRegistered(Account), CertAdded { account: String, fp: String }, CertRemoved { account: String, fp: String }, + AccountEmailSet { account: String, email: Option }, + AccountPasswordSet { account: String, password_hash: String, scram256: String, scram512: String }, + AccountDropped { account: String }, ChannelRegistered { name: String, founder: String, ts: u64 }, ChannelDropped { name: String }, ChannelMlock { name: String, on: String, off: String }, @@ -70,7 +73,12 @@ enum Scope { impl Event { fn scope(&self) -> Scope { match self { - Event::AccountRegistered(_) | Event::CertAdded { .. } | Event::CertRemoved { .. } => Scope::Global, + Event::AccountRegistered(_) + | Event::CertAdded { .. } + | Event::CertRemoved { .. } + | Event::AccountEmailSet { .. } + | Event::AccountPasswordSet { .. } + | Event::AccountDropped { .. } => Scope::Global, Event::ChannelRegistered { .. } | Event::ChannelDropped { .. } | Event::ChannelMlock { .. } @@ -386,6 +394,13 @@ pub enum RegError { Internal, } +// What an ingested entry did to a locally-known account, so the engine can log +// out sessions that were relying on it. +pub enum AccountChange { + TakenOver(String), + Dropped(String), +} + #[derive(Debug)] pub enum CertError { Invalid, // not a plausible fingerprint @@ -468,19 +483,22 @@ impl Db { /// of the gossip seam. Idempotent (re-delivered entries are dropped). Returns /// the account name if this ingest changed its owner (a registration conflict /// resolved against the local claim), so the caller can log out stale sessions. - pub fn ingest(&mut self, entry: LogEntry) -> std::io::Result> { - // Record the current owner of an incoming account before applying, to see - // if this ingest transfers ownership away from us. - let contested = match &entry.event { - Event::AccountRegistered(a) => self.accounts.get(&key(&a.name)).map(|cur| (a.name.clone(), cur.home.clone())), + pub fn ingest(&mut self, entry: LogEntry) -> std::io::Result> { + // Snapshot the incoming account's local owner before applying, to detect a + // takeover (home changed) or a remote drop (it disappears). + let watched: Option<(String, Option)> = match &entry.event { + Event::AccountRegistered(a) => Some((a.name.clone(), self.account(&a.name).map(|c| c.home.clone()))), + Event::AccountDropped { account } => Some((account.clone(), self.account(account).map(|c| c.home.clone()))), _ => None, }; if let Some(event) = self.log.ingest(entry)? { apply(&mut self.accounts, &mut self.channels, event); } - if let Some((name, prev_home)) = contested { - if self.accounts.get(&key(&name)).is_some_and(|cur| cur.home != prev_home) { - return Ok(Some(name)); // ownership moved to another node + if let Some((name, prev_home)) = watched { + match (prev_home, self.account(&name).map(|c| c.home.clone())) { + (Some(prev), Some(cur)) if cur != prev => return Ok(Some(AccountChange::TakenOver(name))), + (Some(_), None) => return Ok(Some(AccountChange::Dropped(name))), + _ => {} } } Ok(None) @@ -611,6 +629,49 @@ impl Db { self.accounts.get(&key(account)).map_or(&[], |a| a.certfps.as_slice()) } + /// Set (or clear) `account`'s email. + pub fn set_email(&mut self, account: &str, email: Option) -> Result<(), RegError> { + let k = key(account); + if !self.accounts.contains_key(&k) { + return Err(RegError::Internal); + } + self.log.append(Event::AccountEmailSet { account: account.to_string(), email: email.clone() }).map_err(|_| RegError::Internal)?; + self.accounts.get_mut(&k).unwrap().email = email; + Ok(()) + } + + /// Replace `account`'s password with freshly derived credentials. + pub fn set_credentials(&mut self, account: &str, creds: Credentials) -> Result<(), RegError> { + let k = key(account); + if !self.accounts.contains_key(&k) { + return Err(RegError::Internal); + } + self.log + .append(Event::AccountPasswordSet { + account: account.to_string(), + password_hash: creds.password_hash.clone(), + scram256: creds.scram256.clone(), + scram512: creds.scram512.clone(), + }) + .map_err(|_| RegError::Internal)?; + let a = self.accounts.get_mut(&k).unwrap(); + a.password_hash = creds.password_hash; + a.scram256 = Some(creds.scram256); + a.scram512 = Some(creds.scram512); + Ok(()) + } + + /// Delete `account`. Ok(false) if it wasn't registered. + pub fn drop_account(&mut self, account: &str) -> Result { + let k = key(account); + if !self.accounts.contains_key(&k) { + return Ok(false); + } + self.log.append(Event::AccountDropped { account: account.to_string() }).map_err(|_| RegError::Internal)?; + self.accounts.remove(&k); + Ok(true) + } + /// Register `fp` to `account`. Fingerprints are one-to-one with accounts. pub fn certfp_add(&mut self, account: &str, fp: &str) -> Result<(), CertError> { let fp = fp.to_ascii_lowercase(); @@ -845,6 +906,21 @@ fn apply(accounts: &mut HashMap, channels: &mut HashMap { + if let Some(a) = accounts.get_mut(&key(&account)) { + a.email = email; + } + } + Event::AccountPasswordSet { account, password_hash, scram256, scram512 } => { + if let Some(a) = accounts.get_mut(&key(&account)) { + a.password_hash = password_hash; + a.scram256 = Some(scram256); + a.scram512 = Some(scram512); + } + } + Event::AccountDropped { account } => { + accounts.remove(&key(&account)); + } Event::ChannelRegistered { name, founder, ts } => { channels.insert(key(&name), ChannelInfo { name, founder, ts, lock_on: String::new(), lock_off: String::new(), access: Vec::new(), akick: Vec::new(), desc: String::new(), entrymsg: String::new() }); } diff --git a/src/engine/mod.rs b/src/engine/mod.rs index a5dcd07..8c1e272 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -123,19 +123,20 @@ impl Engine { } pub fn gossip_ingest(&mut self, entry: LogEntry) -> std::io::Result<()> { - // If ingesting a peer's registration took an account name away from a - // local session (it lost the conflict), clean up after the loser. - if let Some(account) = self.db.ingest(entry)? { - self.handle_account_takeover(&account); + // If ingesting a peer's entry removed an account a local session relied on + // (lost a conflict, or dropped elsewhere), clean up after it. + match self.db.ingest(entry)? { + Some(db::AccountChange::TakenOver(a)) => self.handle_account_gone(&a, "collided with another network and no longer belongs to you"), + Some(db::AccountChange::Dropped(a)) => self.handle_account_gone(&a, "was dropped"), + None => {} } Ok(()) } - // A registration conflict resolved against this node: the name `account` is now - // owned by another node. Log out any local session on it (its credential is - // gone) and drop the channels it had registered locally — their founder - // identity now belongs to another node, so they can't silently transfer. - fn handle_account_takeover(&mut self, account: &str) { + // An account a local session was using is gone (lost a conflict, or dropped on + // another node). Log out those sessions (their credential is gone) and drop the + // channels it founded locally — their founder identity no longer exists here. + fn handle_account_gone(&mut self, account: &str, reason: &str) { let victims = self.network.uids_logged_into(account); let orphaned = self.db.channels_owned_by(account); for chan in &orphaned { @@ -156,7 +157,7 @@ impl Engine { self.emit_irc(NetAction::Notice { from: ns.clone(), to: uid.clone(), - text: format!("Your registration of \x02{account}\x02 collided with another network and it now belongs elsewhere. You have been logged out; please register a different name."), + text: format!("Your account \x02{account}\x02 {reason}. You have been logged out."), }); if !orphaned.is_empty() { self.emit_irc(NetAction::Notice { @@ -544,6 +545,15 @@ impl Engine { out } + // Commit a password change the link layer derived off-thread, then notice the user. + pub fn complete_password_change(&mut self, account: &str, creds: Option, agent: &str, uid: &str) -> Vec { + let text = match creds.and_then(|c| self.db.set_credentials(account, c).ok()) { + Some(()) => format!("Your password for \x02{account}\x02 has been changed."), + None => "Sorry, that didn't work. Please try again in a moment.".to_string(), + }; + vec![NetAction::Notice { from: agent.to_string(), to: uid.to_string(), text }] + } + // Route a PRIVMSG addressed to a service (by uid or nick) into that service, // handing it the sender's resolved nick, login state, and the account store. fn dispatch(&mut self, from: &str, to: &str, text: &str) -> Vec { @@ -1132,6 +1142,54 @@ mod tests { assert!(notice(&to_ns(&mut e, "ALIST"), "#a")); } + // SET EMAIL stores an email; SET PASSWORD defers derivation, and once + // completed the new password authenticates and the old one no longer does. + #[test] + fn nickserv_set_password_and_email() { + let mut e = engine_with("nsset", "alice", "sesame"); + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into() }); + e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() }); + let to_ns = |e: &mut Engine, text: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: text.into() }); + let notice = |out: &[NetAction], needle: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(needle))); + + assert!(notice(&to_ns(&mut e, "SET EMAIL alice@example.org"), "updated")); + assert!(notice(&to_ns(&mut e, "INFO"), "alice@example.org"), "owner INFO shows the email"); + + let out = to_ns(&mut e, "SET PASSWORD newsecret"); + let deferred = out.iter().find_map(|a| match a { + NetAction::DeferPassword { account, password, agent, uid } => Some((account.clone(), password.clone(), agent.clone(), uid.clone())), + _ => None, + }); + let (account, password, agent, uid) = deferred.expect("SET PASSWORD defers derivation"); + assert_eq!(password, "newsecret"); + let creds = Db::derive_credentials(&password, 4096); + e.complete_password_change(&account, creds, &agent, &uid); + assert!(e.db.authenticate("alice", "newsecret").is_some(), "new password works"); + assert!(e.db.authenticate("alice", "sesame").is_none(), "old password rejected"); + } + + // DROP deletes the account, releases and drops the channels it founded, and + // logs the session out; a wrong password is refused. + #[test] + fn nickserv_drop_account() { + let mut e = engine_with("nsdrop", "alice", "sesame"); + e.db.register_channel("#a", "alice").unwrap(); + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into() }); + e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() }); + let to_ns = |e: &mut Engine, text: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: text.into() }); + let notice = |out: &[NetAction], needle: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(needle))); + + assert!(notice(&to_ns(&mut e, "DROP wrongpass"), "Invalid password")); + assert!(e.db.account("alice").is_some(), "wrong password keeps the account"); + + let out = to_ns(&mut e, "DROP sesame"); + assert!(notice(&out, "has been dropped")); + assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes, .. } if channel == "#a" && modes == "-r")), "channel released: {out:?}"); + assert!(out.iter().any(|a| matches!(a, NetAction::Metadata { target, key, value } if target == "000AAAAAB" && key == "accountname" && value.is_empty())), "session logged out: {out:?}"); + assert!(e.db.account("alice").is_none(), "account gone"); + assert!(e.db.channel("#a").is_none(), "founded channel gone"); + } + // IDENTIFY accepts the two-arg account+password form: log into a named // account even when the current nick differs; a wrong password is refused. #[test] diff --git a/src/engine/service.rs b/src/engine/service.rs index ab6b5ba..53fbd11 100644 --- a/src/engine/service.rs +++ b/src/engine/service.rs @@ -57,6 +57,17 @@ impl ServiceCtx { }); } + // 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`. + pub fn defer_password(&mut self, account: impl Into, password: impl Into, agent: impl Into, uid: impl Into) { + self.actions.push(NetAction::DeferPassword { + account: account.into(), + password: password.into(), + agent: agent.into(), + uid: uid.into(), + }); + } + // Log a user into an account: sets the accountname the ircd turns into // RPL_LOGGEDIN (900) and exposes to account-tag / WHOX, the same login the // SASL agent applies. Used after a successful REGISTER / IDENTIFY. diff --git a/src/link.rs b/src/link.rs index 4eca24e..175c07c 100644 --- a/src/link.rs +++ b/src/link.rs @@ -54,6 +54,15 @@ pub async fn run(mut proto: Box, engine: Arc>, addr: } } } + // Password change: same off-thread derivation as register. + NetAction::DeferPassword { account, password, agent, uid } => { + let iterations = engine.lock().await.scram_iterations(); + let creds = tokio::task::spawn_blocking(move || { + Db::derive_credentials(&password, iterations) + }) + .await?; + engine.lock().await.complete_password_change(&account, creds, &agent, &uid) + } action => vec![action], }; for act in outs {