Skip to content

Conversation

@z-jxy
Copy link

@z-jxy z-jxy commented Jan 10, 2026

NT Hash (Pass-The-Hash) Authentication Support

Adds support for NT hash (pass-the-hash) authentication to sspi, allowing authentication using pre-computed NT hashes instead of plaintext passwords.

This resolves #472

Usage

The following snippet is from my fork of ldap3 (downstream crate that needs this functionality).

use sspi::{
    AuthIdentityBuffers, BufferType, ClientRequestFlags, DataRepresentation, Ntlm, NtlmHashBytes,
    SecurityBuffer, SecurityStatus, Sspi, SspiImpl, 
};

impl Ldap {
    // ...
    pub async fn sasl_ntlm_bind_with_hash_sspi(
        &mut self,
        username: &str,
        domain: &str,
        ntlm_hash: &sspi::NtlmHashBytes,
    ) -> Result<LdapResult> {
        const LDAP_RESULT_SASL_BIND_IN_PROGRESS: u32 = 14;

        // Helper to perform NTLM steps
        fn ntlm_step(
            ntlm: &mut Ntlm,
            acq_creds: &mut Option<AuthIdentityBuffers>,
            input: &[u8],
        ) -> Result<Vec<u8>> {
            let mut input = vec![SecurityBuffer::new(input.to_vec(), BufferType::Token)];
            let mut output = vec![SecurityBuffer::new(Vec::new(), BufferType::Token)];

            let mut builder = ntlm
                .initialize_security_context()
                .with_credentials_handle(acq_creds)
                .with_context_requirements(ClientRequestFlags::ALLOCATE_MEMORY)
                .with_target_data_representation(DataRepresentation::Native)
                .with_input(&mut input)
                .with_output(&mut output);

            let result = ntlm
                .initialize_security_context_impl(&mut builder)?
                .resolve_to_result()?;

            match result.status {
                SecurityStatus::CompleteNeeded | SecurityStatus::CompleteAndContinue => {
                    ntlm.complete_auth_token(&mut output)?
                }
                s => s,
            };

            Ok(output.swap_remove(0).buffer)
        }

        // Create credentials with NT hash
        let credentials = AuthIdentityBuffers::from_utf8_with_hash(username, domain, *ntlm_hash);

        let mut ntlm = Ntlm::new();

        // Set channel bindings
        if self.has_tls {
            if let Ok(Some(cert_der)) = self.get_peer_certificate().await {
                ntlm.set_channel_bindings(&cert_der);
            }
        }

        let mut acq_creds = Some(credentials);

        // Send NEGOTIATE (Type 1)
        let req = sasl_bind_req(
            "GSS-SPNEGO",
            Some(&ntlm_step(&mut ntlm, &mut acq_creds, &[])?),
        );
        let (res, _, token) = self.op_call(LdapOp::Single, req).await?;

        if res.rc != LDAP_RESULT_SASL_BIND_IN_PROGRESS {
            return Ok(res);
        }

        let token = token.0.ok_or(LdapError::NoNtlmChallengeToken)?;

        // Process CHALLENGE (Type 2) and send AUTHENTICATE (Type 3)
        let req = sasl_bind_req(
            "GSS-SPNEGO",
            Some(&ntlm_step(&mut ntlm, &mut acq_creds, &token)?),
        );
        Ok(self.op_call(LdapOp::Single, req).await?.0)
    }
}

Disclosure

Most changes here are a result of copy/pasting my own manual implementation into a folder locally and asking Claude to integrate the functionality to this codebase, so it's possible there's some weird integration choices.

Testing

I've tested this against a local AD environment using my fork of ldap3 and was able to authenticate.

Copy link
Collaborator

@TheBestTvarynka TheBestTvarynka left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall, the code is clean and well-documented. I tested the NTLM on my local environment, and it still works well. Thank you very much for the new tests! ❤️

But I have two concerns:

  1. The ffi module does not compile. How to reproduce:

    cd ffi/
    cargo build

    In ffi, we handle raw buffers and convert them into Rust buffers.

  2. I do not like the fact that the feature, which is needed only for NTLM, affects other protocol implementations/credentials handling. The NT hash feature is only supported by the NTLM protocol.
    Personally, I prefer the approach suggested by @awakecoding in #472 (comment):

    If I were to do it again, I think the best approach would be to pass the hash inside the password field with a known prefix unlikely to collide with a real password

    Pros of this approach:

    • It does not affect credentials handling. In your PR, we have CredentialsType and Credentials, and they have completely different meanings.
    • It will not affect credentials handling in the ffi layer and will keep the implementation locally inside the ntlm module.
    • If you want to keep separate types for password and hash, then you can define them inside the ntlm module and parse the password at the beginning of the Ntlm::initialize_security_context_impl method.

cc @CBenoit

@z-jxy
Copy link
Author

z-jxy commented Jan 12, 2026

Thanks for the review!

The ffi module does not compile. How to reproduce:

@TheBestTvarynka Could you share the build error you got? On my machine it compiled

cd ffi
➜ cargo build
warning: profile package spec `bcrypt-pbkdf` in profile `ffi-production` did not match any packages
warning: profile package spec `num-bigint-dig` in profile `ffi-production` did not match any packages
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.42s
➜ ffi ⚡( feature/pass-the-hash)                                                                                       
▶ 

It does fail to compile for me with --all-features, but this seems expected since I'm on Mac

error: tsssp feature should be used only on Windows
  --> src/lib.rs:81:1
   |
81 | compile_error!("tsssp feature should be used only on Windows");
   | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

error: could not compile `sspi` (lib) due to 1 previous error

I do not like the fact that the feature, which is needed only for NTLM, affects other protocol implementations/credentials handling. The NT hash feature is only supported by the NTLM protocol.
Personally, I prefer the approach suggested by @awakecoding in #472 (comment):

I agree this is a bit intrusive to the existing API. I originally implemented it this way downstream since I wanted there to be no confusion that users were passing in a string containing an NT hash vs a plaintext password. Perhaps it's better to have those safeguards downstream.

I think a good middle ground could be to use the existing password field with a known prefix as suggested by @awakecoding, but also keep the separate types for NtlmHash as standalone utilities along with a method like to_sspi_password. This way downstream users have a typesafe way to parse hash values and also convert to the format expected by the API (also allows to more safely change the prefix in the future, but I doubt this is likely to change).

I'm also fine with only doing the prefix approach and documenting usage, my suggested approach is mainly for QoL.

Copy link
Member

@CBenoit CBenoit left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@z-jxy Thank you!

The compilation is failing, but on Windows only from what I see in the CI.

 error[E0615]: attempted to take value of method `password` on type `sspi::AuthIdentityBuffers`
   --> ffi\src\sspi\sec_winnt_auth_identity.rs:821:27
    |
821 |     auth_identity_buffers.password = password;
    |                           ^^^^^^^^ method, not a field
    |
    = help: methods are immutable and cannot be assigned to

error[E0615]: attempted to take value of method `password` on type `sspi::AuthIdentityBuffers`
    --> ffi\src\sspi\sec_winnt_auth_identity.rs:1084:27
     |
1084 |     auth_identity_buffers.password = password;
     |                           ^^^^^^^^ method, not a field
     |
     = help: methods are immutable and cannot be assigned to

For more information about this error, try `rustc --explain E0615`.

Comment on lines 24 to 82
impl TryFrom<&[u8]> for NtlmHash {
type Error = NtlmHashError;

fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
if value.len() != 16 {
return Err(NtlmHashError::ByteLength);
}

let mut hash = [0u8; 16];
hash.copy_from_slice(value);

Ok(NtlmHash(hash))
}
}

impl From<[u8; 16]> for NtlmHash {
fn from(value: [u8; 16]) -> Self {
NtlmHash(value)
}
}

impl From<NtlmHash> for [u8; 16] {
fn from(hash: NtlmHash) -> Self {
hash.0
}
}

impl TryFrom<&str> for NtlmHash {
type Error = NtlmHashError;

fn try_from(value: &str) -> Result<Self, Self::Error> {
if value.len() != 32 {
return Err(NtlmHashError::StringLength);
}

let mut hash = [0u8; 16];
for i in 0..16 {
let hex_byte = &value[i * 2..i * 2 + 2];
hash[i] = u8::from_str_radix(hex_byte, 16).map_err(|_| NtlmHashError::Hex)?;
}

Ok(NtlmHash(hash))
}
}

// for compatibility with clap
impl std::str::FromStr for NtlmHash {
type Err = NtlmHashError;

fn from_str(s: &str) -> Result<Self, Self::Err> {
NtlmHash::try_from(s)
}
}

impl AsRef<NtlmHash> for NtlmHash {
fn as_ref(&self) -> &NtlmHash {
self
}
}
Copy link
Member

@CBenoit CBenoit Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice newtype, but the trait pileup makes it feel heavier than it needs to be. I would avoid some of them, especially for the public API that we can’t change without creating breaking changes.

Good:

  • Newtype wrapper around [u8; 16] is a clean way to avoid mixing arbitrary bytes with "NT hash bytes".
  • TryFrom<&[u8]> is a reasonable convenience, although having an explicit function may be good.

Things that feel unnecessary / non-idiomatic:

  • Too many conversion traits for the amount of value they add.
  • Boundary decision: should the library accept hex strings at all? Since this crate is primarily protocol / auth logic, I think hex parsing should belong in the CLI/app layer instead.

suggestion: Drop at least

  • AsRef<NtlmHash> for NtlmHash
  • TryFrom<&str>
  • impl FromStr for NtlmHash

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree there's a heavy amount of conversion traits. I'd have to review usage from my fork to see where it would come into play downstream. If I remember correctly there were actual scenarios where each of these were needed.

AsRef<NtlmHash> for NtlmHash I think this was because I couldn't decide whether it should take NtlmHash by reference or value in a function. Since it now implements Copy it makes sense to remove this.

impl FromStr for NtlmHash was needed so it can be used as a value parser for clap. Most CLI apps will probably want to just accept the hash as an argument so this is nice to have

TryFrom<&str> I had originally implemented only this, but later realized that impl FromStr for NtlmHash was needed for it to work with clap, so I just forwarded the implementation for FromStr to TryFrom<&str>. Usually my instinct is to reach for .try_into() before I think about .parse(), but I suppose both aren't really needed.

Boundary decision: should the library accept hex strings at all? Since this crate is primarily protocol / auth logic, I think hex parsing should belong in the CLI/app layer instead.

The idea is to use AuthIdentityBuffers::from_utf8_with_hash which accepts the NtlmHash type and will call to_sspi_password on it so they're not directly passing in the hex value

I think keeping the hex parsing logic makes sense though for QoL. We need to parse the hex string to get the underlying bytes for the hmac key, so this would just make that method public for end users.

Scenario

CLI applications are going to receive the hash as a String, this leaves developers with a few options:

  • Accept the CLI argument as a plain String and call format! with the prefix before passing to AuthIdentityBuffers::from_utf8. If it fails, it fails
  • They parse the hex string value into [u8; 16], then construct an NtlmHash, they pass this to AuthIdentityBuffers::from_utf8_with_hash
    • NtlmHash doesn't do much for them here
  • If using clap, they create a new type that wraps NtlmHash, and implement the same hex parsing logic using FromStr
    • This logic will be mostly the same across every application

Instead they could just use the NtlmHash type to parse the string (this can be done with clap since it implements FromStr). Then just pass it directly to AuthIdentityBuffers::from_utf8_with_hash.

@CBenoit
Copy link
Member

CBenoit commented Jan 13, 2026

  1. I do not like the fact that the feature, which is needed only for NTLM, affects other protocol implementations/credentials handling. The NT hash feature is only supported by the NTLM protocol.
    Personally, I prefer the approach suggested by @awakecoding in #472 (comment):

    If I were to do it again, I think the best approach would be to pass the hash inside the password field with a known prefix unlikely to collide with a real password

    Pros of this approach:

    • It does not affect credentials handling. In your PR, we have CredentialsType and Credentials, and they have completely different meanings.
    • It will not affect credentials handling in the ffi layer and will keep the implementation locally inside the ntlm module.
    • If you want to keep separate types for password and hash, then you can define them inside the ntlm module and parse the password at the beginning of the Ntlm::initialize_security_context_impl method.

I agree with this!

@CBenoit
Copy link
Member

CBenoit commented Jan 13, 2026

I do not like the fact that the feature, which is needed only for NTLM, affects other protocol implementations/credentials handling. The NT hash feature is only supported by the NTLM protocol.
Personally, I prefer the approach suggested by @awakecoding in #472 (comment):

I agree this is a bit intrusive to the existing API. I originally implemented it this way downstream since I wanted there to be no confusion that users were passing in a string containing an NT hash vs a plaintext password. Perhaps it's better to have those safeguards downstream.

I think a good middle ground could be to use the existing password field with a known prefix as suggested by @awakecoding, but also keep the separate types for NtlmHash as standalone utilities along with a method like to_sspi_password. This way downstream users have a typesafe way to parse hash values and also convert to the format expected by the API (also allows to more safely change the prefix in the future, but I doubt this is likely to change).

I'm also fine with only doing the prefix approach and documenting usage, my suggested approach is mainly for QoL.

Sorry for the repeated comments.

I’m also sensible to the type safety argument, and I think it does make sense to have a QoL interface like you mention 👍
Basically, a NTLM-specific helper to build a Password with the correct format.

@CBenoit CBenoit changed the title [Feature]: NT Hash Authentication feat: NT Hash Authentication Jan 13, 2026
@z-jxy
Copy link
Author

z-jxy commented Jan 14, 2026

Thanks for all the feedback so far. I've made the following changes:

  • Reverted changes to AuthIdentityBuffers struct and implemented the string prefix approach
  • Moved NtlmHash to src/ntlm/hash.rs
  • Updated docs + tests

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

NTLM hash authentication?

3 participants