Let identified users manage CERT without a password
All checks were successful
CI / check (push) Successful in 4m18s
All checks were successful
CI / check (push) Successful in 4m18s
This commit is contained in:
parent
bfff7dba91
commit
6149a15b34
3 changed files with 48 additions and 16 deletions
|
|
@ -1,17 +1,15 @@
|
||||||
use echo_api::{CertError, Store};
|
use echo_api::{CertError, Store};
|
||||||
use echo_api::{Sender, ServiceCtx};
|
use echo_api::{Sender, ServiceCtx};
|
||||||
|
|
||||||
// CERT ADD|DEL|LIST <password> [fingerprint]: manage the TLS certificate
|
// CERT ADD|DEL|LIST [password] [fingerprint]: manage the TLS certificate
|
||||||
// fingerprints that may log in to your account via SASL EXTERNAL. Each
|
// fingerprints that may log in to your account via SASL EXTERNAL. An identified
|
||||||
// subcommand is password-gated, so it needs no identified-session state.
|
// 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) {
|
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() {
|
match args.get(1).map(|s| s.to_ascii_uppercase()).as_deref() {
|
||||||
Some("LIST") => {
|
Some("LIST") => {
|
||||||
let Some(password) = args.get(2) else {
|
let Some((account, _)) = resolve(me, from, ctx, db, &args[2..]) else {
|
||||||
ctx.notice(me, from.uid, "Syntax: CERT LIST <password>");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let Some(account) = verify(me, from, ctx, db, password) else {
|
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let fps = db.certfps(&account);
|
let fps = db.certfps(&account);
|
||||||
|
|
@ -22,11 +20,11 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some("ADD") => {
|
Some("ADD") => {
|
||||||
let (Some(password), Some(fp)) = (args.get(2), args.get(3)) else {
|
let Some((account, rest)) = resolve(me, from, ctx, db, &args[2..]) else {
|
||||||
ctx.notice(me, from.uid, "Syntax: CERT ADD <password> <fingerprint>");
|
|
||||||
return;
|
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;
|
return;
|
||||||
};
|
};
|
||||||
match db.certfp_add(&account, fp) {
|
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") => {
|
Some("DEL") | Some("REMOVE") => {
|
||||||
let (Some(password), Some(fp)) = (args.get(2), args.get(3)) else {
|
let Some((account, rest)) = resolve(me, from, ctx, db, &args[2..]) else {
|
||||||
ctx.notice(me, from.uid, "Syntax: CERT DEL <password> <fingerprint>");
|
|
||||||
return;
|
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;
|
return;
|
||||||
};
|
};
|
||||||
match db.certfp_del(&account, fp) {
|
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."),
|
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,
|
// 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
|
// 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
|
// guessing oracle (a user can `/nick victim` then CERT LIST <guess>) and each ~1s
|
||||||
|
|
|
||||||
|
|
@ -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: "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: "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: "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: "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: "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." },
|
HelpEntry { cmd: "LIST", summary: "list accounts (oper)", detail: "Syntax: \x02LIST <pattern>\x02\nLists registered accounts matching a glob. Requires the auspex privilege." },
|
||||||
|
|
|
||||||
|
|
@ -343,6 +343,24 @@
|
||||||
assert!(matches!(e.db.certfp_add("bar", fp), Err(db::CertError::InUse)), "a fingerprint maps to one account");
|
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
|
// Standard replies gate: off (default) degrades a service FAIL to a notice so
|
||||||
// nothing is lost; on, the structured FAIL survives with command + code.
|
// nothing is lost; on, the structured FAIL survives with command + code.
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue