IRC: say something on the way out, and prove yourself without a password

Two IRC gaps, closing #65 and #82.

**#65** `quit_message` was stored, settable from two places, and never
sent - every QUIT went out empty, which is what a dropped connection
looks like rather than a deliberate departure. It rides on the handle now,
because the two places that end a connection have an account id and no
view of its config, which is how it came to be forgotten. An account that
set nothing still says something.

**#82** SASL was PLAIN and only PLAIN, hardcoded.

EXTERNAL authenticates with the TLS client certificate already on the
connection - no password is sent at all - so the certificate is set on the
connection rather than in a message, since that is what the server checks.

SCRAM-SHA-256 proves knowledge of the password without sending it, and
proves the server knew it too. The second half is verified rather than
skipped: a server that cannot produce the right signature is refused
outright, because the password was never sent, so a failure there is not a
wrong login - it is a server that cannot show it is the one you registered
with.

HMAC is implemented here rather than pulled in: this crate's `hmac` is
0.13, which pairs with digest 0.11 hashes, while its `sha2` is 0.10, which
needs 0.12. Twenty lines against RFC 4231's vectors beat moving a
dependency the rest of the daemon is built on. SCRAM is checked end to end
against RFC 7677's worked example, proof and server signature both.

PLAIN stays the default, which is the part worth arguing. SCRAM is barely
deployed on IRC - Ergo has it, Libera and Rizon do not - and a server is
only *recommended* to answer an unknown mechanism with the list of ones it
has. Leading with SCRAM would have broken SASL outright on the networks
people actually use, on servers that decline to say why. So it is there
for whoever asks, a certificate implies EXTERNAL, and a server that does
answer with 908 gets its choice honoured automatically.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-02 00:18:43 -04:00
co-authored by Claude Opus 5
parent 696b08a76e
commit 2f37c476eb
7 changed files with 706 additions and 21 deletions
+42
View File
@@ -29,6 +29,22 @@ pub struct IrcAccountConfig {
pub username: Option<String>,
#[serde(default)]
pub quit_message: Option<String>,
/// Which SASL mechanism to use: `external`, `scram-sha-256` or `plain`.
///
/// Absent means "the strongest this account is equipped for" - EXTERNAL if
/// a certificate is configured, then SCRAM-SHA-256, then PLAIN. Naming one
/// pins it, which is what somebody wants when a network advertises a
/// mechanism it does not actually accept.
#[serde(default)]
pub sasl_mechanism: Option<String>,
/// A TLS client certificate, for SASL EXTERNAL.
///
/// The point of EXTERNAL is that no password is sent at all: the
/// certificate already proving the connection also proves the account.
#[serde(default)]
pub sasl_cert_path: Option<String>,
#[serde(default)]
pub sasl_cert_pass: Option<String>,
#[serde(default)]
pub allow_plaintext_sasl: bool,
#[serde(default)]
@@ -607,6 +623,7 @@ impl AccountStore {
/// Empty password means "leave whatever's stored untouched" - same
/// convention nobilis_set_account_sasl() uses (the frontend never reads
/// the password back, see hasPassword/hasNickservPassword instead).
#[allow(clippy::too_many_arguments)]
pub fn set_sasl(
&self,
account_id: &str,
@@ -614,6 +631,9 @@ impl AccountStore {
sasl_user: &str,
password: &str,
allow_plaintext: bool,
mechanism: Option<&str>,
cert_path: Option<&str>,
cert_pass: Option<&str>,
) -> Result<bool> {
self.mutate(account_id, |a| {
a.sasl = enabled;
@@ -622,6 +642,18 @@ impl AccountStore {
if !password.is_empty() {
a.password = Some(password.to_string());
}
// Absent means "leave it alone" and empty means "clear it", so a
// caller that knows nothing about mechanisms cannot wipe one
// somebody set.
if let Some(mechanism) = mechanism {
a.sasl_mechanism = (!mechanism.is_empty()).then(|| mechanism.to_string());
}
if let Some(path) = cert_path {
a.sasl_cert_path = (!path.is_empty()).then(|| path.to_string());
}
if let Some(pass) = cert_pass {
a.sasl_cert_pass = (!pass.is_empty()).then(|| pass.to_string());
}
})
}
@@ -654,6 +686,8 @@ pub fn irc_account_to_json(a: &IrcAccountConfig, state: &str) -> Account {
sasl_enabled: a.sasl,
sasl_username: a.sasl_user.clone().unwrap_or_default(),
allow_plaintext_sasl: a.allow_plaintext_sasl,
sasl_mechanism: a.sasl_mechanism.clone().unwrap_or_default(),
has_sasl_certificate: a.sasl_cert_path.as_deref().is_some_and(|p| !p.is_empty()),
ssl: a.ssl,
has_password: a.password.as_deref().is_some_and(|p| !p.is_empty()),
avatar_url: None,
@@ -680,6 +714,8 @@ pub fn discord_account_to_json(a: &DiscordAccountConfig, state: &str) -> Account
sasl_enabled: false,
sasl_username: String::new(),
allow_plaintext_sasl: false,
sasl_mechanism: String::new(),
has_sasl_certificate: false,
ssl: true,
has_password: !a.token.is_empty(),
avatar_url: a.avatar_url.clone(),
@@ -705,6 +741,8 @@ pub fn sockchat_account_to_json(a: &SockChatAccountConfig, state: &str) -> Accou
sasl_enabled: false,
sasl_username: String::new(),
allow_plaintext_sasl: false,
sasl_mechanism: String::new(),
has_sasl_certificate: false,
ssl: true,
has_password: !a.password.is_empty(),
avatar_url: None,
@@ -739,6 +777,8 @@ pub fn kick_account_to_json(a: &KickAccountConfig, state: &str) -> Account {
sasl_enabled: false,
sasl_username: String::new(),
allow_plaintext_sasl: false,
sasl_mechanism: String::new(),
has_sasl_certificate: false,
ssl: true,
has_password: a.token.as_deref().is_some_and(|t| !t.is_empty()),
avatar_url: None,
@@ -768,6 +808,8 @@ pub fn matrix_account_to_json(a: &MatrixAccountConfig, state: &str, has_key_back
sasl_enabled: false,
sasl_username: String::new(),
allow_plaintext_sasl: false,
sasl_mechanism: String::new(),
has_sasl_certificate: false,
ssl: true,
has_password: !a.password.is_empty(),
avatar_url: None,
+308 -14
View File
@@ -297,6 +297,12 @@ async fn establish(state: &AppState, config: &IrcAccountConfig) -> Result<(Sende
realname: Some(config.realname.clone().unwrap_or_else(|| config.nick.clone())),
// SASL uses AUTHENTICATE, not the server PASS field.
password: if config.sasl { None } else { config.password.clone() },
// The certificate SASL EXTERNAL authenticates with. Set on the
// connection rather than sent in a message: EXTERNAL means "whoever
// this TLS session already proved me to be", so without it here there
// is nothing for the server to check.
client_cert_path: config.sasl_cert_path.clone().filter(|p| !p.is_empty()),
client_cert_pass: config.sasl_cert_pass.clone().filter(|p| !p.is_empty()),
proxy_type: config.use_tor.then_some(ProxyType::Socks5),
proxy_server: config.use_tor.then(|| proxy_host(config).to_string()),
proxy_port: config.use_tor.then(|| proxy_port(config)),
@@ -354,7 +360,14 @@ async fn establish(state: &AppState, config: &IrcAccountConfig) -> Result<(Sende
}
}
state.runtime.insert_irc_handle(&account_id, IrcHandle { sender: sender.clone(), nick: config.nick.clone() });
state.runtime.insert_irc_handle(
&account_id,
IrcHandle {
sender: sender.clone(),
nick: config.nick.clone(),
quit_message: quit_message(config),
},
);
state.runtime.set_conn_state(state, &account_id, ConnState::Connected, None);
state.runtime.ensure_buffer(state, &account_id, &config.host, "server");
@@ -433,16 +446,159 @@ async fn register_with_sasl(state: &AppState, account_id: &str, sender: &Sender,
return Ok(false);
}
state.runtime.report_progress(state, account_id, "Starting SASL PLAIN...");
sender.send_sasl_plain()?;
wait_for(stream, |m| matches!(&m.command, Command::AUTHENTICATE(s) if s == "+")).await
.map_err(|_| anyhow!("server did not respond to AUTHENTICATE PLAIN"))?;
// Strongest first. A server that will not take the one we chose says so
// with 908 and lists what it does take, so the fallback is the server's
// own answer rather than a guess made here.
let mut tried: Vec<SaslMechanism> = Vec::new();
let mut next = Some(preferred_mechanism(config));
while let Some(mechanism) = next {
tried.push(mechanism);
match attempt_sasl(state, account_id, sender, stream, config, mechanism).await {
Ok(()) => {
end_cap_and_register(sender, config)?;
return Ok(true);
}
Err(SaslRefusal::Fatal(e)) => return Err(e),
Err(SaslRefusal::TryAnother(offered)) => {
// Only what the server named, only what we can actually do,
// and never one already tried - or a server that keeps
// offering the same mechanism would loop forever.
next = offered
.iter()
.filter_map(|name| SaslMechanism::parse(name))
.find(|m| !tried.contains(m));
if next.is_none() {
bail!(
"SASL authentication failed - the server accepts {}, and this account is set up for {}",
if offered.is_empty() { "nothing this client speaks".to_string() } else { offered.join(", ") },
tried.iter().map(|m| m.name()).collect::<Vec<_>>().join(", ")
);
}
state.runtime.report_progress(
state,
account_id,
&format!("Server refused {}; trying {}...", tried.last().unwrap().name(), next.unwrap().name()),
);
}
}
}
bail!("SASL authentication failed - check the SASL username and password for this account")
}
/// Which mechanism this account should lead with.
///
/// A certificate is a deliberate act, so its presence is taken as meaning it:
/// somebody who went and configured one wants EXTERNAL, and falling back to
/// sending the password would defeat the point of having set it up.
///
/// Otherwise PLAIN, which is not the strongest and is the right default
/// anyway. SCRAM-SHA-256 is barely deployed on IRC - Ergo has it; Libera,
/// Rizon and most of the rest offer PLAIN and EXTERNAL and nothing else - and
/// the spec only *recommends* that a server answer an unknown mechanism with
/// the list of ones it has. Leading with SCRAM would therefore break SASL
/// outright on the networks people actually use, on servers that decline to
/// say why. So SCRAM is there for whoever names it, and the 908 fallback picks
/// it up automatically on servers that do advertise properly.
fn preferred_mechanism(config: &IrcAccountConfig) -> SaslMechanism {
if let Some(named) = config.sasl_mechanism.as_deref().and_then(SaslMechanism::parse) {
return named;
}
if config.sasl_cert_path.as_deref().is_some_and(|p| !p.is_empty()) {
return SaslMechanism::External;
}
SaslMechanism::Plain
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SaslMechanism {
External,
ScramSha256,
Plain,
}
impl SaslMechanism {
fn name(self) -> &'static str {
match self {
Self::External => "EXTERNAL",
Self::ScramSha256 => "SCRAM-SHA-256",
Self::Plain => "PLAIN",
}
}
fn parse(name: &str) -> Option<Self> {
match name.trim().to_ascii_uppercase().as_str() {
"EXTERNAL" => Some(Self::External),
"SCRAM-SHA-256" | "SCRAM_SHA_256" | "SCRAM-SHA256" => Some(Self::ScramSha256),
"PLAIN" => Some(Self::Plain),
_ => None,
}
}
}
/// Why one mechanism did not work.
///
/// The distinction is the whole point: a refusal that names other mechanisms
/// is worth answering with one of them, while a wrong password is not - and
/// retrying PLAIN after SCRAM already established the password is wrong would
/// send that password in the clear for no reason.
enum SaslRefusal {
Fatal(anyhow::Error),
TryAnother(Vec<String>),
}
impl From<anyhow::Error> for SaslRefusal {
fn from(e: anyhow::Error) -> Self {
Self::Fatal(e)
}
}
async fn attempt_sasl(
state: &AppState,
account_id: &str,
sender: &Sender,
stream: &mut ClientStream,
config: &IrcAccountConfig,
mechanism: SaslMechanism,
) -> std::result::Result<(), SaslRefusal> {
state.runtime.report_progress(state, account_id, &format!("Starting SASL {}...", mechanism.name()));
sender.send(Command::AUTHENTICATE(mechanism.name().to_string())).map_err(anyhow::Error::from)?;
state.runtime.report_progress(state, account_id, "Sending SASL credentials...");
let user = config.sasl_user.clone().unwrap_or_else(|| config.nick.clone());
let pass = config.password.clone().unwrap_or_default();
let payload = base64::engine::general_purpose::STANDARD.encode(format!("\0{user}\0{pass}"));
sender.send_sasl(payload)?;
match mechanism {
// Nothing to send but the authorisation identity, and an empty one
// means "whoever the certificate says".
SaslMechanism::External => {
expect_challenge(stream, "+").await?;
sender.send_sasl("+").map_err(anyhow::Error::from)?;
}
SaslMechanism::Plain => {
expect_challenge(stream, "+").await?;
let payload = base64::engine::general_purpose::STANDARD.encode(format!("\0{user}\0{pass}"));
sender.send_sasl(payload).map_err(anyhow::Error::from)?;
}
SaslMechanism::ScramSha256 => {
let mut scram = crate::backend::irc_sasl::Scram::new(&user, &pass, &crate::backend::irc_sasl::nonce());
expect_challenge(stream, "+").await?;
sender
.send_sasl(base64::engine::general_purpose::STANDARD.encode(scram.client_first()))
.map_err(anyhow::Error::from)?;
let server_first = read_challenge(stream).await?;
let client_final = scram.client_final(&server_first).map_err(SaslRefusal::Fatal)?;
sender
.send_sasl(base64::engine::general_purpose::STANDARD.encode(client_final))
.map_err(anyhow::Error::from)?;
let server_final = read_challenge(stream).await?;
// Checked before the success numeric is believed: a server that
// cannot prove it knew the password is not one to be logged in to,
// whatever it says next.
scram.verify(&server_final).map_err(SaslRefusal::Fatal)?;
sender.send_sasl("+").map_err(anyhow::Error::from)?;
}
}
let result = wait_for(stream, |m| {
matches!(
@@ -451,19 +607,70 @@ async fn register_with_sasl(state: &AppState, account_id: &str, sender: &Sender,
| Command::Response(Response::ERR_SASLFAIL, _)
| Command::Response(Response::ERR_SASLTOOLONG, _)
| Command::Response(Response::ERR_SASLABORT, _)
| Command::Response(Response::RPL_SASLMECHS, _)
)
})
.await
.map_err(|_| anyhow!("timed out waiting for SASL result"))?;
.map_err(|_| SaslRefusal::Fatal(anyhow!("timed out waiting for SASL result")))?;
if !matches!(result.command, Command::Response(Response::RPL_SASLSUCCESS, _)) {
bail!("SASL authentication failed - check the SASL username and password for this account");
match &result.command {
Command::Response(Response::RPL_SASLSUCCESS, _) => Ok(()),
// The server listing what it does take, which is the one refusal
// worth answering with a different mechanism.
Command::Response(Response::RPL_SASLMECHS, args) => Err(SaslRefusal::TryAnother(
args.last().map(|list| list.split(',').map(|m| m.trim().to_string()).collect()).unwrap_or_default(),
)),
_ => Err(SaslRefusal::Fatal(anyhow!(
"SASL {} was refused - check the SASL username and password for this account",
mechanism.name()
))),
}
end_cap_and_register(sender, config)?;
Ok(true)
}
/// Waits for the server's `AUTHENTICATE` and insists it is what was expected.
async fn expect_challenge(stream: &mut ClientStream, wanted: &str) -> std::result::Result<(), SaslRefusal> {
let got = read_challenge(stream).await?;
if got != wanted {
return Err(SaslRefusal::Fatal(anyhow!("server answered AUTHENTICATE with {got:?} rather than {wanted:?}")));
}
Ok(())
}
/// The server's next `AUTHENTICATE` payload, decoded.
///
/// A bare `+` means "nothing", and is passed through as itself rather than
/// decoded - it is not base64 for an empty string, it is the protocol's way of
/// writing one.
async fn read_challenge(stream: &mut ClientStream) -> std::result::Result<String, SaslRefusal> {
let message = wait_for(stream, |m| {
matches!(
&m.command,
Command::AUTHENTICATE(_)
| Command::Response(Response::ERR_SASLFAIL, _)
| Command::Response(Response::RPL_SASLMECHS, _)
)
})
.await
.map_err(|_| SaslRefusal::Fatal(anyhow!("server stopped answering during SASL")))?;
match &message.command {
Command::AUTHENTICATE(payload) if payload == "+" => Ok("+".to_string()),
Command::AUTHENTICATE(payload) => base64::engine::general_purpose::STANDARD
.decode(payload)
.map_err(|e| SaslRefusal::Fatal(anyhow!("server's SASL challenge is not base64: {e}")))
.and_then(|bytes| {
String::from_utf8(bytes)
.map_err(|e| SaslRefusal::Fatal(anyhow!("server's SASL challenge is not text: {e}")))
}),
Command::Response(Response::RPL_SASLMECHS, args) => Err(SaslRefusal::TryAnother(
args.last().map(|list| list.split(',').map(|m| m.trim().to_string()).collect()).unwrap_or_default(),
)),
_ => Err(SaslRefusal::Fatal(anyhow!("SASL was refused before it finished"))),
}
}
/// Capabilities asked for on every connection, whatever else is going on.
///
/// `server-time` is the one that matters: without it a message is stamped
@@ -474,6 +681,20 @@ async fn register_with_sasl(state: &AppState, account_id: &str, sender: &Sender,
/// `multi-prefix` makes RPL_NAMREPLY carry every rank a person holds ("@+nick")
/// instead of only the highest, which is what lets a rank being taken away
/// leave the one underneath it intact.
/// What this account says when it leaves.
///
/// A default rather than nothing, because an empty QUIT is what a dropped
/// connection looks like and a deliberate one should not. Trimmed, since a
/// message of only spaces is the same as none.
fn quit_message(config: &IrcAccountConfig) -> String {
match config.quit_message.as_deref().map(str::trim).filter(|m| !m.is_empty()) {
Some(message) => message.to_string(),
None => DEFAULT_QUIT_MESSAGE.to_string(),
}
}
pub const DEFAULT_QUIT_MESSAGE: &str = "moho";
const WANTED_CAPS: &[&str] = &["server-time", "multi-prefix"];
/// Closes capability negotiation and sends the ordinary NICK/USER pair.
@@ -1228,6 +1449,79 @@ fn sasl_transport_ok(ssl: bool, allow_plaintext: bool) -> bool {
ssl || allow_plaintext
}
#[cfg(test)]
mod quit_tests {
use super::*;
fn config(message: Option<&str>) -> IrcAccountConfig {
IrcAccountConfig { quit_message: message.map(String::from), ..Default::default() }
}
#[test]
fn reads_every_spelling_of_a_mechanism() {
assert_eq!(SaslMechanism::parse("external"), Some(SaslMechanism::External));
assert_eq!(SaslMechanism::parse(" PLAIN "), Some(SaslMechanism::Plain));
for spelling in ["SCRAM-SHA-256", "scram-sha256", "SCRAM_SHA_256"] {
assert_eq!(SaslMechanism::parse(spelling), Some(SaslMechanism::ScramSha256), "for {spelling}");
}
// Something we cannot do is not silently treated as something we can.
assert_eq!(SaslMechanism::parse("SCRAM-SHA-1"), None);
assert_eq!(SaslMechanism::parse("ECDSA-NIST256P-CHALLENGE"), None);
}
#[test]
fn leads_with_the_strongest_this_account_is_equipped_for() {
// PLAIN, deliberately. SCRAM is stronger and almost nothing on IRC
// implements it, and a server refusing an unknown mechanism is only
// *recommended* to say which ones it has - so leading with SCRAM
// would break SASL on Libera and Rizon rather than upgrade it.
let bare = IrcAccountConfig::default();
assert_eq!(preferred_mechanism(&bare), SaslMechanism::Plain);
let with_cert = IrcAccountConfig { sasl_cert_path: Some("/keys/libera.pem".into()), ..Default::default() };
assert_eq!(preferred_mechanism(&with_cert), SaslMechanism::External);
// Configuring a certificate and then naming a mechanism means the
// name: somebody pinning PLAIN has a reason, usually a server that
// advertises what it will not accept.
let pinned = IrcAccountConfig {
sasl_cert_path: Some("/keys/libera.pem".into()),
sasl_mechanism: Some("plain".into()),
..Default::default()
};
assert_eq!(preferred_mechanism(&pinned), SaslMechanism::Plain);
// A mechanism nobody here speaks falls back rather than failing the
// connection over a typo in a config file.
let nonsense = IrcAccountConfig { sasl_mechanism: Some("magic".into()), ..Default::default() };
assert_eq!(preferred_mechanism(&nonsense), SaslMechanism::Plain);
assert_eq!(
preferred_mechanism(&IrcAccountConfig { sasl_mechanism: Some("scram-sha-256".into()), ..Default::default() }),
SaslMechanism::ScramSha256
);
}
#[test]
fn says_what_the_account_says() {
assert_eq!(quit_message(&config(Some("gone fishing"))), "gone fishing");
}
#[test]
fn an_account_with_nothing_to_say_still_says_something() {
// An empty QUIT is what a dropped connection looks like, and leaving
// deliberately should not look like falling over.
assert_eq!(quit_message(&config(None)), DEFAULT_QUIT_MESSAGE);
assert_eq!(quit_message(&config(Some(""))), DEFAULT_QUIT_MESSAGE);
assert_eq!(quit_message(&config(Some(" "))), DEFAULT_QUIT_MESSAGE);
}
#[test]
fn trims_what_it_is_given() {
assert_eq!(quit_message(&config(Some(" bye "))), "bye");
}
}
#[cfg(test)]
mod tests {
use super::*;
+302
View File
@@ -0,0 +1,302 @@
//! SASL mechanisms for IRC, beyond the PLAIN this started with.
//!
//! Three, in the order a connection should prefer them:
//!
//! - **EXTERNAL** proves who you are with the TLS client certificate already
//! on the connection, so no password is sent at all. Nothing to intercept
//! and nothing to reuse elsewhere; where a network supports it, it is the
//! best answer available.
//! - **SCRAM-SHA-256** proves knowledge of the password without sending it,
//! and proves the server knew it too - so a server that has been replaced
//! cannot silently collect passwords.
//! - **PLAIN** sends the password with base64 wrapped round it. An encoding,
//! not a cipher. Fine inside TLS, which is why it is still here, and
//! refused outside it.
//!
//! HMAC is implemented here rather than taken from the `hmac` crate: this
//! project's `hmac` is 0.13, which pairs with digest 0.11 hashes, while its
//! `sha2` is 0.10, which needs 0.12. Rather than move a dependency the rest of
//! the daemon is built on, HMAC-SHA-256 is twenty lines and is checked against
//! RFC 4231's vectors; PBKDF2 on top of it is a loop, checked against
//! RFC 7677's.
use anyhow::{anyhow, bail, Result};
use base64::Engine;
use sha2::{Digest, Sha256};
const BLOCK: usize = 64;
const HASH: usize = 32;
/// HMAC-SHA-256, per RFC 2104.
pub fn hmac_sha256(key: &[u8], data: &[u8]) -> [u8; HASH] {
let mut block = [0u8; BLOCK];
// A key longer than the block is hashed first; a shorter one is padded
// with zeros, which `block` already is.
if key.len() > BLOCK {
block[..HASH].copy_from_slice(&Sha256::digest(key));
} else {
block[..key.len()].copy_from_slice(key);
}
let mut inner_pad = [0x36u8; BLOCK];
let mut outer_pad = [0x5cu8; BLOCK];
for i in 0..BLOCK {
inner_pad[i] ^= block[i];
outer_pad[i] ^= block[i];
}
let mut inner = Sha256::new();
inner.update(inner_pad);
inner.update(data);
let inner = inner.finalize();
let mut outer = Sha256::new();
outer.update(outer_pad);
outer.update(inner);
outer.finalize().into()
}
/// PBKDF2-HMAC-SHA-256 producing one block, which is all SCRAM-SHA-256 wants.
pub fn pbkdf2_sha256(password: &[u8], salt: &[u8], iterations: u32) -> [u8; HASH] {
let mut salted = Vec::with_capacity(salt.len() + 4);
salted.extend_from_slice(salt);
// The block index, which is always 1 for a single-block output.
salted.extend_from_slice(&1u32.to_be_bytes());
let mut u = hmac_sha256(password, &salted);
let mut out = u;
for _ in 1..iterations {
u = hmac_sha256(password, &u);
for (o, byte) in out.iter_mut().zip(u.iter()) {
*o ^= byte;
}
}
out
}
fn b64(bytes: &[u8]) -> String {
base64::engine::general_purpose::STANDARD.encode(bytes)
}
fn unb64(text: &str) -> Result<Vec<u8>> {
base64::engine::general_purpose::STANDARD
.decode(text)
.map_err(|e| anyhow!("server sent something that is not base64: {e}"))
}
/// A SCRAM-SHA-256 exchange, from the client's side.
///
/// Held as a value rather than run as one function because the exchange is
/// three messages with the server answering in between, and the state that
/// spans them - the nonce we chose and the first message we sent - is exactly
/// what the final signature is computed over. Losing track of either is how a
/// SCRAM implementation ends up "working" while verifying nothing.
pub struct Scram {
password: String,
client_first_bare: String,
/// Kept from the server's first message so the final check can use it.
salted_password: Option<[u8; HASH]>,
auth_message: Option<String>,
}
impl Scram {
/// `nonce` is the client's, and must be fresh per exchange - it is what
/// stops a recorded exchange being replayed at us.
pub fn new(username: &str, password: &str, nonce: &str) -> Self {
// The username is escaped, not rejected: "," and "=" are the message's
// own separators, and RFC 5802 gives them names rather than banning
// them from names.
let user = username.replace('=', "=3D").replace(',', "=2C");
Self {
password: password.to_string(),
client_first_bare: format!("n={user},r={nonce}"),
salted_password: None,
auth_message: None,
}
}
/// The first message: `n,,n=<user>,r=<nonce>`.
///
/// The leading `n,,` says no channel binding, which is what a client that
/// does not do channel binding must say - claiming otherwise is how a
/// downgrade goes unnoticed.
pub fn client_first(&self) -> String {
format!("n,,{}", self.client_first_bare)
}
/// Answers the server's first message with the proof.
pub fn client_final(&mut self, server_first: &str) -> Result<String> {
let (mut nonce, mut salt, mut iterations) = (None, None, None);
for field in server_first.split(',') {
match field.split_once('=') {
Some(("r", v)) => nonce = Some(v.to_string()),
Some(("s", v)) => salt = Some(unb64(v)?),
Some(("i", v)) => iterations = v.parse::<u32>().ok(),
// `e=` is the server refusing, and says why.
Some(("e", v)) => bail!("server refused SASL: {v}"),
_ => {}
}
}
let nonce = nonce.ok_or_else(|| anyhow!("server's SCRAM reply carried no nonce"))?;
let salt = salt.ok_or_else(|| anyhow!("server's SCRAM reply carried no salt"))?;
let iterations = iterations.ok_or_else(|| anyhow!("server's SCRAM reply carried no iteration count"))?;
// The server chooses this, and a server choosing 1 would be asking for
// a password that is barely stretched at all.
if iterations < 4096 {
bail!("server asked for {iterations} SCRAM iterations, which is too few to be safe");
}
// Our nonce must be a prefix of the combined one, or the server is not
// answering the message we sent.
let ours = self.client_first_bare.rsplit_once("r=").map(|(_, n)| n).unwrap_or_default();
if !nonce.starts_with(ours) {
bail!("server's SCRAM nonce does not extend ours");
}
// `c=biws` is base64("n,,") - the same no-channel-binding claim, sent
// back so the server can see it was not tampered with in transit.
let client_final_bare = format!("c=biws,r={nonce}");
let salted = pbkdf2_sha256(self.password.as_bytes(), &salt, iterations);
let client_key = hmac_sha256(&salted, b"Client Key");
let stored_key: [u8; HASH] = Sha256::digest(client_key).into();
let auth_message = format!("{},{server_first},{client_final_bare}", self.client_first_bare);
let client_signature = hmac_sha256(&stored_key, auth_message.as_bytes());
let mut proof = client_key;
for (p, sig) in proof.iter_mut().zip(client_signature.iter()) {
*p ^= sig;
}
self.salted_password = Some(salted);
self.auth_message = Some(auth_message);
Ok(format!("{client_final_bare},p={}", b64(&proof)))
}
/// Checks the server's final message.
///
/// Not optional politeness: this is the half of SCRAM that proves the
/// server also knew the password. Skipping it leaves the exchange no
/// better than PLAIN against a server that has been replaced.
pub fn verify(&self, server_final: &str) -> Result<()> {
let salted = self.salted_password.ok_or_else(|| anyhow!("SCRAM finished out of order"))?;
let auth_message = self.auth_message.as_ref().ok_or_else(|| anyhow!("SCRAM finished out of order"))?;
for field in server_final.split(',') {
match field.split_once('=') {
Some(("e", v)) => bail!("server refused SASL: {v}"),
Some(("v", v)) => {
let server_key = hmac_sha256(&salted, b"Server Key");
let expected = hmac_sha256(&server_key, auth_message.as_bytes());
return if unb64(v)? == expected {
Ok(())
} else {
// Worth being blunt about: the password was never
// sent, so this is not a failed login. It is a server
// that cannot prove it is the one you registered with.
bail!("the server could not prove it knows this account's password - do not trust this connection")
};
}
_ => {}
}
}
bail!("server's final SCRAM message carried no signature")
}
}
/// A nonce for one exchange: printable, comma-free, and never reused.
pub fn nonce() -> String {
use rand::Rng;
const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let mut rng = rand::thread_rng();
(0..24).map(|_| ALPHABET[rng.gen_range(0..ALPHABET.len())] as char).collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
#[test]
fn hmac_matches_rfc_4231() {
// Test case 1.
assert_eq!(
hex(&hmac_sha256(&[0x0b; 20], b"Hi There")),
"b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"
);
// Test case 2 - a key shorter than the block.
assert_eq!(
hex(&hmac_sha256(b"Jefe", b"what do ya want for nothing?")),
"5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
);
// Test case 6 - a key longer than the block, which is hashed first.
assert_eq!(
hex(&hmac_sha256(&[0xaa; 131], b"Test Using Larger Than Block-Size Key - Hash Key First")),
"60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54"
);
}
/// RFC 7677's worked example, end to end.
#[test]
fn scram_matches_rfc_7677() {
let mut scram = Scram::new("user", "pencil", "rOprNGfwEbeRWgbNEkqO");
assert_eq!(scram.client_first(), "n,,n=user,r=rOprNGfwEbeRWgbNEkqO");
let server_first = "r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096";
let client_final = scram.client_final(server_first).unwrap();
assert_eq!(
client_final,
"c=biws,r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,p=dHzbZapWIk4jUhN+Ute9ytag9zjfMHgsqmmiz7AndVQ="
);
scram.verify("v=6rriTRBi23WpRR/wtup+mMhUZUn/dB5nLTJRsjl95G4=").unwrap();
}
#[test]
fn a_server_that_cannot_prove_itself_is_refused() {
let mut scram = Scram::new("user", "pencil", "rOprNGfwEbeRWgbNEkqO");
scram
.client_final("r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096")
.unwrap();
// One byte different from the real signature.
let err = scram.verify("v=7rriTRBi23WpRR/wtup+mMhUZUn/dB5nLTJRsjl95G4=").unwrap_err();
assert!(err.to_string().contains("could not prove"), "{err}");
}
#[test]
fn refuses_a_nonce_that_is_not_ours_extended() {
let mut scram = Scram::new("user", "pencil", "ourOwnNonce");
let err = scram.client_final("r=somebodyElsesNonce,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096").unwrap_err();
assert!(err.to_string().contains("does not extend ours"), "{err}");
}
#[test]
fn refuses_an_iteration_count_that_stretches_nothing() {
let mut scram = Scram::new("user", "pencil", "n");
let err = scram.client_final("r=n2,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=1").unwrap_err();
assert!(err.to_string().contains("too few"), "{err}");
}
#[test]
fn passes_the_servers_own_refusal_through() {
let mut scram = Scram::new("user", "pencil", "n");
let err = scram.client_final("e=unknown-user").unwrap_err();
assert!(err.to_string().contains("unknown-user"), "{err}");
}
#[test]
fn escapes_the_separators_a_username_may_contain() {
let scram = Scram::new("a,b=c", "pw", "nonce");
assert_eq!(scram.client_first(), "n,,n=a=2Cb=3Dc,r=nonce");
}
#[test]
fn a_nonce_is_fresh_and_safe_to_put_in_a_message() {
let a = nonce();
let b = nonce();
assert_ne!(a, b);
assert!(!a.contains(','), "a comma would end the field early");
assert_eq!(a.len(), 24);
}
}
+1
View File
@@ -3,6 +3,7 @@ pub mod discord;
pub mod discord_voice;
pub mod irc;
pub mod irc_dcc;
pub mod irc_sasl;
pub mod kick;
pub mod matrix;
pub mod sockchat;
+9
View File
@@ -24,6 +24,15 @@ pub struct Account {
pub sasl_username: String,
#[serde(rename = "allowPlaintextSasl")]
pub allow_plaintext_sasl: bool,
/// Which SASL mechanism this account is pinned to, or empty for the
/// default. IRC-only; empty everywhere else.
#[serde(rename = "saslMechanism")]
pub sasl_mechanism: String,
/// Whether a TLS client certificate is configured for SASL EXTERNAL. The
/// path itself is not reported - a frontend never needs it, and it names a
/// file on the machine the daemon is running on rather than this one.
#[serde(rename = "hasSaslCertificate")]
pub has_sasl_certificate: bool,
/// Whether this account's connection is encrypted.
///
/// Reported so a frontend can tell the truth about what sending
+6
View File
@@ -219,6 +219,9 @@ pub async fn dispatch(
username: p_str_opt(params, "username").map(String::from),
quit_message: p_str_opt(params, "quitMessage").map(String::from),
allow_plaintext_sasl: p_bool(params, "allowPlaintextSasl", false),
sasl_mechanism: p_str_opt(params, "saslMechanism").map(String::from),
sasl_cert_path: p_str_opt(params, "saslCertPath").map(String::from),
sasl_cert_pass: p_str_opt(params, "saslCertPass").map(String::from),
// A link can name channels, so a new account can arrive with
// somewhere to be rather than connecting to nothing.
autojoin: p_str_opt(params, "autojoin").unwrap_or("").to_string(),
@@ -337,6 +340,9 @@ pub async fn dispatch(
p_str(params, "saslUser", ""),
p_str(params, "password", ""),
p_bool(params, "allowPlaintextSasl", false),
p_str_opt(params, "saslMechanism"),
p_str_opt(params, "saslCertPath"),
p_str_opt(params, "saslCertPass"),
)),
},
+38 -7
View File
@@ -198,6 +198,14 @@ impl ConnState {
pub struct IrcHandle {
pub sender: irc::client::Sender,
pub nick: String,
/// What to say on the way out.
///
/// Carried on the handle rather than looked up when the connection is
/// torn down, because the two places that end one - `disconnect` and
/// `reset_connection` - have an account id and no view of its config, and
/// both used to send an empty QUIT. The account's own message was stored,
/// settable from two places, and never reached the wire.
pub quit_message: String,
}
/// Live, in-memory state that sits alongside the persisted AccountStore:
@@ -969,7 +977,7 @@ impl Runtime {
/// really needs cancelling.
pub fn reset_connection(&self, account_id: &str) {
if let Some(handle) = self.irc_handles.lock().unwrap().remove(account_id) {
let _ = handle.sender.send_quit("");
let _ = handle.sender.send_quit(&handle.quit_message);
}
if let Some(handle) = self.task_handles.lock().unwrap().remove(account_id) {
handle.abort();
@@ -1925,15 +1933,25 @@ impl Runtime {
/// the network's own ping-timeout notices the dead peer (confirmed
/// live against Libera: this is exactly what caused a real reconnect
/// failure - "Nickname is already in use" - after an abrupt kill).
pub fn quit_all(&self, message: &str) {
/// `fallback` is used only for an account that set no message of its own:
/// the account's is its own whatever ends the connection, and the daemon
/// shutting down is not a reason to say something different to the
/// channel than a disconnect would.
pub fn quit_all(&self, fallback: &str) {
// Nothing should try to come back up on the way out. IRC reconnects
// by itself now, and its loop reads this on the way out of a
// session - a QUIT sent here ends one, so without clearing the
// intent first the daemon would spend its shutdown reconnecting.
self.wants_connected.lock().unwrap().clear();
let senders: Vec<_> = self.irc_handles.lock().unwrap().values().map(|h| h.sender.clone()).collect();
for sender in senders {
let _ = sender.send_quit(message);
let farewells: Vec<_> = self
.irc_handles
.lock()
.unwrap()
.values()
.map(|h| (h.sender.clone(), h.quit_message.clone()))
.collect();
for (sender, message) in farewells {
let _ = sender.send_quit(if message.is_empty() { fallback } else { &message });
}
}
@@ -1947,8 +1965,8 @@ impl Runtime {
// and an IRC retry loop reading this flag on the way out must see
// that the ending was asked for.
self.set_wants_connected(account_id, false);
if let Some(sender) = self.irc_sender(account_id) {
let _ = sender.send_quit("");
if let Some((sender, quit_message)) = self.irc_farewell(account_id) {
let _ = sender.send_quit(&quit_message);
return true;
}
let handle = self.task_handles.lock().unwrap().remove(account_id);
@@ -1966,6 +1984,19 @@ impl Runtime {
}
}
/// The sender for this account and the message to sign off with.
///
/// One lookup rather than two, so the pair cannot come from different
/// moments - and so nobody has to remember that ending a connection means
/// asking about the message as well.
fn irc_farewell(&self, account_id: &str) -> Option<(irc::client::Sender, String)> {
self.irc_handles
.lock()
.unwrap()
.get(account_id)
.map(|h| (h.sender.clone(), h.quit_message.clone()))
}
pub fn irc_sender(&self, account_id: &str) -> Option<irc::client::Sender> {
self.irc_handles.lock().unwrap().get(account_id).map(|h| h.sender.clone())
}