use std::fmt;
use crate::error::{Error, Result};
use crate::scram::{ScramClient, ScramHash, random_nonce};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SaslMechanism {
Plain,
ScramSha256,
ScramSha512,
}
impl SaslMechanism {
pub const fn as_str(self) -> &'static str {
match self {
SaslMechanism::Plain => "PLAIN",
SaslMechanism::ScramSha256 => "SCRAM-SHA-256",
SaslMechanism::ScramSha512 => "SCRAM-SHA-512",
}
}
const fn sends_cleartext_password(self) -> bool {
matches!(self, SaslMechanism::Plain)
}
}
impl fmt::Display for SaslMechanism {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Clone)]
pub struct SaslConfig {
pub mechanism: SaslMechanism,
pub username: String,
pub password: String,
pub allow_plaintext_password: bool,
}
impl fmt::Debug for SaslConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SaslConfig")
.field("mechanism", &self.mechanism)
.field("username", &self.username)
.field("password", &"<redacted>")
.field("allow_plaintext_password", &self.allow_plaintext_password)
.finish()
}
}
impl SaslConfig {
pub fn new(
mechanism: SaslMechanism,
username: impl Into<String>,
password: impl Into<String>,
) -> Self {
Self {
mechanism,
username: username.into(),
password: password.into(),
allow_plaintext_password: false,
}
}
pub fn allow_plaintext_password(mut self) -> Self {
self.allow_plaintext_password = true;
self
}
pub(crate) fn check_encryption(&self, encrypted: bool) -> Result<()> {
if self.mechanism.sends_cleartext_password() && !encrypted && !self.allow_plaintext_password
{
return Err(Error::Authentication(format!(
"{} over an unencrypted connection would send the password in the clear; \
use TLS or opt in with SaslConfig::allow_plaintext_password",
self.mechanism
)));
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct AuthOutcome {
pub auth_bytes: Vec<u8>,
pub session_lifetime_ms: i64,
}
pub(crate) trait SaslTransport {
async fn handshake(&mut self, mechanism: &str) -> Result<Vec<String>>;
async fn authenticate(&mut self, token: Vec<u8>) -> Result<AuthOutcome>;
}
pub(crate) async fn authenticate<T: SaslTransport>(
config: &SaslConfig,
transport: &mut T,
) -> Result<i64> {
let enabled = transport.handshake(config.mechanism.as_str()).await?;
if !enabled.is_empty() && !enabled.iter().any(|m| m == config.mechanism.as_str()) {
return Err(Error::Authentication(format!(
"broker does not offer {}; it enables [{}]",
config.mechanism,
enabled.join(", ")
)));
}
match config.mechanism {
SaslMechanism::Plain => {
let mut token = Vec::new();
token.push(0);
token.extend_from_slice(config.username.as_bytes());
token.push(0);
token.extend_from_slice(config.password.as_bytes());
let outcome = transport.authenticate(token).await?;
Ok(outcome.session_lifetime_ms)
}
SaslMechanism::ScramSha256 | SaslMechanism::ScramSha512 => {
let hash = if config.mechanism == SaslMechanism::ScramSha256 {
ScramHash::Sha256
} else {
ScramHash::Sha512
};
let mut client =
ScramClient::new(hash, &config.username, &config.password, random_nonce())?;
let server_first = transport.authenticate(client.client_first()).await?;
let client_final = client.client_final(&server_first.auth_bytes)?;
let server_final = transport.authenticate(client_final).await?;
client.verify_server_final(&server_final.auth_bytes)?;
Ok(server_final.session_lifetime_ms)
}
}
}
pub(crate) fn reauth_delay(session_lifetime_ms: i64) -> Option<std::time::Duration> {
if session_lifetime_ms <= 0 {
return None;
}
let lifetime = u64::try_from(session_lifetime_ms).unwrap_or(u64::MAX);
let delay = lifetime.saturating_mul(8) / 10;
Some(std::time::Duration::from_millis(delay.max(1_000)))
}
#[cfg(test)]
mod tests {
use super::*;
struct Fake {
handshake_mechanisms: Vec<String>,
tokens_sent: Vec<Vec<u8>>,
replies: Vec<AuthOutcome>,
}
impl SaslTransport for Fake {
async fn handshake(&mut self, _mechanism: &str) -> Result<Vec<String>> {
Ok(self.handshake_mechanisms.clone())
}
async fn authenticate(&mut self, token: Vec<u8>) -> Result<AuthOutcome> {
self.tokens_sent.push(token);
if self.replies.is_empty() {
panic!("more authenticate calls than canned replies");
}
Ok(self.replies.remove(0))
}
}
#[tokio::test]
async fn plain_sends_the_rfc4616_token_in_one_round_trip() {
let mut fake = Fake {
handshake_mechanisms: vec!["PLAIN".to_owned()],
tokens_sent: Vec::new(),
replies: vec![AuthOutcome {
auth_bytes: Vec::new(),
session_lifetime_ms: 0,
}],
};
let cfg = SaslConfig::new(SaslMechanism::Plain, "alice", "s3cret");
let lifetime = authenticate(&cfg, &mut fake).await.unwrap();
assert_eq!(lifetime, 0);
assert_eq!(fake.tokens_sent.len(), 1);
assert_eq!(fake.tokens_sent[0], b"\0alice\0s3cret");
}
#[tokio::test]
async fn a_mechanism_the_broker_does_not_offer_fails_before_any_token_is_sent() {
let mut fake = Fake {
handshake_mechanisms: vec!["SCRAM-SHA-512".to_owned()],
tokens_sent: Vec::new(),
replies: Vec::new(),
};
let cfg = SaslConfig::new(SaslMechanism::Plain, "alice", "s3cret");
let err = authenticate(&cfg, &mut fake).await.unwrap_err();
assert!(matches!(err, Error::Authentication(_)), "{err:?}");
assert!(fake.tokens_sent.is_empty(), "password must not be sent");
}
#[test]
fn plain_over_plaintext_is_refused_unless_opted_into() {
let cfg = SaslConfig::new(SaslMechanism::Plain, "a", "b");
assert!(cfg.check_encryption(false).is_err());
assert!(cfg.check_encryption(true).is_ok());
assert!(
cfg.clone()
.allow_plaintext_password()
.check_encryption(false)
.is_ok()
);
}
#[test]
fn scram_over_plaintext_is_fine() {
let cfg = SaslConfig::new(SaslMechanism::ScramSha512, "a", "b");
assert!(cfg.check_encryption(false).is_ok());
}
#[test]
fn config_debug_never_prints_the_password() {
let cfg = SaslConfig::new(SaslMechanism::Plain, "alice", "hunter2");
assert!(!format!("{cfg:?}").contains("hunter2"));
}
#[test]
fn no_expiry_means_no_reauth_timer() {
assert!(reauth_delay(0).is_none());
assert!(reauth_delay(-1).is_none());
}
#[test]
fn reauth_fires_comfortably_before_expiry() {
let delay = reauth_delay(60_000).unwrap();
assert_eq!(delay, std::time::Duration::from_millis(48_000));
assert_eq!(
reauth_delay(100).unwrap(),
std::time::Duration::from_secs(1)
);
}
}