From 020e9bd576e796109c91c0e7ebe5e27ea21e229c Mon Sep 17 00:00:00 2001 From: Jean Date: Sun, 12 Jul 2026 14:59:23 +0000 Subject: [PATCH] nickserv: grouped nicks (GROUP/GLIST/UNGROUP) and GHOST A nick can be grouped to an account and then identifies to it; the store resolves an alias to its account everywhere it authenticates. GHOST (and RECOVER) rename off a session using a nick you own. Grouped nicks federate and are dropped with their account. --- modules/nickserv/ghost.rs | 33 +++++++++++++++ modules/nickserv/glist.rs | 17 ++++++++ modules/nickserv/group.rs | 23 +++++++++++ modules/nickserv/nickserv.rs | 14 ++++++- modules/nickserv/ungroup.rs | 24 +++++++++++ src/engine/db.rs | 80 +++++++++++++++++++++++++++++++----- src/engine/mod.rs | 34 +++++++++++++++ 7 files changed, 213 insertions(+), 12 deletions(-) create mode 100644 modules/nickserv/ghost.rs create mode 100644 modules/nickserv/glist.rs create mode 100644 modules/nickserv/group.rs create mode 100644 modules/nickserv/ungroup.rs diff --git a/modules/nickserv/ghost.rs b/modules/nickserv/ghost.rs new file mode 100644 index 0000000..14b646b --- /dev/null +++ b/modules/nickserv/ghost.rs @@ -0,0 +1,33 @@ +use crate::engine::db::Db; +use crate::engine::service::{Sender, ServiceCtx}; +use crate::engine::state::Network; + +// GHOST/RECOVER [password]: rename off a session using a nick you own, +// either by being identified to its account or giving that account's password. +pub fn handle(me: &str, guest_nick: &str, guest_seq: &mut u32, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &Network, db: &Db) { + let Some(&target) = args.get(1) else { + ctx.notice(me, from.uid, "Syntax: GHOST [password]"); + return; + }; + let Some(account) = db.resolve_account(target).map(str::to_string) else { + ctx.notice(me, from.uid, format!("\x02{target}\x02 isn't registered.")); + return; + }; + let owns = from.account == Some(account.as_str()) || args.get(2).is_some_and(|pw| db.authenticate(target, pw).is_some()); + if !owns { + ctx.notice(me, from.uid, format!("Access denied. Identify to \x02{account}\x02 or give its password.")); + return; + } + let Some(ghost) = net.uid_by_nick(target).map(str::to_string) else { + ctx.notice(me, from.uid, format!("Nobody is using \x02{target}\x02.")); + return; + }; + if ghost == from.uid { + ctx.notice(me, from.uid, "That's you."); + return; + } + let guest = format!("{guest_nick}{guest_seq}"); + *guest_seq = guest_seq.wrapping_add(1); + ctx.force_nick(&ghost, &guest); + ctx.notice(me, from.uid, format!("\x02{target}\x02 has been freed.")); +} diff --git a/modules/nickserv/glist.rs b/modules/nickserv/glist.rs new file mode 100644 index 0000000..e1a2221 --- /dev/null +++ b/modules/nickserv/glist.rs @@ -0,0 +1,17 @@ +use crate::engine::db::Db; +use crate::engine::service::{Sender, ServiceCtx}; + +// GLIST: list the nicks grouped to your account. +pub fn handle(me: &str, from: &Sender, ctx: &mut ServiceCtx, db: &Db) { + let Some(account) = from.account else { + ctx.notice(me, from.uid, "You need to be logged in. Identify to NickServ first."); + return; + }; + let mut nicks = db.grouped_nicks(account); + nicks.sort(); + ctx.notice(me, from.uid, format!("Nicks grouped to \x02{account}\x02:")); + ctx.notice(me, from.uid, format!(" \x02{account}\x02 (main)")); + for n in nicks { + ctx.notice(me, from.uid, format!(" \x02{n}\x02")); + } +} diff --git a/modules/nickserv/group.rs b/modules/nickserv/group.rs new file mode 100644 index 0000000..daa44f7 --- /dev/null +++ b/modules/nickserv/group.rs @@ -0,0 +1,23 @@ +use crate::engine::db::Db; +use crate::engine::service::{Sender, ServiceCtx}; + +// GROUP : link your current nick to an existing account, so +// you can identify to it under this nick too. +pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut Db) { + let (Some(&account), Some(&password)) = (args.get(1), args.get(2)) else { + ctx.notice(me, from.uid, "Syntax: GROUP "); + return; + }; + let Some(canonical) = db.authenticate(account, password).map(str::to_string) else { + ctx.notice(me, from.uid, "Invalid account or password."); + return; + }; + if db.account(from.nick).is_some() { + ctx.notice(me, from.uid, format!("\x02{}\x02 is itself a registered account.", from.nick)); + return; + } + match db.group_nick(from.nick, &canonical) { + Ok(()) => ctx.notice(me, from.uid, format!("Your nick \x02{}\x02 is now grouped to \x02{canonical}\x02.", from.nick)), + Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), + } +} diff --git a/modules/nickserv/nickserv.rs b/modules/nickserv/nickserv.rs index 547e63c..5cd3208 100644 --- a/modules/nickserv/nickserv.rs +++ b/modules/nickserv/nickserv.rs @@ -18,6 +18,14 @@ mod alist; mod set; #[path = "drop.rs"] mod drop; +#[path = "group.rs"] +mod group; +#[path = "glist.rs"] +mod glist; +#[path = "ungroup.rs"] +mod ungroup; +#[path = "ghost.rs"] +mod ghost; pub struct NickServ { pub uid: String, @@ -52,7 +60,11 @@ impl Service for NickServ { Some("ALIST") => alist::handle(me, from, ctx, db), 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("GROUP") => group::handle(me, from, args, ctx, db), + Some("GLIST") => glist::handle(me, from, 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("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, \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/ungroup.rs b/modules/nickserv/ungroup.rs new file mode 100644 index 0000000..9aea290 --- /dev/null +++ b/modules/nickserv/ungroup.rs @@ -0,0 +1,24 @@ +use crate::engine::db::Db; +use crate::engine::service::{Sender, ServiceCtx}; + +// UNGROUP [nick]: remove a nick grouped to your account (defaults to your current nick). +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; + }; + let nick = args.get(1).copied().unwrap_or(from.nick); + if nick.eq_ignore_ascii_case(account) { + ctx.notice(me, from.uid, "You can't ungroup your main account name."); + return; + } + if db.resolve_account(nick).map_or(true, |a| !a.eq_ignore_ascii_case(account)) { + ctx.notice(me, from.uid, format!("\x02{nick}\x02 isn't grouped to your account.")); + return; + } + match db.ungroup_nick(nick) { + Ok(true) => ctx.notice(me, from.uid, format!("\x02{nick}\x02 is no longer grouped to \x02{account}\x02.")), + Ok(false) => ctx.notice(me, from.uid, format!("\x02{nick}\x02 isn't a grouped nick.")), + Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), + } +} diff --git a/src/engine/db.rs b/src/engine/db.rs index 3e13b6e..a91fd02 100644 --- a/src/engine/db.rs +++ b/src/engine/db.rs @@ -48,6 +48,8 @@ pub enum Event { AccountEmailSet { account: String, email: Option }, AccountPasswordSet { account: String, password_hash: String, scram256: String, scram512: String }, AccountDropped { account: String }, + NickGrouped { nick: String, account: String }, + NickUngrouped { nick: String }, ChannelRegistered { name: String, founder: String, ts: u64 }, ChannelDropped { name: String }, ChannelMlock { name: String, on: String, off: String }, @@ -78,7 +80,9 @@ impl Event { | Event::CertRemoved { .. } | Event::AccountEmailSet { .. } | Event::AccountPasswordSet { .. } - | Event::AccountDropped { .. } => Scope::Global, + | Event::AccountDropped { .. } + | Event::NickGrouped { .. } + | Event::NickUngrouped { .. } => Scope::Global, Event::ChannelRegistered { .. } | Event::ChannelDropped { .. } | Event::ChannelMlock { .. } @@ -435,6 +439,7 @@ pub struct Credentials { pub struct Db { accounts: HashMap, // keyed by casefolded name channels: HashMap, // keyed by casefolded name + grouped: HashMap, // casefolded alias nick -> canonical account name log: EventLog, // PBKDF2 cost baked into new SCRAM verifiers; lowered by tests. pub(crate) scram_iterations: u32, @@ -472,11 +477,12 @@ impl Db { let (log, events) = EventLog::open(path.into(), origin.into()); let mut accounts = HashMap::new(); let mut channels = HashMap::new(); + let mut grouped = HashMap::new(); for event in events { - apply(&mut accounts, &mut channels, event); + apply(&mut accounts, &mut channels, &mut grouped, event); } tracing::info!(accounts = accounts.len(), channels = channels.len(), "account store loaded"); - Self { accounts, channels, log, scram_iterations: scram::DEFAULT_ITERATIONS } + Self { accounts, channels, grouped, log, scram_iterations: scram::DEFAULT_ITERATIONS } } /// Fold an entry authored by another node into the store — the services-side @@ -492,7 +498,7 @@ impl Db { _ => None, }; if let Some(event) = self.log.ingest(entry)? { - apply(&mut self.accounts, &mut self.channels, event); + apply(&mut self.accounts, &mut self.channels, &mut self.grouped, event); } if let Some((name, prev_home)) = watched { match (prev_home, self.account(&name).map(|c| c.home.clone())) { @@ -526,6 +532,9 @@ impl Db { snapshot.push(Event::ChannelEntryMsgSet { channel: c.name.clone(), msg: c.entrymsg.clone() }); } } + for (nick, account) in &self.grouped { + snapshot.push(Event::NickGrouped { nick: nick.clone(), account: account.clone() }); + } self.log.compact(snapshot)?; tracing::info!(before, after = self.log.len(), "compacted event log"); Ok(()) @@ -552,7 +561,49 @@ impl Db { } pub fn exists(&self, name: &str) -> bool { - self.accounts.contains_key(&key(name)) + self.resolved_key(name).is_some() + } + + // Resolve a name (a registered account, or a nick grouped to one) to the + // owning account's storage key. + fn resolved_key(&self, name: &str) -> Option { + let k = key(name); + if self.accounts.contains_key(&k) { + return Some(k); + } + self.grouped.get(&k).map(|acct| key(acct)) + } + + /// The canonical account name for `name`, whether it is the account itself or + /// a nick grouped to it. + pub fn resolve_account(&self, name: &str) -> Option<&str> { + let k = self.resolved_key(name)?; + self.accounts.get(&k).map(|a| a.name.as_str()) + } + + /// Group alias `nick` to an existing `account`. + pub fn group_nick(&mut self, nick: &str, account: &str) -> Result<(), RegError> { + if !self.accounts.contains_key(&key(account)) { + return Err(RegError::Internal); + } + self.log.append(Event::NickGrouped { nick: nick.to_string(), account: account.to_string() }).map_err(|_| RegError::Internal)?; + self.grouped.insert(key(nick), account.to_string()); + Ok(()) + } + + /// Remove grouped alias `nick`. Ok(false) if it wasn't grouped. + pub fn ungroup_nick(&mut self, nick: &str) -> Result { + if !self.grouped.contains_key(&key(nick)) { + return Ok(false); + } + self.log.append(Event::NickUngrouped { nick: nick.to_string() }).map_err(|_| RegError::Internal)?; + self.grouped.remove(&key(nick)); + Ok(true) + } + + /// The alias nicks grouped to `account` (not the account name itself). + pub fn grouped_nicks(&self, account: &str) -> Vec { + self.grouped.iter().filter(|(_, a)| a.eq_ignore_ascii_case(account)).map(|(nick, _)| nick.clone()).collect() } /// Derive the password-bound half of an account. Pure and CPU-heavy (no @@ -602,13 +653,13 @@ impl Db { /// Check credentials; on success return the account's canonical name (its /// stored casing), else None. pub fn authenticate(&self, name: &str, password: &str) -> Option<&str> { - let account = self.accounts.get(&key(name))?; + let account = self.accounts.get(&self.resolved_key(name)?)?; verify_password(password, &account.password_hash).then_some(account.name.as_str()) } /// The account's canonical name and its SCRAM verifier for `mech`, if any. pub fn scram_lookup(&self, name: &str, mech: &str) -> Option<(&str, &str)> { - let account = self.accounts.get(&key(name))?; + let account = self.accounts.get(&self.resolved_key(name)?)?; let verifier = match mech { "SCRAM-SHA-256" => account.scram256.as_deref(), "SCRAM-SHA-512" => account.scram512.as_deref(), @@ -882,7 +933,7 @@ fn owns_over(held: &Account, claim: &Account) -> bool { // Fold one event into the store. Shared by log replay (`open`) and gossip // ingest, so both routes reconstruct identical state. -fn apply(accounts: &mut HashMap, channels: &mut HashMap, event: Event) { +fn apply(accounts: &mut HashMap, channels: &mut HashMap, grouped: &mut HashMap, event: Event) { match event { Event::AccountRegistered(a) => { // Resolve a concurrent registration of the same name deterministically: @@ -920,6 +971,13 @@ fn apply(accounts: &mut HashMap, channels: &mut HashMap { accounts.remove(&key(&account)); + grouped.retain(|_, a| !a.eq_ignore_ascii_case(&account)); // its aliases go too + } + Event::NickGrouped { nick, account } => { + grouped.insert(key(&nick), account); + } + Event::NickUngrouped { nick } => { + grouped.remove(&key(&nick)); } 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() }); @@ -1052,9 +1110,9 @@ mod tests { ts, home: home.into(), scram256: None, scram512: None, certfps: vec![], }; let converge = |first: &Account, second: &Account| { - let (mut acc, mut ch) = (HashMap::new(), HashMap::new()); - apply(&mut acc, &mut ch, Event::AccountRegistered(first.clone())); - apply(&mut acc, &mut ch, Event::AccountRegistered(second.clone())); + let (mut acc, mut ch, mut gr) = (HashMap::new(), HashMap::new(), HashMap::new()); + apply(&mut acc, &mut ch, &mut gr, Event::AccountRegistered(first.clone())); + apply(&mut acc, &mut ch, &mut gr, Event::AccountRegistered(second.clone())); acc["alice"].password_hash.clone() }; // Earlier registration wins, regardless of which claim applies first. diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 8c1e272..2c788ff 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -1190,6 +1190,40 @@ mod tests { assert!(e.db.channel("#a").is_none(), "founded channel gone"); } + // GROUP links a nick to an account so it identifies there too; GLIST lists the + // aliases; UNGROUP removes one, after which the nick no longer resolves. + #[test] + fn nickserv_grouped_nicks() { + let mut e = engine_with("nsgroup", "alice", "sesame"); + let to_ns = |e: &mut Engine, uid: &str, text: &str| e.handle(NetEvent::Privmsg { from: uid.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))); + // A user on a different nick groups it to alice by password. + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "ali".into(), host: "h".into() }); + assert!(notice(&to_ns(&mut e, "000AAAAAB", "GROUP alice sesame"), "grouped to \x02alice\x02")); + // Identifying as the grouped nick logs into alice. + let out = to_ns(&mut e, "000AAAAAB", "IDENTIFY sesame"); + assert!(out.iter().any(|a| matches!(a, NetAction::Metadata { key, value, .. } if key == "accountname" && value == "alice")), "grouped nick identifies to alice: {out:?}"); + assert!(notice(&to_ns(&mut e, "000AAAAAB", "GLIST"), "ali")); + // Ungroup it; a fresh session on that nick no longer resolves. + assert!(notice(&to_ns(&mut e, "000AAAAAB", "UNGROUP ali"), "no longer grouped")); + e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "ali".into(), host: "h".into() }); + assert!(notice(&to_ns(&mut e, "000AAAAAC", "IDENTIFY sesame"), "isn't registered")); + } + + // GHOST renames off a session using a nick the caller owns. + #[test] + fn nickserv_ghost() { + let mut e = engine_with("nsghost", "alice", "sesame"); + let to_ns = |e: &mut Engine, uid: &str, text: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAA".into(), text: text.into() }); + // A ghost sits on nick alice; the owner identifies from another nick. + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into() }); + e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "owner".into(), host: "h".into() }); + e.handle(NetEvent::Privmsg { from: "000AAAAAC".into(), to: "42SAAAAAA".into(), text: "IDENTIFY alice sesame".into() }); + let out = to_ns(&mut e, "000AAAAAC", "GHOST alice"); + assert!(out.iter().any(|a| matches!(a, NetAction::ForceNick { uid, .. } if uid == "000AAAAAB")), "ghost renamed off: {out:?}"); + assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("freed"))), "{out:?}"); + } + // 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]