diff --git a/modules/nickserv/src/cert.rs b/modules/nickserv/src/cert.rs index e224ee7..5651921 100644 --- a/modules/nickserv/src/cert.rs +++ b/modules/nickserv/src/cert.rs @@ -1,17 +1,15 @@ use echo_api::{CertError, Store}; use echo_api::{Sender, ServiceCtx}; -// CERT ADD|DEL|LIST [fingerprint]: manage the TLS certificate -// fingerprints that may log in to your account via SASL EXTERNAL. Each -// subcommand is password-gated, so it needs no identified-session state. +// CERT ADD|DEL|LIST [password] [fingerprint]: manage the TLS certificate +// fingerprints that may log in to your account via SASL EXTERNAL. An identified +// caller acts on their own account with no password; an unidentified one must +// supply a throttled password (so CERT still works mid-session and can't be +// abused as a guessing oracle). pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { match args.get(1).map(|s| s.to_ascii_uppercase()).as_deref() { Some("LIST") => { - let Some(password) = args.get(2) else { - ctx.notice(me, from.uid, "Syntax: CERT LIST "); - return; - }; - let Some(account) = verify(me, from, ctx, db, password) else { + let Some((account, _)) = resolve(me, from, ctx, db, &args[2..]) else { return; }; let fps = db.certfps(&account); @@ -22,11 +20,11 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: } } Some("ADD") => { - let (Some(password), Some(fp)) = (args.get(2), args.get(3)) else { - ctx.notice(me, from.uid, "Syntax: CERT ADD "); + let Some((account, rest)) = resolve(me, from, ctx, db, &args[2..]) else { return; }; - let Some(account) = verify(me, from, ctx, db, password) else { + let Some(&fp) = rest.first() else { + ctx.notice(me, from.uid, "Syntax: CERT ADD [password] "); return; }; match db.certfp_add(&account, fp) { @@ -38,11 +36,11 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: } } Some("DEL") | Some("REMOVE") => { - let (Some(password), Some(fp)) = (args.get(2), args.get(3)) else { - ctx.notice(me, from.uid, "Syntax: CERT DEL "); + let Some((account, rest)) = resolve(me, from, ctx, db, &args[2..]) else { return; }; - let Some(account) = verify(me, from, ctx, db, password) else { + let Some(&fp) = rest.first() else { + ctx.notice(me, from.uid, "Syntax: CERT DEL [password] "); return; }; match db.certfp_del(&account, fp) { @@ -51,10 +49,26 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: Err(_) => ctx.notice(me, from.uid, "Could not remove the fingerprint, please try again later."), } } - _ => ctx.notice(me, from.uid, "Syntax: CERT ADD|DEL|LIST [fingerprint]"), + _ => ctx.notice(me, from.uid, "Syntax: CERT ADD|DEL|LIST [password] [fingerprint]"), } } +// Resolve the acting account and the arguments that follow it. An identified +// caller operates on their own account and passes no password, so `rest` is +// returned untouched; otherwise the first argument is the throttled password and +// the remainder follows it. +fn resolve<'a>(me: &str, from: &Sender, ctx: &mut ServiceCtx, db: &mut dyn Store, rest: &'a [&'a str]) -> Option<(String, &'a [&'a str])> { + if let Some(account) = from.account { + return Some((account.to_string(), rest)); + } + let Some((&password, tail)) = rest.split_first() else { + ctx.notice(me, from.uid, "Identify to NickServ first, or start the command with your password."); + return None; + }; + let account = verify(me, from, ctx, db, password)?; + Some((account, tail)) +} + // Verify the caller's password against the account matching their current nick, // throttled and recorded like IDENTIFY — so CERT can't be used as an unthrottled // guessing oracle (a user can `/nick victim` then CERT LIST ) and each ~1s diff --git a/modules/nickserv/src/lib.rs b/modules/nickserv/src/lib.rs index e7ed0ef..a13811d 100644 --- a/modules/nickserv/src/lib.rs +++ b/modules/nickserv/src/lib.rs @@ -64,7 +64,7 @@ const TOPICS: &[HelpEntry] = &[ HelpEntry { cmd: "RESETPASS", summary: "reset your password by email", detail: "Syntax: \x02RESETPASS \x02, then \x02RESETPASS \x02\nEmails a reset code, then sets a new password with it." }, HelpEntry { cmd: "CONFIRM", summary: "confirm your email", detail: "Syntax: \x02CONFIRM \x02\nConfirms the email on a newly registered account with the code you were sent." }, HelpEntry { cmd: "DROP", summary: "delete your account", detail: "Syntax: \x02DROP \x02\nDeletes your account and releases the channels you founded." }, - HelpEntry { cmd: "CERT", summary: "manage SASL EXTERNAL certs", detail: "Syntax: \x02CERT ADD [fingerprint]\x02, \x02CERT DEL \x02, \x02CERT LIST\x02\nManages the TLS certificate fingerprints that can log in via SASL EXTERNAL." }, + HelpEntry { cmd: "CERT", summary: "manage SASL EXTERNAL certs", detail: "Syntax: \x02CERT ADD [password] \x02, \x02CERT DEL [password] \x02, \x02CERT LIST [password]\x02\nManages the TLS certificate fingerprints that can log in via SASL EXTERNAL. The password is only needed when you are not already identified." }, HelpEntry { cmd: "AJOIN", summary: "auto-join channels on login", detail: "Syntax: \x02AJOIN ADD <#channel>\x02, \x02AJOIN DEL <#channel>\x02, \x02AJOIN LIST\x02\nChannels you are auto-joined to when you identify." }, HelpEntry { cmd: "UPDATE", summary: "refresh your session", detail: "Syntax: \x02UPDATE\x02\nRe-applies your auto-joins and vhost and re-checks for new memos." }, HelpEntry { cmd: "LIST", summary: "list accounts (oper)", detail: "Syntax: \x02LIST \x02\nLists registered accounts matching a glob. Requires the auspex privilege." }, diff --git a/src/engine/tests.rs b/src/engine/tests.rs index d498baa..882dfa5 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -343,6 +343,24 @@ assert!(matches!(e.db.certfp_add("bar", fp), Err(db::CertError::InUse)), "a fingerprint maps to one account"); } + // An identified caller manages certs without re-entering a password. + #[test] + fn cert_works_for_identified_user_without_password() { + let mut e = engine_with("certid", "foo", "sesame"); + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "foo".into(), host: "host.example".into() , ip: "0.0.0.0".into() }); + e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() }); + let fp = "aabbccddeeff00112233445566778899"; + let cert = |e: &mut Engine, t: String| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: t }); + + let add = cert(&mut e, format!("CERT ADD {fp}")); + assert!(add.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Added"))), "add without password: {add:?}"); + let list = cert(&mut e, "CERT LIST".into()); + assert!(list.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(fp))), "list without password: {list:?}"); + let del = cert(&mut e, format!("CERT DEL {fp}")); + assert!(del.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Removed"))), "del without password: {del:?}"); + assert!(e.db.certfp_owner(fp).is_none(), "fingerprint removed"); + } + // Standard replies gate: off (default) degrades a service FAIL to a notice so // nothing is lost; on, the structured FAIL survives with command + code. #[test]