tacacs_plus/
lib.rs

1//! # tacacs-plus
2//!
3//! Rust client implementation for the TACACS+ ([RFC8907](https://www.rfc-editor.org/rfc/rfc8907)) protocol.
4
5#![warn(missing_docs)]
6
7use std::fmt;
8use std::sync::Arc;
9
10use futures::lock::Mutex;
11use futures::{AsyncRead, AsyncWrite};
12use rand::Rng;
13
14use tacacs_plus_protocol::Arguments;
15use tacacs_plus_protocol::{authentication, authorization};
16use tacacs_plus_protocol::{AuthenticationContext, AuthenticationService};
17use tacacs_plus_protocol::{HeaderInfo, MajorVersion, MinorVersion, Version};
18use tacacs_plus_protocol::{Packet, PacketFlags};
19
20mod inner;
21pub use inner::{ConnectionFactory, ConnectionFuture};
22
23mod response;
24pub use response::{
25    AccountingResponse, AuthenticationResponse, AuthorizationResponse, ResponseStatus,
26};
27
28mod context;
29pub use context::{ContextBuilder, SessionContext};
30
31mod error;
32pub use error::ClientError;
33
34mod task;
35pub use task::AccountingTask;
36
37// reexported for ease of access
38pub use tacacs_plus_protocol as protocol;
39pub use tacacs_plus_protocol::{Argument, AuthenticationMethod, FieldText};
40
41/// A TACACS+ client.
42#[derive(Clone)]
43pub struct Client<S> {
44    /// The underlying TCP connection of the client.
45    inner: Arc<Mutex<inner::ClientInner<S>>>,
46
47    /// The shared secret used for packet obfuscation, if provided.
48    secret: Option<Vec<u8>>,
49}
50
51/// The type of authentication used for a given session.
52///
53/// More of these might be added in the future, but the variants here are
54/// the only currently supported authentication types with a [`Client`].
55#[non_exhaustive]
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
57pub enum AuthenticationType {
58    /// Authentication via the Password Authentication Protocol (PAP).
59    Pap,
60    /// Authentication via the Challenge-Authentication Protocol (CHAP).
61    Chap,
62}
63
64impl<S: AsyncRead + AsyncWrite + Unpin> Client<S> {
65    /// Initializes a new TACACS+ client that uses the provided factory to open connections to a server.
66    ///
67    /// [RFC8907 section 10.5.1] specifies that clients SHOULD NOT allow secret keys less
68    /// than 16 characters in length. This constructor does not check for that, but
69    /// consider yourself warned.
70    ///
71    /// If an incorrect secret is provided to this constructor, you might notice
72    /// [`ClientError::InvalidPacketReceived`] errors when attempting different TACACS+ operations.
73    /// Specific inner error variants in such cases could be
74    /// [`WrongBodyBufferSize`](tacacs_plus_protocol::DeserializeError::WrongBodyBufferSize) or
75    /// [`BadText`](tacacs_plus_protocol::DeserializeError::BadText).
76    ///
77    /// Additionally, if a secret is provided in this constructor but one is not configured for the remote TACACS+ server,
78    /// or vice versa, you will again see [`ClientError::InvalidPacketReceived`] errors, but rather with an inner error variant of
79    /// [`DeserializeError::IncorrectUnencryptedFlag`](tacacs_plus_protocol::DeserializeError::IncorrectUnencryptedFlag).
80    ///
81    /// If no secret is provided in this constructor, the returned client does not obfuscate packets
82    /// sent over the provided connection. Per [RFC8907 section 4.5], unobfuscated
83    /// packet transfer MUST NOT be used in production, so prefer to provide a secret (of a secure length)
84    /// where possible.
85    ///
86    /// [RFC8907 section 4.5]: https://www.rfc-editor.org/rfc/rfc8907.html#section-4.5-16
87    pub fn new<K: AsRef<[u8]>>(
88        connection_factory: ConnectionFactory<S>,
89        secret: Option<K>,
90    ) -> Self {
91        let inner = inner::ClientInner::new(connection_factory);
92
93        Self {
94            inner: Arc::new(Mutex::new(inner)),
95            secret: secret.map(|s| s.as_ref().to_owned()),
96        }
97    }
98
99    fn make_header(&self, sequence_number: u8, minor_version: MinorVersion) -> HeaderInfo {
100        // generate random id for this session
101        // rand::ThreadRng implements CryptoRng, so it should be suitable for use as a CSPRNG
102        let session_id: u32 = rand::thread_rng().gen();
103
104        // set single connection/unencrypted flags accordingly
105        let flags = if self.secret.is_some() {
106            PacketFlags::SINGLE_CONNECTION
107        } else {
108            PacketFlags::SINGLE_CONNECTION | PacketFlags::UNENCRYPTED
109        };
110
111        HeaderInfo::new(
112            Version::new(MajorVersion::RFC8907, minor_version),
113            sequence_number,
114            flags,
115            session_id,
116        )
117    }
118
119    fn pap_login_start_packet<'packet>(
120        &self,
121        context: &'packet SessionContext,
122        password: &'packet str,
123    ) -> Result<Packet<authentication::Start<'packet>>, ClientError> {
124        use protocol::authentication::BadStart;
125
126        Ok(Packet::new(
127            // sequence number = 1 (first packet in session)
128            // also set minor version accordingly
129            self.make_header(1, MinorVersion::V1),
130            authentication::Start::new(
131                authentication::Action::Login,
132                AuthenticationContext {
133                    privilege_level: context.privilege_level,
134                    authentication_type: protocol::AuthenticationType::Pap,
135                    service: AuthenticationService::Login,
136                },
137                context.as_user_information()?,
138                Some(password.as_bytes().try_into()?),
139            )
140            .map_err(|err| match err {
141                // SAFETY: the version, authentication type & saction fields are hard-coded to valid values so neither of these errors can occur
142                BadStart::AuthTypeNotSet | BadStart::IncompatibleActionAndType => unreachable!(),
143                // we have to have a catch-all case since BadStart is marked #[non_exhaustive]
144                _ => ClientError::InvalidPacketData,
145            })?,
146        ))
147    }
148
149    fn chap_login_start_packet<'packet>(
150        &self,
151        context: &'packet SessionContext,
152        password: &'packet str,
153    ) -> Result<Packet<authentication::Start<'packet>>, ClientError> {
154        use md5::{Digest, Md5};
155        use protocol::authentication::BadStart;
156
157        // generate random PPP ID/challenge
158        let ppp_id: u8 = rand::thread_rng().gen();
159        let challenge = uuid::Uuid::new_v4();
160
161        // "The Response Value is the one-way hash calculated over a stream of octets consisting of the Identifier,
162        // followed by (concatenated with) the "secret", followed by (concatenated with) the Challenge Value."
163        // RFC1334 section 3.2.1 ("Value" subheading): https://www.rfc-editor.org/rfc/rfc1334.html#section-3.2.1
164        //
165        // "The MD5 algorithm option is always used." (RFC8907 section 5.4.2.3)
166        // https://www.rfc-editor.org/rfc/rfc8907.html#section-5.4.2.3-4
167        let mut hasher = Md5::new();
168        hasher.update([ppp_id]);
169        hasher.update(password.as_bytes()); // the secret is the password in this case
170        hasher.update(challenge);
171        let response = hasher.finalize();
172
173        // "the data field is a concatenation of the PPP id, the challenge, and the response"
174        // RFC8907 section 5.4.2.3: https://www.rfc-editor.org/rfc/rfc8907.html#section-5.4.2.3-2
175        let mut data = vec![ppp_id];
176        data.extend(challenge.as_bytes());
177        data.extend(response);
178
179        Ok(Packet::new(
180            self.make_header(1, MinorVersion::V1),
181            authentication::Start::new(
182                authentication::Action::Login,
183                AuthenticationContext {
184                    privilege_level: context.privilege_level,
185                    authentication_type: protocol::AuthenticationType::Chap,
186                    service: AuthenticationService::Login,
187                },
188                context.as_user_information()?,
189                Some(data.try_into()?),
190            )
191            .map_err(|err| match err {
192                // SAFETY: the version, authentication type & action fields are hard-coded to valid values so the start constructor will not fail
193                BadStart::AuthTypeNotSet | BadStart::IncompatibleActionAndType => unreachable!(),
194                _ => ClientError::InvalidPacketData,
195            })?,
196        ))
197    }
198
199    /// Authenticates against a TACACS+ server with a username and password using the specified protocol.
200    pub async fn authenticate(
201        &self,
202        context: SessionContext,
203        password: &str,
204        authentication_type: AuthenticationType,
205    ) -> Result<AuthenticationResponse, ClientError> {
206        use protocol::authentication::ReplyOwned;
207
208        let start_packet = match authentication_type {
209            AuthenticationType::Pap => self.pap_login_start_packet(&context, password),
210            AuthenticationType::Chap => self.chap_login_start_packet(&context, password),
211        }?;
212
213        // block expression is used here to ensure that the connection mutex is only locked during communication
214        let reply = {
215            let secret_key = self.secret.as_deref();
216
217            let mut inner = self.inner.lock().await;
218            inner.send_packet(start_packet, secret_key).await?;
219
220            // response: whether authentication succeeded
221            let reply = inner.receive_packet::<ReplyOwned>(secret_key, 2).await?;
222
223            inner.set_internal_single_connect_status(reply.header());
224            inner
225                .post_session_cleanup(reply.body().status == authentication::Status::Error)
226                .await?;
227
228            reply
229        };
230
231        let reply_status = ResponseStatus::try_from(reply.body().status);
232        let user_message = reply.body().server_message.clone();
233        let data = reply.body().data.clone();
234
235        match reply_status {
236            Ok(status) => Ok(AuthenticationResponse {
237                status,
238                user_message,
239                data,
240            }),
241            Err(response::BadAuthenticationStatus(status)) => {
242                Err(ClientError::AuthenticationError {
243                    status,
244                    data,
245                    user_message,
246                })
247            }
248        }
249    }
250
251    /// Performs TACACS+ authorization against the server with the provided arguments.
252    ///
253    /// A merged `Vec` of all of the sent and received arguments is returned, with values replaced from
254    /// the server as necessary. No guarantees are made for the replacement of several arguments with
255    /// the same name, however, since even RFC8907 doesn't specify how to handle that case.
256    pub async fn authorize(
257        &self,
258        context: SessionContext,
259        arguments: Vec<Argument<'_>>,
260    ) -> Result<AuthorizationResponse, ClientError> {
261        use authorization::ReplyOwned;
262
263        let request_packet = Packet::new(
264            // use default minor version, since there's no reason to use v1 outside of authentication
265            self.make_header(1, MinorVersion::Default),
266            authorization::Request::new(
267                context.authentication_method(),
268                AuthenticationContext {
269                    privilege_level: context.privilege_level,
270                    authentication_type: protocol::AuthenticationType::NotSet,
271                    // TODO: allow this to be specified as well? for guest it should probably be none
272                    service: AuthenticationService::Login,
273                },
274                context.as_user_information()?,
275                Arguments::new(&arguments).ok_or(ClientError::TooManyArguments)?,
276            ),
277        );
278
279        // the inner mutex is locked within a block to ensure it's only locked as long as necessary
280        let reply = {
281            let secret_key = self.secret.as_deref();
282
283            let mut inner = self.inner.lock().await;
284            inner.send_packet(request_packet, secret_key).await?;
285
286            let reply: Packet<ReplyOwned> = inner.receive_packet(secret_key, 2).await?;
287
288            // update inner state based on response
289            inner.set_internal_single_connect_status(reply.header());
290            inner
291                .post_session_cleanup(reply.body().status == authorization::Status::Error)
292                .await?;
293
294            reply
295        };
296
297        let packet_status = reply.body().status;
298        let user_message = reply.body().server_message.clone();
299        let admin_message = reply.body().data.clone();
300
301        match ResponseStatus::try_from(packet_status) {
302            Ok(status) => {
303                let owned_arguments = arguments.into_iter().map(Argument::into_owned).collect();
304
305                let merged_arguments = merge_authorization_arguments(
306                    packet_status == authorization::Status::PassReplace,
307                    owned_arguments,
308                    reply.body().arguments.clone(),
309                );
310
311                Ok(AuthorizationResponse {
312                    status,
313                    arguments: merged_arguments,
314                    user_message,
315                    admin_message,
316                })
317            }
318            Err(response::BadAuthorizationStatus(status)) => Err(ClientError::AuthorizationError {
319                status,
320                user_message,
321                admin_message,
322            }),
323        }
324    }
325
326    /// Starts tracking a task via the TACACS+ accounting mechanism.
327    ///
328    /// The `task_id` and `start_time` arguments specified in [RFC8907 section 8.3] are set internally in addition
329    /// to the provided arguments.
330    ///
331    /// This function only sends a start record to a TACACS+ server; the [`update()`](AccountingTask::update) and
332    /// [`stop()`](AccountingTask::stop) methods on the returned [`AccountingTask`] should be used for sending
333    /// additional accounting records.
334    ///
335    /// [RFC8907 section 8.3]: https://www.rfc-editor.org/rfc/rfc8907.html#name-accounting-arguments
336    pub async fn account_begin<'args, A: AsRef<[Argument<'args>]>>(
337        &self,
338        context: SessionContext,
339        arguments: A,
340    ) -> Result<(AccountingTask<&Self>, AccountingResponse), ClientError> {
341        AccountingTask::start(self, context, arguments).await
342    }
343}
344
345impl<S: fmt::Debug> fmt::Debug for Client<S> {
346    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
347        // adapted from std mutex impl
348        let inner_debug = match self.inner.try_lock() {
349            Some(inner) => format!("{inner:?}"),
350            None => String::from("(locked)"),
351        };
352
353        // we explicitly omit the secret here to avoid exposing it
354        f.debug_struct("Client")
355            .field("inner", &inner_debug)
356            .finish_non_exhaustive()
357    }
358}
359
360/// Merges the sent & received arguments within a successful authorization session.
361///
362/// Note that this assumes there are no duplicate arguments, as even RFC8907 is unclear
363/// on how to handle that case.
364fn merge_authorization_arguments(
365    replacing: bool,
366    mut sent_arguments: Vec<Argument<'static>>,
367    mut received_arguments: Vec<Argument<'static>>,
368) -> Vec<Argument<'static>> {
369    if replacing {
370        for received in received_arguments.into_iter() {
371            if let Some(sent) = sent_arguments
372                .iter_mut()
373                .find(|arg| arg.name() == received.name())
374            {
375                sent.set_value(received.value().clone());
376            } else {
377                sent_arguments.push(received);
378            }
379        }
380    } else {
381        sent_arguments.append(&mut received_arguments);
382    }
383    sent_arguments
384}