use crate::ber;
use crate::protocol::{
LdapMessage, LdapResult, Operation, ProtocolOp, SearchOutcome, SearchRequest,
};
use rustlavel_core::{Error, Result};
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Encryption {
None,
StartTls,
#[default]
Ldaps,
}
#[derive(Debug, Clone)]
pub struct LdapConfig {
pub host: String,
pub port: u16,
pub encryption: Encryption,
pub verify_certificate: bool,
pub allow_plaintext_password: bool,
pub connect_timeout: Duration,
pub timeout: Duration,
pub max_message_size: usize,
}
impl LdapConfig {
pub fn parse(url: &str) -> Result<LdapConfig> {
let (scheme, rest) = url.split_once("://").ok_or_else(|| {
Error::msg(format!(
"`{url}` is not an LDAP URL. It should start with `ldap://` or `ldaps://` — the \
scheme is what decides whether the password is encrypted, so it is not optional."
))
})?;
let (encryption, default_port) = match scheme.to_ascii_lowercase().as_str() {
"ldap" => (Encryption::StartTls, 389),
"ldaps" => (Encryption::Ldaps, 636),
other => {
return Err(Error::msg(format!(
"`{other}` is not an LDAP scheme. Use `ldaps://` for TLS from the first byte, \
or `ldap://` for port 389 with StartTLS."
)));
}
};
let authority = rest.trim_end_matches('/');
if authority.contains('/') {
return Err(Error::msg(format!(
"`{url}` has a path. This package takes the search base and filter as arguments, \
not as part of the URL — give it just `{scheme}://host:port`."
)));
}
if authority.is_empty() {
return Err(Error::msg(format!("`{url}` names no host")));
}
let (host, port) = split_authority(authority, default_port)?;
Ok(LdapConfig {
host,
port,
encryption,
verify_certificate: true,
allow_plaintext_password: false,
connect_timeout: Duration::from_secs(10),
timeout: Duration::from_secs(30),
max_message_size: 8 * 1024 * 1024,
})
}
pub fn plaintext(mut self) -> LdapConfig {
self.encryption = Encryption::None;
self
}
pub fn start_tls(mut self) -> LdapConfig {
self.encryption = Encryption::StartTls;
self
}
pub fn ldaps(mut self) -> LdapConfig {
self.encryption = Encryption::Ldaps;
self
}
pub fn port(mut self, port: u16) -> LdapConfig {
self.port = port;
self
}
pub fn allow_plaintext_password(mut self) -> LdapConfig {
self.allow_plaintext_password = true;
self
}
pub fn dangerously_accept_any_certificate(mut self) -> LdapConfig {
self.verify_certificate = false;
self
}
pub fn timeout(mut self, timeout: Duration) -> LdapConfig {
self.timeout = timeout;
self
}
pub fn connect_timeout(mut self, timeout: Duration) -> LdapConfig {
self.connect_timeout = timeout;
self
}
pub fn max_message_size(mut self, bytes: usize) -> LdapConfig {
self.max_message_size = bytes;
self
}
pub fn address(&self) -> String {
if self.host.contains(':') {
return format!("[{}]:{}", self.host, self.port);
}
format!("{}:{}", self.host, self.port)
}
}
fn split_authority(authority: &str, default_port: u16) -> Result<(String, u16)> {
if let Some(rest) = authority.strip_prefix('[') {
let (host, tail) = rest.split_once(']').ok_or_else(|| {
Error::msg(format!("`{authority}` opens a bracketed IPv6 address but never closes it"))
})?;
let port = match tail.strip_prefix(':') {
Some(port) => parse_port(port)?,
None if tail.is_empty() => default_port,
None => return Err(Error::msg(format!("`{authority}` has junk after the address"))),
};
return Ok((host.to_string(), port));
}
match authority.rsplit_once(':') {
Some((host, port)) => Ok((host.to_string(), parse_port(port)?)),
None => Ok((authority.to_string(), default_port)),
}
}
fn parse_port(port: &str) -> Result<u16> {
port.parse().map_err(|_| Error::msg(format!("`{port}` is not a port number")))
}
enum LdapStream {
Plain(TcpStream),
Tls(Box<tokio_rustls::client::TlsStream<TcpStream>>),
Upgrading,
}
impl LdapStream {
fn is_encrypted(&self) -> bool {
matches!(self, LdapStream::Tls(_))
}
async fn write_all(&mut self, bytes: &[u8]) -> std::io::Result<()> {
match self {
LdapStream::Plain(stream) => stream.write_all(bytes).await,
LdapStream::Tls(stream) => stream.write_all(bytes).await,
LdapStream::Upgrading => Err(mid_upgrade()),
}
}
async fn flush(&mut self) -> std::io::Result<()> {
match self {
LdapStream::Plain(stream) => stream.flush().await,
LdapStream::Tls(stream) => stream.flush().await,
LdapStream::Upgrading => Err(mid_upgrade()),
}
}
async fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
match self {
LdapStream::Plain(stream) => stream.read(buffer).await,
LdapStream::Tls(stream) => stream.read(buffer).await,
LdapStream::Upgrading => Err(mid_upgrade()),
}
}
async fn shutdown(&mut self) -> std::io::Result<()> {
match self {
LdapStream::Plain(stream) => stream.shutdown().await,
LdapStream::Tls(stream) => stream.shutdown().await,
LdapStream::Upgrading => Ok(()),
}
}
}
fn mid_upgrade() -> std::io::Error {
std::io::Error::other("the connection was left mid-upgrade; this is a bug in this client")
}
pub struct LdapConnection {
stream: LdapStream,
config: LdapConfig,
buffer: Vec<u8>,
next_id: i64,
bound_as: Option<String>,
}
impl LdapConnection {
pub async fn connect(config: &LdapConfig) -> Result<LdapConnection> {
let address = config.address();
let tcp = tokio::time::timeout(config.connect_timeout, TcpStream::connect(&address))
.await
.map_err(|_| {
Error::msg(format!(
"connecting to the directory at {address} timed out after {:?}",
config.connect_timeout
))
})?
.map_err(|error| {
Error::msg(format!("cannot reach the directory at {address}: {error}"))
})?;
let _ = tcp.set_nodelay(true);
let mut connection = LdapConnection {
stream: LdapStream::Plain(tcp),
config: config.clone(),
buffer: Vec::with_capacity(4096),
next_id: 1,
bound_as: None,
};
match config.encryption {
Encryption::None => {}
Encryption::Ldaps => connection.upgrade().await?,
Encryption::StartTls => connection.start_tls().await?,
}
Ok(connection)
}
pub fn is_encrypted(&self) -> bool {
self.stream.is_encrypted()
}
pub fn bound_as(&self) -> Option<&str> {
self.bound_as.as_deref()
}
pub fn config(&self) -> &LdapConfig {
&self.config
}
pub async fn bind(&mut self, dn: &str, password: &str) -> Result<LdapResult> {
let operation = Operation::simple_bind(dn, password)?;
let result = self.bind_with(operation).await?;
if result.is_success() {
self.bound_as = Some(dn.to_string());
}
Ok(result)
}
pub async fn bind_anonymous(&mut self) -> Result<LdapResult> {
let result = self.bind_with(Operation::anonymous_bind()).await?;
if result.is_success() {
self.bound_as = None;
}
Ok(result)
}
async fn bind_with(&mut self, operation: Operation) -> Result<LdapResult> {
let id = self.send(&operation).await?;
let message = self.receive(id).await?;
match message.op {
ProtocolOp::BindResponse { result, .. } => Ok(result),
other => Err(Error::msg(format!(
"asked the directory to bind and it answered with {}",
other.name()
))),
}
}
pub async fn search(&mut self, request: &SearchRequest) -> Result<SearchOutcome> {
let operation = Operation::search(request);
let id = self.send(&operation).await?;
let mut entries = Vec::new();
let mut referrals = Vec::new();
loop {
let message = self.receive(id).await?;
match message.op {
ProtocolOp::SearchResultEntry(entry) => entries.push(entry),
ProtocolOp::SearchResultReference(uris) => referrals.push(uris),
ProtocolOp::SearchResultDone(result) => {
return Ok(SearchOutcome { entries, referrals, result });
}
ProtocolOp::IntermediateResponse => {}
other => {
return Err(Error::msg(format!(
"the directory sent {} in the middle of a search",
other.name()
)));
}
}
}
}
pub async fn unbind(mut self) -> Result<()> {
let operation = Operation::unbind();
let id = self.next_message_id()?;
let bytes = operation.encode(id);
let _ = self.stream.write_all(&bytes).await;
let _ = self.stream.flush().await;
let _ = self.stream.shutdown().await;
Ok(())
}
pub async fn send(&mut self, operation: &Operation) -> Result<i64> {
if operation.carries_password()
&& !self.is_encrypted()
&& !self.config.allow_plaintext_password
{
return Err(Error::msg(format!(
"refusing to send a simple bind to {} over an unencrypted connection: the \
password would go out in the clear, readable by anything on the path. Use \
`ldaps://` (TLS from the first byte) or StartTLS. If this really is a local test \
directory, say so with `LdapConfig::allow_plaintext_password`.",
self.config.address()
)));
}
let id = self.next_message_id()?;
let bytes = operation.encode(id);
tokio::time::timeout(self.config.timeout, self.stream.write_all(&bytes))
.await
.map_err(|_| self.timed_out(operation.kind()))?
.map_err(|error| {
Error::msg(format!(
"writing the {} to {} failed: {error}",
operation.kind(),
self.config.address()
))
})?;
self.stream.flush().await.map_err(Error::Io)?;
Ok(id)
}
pub async fn receive(&mut self, expected_id: i64) -> Result<LdapMessage> {
let message = self.read_message().await?;
if message.id == expected_id {
return Ok(message);
}
if message.id == 0 {
let reason = message
.op
.result()
.map(|result| format!("{}: {}", result.code, result.diagnostic))
.unwrap_or_else(|| message.op.name().to_string());
return Err(Error::msg(format!(
"the directory at {} sent an unsolicited notification and is closing the \
connection — {reason}",
self.config.address()
)));
}
Err(Error::msg(format!(
"the directory answered message {} while message {expected_id} was the one \
outstanding. Refusing to treat one operation's answer as another's.",
message.id
)))
}
async fn read_message(&mut self) -> Result<LdapMessage> {
loop {
if let Some(size) = ber::element_size(&self.buffer, self.config.max_message_size)? {
let bytes: Vec<u8> = self.buffer.drain(..size).collect();
return LdapMessage::parse(&bytes);
}
let mut chunk = [0u8; 8192];
let read = tokio::time::timeout(self.config.timeout, self.stream.read(&mut chunk))
.await
.map_err(|_| self.timed_out("a response"))?
.map_err(|error| {
Error::msg(format!(
"reading from the directory at {} failed: {error}",
self.config.address()
))
})?;
if read == 0 {
return Err(Error::msg(format!(
"the directory at {} closed the connection with {} bytes of a message \
outstanding",
self.config.address(),
self.buffer.len()
)));
}
self.buffer.extend_from_slice(&chunk[..read]);
}
}
fn timed_out(&self, what: &str) -> Error {
Error::msg(format!(
"the directory at {} did not finish {what} within {:?}",
self.config.address(),
self.config.timeout
))
}
fn next_message_id(&mut self) -> Result<i64> {
if self.next_id > i64::from(i32::MAX) {
return Err(Error::msg(
"this connection has used every message id; open a new one",
));
}
let id = self.next_id;
self.next_id += 1;
Ok(id)
}
async fn start_tls(&mut self) -> Result<()> {
let operation = Operation::start_tls();
let id = self.send(&operation).await?;
let message = self.receive(id).await?;
let result = match message.op {
ProtocolOp::ExtendedResponse { result, .. } => result,
other => {
return Err(Error::msg(format!(
"asked for StartTLS and the directory answered with {}",
other.name()
)));
}
};
if !result.is_success() {
return Err(Error::msg(format!(
"the directory at {} refused StartTLS: {}{}. It is not configured for TLS on \
this port. Connecting anyway would send the password in the clear, so this \
stops here rather than downgrading.",
self.config.address(),
result.code,
match result.diagnostic.as_str() {
"" => String::new(),
diagnostic => format!(" — {diagnostic}"),
}
)));
}
if !self.buffer.is_empty() {
return Err(Error::msg(
"the directory sent data after agreeing to StartTLS but before the handshake. \
Those bytes are unauthenticated, so the connection is abandoned rather than \
trusted.",
));
}
self.upgrade().await
}
async fn upgrade(&mut self) -> Result<()> {
let plain = match std::mem::replace(&mut self.stream, LdapStream::Upgrading) {
LdapStream::Plain(stream) => stream,
LdapStream::Tls(stream) => {
self.stream = LdapStream::Tls(stream);
return Err(Error::msg("this connection is already encrypted"));
}
LdapStream::Upgrading => return Err(Error::msg(mid_upgrade().to_string())),
};
let connector = tokio_rustls::TlsConnector::from(client_config(&self.config));
let name = rustls::pki_types::ServerName::try_from(self.config.host.clone())
.map_err(|_| {
Error::msg(format!("`{}` is not a valid TLS server name", self.config.host))
})?;
let tls = tokio::time::timeout(self.config.connect_timeout, connector.connect(name, plain))
.await
.map_err(|_| {
Error::msg(format!(
"the TLS handshake with {} timed out",
self.config.address()
))
})?
.map_err(|error| {
Error::msg(format!(
"the TLS handshake with {} failed: {error}. If this is a development \
directory with a self-signed certificate, say so explicitly with \
`dangerously_accept_any_certificate` rather than turning encryption off.",
self.config.address()
))
})?;
self.stream = LdapStream::Tls(Box::new(tls));
Ok(())
}
}
fn client_config(config: &LdapConfig) -> Arc<rustls::ClientConfig> {
use std::sync::OnceLock;
static VERIFYING: OnceLock<Arc<rustls::ClientConfig>> = OnceLock::new();
static TRUSTING: OnceLock<Arc<rustls::ClientConfig>> = OnceLock::new();
if config.verify_certificate {
return VERIFYING
.get_or_init(|| {
let roots =
rustls::RootCertStore { roots: webpki_roots::TLS_SERVER_ROOTS.to_vec() };
Arc::new(
rustls::ClientConfig::builder()
.with_root_certificates(roots)
.with_no_client_auth(),
)
})
.clone();
}
TRUSTING
.get_or_init(|| {
Arc::new(
rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(TrustAnyCertificate::new()))
.with_no_client_auth(),
)
})
.clone()
}
#[derive(Debug)]
struct TrustAnyCertificate(Arc<rustls::crypto::CryptoProvider>);
impl TrustAnyCertificate {
fn new() -> TrustAnyCertificate {
let provider = rustls::crypto::CryptoProvider::get_default()
.cloned()
.unwrap_or_else(|| Arc::new(rustls::crypto::aws_lc_rs::default_provider()));
TrustAnyCertificate(provider)
}
}
impl rustls::client::danger::ServerCertVerifier for TrustAnyCertificate {
fn verify_server_cert(
&self,
_end_entity: &rustls::pki_types::CertificateDer<'_>,
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
_server_name: &rustls::pki_types::ServerName<'_>,
_ocsp_response: &[u8],
_now: rustls::pki_types::UnixTime,
) -> std::result::Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
Ok(rustls::client::danger::ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls12_signature(
message,
cert,
dss,
&self.0.signature_verification_algorithms,
)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls13_signature(
message,
cert,
dss,
&self.0.signature_verification_algorithms,
)
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
self.0.signature_verification_algorithms.supported_schemes()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn urls_carry_the_security_decision_in_their_scheme() {
let ldaps = LdapConfig::parse("ldaps://dc.example.test").unwrap();
assert_eq!(ldaps.host, "dc.example.test");
assert_eq!(ldaps.port, 636);
assert_eq!(ldaps.encryption, Encryption::Ldaps);
let ldap = LdapConfig::parse("ldap://dc.example.test").unwrap();
assert_eq!(ldap.port, 389);
assert_eq!(ldap.encryption, Encryption::StartTls);
assert_eq!(LdapConfig::parse("ldap://dc.example.test:3389").unwrap().port, 3389);
assert_eq!(LdapConfig::parse("LDAPS://Host").unwrap().encryption, Encryption::Ldaps);
assert_eq!(LdapConfig::parse("ldaps://[::1]:1636").unwrap().host, "::1");
assert_eq!(LdapConfig::parse("ldaps://[::1]").unwrap().port, 636);
assert_eq!(LdapConfig::parse("ldaps://[::1]:1636").unwrap().address(), "[::1]:1636");
}
#[test]
fn the_defaults_are_the_careful_ones() {
let config = LdapConfig::parse("ldaps://dc.example.test").unwrap();
assert!(config.verify_certificate, "certificates are checked unless told otherwise");
assert!(!config.allow_plaintext_password, "and passwords do not go out in the clear");
}
#[test]
fn a_url_without_a_scheme_says_why_the_scheme_matters() {
let error = LdapConfig::parse("dc.example.test").unwrap_err().to_string();
assert!(error.contains("ldaps://"), "got {error}");
assert!(error.contains("encrypted"), "the error should say what is at stake: {error}");
assert!(LdapConfig::parse("http://dc.example.test").is_err());
assert!(LdapConfig::parse("ldap://dc.example.test/dc=example").is_err());
assert!(LdapConfig::parse("ldap://").is_err());
assert!(LdapConfig::parse("ldap://host:not-a-port").is_err());
assert!(LdapConfig::parse("ldaps://[::1").is_err());
}
#[tokio::test]
async fn a_simple_bind_over_plain_tcp_is_refused_and_the_error_says_why() {
let config = LdapConfig::parse("ldap://127.0.0.1:1").unwrap().plaintext();
let mut connection = LdapConnection {
stream: LdapStream::Upgrading,
config,
buffer: Vec::new(),
next_id: 1,
bound_as: None,
};
let error = connection.bind("cn=admin", "secret").await.unwrap_err().to_string();
assert!(error.contains("unencrypted"), "got {error}");
assert!(error.contains("ldaps://"), "the error should name the fix: {error}");
assert!(error.contains("allow_plaintext_password"), "and the opt-in: {error}");
}
#[tokio::test]
async fn an_empty_password_never_reaches_the_transport_check_or_the_socket() {
let config = LdapConfig::parse("ldap://127.0.0.1:1")
.unwrap()
.plaintext()
.allow_plaintext_password();
let mut connection = LdapConnection {
stream: LdapStream::Upgrading,
config,
buffer: Vec::new(),
next_id: 1,
bound_as: None,
};
let error = connection.bind("cn=admin", "").await.unwrap_err().to_string();
assert!(error.contains("anonymous"), "got {error}");
}
#[tokio::test]
async fn an_unreachable_directory_names_the_address() {
let config = LdapConfig::parse("ldaps://127.0.0.1:1")
.unwrap()
.connect_timeout(Duration::from_secs(2));
let error = match LdapConnection::connect(&config).await {
Ok(_) => panic!("something is listening on port 1"),
Err(error) => error.to_string(),
};
assert!(error.contains("127.0.0.1:1"), "got {error}");
}
#[test]
fn message_ids_start_at_one_and_never_repeat() {
let mut connection = LdapConnection {
stream: LdapStream::Upgrading,
config: LdapConfig::parse("ldaps://host").unwrap(),
buffer: Vec::new(),
next_id: 1,
bound_as: None,
};
assert_eq!(connection.next_message_id().unwrap(), 1);
assert_eq!(connection.next_message_id().unwrap(), 2);
assert_eq!(connection.next_message_id().unwrap(), 3);
connection.next_id = i64::from(i32::MAX) + 1;
assert!(connection.next_message_id().is_err(), "exhaustion is an error, not a wrap");
}
#[test]
fn the_key_exchange_leads_with_a_post_quantum_hybrid() {
let config = client_config(&LdapConfig::parse("ldaps://host").unwrap());
let offered: Vec<String> = config
.crypto_provider()
.kx_groups
.iter()
.map(|group| format!("{:?}", group.name()))
.collect();
assert_eq!(
offered.first().map(String::as_str),
Some("X25519MLKEM768"),
"the post-quantum hybrid must lead the ClientHello; offered: {offered:?}"
);
assert!(
offered.iter().any(|name| name == "X25519"),
"a classical group must remain, for directories that have not caught up: {offered:?}"
);
}
}