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.
This commit is contained in:
parent
ef89f97158
commit
020e9bd576
7 changed files with 213 additions and 12 deletions
|
|
@ -48,6 +48,8 @@ pub enum Event {
|
|||
AccountEmailSet { account: String, email: Option<String> },
|
||||
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<String, Account>, // keyed by casefolded name
|
||||
channels: HashMap<String, ChannelInfo>, // keyed by casefolded name
|
||||
grouped: HashMap<String, String>, // 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<String> {
|
||||
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<bool, RegError> {
|
||||
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<String> {
|
||||
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<String, Account>, channels: &mut HashMap<String, ChannelInfo>, event: Event) {
|
||||
fn apply(accounts: &mut HashMap<String, Account>, channels: &mut HashMap<String, ChannelInfo>, grouped: &mut HashMap<String, String>, 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<String, Account>, channels: &mut HashMap<String,
|
|||
}
|
||||
Event::AccountDropped { account } => {
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue