I recently had the opportunity to investigate LDAP authentication and particularly SASL (Simple Authentication and Security Layer) binding with DIGEST-MD5 (“SASL/DIGEST-MD5”), because this approach provides network communication security with little sever configuration.

Currently, there are two popular LDAP gems ruby-ldap and ruby-net-ldap that provide API for simple binding on plain or SSL connections; however, in my test, both gems failed to create SASL/DIGEST-MD5 binding correctly.

Summarized below are some of my observations during the test:

In my test with ruby-ldap, I tried several types of SASL binding mechanisms (i.e. DIGEST-MD5, GSS-SPNEGO, CRAM-MD5, etc.). No matter which type I chose, the SASL binding method always returned “Local Error” message. As we know, ruby-ldap is a wrapper for the libldap c library, which redirects all SASL calls to Cyrus SASL library. Since there is no easy way to debug the error, I used wireshark packet sniffer to examine the packets sequences, and found out that the SASL binding request is never sent out after the initial TCP syn/ack/syn-ack packets. It appears that the wrapper for the libldap c library is not working properly. A closer examination at the ruby-net-ldap reveals that the gem has implemented the full LDAP stack in Ruby even though it does not support SASL binding. The gem utilizes Ruby TCPSocket class to communicate with the LDAP server, and provides LDAP packet parser and constructor. ruby-net-ldap has recently been upgraded to net-ldap, which provides a framework for SASL binding.

Clearly, net-ldap is the only option for the SASL/DIGEST-MD5 implementation. To complete a SASL/DIGEST-MD5 authentication, the client needs to communicate with sever in the following sequence:

The client sends an LDAP binding request with authentication=SASL and mechanism=DIGEST-MD5. The server generates an initial “digest challenge”, and sends it to the client. The client calculates “digest response” based on username, password, a random key and some information from the server’s challenge packet and returns the result to the server. The server validates the hash value and responses with a binding success packet.

The following net-ldap code ensures the correct SASL binding sequence and LDAP packet format:

#msgid should always start with 0 #LDAP uses BER format to denote data, http://www.vijaymukhi.com/vmis/berldap.htm #challenge_response is the Proc object which will parse the #digest challenge and provide the correct response to the server. def bind_sasl auth mech,cred,chall = auth[:mechanism],auth[:initial_credential],auth[:challenge_response] raise LdapError.new( "invalid binding information" ) unless (mech && cred && chall) n = 0 loop { #start with initial credential for binding request msgid = next_msgid.to_ber #construct the LDAP payload from bottom up sasl = [mech.to_ber, cred.to_ber].to_ber_contextspecific(3) request = [LdapVersion.to_ber, "".to_ber, sasl].to_ber_appsequence(0) request_pkt = [msgid, request].to_ber_sequence #send out the packet through tcp socket @conn.write request_pkt #read server challenge from the socket and parse out the LDAP payload (be = @conn.read_ber(AsnSyntax) and pdu = Net::LdapPdu.new( be )) or raise LdapError.new( "no bind result" ) return pdu.result_code unless pdu.result_code == 14 # saslBindInProgress raise LdapError.new("sasl challenge overflow") if ((n+= 1) > MaxSaslChallenges) #decode the LDAP payload and pass it to challenlge_response proc cred = chall.call( pdu.result_server_sasl_creds ) #this credential will be send to server as the challenge response } end 1 #msgid should always start with 0 #LDAP uses BER format to denote data, http://www.vijaymukhi.com/vmis/berldap.htm #challenge_response is the Proc object which will parse the #digest challenge and provide the correct response to the server. def bind_sasl auth mech,cred,chall = auth[:mechanism],auth[:initial_credential],auth[:challenge_response] raise LdapError.new( "invalid binding information" ) unless (mech && cred && chall) n = 0 loop { #start with initial credential for binding request msgid = next_msgid.to_ber #construct the LDAP payload from bottom up sasl = [mech.to_ber, cred.to_ber].to_ber_contextspecific(3) request = [LdapVersion.to_ber, "".to_ber, sasl].to_ber_appsequence(0) request_pkt = [msgid, request].to_ber_sequence #send out the packet through tcp socket @conn.write request_pkt #read server challenge from the socket and parse out the LDAP payload (be = @conn.read_ber(AsnSyntax) and pdu = Net::LdapPdu.new( be )) or raise LdapError.new( "no bind result" ) return pdu.result_code unless pdu.result_code == 14 # saslBindInProgress raise LdapError.new("sasl challenge overflow") if ((n+= 1) > MaxSaslChallenges) #decode the LDAP payload and pass it to challenlge_response proc cred = chall.call( pdu.result_server_sasl_creds ) #this credential will be send to server as the challenge response } end

In addition to implementing communication sequence, I forked a gem pyu-ruby-sasl from ruby-sasl to parse server challenge and generate the correct digest response and fixing several issues while connecting to Active Directory. The following example shows how to parse server challenge and generate client digest response.

def sasl_digest_md5(bind_dn, password, host) challenge_response = Proc.new do |cred| pref = SASL::Preferences.new :digest_uri => "ldap/#{host}", :username => bind_dn, :has_password? => true, :password =>password sasl = SASL.new("DIGEST-MD5", pref) response = sasl.receive("challenge", cred) response[1] end {:mechanism => “DIGEST-MD5”, :initial_credential => ‘’, :challenge_response => challenge_response} end 1 def sasl_digest_md5 ( bind_dn , password , host ) challenge_response = Proc . new do | cred | pref = SASL :: Preferences . new : digest_uri =& gt ; "ldap/#{host}" , : username =& gt ; bind_dn , : has_password ? =& gt ; true , : password =& gt ; password sasl = SASL . new ( "DIGEST-MD5" , pref ) response = sasl . receive ( "challenge" , cred ) response [ 1 ] end { : mechanism =& gt ; “ DIGEST - MD5 ” , : initial_credential =& gt ; ‘’ , : challenge_response =& gt ; challenge_response } end

After I implemented the above methods, I only need following lines to perform a SASL/DIGEST-MD5 binding to Active Directory server:

conn = Net::LDAP::Connection.new(:host => “pyub8bb.score.local”, :port => 389) result = conn.bind_sasl(sasl_digest_md5(‘SCORE\pyu’, ‘password’, ‘pyub8bb.score.local’)) == 0 1 conn = Net :: LDAP :: Connection . new ( : host =& gt ; “ pyub8bb . score . local ” , : port =& gt ; 389 ) result = conn . bind_sasl ( sasl_digest_md5 ( ‘ SCORE \ pyu ’ , ‘ password ’ , ‘ pyub8bb . score . local ’ ) ) == 0

Graph 1



*Graph 1 demonstrates the SASL biding packet sequence between client and server:

Graph 2

Graph 3



*Graph 2 shows the server digest-change packet format, and *Graph 3 shows the content.

Graph 4

Graph 5



*Graphs 4 and 5 show the client challenge response packet format and content.

In our latest OSS gem OmniAuth, we included several implementations for different LDAP authentication mechanisms in oa-enterprise sub-gem.

Thanks to all the good gems, implementing LDAP authentication is still fairly easy in Ruby. However, we should not forget about Java; its JNDI package provides all LDAP authentication mechanisms including much more complicated GSSAPI/Kerberos authentication. We should seriously consider using JRuby.