Add SASL EXTERNAL
This commit is contained in:
parent
831f538a98
commit
a382ac8ad3
3 changed files with 264 additions and 5 deletions
|
|
@ -22,6 +22,10 @@ pub struct Account {
|
|||
pub scram256: Option<String>,
|
||||
#[serde(default)]
|
||||
pub scram512: Option<String>,
|
||||
// TLS client-certificate fingerprints (lowercase hex) that may log in to
|
||||
// this account via SASL EXTERNAL. Each fingerprint maps to one account.
|
||||
#[serde(default)]
|
||||
pub certfps: Vec<String>,
|
||||
}
|
||||
|
||||
// Event-sourced persistence: every change is an Event appended to a JSONL log,
|
||||
|
|
@ -31,13 +35,31 @@ pub struct Account {
|
|||
#[serde(tag = "event")]
|
||||
pub enum Event {
|
||||
AccountRegistered(Account),
|
||||
CertAdded { account: String, fp: String },
|
||||
CertRemoved { account: String, fp: String },
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum RegError {
|
||||
Exists,
|
||||
Internal,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum CertError {
|
||||
Invalid, // not a plausible fingerprint
|
||||
InUse, // already registered (to any account)
|
||||
NoAccount, // target account does not exist
|
||||
Internal, // persistence failed
|
||||
}
|
||||
|
||||
// A fingerprint is hex (hash digest), optionally colon-separated. Bound the
|
||||
// length so a junk value can't bloat an account.
|
||||
fn valid_fp(fp: &str) -> bool {
|
||||
let hex = fp.chars().filter(|c| *c != ':').count();
|
||||
(32..=128).contains(&hex) && fp.chars().all(|c| c.is_ascii_hexdigit() || c == ':')
|
||||
}
|
||||
|
||||
// The expensive, password-derived half of an account, computed once at
|
||||
// registration. Split out from `register` so the derivation (argon2 + two SCRAM
|
||||
// verifiers, ~1s at the default cost) can run off the reactor via spawn_blocking.
|
||||
|
|
@ -72,6 +94,16 @@ impl Db {
|
|||
Ok(Event::AccountRegistered(a)) => {
|
||||
accounts.insert(key(&a.name), a);
|
||||
}
|
||||
Ok(Event::CertAdded { account, fp }) => {
|
||||
if let Some(a) = accounts.get_mut(&key(&account)) {
|
||||
a.certfps.push(fp);
|
||||
}
|
||||
}
|
||||
Ok(Event::CertRemoved { account, fp }) => {
|
||||
if let Some(a) = accounts.get_mut(&key(&account)) {
|
||||
a.certfps.retain(|c| *c != fp);
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!(%e, "skipping malformed event log line"),
|
||||
}
|
||||
}
|
||||
|
|
@ -107,6 +139,7 @@ impl Db {
|
|||
ts: now(),
|
||||
scram256: Some(creds.scram256),
|
||||
scram512: Some(creds.scram512),
|
||||
certfps: Vec::new(),
|
||||
};
|
||||
self.append(&Event::AccountRegistered(account.clone())).map_err(|_| RegError::Internal)?;
|
||||
self.accounts.insert(key(name), account);
|
||||
|
|
@ -139,6 +172,50 @@ impl Db {
|
|||
Some((account.name.as_str(), verifier))
|
||||
}
|
||||
|
||||
/// The canonical name of the account owning `fp`, if any. Fingerprints are
|
||||
/// globally unique, so this is unambiguous.
|
||||
pub fn certfp_owner(&self, fp: &str) -> Option<&str> {
|
||||
let fp = fp.to_ascii_lowercase();
|
||||
self.accounts.values().find(|a| a.certfps.iter().any(|c| *c == fp)).map(|a| a.name.as_str())
|
||||
}
|
||||
|
||||
/// The fingerprints registered to an account (empty if unknown/none).
|
||||
pub fn certfps(&self, account: &str) -> &[String] {
|
||||
self.accounts.get(&key(account)).map_or(&[], |a| a.certfps.as_slice())
|
||||
}
|
||||
|
||||
/// Register `fp` to `account`. Fingerprints are one-to-one with accounts.
|
||||
pub fn certfp_add(&mut self, account: &str, fp: &str) -> Result<(), CertError> {
|
||||
let fp = fp.to_ascii_lowercase();
|
||||
if !valid_fp(&fp) {
|
||||
return Err(CertError::Invalid);
|
||||
}
|
||||
if self.certfp_owner(&fp).is_some() {
|
||||
return Err(CertError::InUse);
|
||||
}
|
||||
let k = key(account);
|
||||
if !self.accounts.contains_key(&k) {
|
||||
return Err(CertError::NoAccount);
|
||||
}
|
||||
self.append(&Event::CertAdded { account: account.to_string(), fp: fp.clone() }).map_err(|_| CertError::Internal)?;
|
||||
self.accounts.get_mut(&k).unwrap().certfps.push(fp);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove `fp` from `account`. Ok(false) if the account had no such fingerprint.
|
||||
pub fn certfp_del(&mut self, account: &str, fp: &str) -> Result<bool, CertError> {
|
||||
let fp = fp.to_ascii_lowercase();
|
||||
let k = key(account);
|
||||
match self.accounts.get(&k) {
|
||||
None => return Err(CertError::NoAccount),
|
||||
Some(a) if !a.certfps.iter().any(|c| *c == fp) => return Ok(false),
|
||||
Some(_) => {}
|
||||
}
|
||||
self.append(&Event::CertRemoved { account: account.to_string(), fp: fp.clone() }).map_err(|_| CertError::Internal)?;
|
||||
self.accounts.get_mut(&k).unwrap().certfps.retain(|c| *c != fp);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn append(&self, event: &Event) -> std::io::Result<()> {
|
||||
let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&self.path)?;
|
||||
writeln!(f, "{}", serde_json::to_string(event).unwrap_or_default())
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ use state::Network;
|
|||
|
||||
// SASL mechanisms we offer, strongest first. Advertised to the uplink as the
|
||||
// `sasl=` capability value (IRCv3 SASL 3.2 mechanism list) and the set we accept.
|
||||
const SASL_MECHS: &str = "SCRAM-SHA-512,SCRAM-SHA-256,PLAIN";
|
||||
const SASL_MECHS: &str = "EXTERNAL,SCRAM-SHA-512,SCRAM-SHA-256,PLAIN";
|
||||
|
||||
// A client's base64 response is split into chunks of this length; a chunk
|
||||
// shorter than this (or a lone "+") marks the end of the response (SASL 3.1).
|
||||
|
|
@ -31,6 +31,8 @@ enum SaslSession {
|
|||
Plain { response: String },
|
||||
// SCRAM: which hash, and where we are in the challenge/response.
|
||||
Scram { hash: scram::Hash, step: ScramStep },
|
||||
// EXTERNAL: the TLS cert fingerprints the ircd relayed for this client.
|
||||
External { fingerprints: Vec<String> },
|
||||
}
|
||||
|
||||
// An in-progress exchange with its last-activity stamp, so abandoned sessions
|
||||
|
|
@ -169,6 +171,12 @@ impl Engine {
|
|||
self.stash_sasl(client.clone(), SaslSession::Plain { response: String::new() });
|
||||
mk("C", vec!["+".to_string()]) // empty challenge -> client sends the payload
|
||||
}
|
||||
Some("EXTERNAL") => {
|
||||
// The ircd appends the client's TLS cert fingerprints after the mechanism.
|
||||
let fingerprints = data.iter().skip(1).map(|f| f.to_ascii_lowercase()).collect();
|
||||
self.stash_sasl(client.clone(), SaslSession::External { fingerprints });
|
||||
mk("C", vec!["+".to_string()]) // client sends its authzid (or "+")
|
||||
}
|
||||
Some(mech) if scram::Hash::from_mech(mech).is_some() => {
|
||||
let hash = scram::Hash::from_mech(mech).unwrap();
|
||||
self.stash_sasl(client.clone(), SaslSession::Scram { hash, step: ScramStep::ClientFirst });
|
||||
|
|
@ -200,6 +208,7 @@ impl Engine {
|
|||
}
|
||||
}
|
||||
Some(SaslSession::Scram { hash, step }) => self.sasl_scram(&agent, &client, hash, step, chunk),
|
||||
Some(SaslSession::External { fingerprints }) => self.sasl_external(&agent, &client, fingerprints, chunk),
|
||||
}
|
||||
}
|
||||
"D" => {
|
||||
|
|
@ -263,6 +272,27 @@ impl Engine {
|
|||
}
|
||||
}
|
||||
|
||||
// SASL EXTERNAL: the credential is the client's TLS cert fingerprint, which
|
||||
// the ircd handed us in the S message (already validated at the handshake).
|
||||
// Match it to an account; an authzid, if given, must name that same account.
|
||||
fn sasl_external(&self, agent: &str, client: &str, fingerprints: Vec<String>, chunk: &str) -> Vec<NetAction> {
|
||||
let authzid = if chunk == "+" || chunk.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
match STANDARD.decode(chunk).ok().and_then(|b| String::from_utf8(b).ok()) {
|
||||
Some(a) => a,
|
||||
None => return sasl_fail(agent, client),
|
||||
}
|
||||
};
|
||||
match fingerprints.iter().find_map(|fp| self.db.certfp_owner(fp)) {
|
||||
Some(account) if authzid.is_empty() || authzid.eq_ignore_ascii_case(account) => {
|
||||
let account = account.to_string();
|
||||
sasl_success(agent, client, account)
|
||||
}
|
||||
_ => sasl_fail(agent, client),
|
||||
}
|
||||
}
|
||||
|
||||
// Authority side of the IRCv3 account-registration relay. Hands the work to
|
||||
// the link layer (which derives the password off-thread) via DeferRegister.
|
||||
fn account_request(&mut self, reqid: String, kind: String, account: String, p2: String, p3: String) -> Vec<NetAction> {
|
||||
|
|
@ -330,6 +360,11 @@ fn login_plain(b64: &str, db: &Db) -> Option<String> {
|
|||
db.authenticate(authcid, passwd).map(str::to_string)
|
||||
}
|
||||
|
||||
// Report SASL failure to the ircd (drives 904).
|
||||
fn sasl_fail(agent: &str, client: &str) -> Vec<NetAction> {
|
||||
vec![NetAction::Sasl { agent: agent.to_string(), client: client.to_string(), mode: "D".to_string(), data: vec!["F".to_string()] }]
|
||||
}
|
||||
|
||||
// The two actions that complete any mechanism: set the account (drives 900),
|
||||
// then report SASL success (drives 903).
|
||||
fn sasl_success(agent: &str, client: &str, account: String) -> Vec<NetAction> {
|
||||
|
|
@ -603,4 +638,87 @@ mod tests {
|
|||
e.handle(NetEvent::Quit { uid: "ZZZ".into() });
|
||||
assert!(!e.sasl_sessions.contains_key("ZZZ"), "quit should drop the exchange");
|
||||
}
|
||||
|
||||
// Drive a SASL step with a multi-field data vector (EXTERNAL carries the
|
||||
// mechanism plus the ircd-supplied fingerprints in one message).
|
||||
fn sasl_multi(e: &mut Engine, mode: &str, data: &[&str]) -> Vec<NetAction> {
|
||||
e.handle(NetEvent::Sasl {
|
||||
client: "000AAAAAB".into(),
|
||||
agent: "*".into(),
|
||||
mode: mode.into(),
|
||||
data: data.iter().map(|s| s.to_string()).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
// EXTERNAL with a registered fingerprint logs in; matching is case-insensitive.
|
||||
#[test]
|
||||
fn external_success_with_registered_cert() {
|
||||
let mut e = engine_with("extok", "foo", "sesame");
|
||||
e.db.certfp_add("foo", "aabbccddeeff00112233445566778899").unwrap();
|
||||
assert_eq!(datum(&sasl_multi(&mut e, "S", &["EXTERNAL", "AABBCCDDEEFF00112233445566778899"])), ("C", "+"));
|
||||
assert!(is_success(&sasl(&mut e, "C", "+")), "empty authzid should log in");
|
||||
}
|
||||
|
||||
// An authzid, if sent, must name the cert's own account.
|
||||
#[test]
|
||||
fn external_authzid_must_match() {
|
||||
let mut e = engine_with("extaz", "foo", "sesame");
|
||||
e.db.certfp_add("foo", "aabbccddeeff00112233445566778899").unwrap();
|
||||
sasl_multi(&mut e, "S", &["EXTERNAL", "aabbccddeeff00112233445566778899"]);
|
||||
assert!(is_success(&sasl(&mut e, "C", &STANDARD.encode("foo"))), "matching authzid ok");
|
||||
|
||||
let mut e = engine_with("extaz2", "foo", "sesame");
|
||||
e.db.certfp_add("foo", "aabbccddeeff00112233445566778899").unwrap();
|
||||
sasl_multi(&mut e, "S", &["EXTERNAL", "aabbccddeeff00112233445566778899"]);
|
||||
assert_eq!(datum(&sasl(&mut e, "C", &STANDARD.encode("bar"))), ("D", "F"), "foreign authzid rejected");
|
||||
}
|
||||
|
||||
// An unknown fingerprint, or no fingerprint at all (plaintext client), fails.
|
||||
#[test]
|
||||
fn external_without_registered_cert_fails() {
|
||||
let mut e = engine_with("extno", "foo", "sesame");
|
||||
sasl_multi(&mut e, "S", &["EXTERNAL", "0011223344556677889900aabbccddee"]);
|
||||
assert_eq!(datum(&sasl(&mut e, "C", "+")), ("D", "F"), "unknown fp rejected");
|
||||
|
||||
sasl_multi(&mut e, "S", &["EXTERNAL"]); // no fingerprint supplied
|
||||
assert_eq!(datum(&sasl(&mut e, "C", "+")), ("D", "F"), "no cert rejected");
|
||||
}
|
||||
|
||||
// End to end: enrol a cert with NickServ CERT ADD, then log in with EXTERNAL.
|
||||
#[test]
|
||||
fn nickserv_cert_add_enables_external() {
|
||||
let mut e = engine_with("nscert", "foo", "sesame");
|
||||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "foo".into() });
|
||||
let fp = "aabbccddeeff00112233445566778899";
|
||||
|
||||
let out = e.handle(NetEvent::Privmsg {
|
||||
from: "000AAAAAB".into(),
|
||||
to: "42SAAAAAA".into(),
|
||||
text: format!("CERT ADD sesame {fp}"),
|
||||
});
|
||||
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Added"))), "{out:?}");
|
||||
|
||||
sasl_multi(&mut e, "S", &["EXTERNAL", fp]);
|
||||
assert!(is_success(&sasl(&mut e, "C", "+")), "external should work after CERT ADD");
|
||||
}
|
||||
|
||||
// A wrong password can't enrol a cert, and a fingerprint is one account only.
|
||||
#[test]
|
||||
fn cert_add_is_guarded_and_unique() {
|
||||
let mut e = engine_with("certguard", "foo", "sesame");
|
||||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "foo".into() });
|
||||
let fp = "aabbccddeeff00112233445566778899";
|
||||
|
||||
let bad = e.handle(NetEvent::Privmsg {
|
||||
from: "000AAAAAB".into(),
|
||||
to: "42SAAAAAA".into(),
|
||||
text: format!("CERT ADD wrongpw {fp}"),
|
||||
});
|
||||
assert!(bad.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Invalid password"))), "{bad:?}");
|
||||
assert!(e.db.certfp_owner(fp).is_none(), "bad password must not enrol");
|
||||
|
||||
e.db.certfp_add("foo", fp).unwrap();
|
||||
e.db.register("bar", "sesame", None).unwrap();
|
||||
assert!(matches!(e.db.certfp_add("bar", fp), Err(db::CertError::InUse)), "a fingerprint maps to one account");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use crate::engine::db::Db;
|
||||
use crate::engine::db::{CertError, Db};
|
||||
use crate::engine::service::{Sender, Service, ServiceCtx};
|
||||
use crate::proto::RegReply;
|
||||
|
||||
|
|
@ -48,11 +48,75 @@ impl Service for NickServ {
|
|||
None => ctx.notice(me, from.uid, "Invalid password."),
|
||||
}
|
||||
}
|
||||
Some("HELP") => {
|
||||
ctx.notice(me, from.uid, "Commands: REGISTER <password> [email], IDENTIFY <password>.")
|
||||
}
|
||||
Some("CERT") => self.cert(from, &args, ctx, db),
|
||||
Some("HELP") => ctx.notice(
|
||||
me,
|
||||
from.uid,
|
||||
"Commands: REGISTER <password> [email], IDENTIFY <password>, CERT ADD|DEL|LIST <password> [fingerprint].",
|
||||
),
|
||||
Some(other) => ctx.notice(me, from.uid, format!("Unknown command: {}. Try HELP.", other)),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NickServ {
|
||||
// CERT ADD|DEL|LIST <password> [fingerprint]: manage the TLS certificate
|
||||
// fingerprints that may log in to your account via SASL EXTERNAL. Gated by
|
||||
// the account password, so it needs no separate identified-session state.
|
||||
fn cert(&self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut Db) {
|
||||
let me = self.uid.as_str();
|
||||
// All subcommands authenticate the sender's nick's account by password.
|
||||
let auth = |db: &Db, password: &str| db.authenticate(from.nick, password).map(str::to_string);
|
||||
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) = auth(db, password) else {
|
||||
ctx.notice(me, from.uid, "Invalid password.");
|
||||
return;
|
||||
};
|
||||
let fps = db.certfps(&account);
|
||||
if fps.is_empty() {
|
||||
ctx.notice(me, from.uid, "No certificate fingerprints are registered to your account.");
|
||||
} else {
|
||||
ctx.notice(me, from.uid, format!("Registered fingerprints: {}", fps.join(", ")));
|
||||
}
|
||||
}
|
||||
Some("ADD") => {
|
||||
let (Some(password), Some(fp)) = (args.get(2), args.get(3)) else {
|
||||
ctx.notice(me, from.uid, "Syntax: CERT ADD <password> <fingerprint>");
|
||||
return;
|
||||
};
|
||||
let Some(account) = auth(db, password) else {
|
||||
ctx.notice(me, from.uid, "Invalid password.");
|
||||
return;
|
||||
};
|
||||
match db.certfp_add(&account, fp) {
|
||||
Ok(()) => ctx.notice(me, from.uid, format!("Added fingerprint \x02{fp}\x02. You can now log in with SASL EXTERNAL.")),
|
||||
Err(CertError::InUse) => ctx.notice(me, from.uid, "That fingerprint is already registered."),
|
||||
Err(CertError::Invalid) => ctx.notice(me, from.uid, "That does not look like a certificate fingerprint."),
|
||||
Err(CertError::NoAccount | CertError::Internal) => ctx.notice(me, from.uid, "Could not add the fingerprint, please try again later."),
|
||||
}
|
||||
}
|
||||
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>");
|
||||
return;
|
||||
};
|
||||
let Some(account) = auth(db, password) else {
|
||||
ctx.notice(me, from.uid, "Invalid password.");
|
||||
return;
|
||||
};
|
||||
match db.certfp_del(&account, fp) {
|
||||
Ok(true) => ctx.notice(me, from.uid, format!("Removed fingerprint \x02{fp}\x02.")),
|
||||
Ok(false) => ctx.notice(me, from.uid, "No such fingerprint is registered to your account."),
|
||||
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]"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue