Let identified users manage CERT without a password
All checks were successful
CI / check (push) Successful in 4m18s

This commit is contained in:
Jean Chevronnet 2026-07-19 17:17:36 +00:00
parent bfff7dba91
commit 6149a15b34
No known key found for this signature in database
3 changed files with 48 additions and 16 deletions

View file

@ -1,17 +1,15 @@
use echo_api::{CertError, Store};
use echo_api::{Sender, ServiceCtx};
// CERT ADD|DEL|LIST <password> [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 <password>");
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 <password> <fingerprint>");
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] <fingerprint>");
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 <password> <fingerprint>");
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] <fingerprint>");
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 <password> [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 <guess>) and each ~1s

View file

@ -64,7 +64,7 @@ const TOPICS: &[HelpEntry] = &[
HelpEntry { cmd: "RESETPASS", summary: "reset your password by email", detail: "Syntax: \x02RESETPASS <account>\x02, then \x02RESETPASS <account> <code> <newpassword>\x02\nEmails a reset code, then sets a new password with it." },
HelpEntry { cmd: "CONFIRM", summary: "confirm your email", detail: "Syntax: \x02CONFIRM <code>\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 <password>\x02\nDeletes your account and releases the channels you founded." },
HelpEntry { cmd: "CERT", summary: "manage SASL EXTERNAL certs", detail: "Syntax: \x02CERT ADD <password> [fingerprint]\x02, \x02CERT DEL <fingerprint>\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] <fingerprint>\x02, \x02CERT DEL [password] <fingerprint>\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 <pattern>\x02\nLists registered accounts matching a glob. Requires the auspex privilege." },

View file

@ -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]