Skip to main content

rustlavel_ldap/
connection.rs

1//! One connection to a directory: TCP, optionally encrypted, and the loop that
2//! matches an answer to the question that asked it.
3//!
4//! # There is no connection pool here
5//!
6//! Deliberately, and it is worth saying out loud rather than leaving as an
7//! omission. An LDAP connection is not stateless the way an HTTP one is: it
8//! carries an *authorization identity*, set by the last successful bind. Two
9//! callers sharing a pooled connection would be sharing whoever bound last,
10//! and a failed bind leaves a connection anonymous rather than closed — so a
11//! pool that handed that connection back out would hand out an anonymous
12//! session that used to be an administrator's.
13//!
14//! Authentication is also not a hot path. One connection per attempt, closed
15//! afterwards, is what [`crate::directory`] does, and it is both simpler and
16//! the only version that is obviously correct. A pool for *search* traffic
17//! bound as a single service account is a reasonable thing to want later; it
18//! would key on the bound DN, and it is not this.
19//!
20//! # Encryption
21//!
22//! Three ways to get there, matching what deployments actually look like:
23//!
24//! * [`Encryption::Ldaps`] — TLS from the first byte, port 636. Never
25//!   standardised, universally deployed.
26//! * [`Encryption::StartTls`] — connect in the clear on port 389, then send
27//!   the extended request from RFC 4511 §4.14 and upgrade. If the directory
28//!   says no, the connection fails; it never continues in the clear.
29//! * [`Encryption::None`] — no encryption. A simple bind over this is refused
30//!   unless the caller has said, in as many words, that it is acceptable.
31
32use crate::ber;
33use crate::protocol::{
34    LdapMessage, LdapResult, Operation, ProtocolOp, SearchOutcome, SearchRequest,
35};
36use rustlavel_core::{Error, Result};
37use std::sync::Arc;
38use std::time::Duration;
39use tokio::io::{AsyncReadExt, AsyncWriteExt};
40use tokio::net::TcpStream;
41
42/// How a connection gets encrypted, if it does.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
44pub enum Encryption {
45    /// Plain TCP. Everything, the password included, is on the wire in the
46    /// clear — see [`LdapConfig::allow_plaintext_password`] for the one way to
47    /// send a bind over it.
48    None,
49    /// Connect in the clear, then upgrade with the StartTLS extended operation.
50    /// The default for port 389.
51    StartTls,
52    /// TLS from the first byte. The default for port 636.
53    #[default]
54    Ldaps,
55}
56
57/// Where the directory is and how much to insist on before talking to it.
58///
59/// Holds no credentials, which is why it can derive `Debug`: the bind DN and
60/// password live in [`crate::directory::Directory`], which cannot.
61#[derive(Debug, Clone)]
62pub struct LdapConfig {
63    pub host: String,
64    pub port: u16,
65    pub encryption: Encryption,
66    /// Whether the server's certificate has to chain to a trusted root and
67    /// name the host. Off is for a laboratory and nowhere else.
68    pub verify_certificate: bool,
69    /// Whether a simple bind may go over an unencrypted connection.
70    ///
71    /// False by default, and the error when it fires says why. A simple bind
72    /// puts the password on the wire verbatim; this is the most common LDAP
73    /// deployment mistake there is, and it is invisible from the outside
74    /// because everything works.
75    pub allow_plaintext_password: bool,
76    pub connect_timeout: Duration,
77    /// How long to wait for one response.
78    pub timeout: Duration,
79    /// The largest message this client will buffer.
80    ///
81    /// Enforced from the length header, before anything is read, so a directory
82    /// cannot make a client allocate by announcing a size it never sends.
83    pub max_message_size: usize,
84}
85
86impl LdapConfig {
87    /// Parse `ldap://host:port` or `ldaps://host:port`.
88    ///
89    /// `ldaps://` implies port 636 and TLS from the first byte. `ldap://`
90    /// implies port 389 and, by default, StartTLS — because a URL is not a
91    /// security decision and defaulting to the encrypted form of the same port
92    /// is the version that fails loudly rather than leaking quietly. A caller
93    /// who genuinely wants plain text asks with [`LdapConfig::plaintext`].
94    pub fn parse(url: &str) -> Result<LdapConfig> {
95        let (scheme, rest) = url.split_once("://").ok_or_else(|| {
96            Error::msg(format!(
97                "`{url}` is not an LDAP URL. It should start with `ldap://` or `ldaps://` — the \
98                 scheme is what decides whether the password is encrypted, so it is not optional."
99            ))
100        })?;
101
102        let (encryption, default_port) = match scheme.to_ascii_lowercase().as_str() {
103            "ldap" => (Encryption::StartTls, 389),
104            "ldaps" => (Encryption::Ldaps, 636),
105            other => {
106                return Err(Error::msg(format!(
107                    "`{other}` is not an LDAP scheme. Use `ldaps://` for TLS from the first byte, \
108                     or `ldap://` for port 389 with StartTLS."
109                )));
110            }
111        };
112
113        // Anything after the authority is a search base and a filter in
114        // RFC 4516 terms. This package takes those as arguments rather than
115        // buried in a URL, so they are refused here instead of ignored.
116        let authority = rest.trim_end_matches('/');
117        if authority.contains('/') {
118            return Err(Error::msg(format!(
119                "`{url}` has a path. This package takes the search base and filter as arguments, \
120                 not as part of the URL — give it just `{scheme}://host:port`."
121            )));
122        }
123        if authority.is_empty() {
124            return Err(Error::msg(format!("`{url}` names no host")));
125        }
126
127        let (host, port) = split_authority(authority, default_port)?;
128
129        Ok(LdapConfig {
130            host,
131            port,
132            encryption,
133            verify_certificate: true,
134            allow_plaintext_password: false,
135            connect_timeout: Duration::from_secs(10),
136            timeout: Duration::from_secs(30),
137            max_message_size: 8 * 1024 * 1024,
138        })
139    }
140
141    /// Do not encrypt at all.
142    ///
143    /// Separate from [`LdapConfig::allow_plaintext_password`] on purpose: this
144    /// says "no TLS", that one says "and send the password anyway". A read-only
145    /// anonymous search over plain TCP is a perfectly ordinary thing to do, and
146    /// needs only the first.
147    pub fn plaintext(mut self) -> LdapConfig {
148        self.encryption = Encryption::None;
149        self
150    }
151
152    pub fn start_tls(mut self) -> LdapConfig {
153        self.encryption = Encryption::StartTls;
154        self
155    }
156
157    pub fn ldaps(mut self) -> LdapConfig {
158        self.encryption = Encryption::Ldaps;
159        self
160    }
161
162    pub fn port(mut self, port: u16) -> LdapConfig {
163        self.port = port;
164        self
165    }
166
167    /// Send a simple bind even though the connection is not encrypted.
168    ///
169    /// The named-in-full opt-in the security rule asks for. It exists because a
170    /// test container on loopback is a real case; it should not appear in
171    /// anything that talks to a directory over a network.
172    pub fn allow_plaintext_password(mut self) -> LdapConfig {
173        self.allow_plaintext_password = true;
174        self
175    }
176
177    /// Believe whatever certificate the directory presents.
178    ///
179    /// This turns TLS into protection against a passive listener and nothing
180    /// else: an active attacker simply presents a certificate of their own. The
181    /// name is long because the call site is where somebody will read it.
182    pub fn dangerously_accept_any_certificate(mut self) -> LdapConfig {
183        self.verify_certificate = false;
184        self
185    }
186
187    pub fn timeout(mut self, timeout: Duration) -> LdapConfig {
188        self.timeout = timeout;
189        self
190    }
191
192    pub fn connect_timeout(mut self, timeout: Duration) -> LdapConfig {
193        self.connect_timeout = timeout;
194        self
195    }
196
197    pub fn max_message_size(mut self, bytes: usize) -> LdapConfig {
198        self.max_message_size = bytes;
199        self
200    }
201
202    pub fn address(&self) -> String {
203        if self.host.contains(':') {
204            return format!("[{}]:{}", self.host, self.port);
205        }
206        format!("{}:{}", self.host, self.port)
207    }
208}
209
210/// `host`, `host:port`, `[v6]` or `[v6]:port`.
211fn split_authority(authority: &str, default_port: u16) -> Result<(String, u16)> {
212    if let Some(rest) = authority.strip_prefix('[') {
213        let (host, tail) = rest.split_once(']').ok_or_else(|| {
214            Error::msg(format!("`{authority}` opens a bracketed IPv6 address but never closes it"))
215        })?;
216        let port = match tail.strip_prefix(':') {
217            Some(port) => parse_port(port)?,
218            None if tail.is_empty() => default_port,
219            None => return Err(Error::msg(format!("`{authority}` has junk after the address"))),
220        };
221        return Ok((host.to_string(), port));
222    }
223
224    match authority.rsplit_once(':') {
225        Some((host, port)) => Ok((host.to_string(), parse_port(port)?)),
226        None => Ok((authority.to_string(), default_port)),
227    }
228}
229
230fn parse_port(port: &str) -> Result<u16> {
231    port.parse().map_err(|_| Error::msg(format!("`{port}` is not a port number")))
232}
233
234/// A socket that may or may not have been upgraded.
235///
236/// An enum rather than a boxed trait object: there are exactly two cases, both
237/// known at compile time. `Upgrading` exists for the instant during StartTLS
238/// when the plain socket has been taken out and the encrypted one is not yet
239/// in — a real placeholder, so a bug there reports itself instead of hanging on
240/// a dead file descriptor.
241enum LdapStream {
242    Plain(TcpStream),
243    Tls(Box<tokio_rustls::client::TlsStream<TcpStream>>),
244    Upgrading,
245}
246
247impl LdapStream {
248    fn is_encrypted(&self) -> bool {
249        matches!(self, LdapStream::Tls(_))
250    }
251
252    async fn write_all(&mut self, bytes: &[u8]) -> std::io::Result<()> {
253        match self {
254            LdapStream::Plain(stream) => stream.write_all(bytes).await,
255            LdapStream::Tls(stream) => stream.write_all(bytes).await,
256            LdapStream::Upgrading => Err(mid_upgrade()),
257        }
258    }
259
260    async fn flush(&mut self) -> std::io::Result<()> {
261        match self {
262            LdapStream::Plain(stream) => stream.flush().await,
263            LdapStream::Tls(stream) => stream.flush().await,
264            LdapStream::Upgrading => Err(mid_upgrade()),
265        }
266    }
267
268    async fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
269        match self {
270            LdapStream::Plain(stream) => stream.read(buffer).await,
271            LdapStream::Tls(stream) => stream.read(buffer).await,
272            LdapStream::Upgrading => Err(mid_upgrade()),
273        }
274    }
275
276    async fn shutdown(&mut self) -> std::io::Result<()> {
277        match self {
278            LdapStream::Plain(stream) => stream.shutdown().await,
279            LdapStream::Tls(stream) => stream.shutdown().await,
280            LdapStream::Upgrading => Ok(()),
281        }
282    }
283}
284
285fn mid_upgrade() -> std::io::Error {
286    std::io::Error::other("the connection was left mid-upgrade; this is a bug in this client")
287}
288
289/// A live connection to a directory.
290///
291/// **No `Debug`.** The bytes it writes hold a password whenever a simple bind
292/// goes out, and a type that can be printed is a type that ends up in a log.
293pub struct LdapConnection {
294    stream: LdapStream,
295    config: LdapConfig,
296    /// Bytes read but not yet framed into a message. LDAP messages do not align
297    /// with TCP reads in either direction.
298    buffer: Vec<u8>,
299    next_id: i64,
300    bound_as: Option<String>,
301}
302
303impl LdapConnection {
304    /// Open a connection and get it encrypted, if the configuration says so.
305    pub async fn connect(config: &LdapConfig) -> Result<LdapConnection> {
306        let address = config.address();
307
308        let tcp = tokio::time::timeout(config.connect_timeout, TcpStream::connect(&address))
309            .await
310            .map_err(|_| {
311                Error::msg(format!(
312                    "connecting to the directory at {address} timed out after {:?}",
313                    config.connect_timeout
314                ))
315            })?
316            .map_err(|error| {
317                Error::msg(format!("cannot reach the directory at {address}: {error}"))
318            })?;
319
320        // Every message is a request followed by a wait, so Nagle only ever
321        // adds latency here.
322        let _ = tcp.set_nodelay(true);
323
324        let mut connection = LdapConnection {
325            stream: LdapStream::Plain(tcp),
326            config: config.clone(),
327            buffer: Vec::with_capacity(4096),
328            next_id: 1,
329            bound_as: None,
330        };
331
332        match config.encryption {
333            Encryption::None => {}
334            Encryption::Ldaps => connection.upgrade().await?,
335            Encryption::StartTls => connection.start_tls().await?,
336        }
337
338        Ok(connection)
339    }
340
341    /// Whether this connection is encrypted right now.
342    pub fn is_encrypted(&self) -> bool {
343        self.stream.is_encrypted()
344    }
345
346    /// The DN of the last successful non-anonymous bind, if there was one.
347    pub fn bound_as(&self) -> Option<&str> {
348        self.bound_as.as_deref()
349    }
350
351    pub fn config(&self) -> &LdapConfig {
352        &self.config
353    }
354
355    /// A simple bind: name plus password.
356    ///
357    /// Refuses two things before writing a byte. An empty password, because
358    /// LDAP would answer it with `success` — see [`Operation::simple_bind`].
359    /// And an unencrypted connection, because a simple bind puts the password
360    /// on the wire verbatim and there is no way to un-send it.
361    pub async fn bind(&mut self, dn: &str, password: &str) -> Result<LdapResult> {
362        let operation = Operation::simple_bind(dn, password)?;
363        let result = self.bind_with(operation).await?;
364        if result.is_success() {
365            self.bound_as = Some(dn.to_string());
366        }
367        Ok(result)
368    }
369
370    /// An anonymous bind — no name and no password, and no authentication.
371    ///
372    /// Spelled out so it cannot be arrived at by leaving a field empty.
373    pub async fn bind_anonymous(&mut self) -> Result<LdapResult> {
374        let result = self.bind_with(Operation::anonymous_bind()).await?;
375        if result.is_success() {
376            self.bound_as = None;
377        }
378        Ok(result)
379    }
380
381    async fn bind_with(&mut self, operation: Operation) -> Result<LdapResult> {
382        let id = self.send(&operation).await?;
383        let message = self.receive(id).await?;
384
385        match message.op {
386            ProtocolOp::BindResponse { result, .. } => Ok(result),
387            other => Err(Error::msg(format!(
388                "asked the directory to bind and it answered with {}",
389                other.name()
390            ))),
391        }
392    }
393
394    /// Run a search to completion, collecting every entry.
395    ///
396    /// Entries are collected rather than streamed because the searches this
397    /// package exists for return one entry. A search that could return
398    /// thousands wants a paged control and an iterator, and should not pretend
399    /// this is that.
400    pub async fn search(&mut self, request: &SearchRequest) -> Result<SearchOutcome> {
401        let operation = Operation::search(request);
402        let id = self.send(&operation).await?;
403
404        let mut entries = Vec::new();
405        let mut referrals = Vec::new();
406
407        loop {
408            let message = self.receive(id).await?;
409            match message.op {
410                ProtocolOp::SearchResultEntry(entry) => entries.push(entry),
411                ProtocolOp::SearchResultReference(uris) => referrals.push(uris),
412                ProtocolOp::SearchResultDone(result) => {
413                    return Ok(SearchOutcome { entries, referrals, result });
414                }
415                // A directory may send these in the middle of a search when a
416                // control asks for them. Nothing here asked, so ignore rather
417                // than fail: an unexpected extra message is not a wrong answer.
418                ProtocolOp::IntermediateResponse => {}
419                other => {
420                    return Err(Error::msg(format!(
421                        "the directory sent {} in the middle of a search",
422                        other.name()
423                    )));
424                }
425            }
426        }
427    }
428
429    /// Say goodbye and close.
430    ///
431    /// `UnbindRequest` has no response — it means "I am closing this", not
432    /// "please reply" — so this writes and shuts down without waiting. Consumes
433    /// the connection, because there is nothing left to do with it.
434    pub async fn unbind(mut self) -> Result<()> {
435        let operation = Operation::unbind();
436        let id = self.next_message_id()?;
437        let bytes = operation.encode(id);
438
439        // Best effort: the point of an unbind is politeness to the directory's
440        // connection table, and a socket that has already gone is not a failure
441        // the caller can do anything about.
442        let _ = self.stream.write_all(&bytes).await;
443        let _ = self.stream.flush().await;
444        let _ = self.stream.shutdown().await;
445        Ok(())
446    }
447
448    /// Write one operation and return the message id it went out under.
449    ///
450    /// This is where the transport rule is enforced, because it is the single
451    /// place every request passes through.
452    pub async fn send(&mut self, operation: &Operation) -> Result<i64> {
453        if operation.carries_password()
454            && !self.is_encrypted()
455            && !self.config.allow_plaintext_password
456        {
457            return Err(Error::msg(format!(
458                "refusing to send a simple bind to {} over an unencrypted connection: the \
459                 password would go out in the clear, readable by anything on the path. Use \
460                 `ldaps://` (TLS from the first byte) or StartTLS. If this really is a local test \
461                 directory, say so with `LdapConfig::allow_plaintext_password`.",
462                self.config.address()
463            )));
464        }
465
466        let id = self.next_message_id()?;
467        let bytes = operation.encode(id);
468
469        tokio::time::timeout(self.config.timeout, self.stream.write_all(&bytes))
470            .await
471            .map_err(|_| self.timed_out(operation.kind()))?
472            .map_err(|error| {
473                Error::msg(format!(
474                    "writing the {} to {} failed: {error}",
475                    operation.kind(),
476                    self.config.address()
477                ))
478            })?;
479
480        self.stream.flush().await.map_err(Error::Io)?;
481        Ok(id)
482    }
483
484    /// Read the response to a particular message id.
485    ///
486    /// LDAP lets several operations be outstanding at once, and the id is the
487    /// only thing that says which answer belongs to which question. This client
488    /// asks one thing at a time, so anything else arriving means either the
489    /// directory is confused or something is injecting messages — and in both
490    /// cases carrying on with the wrong answer is worse than stopping.
491    pub async fn receive(&mut self, expected_id: i64) -> Result<LdapMessage> {
492        let message = self.read_message().await?;
493
494        if message.id == expected_id {
495            return Ok(message);
496        }
497
498        // Message id zero is an unsolicited notification (RFC 4511 §4.4): the
499        // directory speaking without being asked, which in practice is the
500        // Notice of Disconnection immediately before it hangs up.
501        if message.id == 0 {
502            let reason = message
503                .op
504                .result()
505                .map(|result| format!("{}: {}", result.code, result.diagnostic))
506                .unwrap_or_else(|| message.op.name().to_string());
507            return Err(Error::msg(format!(
508                "the directory at {} sent an unsolicited notification and is closing the \
509                 connection — {reason}",
510                self.config.address()
511            )));
512        }
513
514        Err(Error::msg(format!(
515            "the directory answered message {} while message {expected_id} was the one \
516             outstanding. Refusing to treat one operation's answer as another's.",
517            message.id
518        )))
519    }
520
521    async fn read_message(&mut self) -> Result<LdapMessage> {
522        loop {
523            if let Some(size) = ber::element_size(&self.buffer, self.config.max_message_size)? {
524                let bytes: Vec<u8> = self.buffer.drain(..size).collect();
525                return LdapMessage::parse(&bytes);
526            }
527
528            let mut chunk = [0u8; 8192];
529            let read = tokio::time::timeout(self.config.timeout, self.stream.read(&mut chunk))
530                .await
531                .map_err(|_| self.timed_out("a response"))?
532                .map_err(|error| {
533                    Error::msg(format!(
534                        "reading from the directory at {} failed: {error}",
535                        self.config.address()
536                    ))
537                })?;
538
539            if read == 0 {
540                return Err(Error::msg(format!(
541                    "the directory at {} closed the connection with {} bytes of a message \
542                     outstanding",
543                    self.config.address(),
544                    self.buffer.len()
545                )));
546            }
547
548            self.buffer.extend_from_slice(&chunk[..read]);
549        }
550    }
551
552    fn timed_out(&self, what: &str) -> Error {
553        Error::msg(format!(
554            "the directory at {} did not finish {what} within {:?}",
555            self.config.address(),
556            self.config.timeout
557        ))
558    }
559
560    fn next_message_id(&mut self) -> Result<i64> {
561        // Zero is reserved for unsolicited notifications, so ids start at one.
562        // Running out is not realistic on one connection, but silently wrapping
563        // would make two operations share an id, which is the one thing the id
564        // exists to prevent.
565        if self.next_id > i64::from(i32::MAX) {
566            return Err(Error::msg(
567                "this connection has used every message id; open a new one",
568            ));
569        }
570        let id = self.next_id;
571        self.next_id += 1;
572        Ok(id)
573    }
574
575    /// Ask the directory to upgrade the connection, then do it.
576    async fn start_tls(&mut self) -> Result<()> {
577        let operation = Operation::start_tls();
578        let id = self.send(&operation).await?;
579        let message = self.receive(id).await?;
580
581        let result = match message.op {
582            ProtocolOp::ExtendedResponse { result, .. } => result,
583            other => {
584                return Err(Error::msg(format!(
585                    "asked for StartTLS and the directory answered with {}",
586                    other.name()
587                )));
588            }
589        };
590
591        if !result.is_success() {
592            return Err(Error::msg(format!(
593                "the directory at {} refused StartTLS: {}{}. It is not configured for TLS on \
594                 this port. Connecting anyway would send the password in the clear, so this \
595                 stops here rather than downgrading.",
596                self.config.address(),
597                result.code,
598                match result.diagnostic.as_str() {
599                    "" => String::new(),
600                    diagnostic => format!(" — {diagnostic}"),
601                }
602            )));
603        }
604
605        // RFC 4511 §4.14.3.1: nothing may be pending across the upgrade. There
606        // is nothing pending — this client asks one question at a time — but if
607        // bytes did arrive between the response and here, they arrived before
608        // the handshake and are therefore unauthenticated. Refuse them.
609        if !self.buffer.is_empty() {
610            return Err(Error::msg(
611                "the directory sent data after agreeing to StartTLS but before the handshake. \
612                 Those bytes are unauthenticated, so the connection is abandoned rather than \
613                 trusted.",
614            ));
615        }
616
617        self.upgrade().await
618    }
619
620    /// Run the TLS handshake over whatever socket is there now.
621    async fn upgrade(&mut self) -> Result<()> {
622        let plain = match std::mem::replace(&mut self.stream, LdapStream::Upgrading) {
623            LdapStream::Plain(stream) => stream,
624            LdapStream::Tls(stream) => {
625                self.stream = LdapStream::Tls(stream);
626                return Err(Error::msg("this connection is already encrypted"));
627            }
628            LdapStream::Upgrading => return Err(Error::msg(mid_upgrade().to_string())),
629        };
630
631        let connector = tokio_rustls::TlsConnector::from(client_config(&self.config));
632
633        // An IP address is a valid server name for rustls, and one that no
634        // public certificate carries — which is why connecting to 127.0.0.1
635        // with verification on fails, correctly, and the error says so.
636        let name = rustls::pki_types::ServerName::try_from(self.config.host.clone())
637            .map_err(|_| {
638                Error::msg(format!("`{}` is not a valid TLS server name", self.config.host))
639            })?;
640
641        let tls = tokio::time::timeout(self.config.connect_timeout, connector.connect(name, plain))
642            .await
643            .map_err(|_| {
644                Error::msg(format!(
645                    "the TLS handshake with {} timed out",
646                    self.config.address()
647                ))
648            })?
649            .map_err(|error| {
650                Error::msg(format!(
651                    "the TLS handshake with {} failed: {error}. If this is a development \
652                     directory with a self-signed certificate, say so explicitly with \
653                     `dangerously_accept_any_certificate` rather than turning encryption off.",
654                    self.config.address()
655                ))
656            })?;
657
658        self.stream = LdapStream::Tls(Box::new(tls));
659        Ok(())
660    }
661}
662
663/// The rustls configuration a connection implies.
664///
665/// Trust anchors come from webpki-roots rather than the OS store, so behaviour
666/// is identical on a laptop and in a scratch container.
667///
668/// The key exchange groups come from the provider chosen in `Cargo.toml`, and
669/// that is the one security decision in this function: with
670/// `prefer-post-quantum`, X25519MLKEM768 leads the list, so its key share goes
671/// out in the first ClientHello rather than costing a HelloRetryRequest. It
672/// matters more here than almost anywhere else in this framework — a recorded
673/// LDAP handshake, decrypted years from now, yields a directory password.
674fn client_config(config: &LdapConfig) -> Arc<rustls::ClientConfig> {
675    use std::sync::OnceLock;
676    static VERIFYING: OnceLock<Arc<rustls::ClientConfig>> = OnceLock::new();
677    static TRUSTING: OnceLock<Arc<rustls::ClientConfig>> = OnceLock::new();
678
679    if config.verify_certificate {
680        return VERIFYING
681            .get_or_init(|| {
682                let roots =
683                    rustls::RootCertStore { roots: webpki_roots::TLS_SERVER_ROOTS.to_vec() };
684                Arc::new(
685                    rustls::ClientConfig::builder()
686                        .with_root_certificates(roots)
687                        .with_no_client_auth(),
688                )
689            })
690            .clone();
691    }
692
693    TRUSTING
694        .get_or_init(|| {
695            Arc::new(
696                rustls::ClientConfig::builder()
697                    .dangerous()
698                    .with_custom_certificate_verifier(Arc::new(TrustAnyCertificate::new()))
699                    .with_no_client_auth(),
700            )
701        })
702        .clone()
703}
704
705/// Believes any certificate.
706///
707/// Encryption without authentication: this stops a passive listener and nothing
708/// else, because an active attacker presents a certificate of their own. It
709/// exists for a test container, and the builder method that reaches it is named
710/// so that nobody can claim they were not told.
711#[derive(Debug)]
712struct TrustAnyCertificate(Arc<rustls::crypto::CryptoProvider>);
713
714impl TrustAnyCertificate {
715    fn new() -> TrustAnyCertificate {
716        let provider = rustls::crypto::CryptoProvider::get_default()
717            .cloned()
718            .unwrap_or_else(|| Arc::new(rustls::crypto::aws_lc_rs::default_provider()));
719        TrustAnyCertificate(provider)
720    }
721}
722
723impl rustls::client::danger::ServerCertVerifier for TrustAnyCertificate {
724    fn verify_server_cert(
725        &self,
726        _end_entity: &rustls::pki_types::CertificateDer<'_>,
727        _intermediates: &[rustls::pki_types::CertificateDer<'_>],
728        _server_name: &rustls::pki_types::ServerName<'_>,
729        _ocsp_response: &[u8],
730        _now: rustls::pki_types::UnixTime,
731    ) -> std::result::Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
732        Ok(rustls::client::danger::ServerCertVerified::assertion())
733    }
734
735    fn verify_tls12_signature(
736        &self,
737        message: &[u8],
738        cert: &rustls::pki_types::CertificateDer<'_>,
739        dss: &rustls::DigitallySignedStruct,
740    ) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
741        rustls::crypto::verify_tls12_signature(
742            message,
743            cert,
744            dss,
745            &self.0.signature_verification_algorithms,
746        )
747    }
748
749    fn verify_tls13_signature(
750        &self,
751        message: &[u8],
752        cert: &rustls::pki_types::CertificateDer<'_>,
753        dss: &rustls::DigitallySignedStruct,
754    ) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
755        rustls::crypto::verify_tls13_signature(
756            message,
757            cert,
758            dss,
759            &self.0.signature_verification_algorithms,
760        )
761    }
762
763    fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
764        self.0.signature_verification_algorithms.supported_schemes()
765    }
766}
767
768#[cfg(test)]
769mod tests {
770    use super::*;
771
772    #[test]
773    fn urls_carry_the_security_decision_in_their_scheme() {
774        let ldaps = LdapConfig::parse("ldaps://dc.example.test").unwrap();
775        assert_eq!(ldaps.host, "dc.example.test");
776        assert_eq!(ldaps.port, 636);
777        assert_eq!(ldaps.encryption, Encryption::Ldaps);
778
779        // Plain `ldap://` gets StartTLS, not plain text. A URL is not a
780        // security decision, and the failure mode of guessing wrong here is a
781        // password in the clear.
782        let ldap = LdapConfig::parse("ldap://dc.example.test").unwrap();
783        assert_eq!(ldap.port, 389);
784        assert_eq!(ldap.encryption, Encryption::StartTls);
785
786        assert_eq!(LdapConfig::parse("ldap://dc.example.test:3389").unwrap().port, 3389);
787        assert_eq!(LdapConfig::parse("LDAPS://Host").unwrap().encryption, Encryption::Ldaps);
788        assert_eq!(LdapConfig::parse("ldaps://[::1]:1636").unwrap().host, "::1");
789        assert_eq!(LdapConfig::parse("ldaps://[::1]").unwrap().port, 636);
790        assert_eq!(LdapConfig::parse("ldaps://[::1]:1636").unwrap().address(), "[::1]:1636");
791    }
792
793    #[test]
794    fn the_defaults_are_the_careful_ones() {
795        let config = LdapConfig::parse("ldaps://dc.example.test").unwrap();
796        assert!(config.verify_certificate, "certificates are checked unless told otherwise");
797        assert!(!config.allow_plaintext_password, "and passwords do not go out in the clear");
798    }
799
800    #[test]
801    fn a_url_without_a_scheme_says_why_the_scheme_matters() {
802        let error = LdapConfig::parse("dc.example.test").unwrap_err().to_string();
803        assert!(error.contains("ldaps://"), "got {error}");
804        assert!(error.contains("encrypted"), "the error should say what is at stake: {error}");
805
806        assert!(LdapConfig::parse("http://dc.example.test").is_err());
807        assert!(LdapConfig::parse("ldap://dc.example.test/dc=example").is_err());
808        assert!(LdapConfig::parse("ldap://").is_err());
809        assert!(LdapConfig::parse("ldap://host:not-a-port").is_err());
810        assert!(LdapConfig::parse("ldaps://[::1").is_err());
811    }
812
813    #[tokio::test]
814    async fn a_simple_bind_over_plain_tcp_is_refused_and_the_error_says_why() {
815        // The check has to fire before the socket is touched, which is what
816        // makes it testable without a directory: this configuration points at
817        // a port nothing is listening on, and the refusal still comes from the
818        // transport rule rather than from a connection failure.
819        //
820        // Built by hand rather than through `connect`, because the point is
821        // that no byte reaches a socket.
822        let config = LdapConfig::parse("ldap://127.0.0.1:1").unwrap().plaintext();
823        let mut connection = LdapConnection {
824            stream: LdapStream::Upgrading,
825            config,
826            buffer: Vec::new(),
827            next_id: 1,
828            bound_as: None,
829        };
830
831        let error = connection.bind("cn=admin", "secret").await.unwrap_err().to_string();
832        assert!(error.contains("unencrypted"), "got {error}");
833        assert!(error.contains("ldaps://"), "the error should name the fix: {error}");
834        assert!(error.contains("allow_plaintext_password"), "and the opt-in: {error}");
835    }
836
837    #[tokio::test]
838    async fn an_empty_password_never_reaches_the_transport_check_or_the_socket() {
839        // Two guards, and the empty-password one is first: an empty password is
840        // refused even where plaintext has been explicitly allowed, because it
841        // is not a transport problem — a directory answers it with `success`
842        // over TLS just as happily.
843        let config = LdapConfig::parse("ldap://127.0.0.1:1")
844            .unwrap()
845            .plaintext()
846            .allow_plaintext_password();
847        let mut connection = LdapConnection {
848            stream: LdapStream::Upgrading,
849            config,
850            buffer: Vec::new(),
851            next_id: 1,
852            bound_as: None,
853        };
854
855        let error = connection.bind("cn=admin", "").await.unwrap_err().to_string();
856        assert!(error.contains("anonymous"), "got {error}");
857    }
858
859    #[tokio::test]
860    async fn an_unreachable_directory_names_the_address() {
861        let config = LdapConfig::parse("ldaps://127.0.0.1:1")
862            .unwrap()
863            .connect_timeout(Duration::from_secs(2));
864        // `LdapConnection` has no `Debug` — it writes passwords — so the error
865        // comes out of a match rather than `unwrap_err`.
866        let error = match LdapConnection::connect(&config).await {
867            Ok(_) => panic!("something is listening on port 1"),
868            Err(error) => error.to_string(),
869        };
870        assert!(error.contains("127.0.0.1:1"), "got {error}");
871    }
872
873    #[test]
874    fn message_ids_start_at_one_and_never_repeat() {
875        // Zero means "unsolicited notification", so it can never be a request's
876        // id, and a repeated id would let one operation's answer be read as
877        // another's.
878        let mut connection = LdapConnection {
879            stream: LdapStream::Upgrading,
880            config: LdapConfig::parse("ldaps://host").unwrap(),
881            buffer: Vec::new(),
882            next_id: 1,
883            bound_as: None,
884        };
885
886        assert_eq!(connection.next_message_id().unwrap(), 1);
887        assert_eq!(connection.next_message_id().unwrap(), 2);
888        assert_eq!(connection.next_message_id().unwrap(), 3);
889
890        connection.next_id = i64::from(i32::MAX) + 1;
891        assert!(connection.next_message_id().is_err(), "exhaustion is an error, not a wrap");
892    }
893
894    /// The one property of the TLS setup worth a test.
895    ///
896    /// Only the key exchange is at risk from a quantum computer, and it is at
897    /// risk *today*: an observer can record a handshake now and decrypt it once
898    /// the machine exists. For an LDAP bind that means a directory password
899    /// with a very long shelf life.
900    ///
901    /// So this asserts the hybrid group is offered, and offered *first*:
902    /// rustls sends a key share only for the leading groups, and a hybrid
903    /// listed last is one a server can reach only through a second round trip
904    /// most will not bother with. Switching the provider back to `ring`
905    /// silently loses this.
906    #[test]
907    fn the_key_exchange_leads_with_a_post_quantum_hybrid() {
908        let config = client_config(&LdapConfig::parse("ldaps://host").unwrap());
909
910        let offered: Vec<String> = config
911            .crypto_provider()
912            .kx_groups
913            .iter()
914            .map(|group| format!("{:?}", group.name()))
915            .collect();
916
917        assert_eq!(
918            offered.first().map(String::as_str),
919            Some("X25519MLKEM768"),
920            "the post-quantum hybrid must lead the ClientHello; offered: {offered:?}"
921        );
922        assert!(
923            offered.iter().any(|name| name == "X25519"),
924            "a classical group must remain, for directories that have not caught up: {offered:?}"
925        );
926    }
927}