Complete IRCv3 SASL 3.2 login for PLAIN
Turn the PLAIN verification exchange into a full account login: - Advertise the mechanism list to the uplink as `saslmechlist` network metadata, so clients see `sasl=PLAIN` in CAP LS (SASL 3.2). - On a valid response set the client's `accountname` metadata (drives RPL_LOGGEDIN 900) before reporting success (D S, drives 903); a bad credential or unknown mechanism reports failure (D F, drives 904). - Reassemble the base64 response from the uplink's 400-byte AUTHENTICATE chunks, ending on a short chunk or a lone "+", with an upper bound to cap a pre-auth client's buffer. - Log the user in on NickServ REGISTER and IDENTIFY too, through the same accountname metadata, and return the canonical account name from the store so the login keeps its registered casing. Verified against irctest server_tests/sasl.py (PLAIN success/failure, non-ASCII, no-authzid, chunked payloads, unknown mechanism, too-long, retry) on InspIRCd + fedserv.
This commit is contained in:
parent
63028d99e5
commit
65deeef2f7
6 changed files with 117 additions and 28 deletions
|
|
@ -76,11 +76,11 @@ impl Db {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn verify(&self, name: &str, password: &str) -> bool {
|
/// Check credentials; on success return the account's canonical name (its
|
||||||
match self.accounts.get(&key(name)) {
|
/// stored casing), else None.
|
||||||
Some(a) => verify_password(password, &a.password_hash),
|
pub fn authenticate(&self, name: &str, password: &str) -> Option<&str> {
|
||||||
None => false,
|
let account = self.accounts.get(&key(name))?;
|
||||||
}
|
verify_password(password, &account.password_hash).then_some(account.name.as_str())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn append(&self, event: &Event) -> std::io::Result<()> {
|
fn append(&self, event: &Event) -> std::io::Result<()> {
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,30 @@ use db::{Db, RegError};
|
||||||
use service::{Sender, Service, ServiceCtx};
|
use service::{Sender, Service, ServiceCtx};
|
||||||
use state::Network;
|
use state::Network;
|
||||||
|
|
||||||
|
// SASL mechanisms we offer. Advertised to the uplink as the `sasl=` capability
|
||||||
|
// value (IRCv3 SASL 3.2 mechanism list) and the set we accept in the exchange.
|
||||||
|
const SASL_MECHS: &str = "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).
|
||||||
|
const MAX_AUTHENTICATE: usize = 400;
|
||||||
|
|
||||||
|
// Upper bound on a reassembled response, to cap a pre-auth client's buffer.
|
||||||
|
const MAX_SASL_RESPONSE: usize = 8 * 1024;
|
||||||
|
|
||||||
|
// A client's in-progress SASL exchange: the chosen mechanism and the base64
|
||||||
|
// response reassembled from the uplink's fixed-size AUTHENTICATE chunks.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct SaslSession {
|
||||||
|
mech: String,
|
||||||
|
response: String,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Engine {
|
pub struct Engine {
|
||||||
services: Vec<Box<dyn Service>>,
|
services: Vec<Box<dyn Service>>,
|
||||||
network: Network,
|
network: Network,
|
||||||
db: Db,
|
db: Db,
|
||||||
sasl_sessions: HashMap<String, String>, // client uid -> in-progress mechanism
|
sasl_sessions: HashMap<String, SaslSession>, // client uid -> in-progress exchange
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Engine {
|
impl Engine {
|
||||||
|
|
@ -33,6 +52,13 @@ impl Engine {
|
||||||
gecos: svc.gecos().to_string(),
|
gecos: svc.gecos().to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Advertise our SASL mechanisms so the uplink can offer `sasl=PLAIN` to
|
||||||
|
// clients in CAP LS (IRCv3 SASL 3.2).
|
||||||
|
out.push(NetAction::Metadata {
|
||||||
|
target: "*".to_string(),
|
||||||
|
key: "saslmechlist".to_string(),
|
||||||
|
value: SASL_MECHS.to_string(),
|
||||||
|
});
|
||||||
out.push(NetAction::EndBurst);
|
out.push(NetAction::EndBurst);
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
@ -57,10 +83,10 @@ impl Engine {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SASL agent side of the exchange the ircd relays to us (modes H/S/C/D).
|
// SASL agent side of the exchange the ircd relays to us (modes H/S/C/D),
|
||||||
// PLAIN only for now: verify the credentials against the account store. NOTE:
|
// per IRCv3 SASL 3.2. PLAIN only: on a valid client response we set the
|
||||||
// this proves the password; wiring the account login (metadata) + advertising
|
// client's account (drives 900) then report success (drives 903); a bad
|
||||||
// the mechanism list to the ircd are the remaining integration steps.
|
// credential or unknown mechanism reports failure (drives 904).
|
||||||
fn sasl(&mut self, client: String, mode: String, data: Vec<String>) -> Vec<NetAction> {
|
fn sasl(&mut self, client: String, mode: String, data: Vec<String>) -> Vec<NetAction> {
|
||||||
let agent = match self.services.first() {
|
let agent = match self.services.first() {
|
||||||
Some(s) => s.uid().to_string(),
|
Some(s) => s.uid().to_string(),
|
||||||
|
|
@ -73,15 +99,54 @@ impl Engine {
|
||||||
"H" => Vec::new(), // host info
|
"H" => Vec::new(), // host info
|
||||||
"S" => match data.first().map(String::as_str) {
|
"S" => match data.first().map(String::as_str) {
|
||||||
Some("PLAIN") => {
|
Some("PLAIN") => {
|
||||||
self.sasl_sessions.insert(client.clone(), "PLAIN".to_string());
|
self.sasl_sessions.insert(
|
||||||
|
client.clone(),
|
||||||
|
SaslSession { mech: "PLAIN".to_string(), response: String::new() },
|
||||||
|
);
|
||||||
mk("C", vec!["+".to_string()]) // empty challenge -> client sends the payload
|
mk("C", vec!["+".to_string()]) // empty challenge -> client sends the payload
|
||||||
}
|
}
|
||||||
_ => mk("D", vec!["F".to_string()]), // unsupported mechanism
|
_ => mk("D", vec!["F".to_string()]), // unsupported mechanism
|
||||||
},
|
},
|
||||||
"C" => {
|
"C" => {
|
||||||
let is_plain = self.sasl_sessions.remove(&client).as_deref() == Some("PLAIN");
|
// Reassemble the base64 response: append each chunk until one is
|
||||||
let ok = is_plain && verify_plain(&data, &self.db);
|
// shorter than a full chunk (or a lone "+"), which ends it.
|
||||||
mk("D", vec![if ok { "S".to_string() } else { "F".to_string() }])
|
let chunk = data.first().map(String::as_str).unwrap_or("");
|
||||||
|
let overflowed = match self.sasl_sessions.get_mut(&client) {
|
||||||
|
Some(s) => {
|
||||||
|
if chunk != "+" {
|
||||||
|
s.response.push_str(chunk);
|
||||||
|
}
|
||||||
|
s.response.len() > MAX_SASL_RESPONSE
|
||||||
|
}
|
||||||
|
None => return mk("D", vec!["F".to_string()]),
|
||||||
|
};
|
||||||
|
if overflowed {
|
||||||
|
self.sasl_sessions.remove(&client);
|
||||||
|
return mk("D", vec!["F".to_string()]);
|
||||||
|
}
|
||||||
|
if chunk.len() >= MAX_AUTHENTICATE {
|
||||||
|
return Vec::new(); // more chunks still to come
|
||||||
|
}
|
||||||
|
let session = self.sasl_sessions.remove(&client).unwrap_or_default();
|
||||||
|
let account = (session.mech == "PLAIN")
|
||||||
|
.then(|| login_plain(&session.response, &self.db))
|
||||||
|
.flatten();
|
||||||
|
match account {
|
||||||
|
Some(account) => vec![
|
||||||
|
NetAction::Metadata {
|
||||||
|
target: client.clone(),
|
||||||
|
key: "accountname".to_string(),
|
||||||
|
value: account,
|
||||||
|
},
|
||||||
|
NetAction::Sasl {
|
||||||
|
agent: agent.clone(),
|
||||||
|
client: client.clone(),
|
||||||
|
mode: "D".to_string(),
|
||||||
|
data: vec!["S".to_string()],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
None => mk("D", vec!["F".to_string()]),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
"D" => {
|
"D" => {
|
||||||
self.sasl_sessions.remove(&client);
|
self.sasl_sessions.remove(&client);
|
||||||
|
|
@ -131,17 +196,16 @@ impl Engine {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decode a SASL PLAIN payload (authzid \0 authcid \0 passwd) and verify it.
|
// Decode a SASL PLAIN payload (authzid \0 authcid \0 passwd) and authenticate
|
||||||
fn verify_plain(data: &[String], db: &Db) -> bool {
|
// it, returning the canonical account name on success.
|
||||||
|
fn login_plain(b64: &str, db: &Db) -> Option<String> {
|
||||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||||
let Some(b64) = data.first() else { return false };
|
let raw = STANDARD.decode(b64).ok()?;
|
||||||
let Ok(raw) = STANDARD.decode(b64) else { return false };
|
|
||||||
let parts: Vec<&[u8]> = raw.split(|&b| b == 0).collect();
|
let parts: Vec<&[u8]> = raw.split(|&b| b == 0).collect();
|
||||||
if parts.len() != 3 {
|
if parts.len() != 3 {
|
||||||
return false;
|
return None;
|
||||||
}
|
|
||||||
match (std::str::from_utf8(parts[1]), std::str::from_utf8(parts[2])) {
|
|
||||||
(Ok(authcid), Ok(passwd)) => db.verify(authcid, passwd),
|
|
||||||
_ => false,
|
|
||||||
}
|
}
|
||||||
|
let authcid = std::str::from_utf8(parts[1]).ok()?;
|
||||||
|
let passwd = std::str::from_utf8(parts[2]).ok()?;
|
||||||
|
db.authenticate(authcid, passwd).map(str::to_string)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,4 +32,15 @@ impl ServiceCtx {
|
||||||
text: text.into(),
|
text: text.into(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Log a user into an account: sets the accountname the ircd turns into
|
||||||
|
// RPL_LOGGEDIN (900) and exposes to account-tag / WHOX, the same login the
|
||||||
|
// SASL agent applies. Used after a successful REGISTER / IDENTIFY.
|
||||||
|
pub fn login(&mut self, uid: &str, account: &str) {
|
||||||
|
self.actions.push(NetAction::Metadata {
|
||||||
|
target: uid.to_string(),
|
||||||
|
key: "accountname".to_string(),
|
||||||
|
value: account.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,10 @@ impl Protocol for InspIrcd {
|
||||||
}
|
}
|
||||||
vec![self.from_us(line)]
|
vec![self.from_us(line)]
|
||||||
}
|
}
|
||||||
|
// METADATA <target> <key> :<value> — "*" is server-global metadata.
|
||||||
|
NetAction::Metadata { target, key, value } => {
|
||||||
|
vec![self.from_us(format!("METADATA {} {} :{}", target, key, value))]
|
||||||
|
}
|
||||||
NetAction::Raw(s) => vec![s.clone()],
|
NetAction::Raw(s) => vec![s.clone()],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,9 @@ pub enum NetAction {
|
||||||
AccountResponse { reqid: String, kind: String, account: String, status: String, code: String, message: String },
|
AccountResponse { reqid: String, kind: String, account: String, status: String, code: String, message: String },
|
||||||
// A SASL exchange step back to the ircd, sourced from our SASL agent. mode = C/D.
|
// A SASL exchange step back to the ircd, sourced from our SASL agent. mode = C/D.
|
||||||
Sasl { agent: String, client: String, mode: String, data: Vec<String> },
|
Sasl { agent: String, client: String, mode: String, data: Vec<String> },
|
||||||
|
// Publish network state to the uplink: target "*" is server-global (e.g. the
|
||||||
|
// advertised SASL mechanism list), otherwise a user uid (e.g. their account).
|
||||||
|
Metadata { target: String, key: String, value: String },
|
||||||
Raw(String),
|
Raw(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,11 @@ impl Service for NickServ {
|
||||||
};
|
};
|
||||||
let email = args.get(2).map(|s| s.to_string());
|
let email = args.get(2).map(|s| s.to_string());
|
||||||
match db.register(from.nick, password, email) {
|
match db.register(from.nick, password, email) {
|
||||||
Ok(()) => ctx.notice(me, from.uid, format!("Nickname \x02{}\x02 is now registered.", from.nick)),
|
Ok(()) => {
|
||||||
|
// Registering identifies you to the nick right away (drives 900).
|
||||||
|
ctx.login(from.uid, from.nick);
|
||||||
|
ctx.notice(me, from.uid, format!("Nickname \x02{}\x02 is now registered.", from.nick));
|
||||||
|
}
|
||||||
Err(RegError::Exists) => ctx.notice(me, from.uid, format!("Nickname \x02{}\x02 is already registered.", from.nick)),
|
Err(RegError::Exists) => ctx.notice(me, from.uid, format!("Nickname \x02{}\x02 is already registered.", from.nick)),
|
||||||
Err(RegError::Internal) => ctx.notice(me, from.uid, "Registration failed, please try again later."),
|
Err(RegError::Internal) => ctx.notice(me, from.uid, "Registration failed, please try again later."),
|
||||||
}
|
}
|
||||||
|
|
@ -37,10 +41,13 @@ impl Service for NickServ {
|
||||||
ctx.notice(me, from.uid, "Syntax: IDENTIFY <password>");
|
ctx.notice(me, from.uid, "Syntax: IDENTIFY <password>");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if db.verify(from.nick, password) {
|
match db.authenticate(from.nick, password) {
|
||||||
ctx.notice(me, from.uid, format!("You are now identified for \x02{}\x02.", from.nick));
|
Some(account) => {
|
||||||
} else {
|
let account = account.to_string();
|
||||||
ctx.notice(me, from.uid, "Invalid password.");
|
ctx.login(from.uid, &account);
|
||||||
|
ctx.notice(me, from.uid, format!("You are now identified for \x02{}\x02.", account));
|
||||||
|
}
|
||||||
|
None => ctx.notice(me, from.uid, "Invalid password."),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some("HELP") => {
|
Some("HELP") => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue