From 19486c1942c94d73de73d0c91d8b5d212e89676f Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Sat, 29 Aug 2026 14:04:32 -0700 Subject: [PATCH] feat(auth): SSPI/NTLM Windows authentication on Unix via sspi-rs Add an optional `sspi-rs` feature that enables `AuthMethod::Windows` (and `AuthMethod::windows()`) on Unix platforms, implementing the NTLM handshake with the pure-Rust `sspi` crate. This provides Windows-style authentication without requiring a Kerberos/GSSAPI setup, closing the gap for Linux and macOS clients (#407, #276, #97). - Add `sspi` (unix target) dependency and `sspi-rs` feature; include it in the `all` feature set. - Gate `WindowsAuth`, the `AuthMethod::Windows` variant and the `windows()` constructor on `all(unix, feature = "sspi-rs")` in addition to the existing Windows `winauth` path. - Implement the two-leg NTLM negotiate/authenticate exchange in `Connection::login` for the Unix `sspi-rs` path, reusing the existing SSPI token flushing and `integrated_security` login plumbing. - Extend the connection-string parser so `IntegratedSecurity=SSPI` selects NTLM when a username/password is supplied and falls back to Kerberos (`Integrated`) only when `integrated-auth-gssapi` is enabled and no credentials are given. - Add an `Error::SspiRs` variant and `From` conversion. The existing Windows `winauth` and Unix `integrated-auth-gssapi` paths are left intact; the gssapi connection-string arm is only disabled when `sspi-rs` is active on Unix to avoid overlapping match arms. --- Cargo.toml | 6 +++ src/client/auth.rs | 32 ++++++++---- src/client/config.rs | 35 ++++++++++++- src/client/connection.rs | 87 ++++++++++++++++++++++++++++++- src/error.rs | 15 +++++- src/tds/codec/login.rs | 5 +- src/tds/codec/token/token_sspi.rs | 5 +- src/tds/context.rs | 5 +- src/tds/stream/token.rs | 2 +- 9 files changed, 175 insertions(+), 17 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index b361407..c2b6d10 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -77,6 +77,7 @@ winauth = { version = "0.0.4", optional = true } [target.'cfg(unix)'.dependencies] libgssapi = { version = "0.8.1", optional = true, default-features = false } +sspi = { version = "0.18", optional = true } libc = "0.2" [dependencies.async-native-tls] @@ -220,6 +221,7 @@ all = [ "bigdecimal", "native-tls", "serde", + "sspi-rs", ] default = ["tds80", "winauth", "native-tls"] tds73 = [] @@ -234,6 +236,10 @@ bigdecimal = ["bigdecimal_"] rustls = ["tokio-rustls", "tokio-util", "rustls-native-certs"] native-tls = ["async-native-tls"] vendored-openssl = ["opentls"] +# Enables Windows-style SSPI/NTLM authentication (`AuthMethod::Windows`) on Unix +# platforms without requiring Kerberos, via the pure-Rust `sspi` crate. On +# Windows the same authentication is provided by the `winauth` feature. +sspi-rs = ["sspi"] # Optional serde Serialize/Deserialize impls for query result types # (Row, Column, ColumnData, Numeric, ColumnType and time/xml types). serde = ["dep:serde", "uuid/serde"] diff --git a/src/client/auth.rs b/src/client/auth.rs index 8e99f70..c440d20 100644 --- a/src/client/auth.rs +++ b/src/client/auth.rs @@ -23,16 +23,22 @@ impl Debug for SqlServerAuth { } #[derive(Clone, PartialEq, Eq)] -#[cfg(any(all(windows, feature = "winauth"), doc))] -#[cfg_attr(docsrs, doc(all(windows, feature = "winauth")))] +#[cfg(any(all(windows, feature = "winauth"), all(unix, feature = "sspi-rs"), doc))] +#[cfg_attr( + docsrs, + doc(cfg(any(all(windows, feature = "winauth"), all(unix, feature = "sspi-rs")))) +)] pub struct WindowsAuth { pub(crate) user: String, pub(crate) password: String, pub(crate) domain: Option, } -#[cfg(any(all(windows, feature = "winauth"), doc))] -#[cfg_attr(docsrs, doc(all(windows, feature = "winauth")))] +#[cfg(any(all(windows, feature = "winauth"), all(unix, feature = "sspi-rs"), doc))] +#[cfg_attr( + docsrs, + doc(cfg(any(all(windows, feature = "winauth"), all(unix, feature = "sspi-rs")))) +)] impl Debug for WindowsAuth { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("WindowsAuth") @@ -48,9 +54,14 @@ impl Debug for WindowsAuth { pub enum AuthMethod { /// Authenticate directly with SQL Server. SqlServer(SqlServerAuth), - /// Authenticate with Windows credentials. - #[cfg(any(all(windows, feature = "winauth"), doc))] - #[cfg_attr(docsrs, doc(cfg(all(windows, feature = "winauth"))))] + /// Authenticate with Windows credentials. On Windows this uses SSPI via the + /// `winauth` feature; on Unix it uses NTLM (no Kerberos) via the `sspi-rs` + /// feature. + #[cfg(any(all(windows, feature = "winauth"), all(unix, feature = "sspi-rs"), doc))] + #[cfg_attr( + docsrs, + doc(cfg(any(all(windows, feature = "winauth"), all(unix, feature = "sspi-rs")))) + )] Windows(WindowsAuth), /// Authenticate as the currently logged in user. On Windows uses SSPI and /// Kerberos on Unix platforms. @@ -81,8 +92,11 @@ impl AuthMethod { } /// Construct a new Windows authentication configuration. - #[cfg(any(all(windows, feature = "winauth"), doc))] - #[cfg_attr(docsrs, doc(cfg(all(windows, feature = "winauth"))))] + #[cfg(any(all(windows, feature = "winauth"), all(unix, feature = "sspi-rs"), doc))] + #[cfg_attr( + docsrs, + doc(cfg(any(all(windows, feature = "winauth"), all(unix, feature = "sspi-rs")))) + )] pub fn windows(user: impl AsRef, password: impl ToString) -> Self { let (domain, user) = match user.as_ref().find('\\') { Some(idx) => (Some(&user.as_ref()[..idx]), &user.as_ref()[idx + 1..]), diff --git a/src/client/config.rs b/src/client/config.rs index 7f68725..6a3cbdc 100644 --- a/src/client/config.rs +++ b/src/client/config.rs @@ -564,7 +564,23 @@ pub(crate) trait ConfigString { (None, None) => Ok(AuthMethod::Integrated), _ => Ok(AuthMethod::windows(user.unwrap_or(""), pw.unwrap_or(""))), }, - #[cfg(feature = "integrated-auth-gssapi")] + // On Unix with `sspi-rs`, `IntegratedSecurity=SSPI` (or a truthy + // value) uses NTLM when a username/password is supplied, and falls + // back to Kerberos (`Integrated`) only if `integrated-auth-gssapi` + // is also enabled and no credentials are given. + #[cfg(all(unix, feature = "sspi-rs"))] + Some(val) if val.to_lowercase() == "sspi" || Self::parse_bool(val)? => { + match (user, pw) { + (Some(user), Some(pw)) => Ok(AuthMethod::windows(user, pw)), + #[cfg(feature = "integrated-auth-gssapi")] + (None, None) => Ok(AuthMethod::Integrated), + _ => Ok(AuthMethod::windows(user.unwrap_or(""), pw.unwrap_or(""))), + } + } + #[cfg(all( + feature = "integrated-auth-gssapi", + not(all(unix, feature = "sspi-rs")) + ))] Some(val) if val.to_lowercase() == "sspi" || Self::parse_bool(val)? => { Ok(AuthMethod::Integrated) } @@ -695,4 +711,21 @@ mod tests { assert_eq!("localhost:1433", config.get_addr()); assert_eq!(Some("master"), config.database.as_deref()); } + + #[cfg(all(unix, feature = "sspi-rs"))] + #[test] + fn ado_integrated_security_sspi_with_credentials_uses_windows_ntlm() { + let config = Config::from_ado_string( + "server=tcp:localhost,1433;IntegratedSecurity=SSPI;uid=DOMAIN\\user;pwd=secret", + ) + .unwrap(); + + match config.auth { + AuthMethod::Windows(auth) => { + assert_eq!("user", auth.user); + assert_eq!(Some("DOMAIN"), auth.domain.as_deref()); + } + other => panic!("expected Windows NTLM auth, got {other:?}"), + } + } } diff --git a/src/client/connection.rs b/src/client/connection.rs index 6a4cefb..6a0524d 100644 --- a/src/client/connection.rs +++ b/src/client/connection.rs @@ -18,7 +18,7 @@ use crate::{ }; use asynchronous_codec::Framed; use bytes::BytesMut; -#[cfg(any(windows, feature = "integrated-auth-gssapi"))] +#[cfg(any(windows, feature = "integrated-auth-gssapi", feature = "sspi-rs"))] use codec::TokenSspi; use futures_util::io::{AsyncRead, AsyncWrite, AsyncWriteExt}; use futures_util::ready; @@ -32,6 +32,11 @@ use libgssapi::{ oid::{OidSet, GSS_MECH_KRB5, GSS_NT_KRB5_PRINCIPAL}, }; use pretty_hex::*; +#[cfg(all(unix, feature = "sspi-rs"))] +use sspi::{ + AuthIdentity, BufferType, ClientRequestFlags, CredentialUse, DataRepresentation, Ntlm, + SecurityBuffer, Sspi, SspiImpl, Username, +}; #[cfg(all(unix, feature = "integrated-auth-gssapi"))] use std::ops::Deref; use std::{cmp, fmt::Debug, io, pin::Pin, task}; @@ -155,7 +160,7 @@ impl Connection { TokenStream::new(self).flush_done().await } - #[cfg(any(windows, feature = "integrated-auth-gssapi"))] + #[cfg(any(windows, feature = "integrated-auth-gssapi", feature = "sspi-rs"))] /// Flush the incoming token stream until receiving `SSPI` token. async fn flush_sspi(&mut self) -> crate::Result { TokenStream::new(self).flush_sspi().await @@ -488,6 +493,84 @@ impl Connection { self.send(header, next_token).await?; } + #[cfg(all(unix, feature = "sspi-rs"))] + AuthMethod::Windows(auth) => { + let mut ntlm = Ntlm::new(); + + let username = + Username::new(&auth.user, auth.domain.as_deref()).map_err(sspi::Error::from)?; + + let identity = AuthIdentity { + username, + password: auth.password.clone().into(), + }; + + let mut creds = ntlm + .acquire_credentials_handle() + .with_credential_use(CredentialUse::Outbound) + .with_auth_data(&identity) + .execute(&mut ntlm)?; + + let spn = self.context.spn().to_string(); + + // First leg of the NTLM handshake: produce the NEGOTIATE token + // and ship it in the login packet as integrated security data. + let mut input = vec![SecurityBuffer::new(Vec::new(), BufferType::Token)]; + let mut output = vec![SecurityBuffer::new(Vec::new(), BufferType::Token)]; + + let mut builder = ntlm + .initialize_security_context() + .with_credentials_handle(&mut creds.credentials_handle) + .with_context_requirements( + ClientRequestFlags::CONFIDENTIALITY | ClientRequestFlags::ALLOCATE_MEMORY, + ) + .with_target_data_representation(DataRepresentation::Native) + .with_target_name(&spn) + .with_input(&mut input) + .with_output(&mut output); + + ntlm.initialize_security_context_impl(&mut builder)? + .resolve_to_result()?; + + login_message.integrated_security(Some(output[0].buffer.clone())); + + let id = self.context.next_packet_id(); + self.send(PacketHeader::login(id), login_message).await?; + self = self.post_login_encryption(encryption); + + // Second leg: consume the server's CHALLENGE token and reply + // with the AUTHENTICATE token. + let sspi_bytes = self.flush_sspi().await?; + + let mut input = vec![SecurityBuffer::new( + sspi_bytes.as_ref().to_vec(), + BufferType::Token, + )]; + let mut output = vec![SecurityBuffer::new(Vec::new(), BufferType::Token)]; + + let mut builder = ntlm + .initialize_security_context() + .with_credentials_handle(&mut creds.credentials_handle) + .with_context_requirements( + ClientRequestFlags::CONFIDENTIALITY | ClientRequestFlags::ALLOCATE_MEMORY, + ) + .with_target_data_representation(DataRepresentation::Native) + .with_target_name(&spn) + .with_input(&mut input) + .with_output(&mut output); + + ntlm.initialize_security_context_impl(&mut builder)? + .resolve_to_result()?; + + event!(Level::TRACE, authenticate_len = output[0].buffer.len()); + + let id = self.context.next_packet_id(); + self.send( + PacketHeader::login(id), + TokenSspi::new(output[0].buffer.clone()), + ) + .await?; + } #[cfg(all(windows, feature = "winauth"))] AuthMethod::Windows(auth) => { let spn = self.context.spn().to_string(); diff --git a/src/error.rs b/src/error.rs index 42d1fc0..1abb1bf 100644 --- a/src/error.rs +++ b/src/error.rs @@ -45,6 +45,11 @@ pub enum Error { /// An error from the GSSAPI library. #[error("GSSAPI Error: {}", _0)] Gssapi(String), + #[cfg(any(all(unix, feature = "sspi-rs"), doc))] + #[cfg_attr(docsrs, doc(cfg(all(unix, feature = "sspi-rs"))))] + /// An error from the `sspi` (sspi-rs) library. + #[error("sspi-rs Error: {}", _0)] + SspiRs(String), #[error( "Server requested a connection to an alternative address: `{}:{}`", host, @@ -80,7 +85,7 @@ impl Error { impl From for Error { fn from(e: uuid::Error) -> Self { - Self::Conversion(format!("Error convertiong a Guid value {}", e).into()) + Self::Conversion(format!("Error converting a Guid value {}", e).into()) } } @@ -152,6 +157,14 @@ impl From for Error { } } +#[cfg(all(unix, feature = "sspi-rs"))] +#[cfg_attr(docsrs, doc(cfg(all(unix, feature = "sspi-rs"))))] +impl From for Error { + fn from(err: sspi::Error) -> Error { + Error::SspiRs(format!("{}", err)) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/tds/codec/login.rs b/src/tds/codec/login.rs index 5904a3c..90a72d0 100644 --- a/src/tds/codec/login.rs +++ b/src/tds/codec/login.rs @@ -235,7 +235,10 @@ impl<'a> LoginMessage<'a> { get_computer_name().map(Cow::Owned).unwrap_or_default() } - #[cfg(any(all(unix, feature = "integrated-auth-gssapi"), windows))] + #[cfg(any( + all(unix, any(feature = "integrated-auth-gssapi", feature = "sspi-rs")), + windows + ))] pub fn integrated_security(&mut self, bytes: Option>) { if bytes.is_some() { self.option_flags_2.insert(OptionFlag2::IntegratedSecurity); diff --git a/src/tds/codec/token/token_sspi.rs b/src/tds/codec/token/token_sspi.rs index 954d6dd..c2ef327 100644 --- a/src/tds/codec/token/token_sspi.rs +++ b/src/tds/codec/token/token_sspi.rs @@ -12,7 +12,10 @@ impl AsRef<[u8]> for TokenSspi { } impl TokenSspi { - #[cfg(any(windows, all(unix, feature = "integrated-auth-gssapi")))] + #[cfg(any( + windows, + all(unix, any(feature = "integrated-auth-gssapi", feature = "sspi-rs")) + ))] pub fn new(bytes: Vec) -> Self { Self(bytes) } diff --git a/src/tds/context.rs b/src/tds/context.rs index 673ba73..a9ebcc0 100644 --- a/src/tds/context.rs +++ b/src/tds/context.rs @@ -78,7 +78,10 @@ impl Context { self.spn = Some(format!("MSSQLSvc/{}:{}", host.as_ref(), port)); } - #[cfg(any(windows, all(unix, feature = "integrated-auth-gssapi")))] + #[cfg(any( + windows, + all(unix, any(feature = "integrated-auth-gssapi", feature = "sspi-rs")) + ))] pub fn spn(&self) -> &str { self.spn.as_deref().unwrap_or("") } diff --git a/src/tds/stream/token.rs b/src/tds/stream/token.rs index 574467d..17be816 100644 --- a/src/tds/stream/token.rs +++ b/src/tds/stream/token.rs @@ -109,7 +109,7 @@ where } } - #[cfg(any(windows, feature = "integrated-auth-gssapi"))] + #[cfg(any(windows, feature = "integrated-auth-gssapi", feature = "sspi-rs"))] pub(crate) async fn flush_sspi(self) -> crate::Result { let mut stream = self.try_unfold(); let mut last_error = None;