/* * Sun Public License * * The contents of this file are subject to the Sun Public License Version * 1.0 (the "License"). You may not use this file except in compliance with * the License. A copy of the License is available at http://www.sun.com/ * * The Original Code is the SLAMD Distributed Load Generation Engine. * The Initial Developer of the Original Code is Neil A. Wilson. * Portions created by Neil A. Wilson are Copyright (C) 2004-2010. * Some preexisting portions Copyright (C) 2002-2006 Sun Microsystems, Inc. * All Rights Reserved. * * Contributor(s): Neil A. Wilson */ package com.slamd.jobs; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.io.UnsupportedEncodingException; import java.net.Socket; import java.security.MessageDigest; import java.security.SecureRandom; import java.util.StringTokenizer; import netscape.ldap.LDAPException; import netscape.ldap.LDAPSocketFactory; import com.slamd.asn1.ASN1Element; import com.slamd.asn1.ASN1Exception; import com.slamd.asn1.ASN1Integer; import com.slamd.asn1.ASN1OctetString; import com.slamd.asn1.ASN1Reader; import com.slamd.asn1.ASN1Sequence; import com.slamd.asn1.ASN1Writer; import com.slamd.common.Constants; import com.slamd.common.SLAMDException; /** * This class provides an implementation of an LDAP socket factory that can be * used to perform authentication to the directory server using the DIGEST-MD5 * SASL mechanism. It is a relatively ugly hack because the LDAP SDK for Java * does not provide very good support for SASL authentication. * <BR><BR> * There are several things that should be noted about this implementation: * <UL> * <LI>The <CODE>setAuthenticationInfo</CODE> method must be called to provide * the identity and credentials of the user that is to be authenticated. * This must be done before calling the <CODE>connect</CODE> method of the * <CODE>LDAPConnection</CODE> object with which this socket factory is * associated.</LI> * <LI>When calling the <CODE>connect</CODE> method on the * <CODE>LDAPConnection</CODE> object with which this socket factory is * associated, you must only use the version that provides the host name * and port number of the directory server. Do not use any version that * specifies the LDAP protocol version or bind information because that * will perform a bind using simple authentication and will negate the * effect of the DIGEST-MD5 bind. Further, once the connection has * been established, do not call any variants of the * <CODE>authenticate</CODE> or <CODE>bind</CODE> methods.</LI> * <LI>Because the DIGEST-MD5 authentication is performed outside of the LDAP * SDK for Java, the SDK itself has no knowledge of that authentication. * Therefore, methods like <CODE>getAuthenticationDN</CODE>, * <CODE>getAuthenticationMethod</CODE>, * <CODE>getAuthenticationPassword</CODE>, and * <CODE>isAuthenticated</CODE> may not be used because they will provide * an incorrect response.</LI> * <LI>Because the authentication ID and credentials are provided outside the * <CODE>makeSocket</CODE> method, this implementation is not threadsafe. * Therefore, if it is expected that multiple threads may attempt to * concurrently create connections using DIGEST-MD5 authentication, then * they must each have their own instance of this socket factory. It is * not sufficient to use synchronization in an attempt to prevent * concurrent usage of the same instance.</LI> * <LI>It is possible to use this socket factory in conjunction with another * socket factory for additional functionality (e.g., DIGEST-MD5 * authentication over an SSL-based connection). To use this socket * factory in conjunction with another socket factory, call the * <CODE>setAdditionalSocketFactory</CODE> method to provide the * additional socket factory. The <CODE>makeSocket</CODE> method of that * socket factory will be invoked as part of the <CODE>makeSocket</CODE> * method of this socket factory. Note that some socket factory * implementations may not behave as expected when used in this * manner.</LI> * </UL> * * * @author Neil A. Wilson */ public class LDAPDigestMD5SocketFactory implements LDAPSocketFactory { /** * The set of characters that will be used to generate the cnonce. */ public static final char[] CNONCE_ALPHABET = ("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + "1234567890+/").toCharArray(); /** * The algorithm used by JCE to perform MD5 hashing. */ public static final String JCE_DIGEST_ALGORITHM = "MD5"; /** * The ASN.1 type used to denote an LDAP bind request protocol op. */ public static final byte LDAP_BIND_REQUEST_TYPE = 0x60; /** * The ASN.1 type used to denote an LDAP bind response protocol op. */ public static final byte LDAP_BIND_RESPONSE_TYPE = 0x61; /** * The ASN.1 type used to denote the SASL credentials in an LDAP bind request. */ public static final byte LDAP_SASL_CREDENTIALS_TYPE = (byte) 0xA3; /** * The ASN.1 type used to denote the SASL credentials in an LDAP bind * response. */ public static final byte LDAP_SERVER_SASL_CREDENTIALS_TYPE = (byte) 0x87; /** * The quality of protection that will be used for all authentications. This * implementation does not support either integrity or confidentiality. */ public static final String QOP_AUTH = "auth"; /** * The name of the DIGEST-MD5 SASL mechanism as it must appear in LDAP bind * requests. */ public static final String SASL_MECHANISM_NAME = "DIGEST-MD5"; // An additional socket factory that can be used to create the connection with // some additional layer of security (e.g., SSL/TLS). private LDAPSocketFactory socketFactory; // The object used to create MD5 digests. private MessageDigest md5Digest; // The random number generator used to create cnonce values. private SecureRandom random; // The authentication ID that should be used when performing the // authentication. private String authID; // The clear-text password that should be used when performing the // authentication. private String password; /** * Creates a new instance of this DIGEST-MD5 authenticator. Note that * creating an instance of this class for the first time in the life of the * JVM can take a few seconds because of the time required to initialize the * entropy for the random number generator. * * @throws SLAMDException If a problem occurs while initializing this * DIGEST-MD5 authenticator. */ public LDAPDigestMD5SocketFactory() throws SLAMDException { try { md5Digest = MessageDigest.getInstance(JCE_DIGEST_ALGORITHM); } catch (Exception e) { throw new SLAMDException("Unable to initialize the MD5 digester: " + e, e); } random = new SecureRandom(); authID = null; password = null; socketFactory = null; } /** * Specifies the authentication ID and password for use with the next * connection. * * @param authID The authentication ID for use with the next connection. * @param password The password for use with the next connection. */ public void setAuthenticationInfo(String authID, String password) { this.authID = authID; this.password = password; } /** * Specifies an additional socket factory that should be used when creating * connections to the directory server using this socket factory. This makes * it possible to stack this socket factory on top of another one, which * allows for things like using DIGEST-MD5 on top of an SSL-based connection. * * @param socketFactory The additional socket factory that should be used * when creating connections to the directory server * using this socket factory. */ public void setAdditionalSocketFactory(LDAPSocketFactory socketFactory) { this.socketFactory = socketFactory; } /** * Establishes a new connection to the directory server and performs a SASL * bind using DIGEST-MD5 before handing the socket off to the Java SDK. * * @param host The address of the server to which the connection should be * established. * @param port The port number of the server to which the connection should * be established. * * @return The socket that may be used to communicate with the directory * server. * * @throws LDAPException If a problem occurs while creating the socket. */ public Socket makeSocket(String host, int port) throws LDAPException { if ((authID == null) || (password == null)) { throw new LDAPException("Authentication ID and/or password has not been" + "specified.", LDAPException.PARAM_ERROR); } Socket socket; if (socketFactory == null) { try { socket = new Socket(host, port); } catch (IOException ioe) { throw new LDAPException("Unable to connect to " + host + ':' + port + " -- " + ioe, LDAPException.CONNECT_ERROR); } } else { socket = socketFactory.makeSocket(host, port); } // Tap into the input and output streams and use them to create an ASN.1 // reader and writer. InputStream inputStream; OutputStream outputStream; ASN1Reader asn1Reader; ASN1Writer asn1Writer; try { inputStream = socket.getInputStream(); outputStream = socket.getOutputStream(); asn1Reader = new ASN1Reader(inputStream); asn1Writer = new ASN1Writer(outputStream); } catch (IOException ioe) { throw new LDAPException("Unable to get input and/or output stream -- " + ioe, LDAPException.CONNECT_ERROR); } // Bind the connection to the directory server. try { doBind(asn1Reader, asn1Writer, host, authID, password); } catch (LDAPException le) { throw le; } catch (Exception e) { throw new LDAPException("Internal failure while processing the bind: " + e); } // Return the socket to the caller. return socket; } /** * Handles the process of actually performing the bind. * * @param asn1Reader The ASN.1 reader used top read responses from the * server. * @param asn1Writer The ASN.1 writer used to write requests to the server. * @param host The address of the directory server, used to construct * the digest-uri field for the authentication. * @param authID The authentication ID of the user that is to perform * the bind. It is generally in the form "dn:{userdn}". * @param password The password for the user indicated in the auth ID. * * @throws LDAPException If any problem occurs while processing the bind. */ private void doBind(ASN1Reader asn1Reader, ASN1Writer asn1Writer, String host, String authID, String password) throws LDAPException { // First, create the LDAP message for the bind request. ASN1Element[] saslCredentialElements = { new ASN1OctetString(SASL_MECHANISM_NAME), new ASN1OctetString() }; ASN1Element[] bindRequestElements = { new ASN1Integer(3), new ASN1OctetString(), new ASN1Sequence(LDAP_SASL_CREDENTIALS_TYPE, saslCredentialElements) }; ASN1Element[] ldapMessageElements = { new ASN1Integer(1), new ASN1Sequence(LDAP_BIND_REQUEST_TYPE, bindRequestElements) }; ASN1Element messageElement = new ASN1Sequence(ldapMessageElements); // Send the request to the server. try { asn1Writer.writeElement(messageElement); } catch (IOException ioe) { throw new LDAPException("Unable to send the initial bind request to " + "the server: " + ioe, LDAPException.CONNECT_ERROR); } // Read the response from the server. ASN1Element responseElement; try { responseElement = asn1Reader.readElement(Constants.MAX_BLOCKING_READ_TIME); } catch (ASN1Exception ae) { throw new LDAPException("Unable to decode the initial bind response " + "from the server: " + ae, LDAPException.UNAVAILABLE); } catch (IOException ioe) { throw new LDAPException("Unable to read the initial bind response " + "from the server: " + ioe, LDAPException.CONNECT_ERROR); } // Decode the element as a bind response. String responseData = null; try { ASN1Element[] elements = responseElement.decodeAsSequence().getElements(); if (elements.length != 2) { throw new LDAPException("Unable to decode the initial bind response " + "from the server: response element had an " + "invalid number of elements.", LDAPException.UNAVAILABLE); } if (elements[1].getType() != LDAP_BIND_RESPONSE_TYPE) { throw new LDAPException("Unable to decode the initial bind response " + "from the server: response element had an " + "invalid protocol op type.", LDAPException.UNAVAILABLE); } elements = elements[1].decodeAsSequence().getElements(); int resultCode = elements[0].decodeAsEnumerated().getIntValue(); if (resultCode != LDAPException.SASL_BIND_IN_PROGRESS) { throw new LDAPException("Unable to decode the initial bind response " + "from the server: inappropriate result code.", LDAPException.UNAVAILABLE); } for (int i=1; i < elements.length; i++) { if (elements[i].getType() == LDAP_SERVER_SASL_CREDENTIALS_TYPE) { responseData = elements[i].decodeAsOctetString().getStringValue(); } } if (responseData == null) { throw new LDAPException("Unable to decode the initial bind response " + "from the server: could not obtain the " + "server SASL credentials.", LDAPException.UNAVAILABLE); } } catch (ASN1Exception ae) { throw new LDAPException("Unable to decode the initial bind response " + "from the server: " + ae, LDAPException.UNAVAILABLE); } // Parse the response data. We need to get the nonce, the realm, and the // character set. StringTokenizer tokenizer = new StringTokenizer(responseData, ","); String nonce = null; String realm = null; String charSet = "utf-8"; while (tokenizer.hasMoreTokens()) { String token = tokenizer.nextToken(); int equalPos = token.indexOf('='); String tokenName = token.substring(0, equalPos).toLowerCase(); String tokenValue = token.substring(equalPos+1); if (tokenValue.startsWith("\"")) { tokenValue = tokenValue.substring(1, (tokenValue.length() - 1)); } if (tokenName.equals("nonce")) { nonce = tokenValue; } else if (tokenName.equals("realm")) { realm = tokenValue; } else if (tokenName.equals("charset")) { charSet = tokenValue; } } // Make sure that at least the nonce and the realm were provided. if ((nonce == null) || (nonce.length() == 0)) { throw new LDAPException("Unable to decode the initial bind response " + "from the server: could not extract the nonce " + "from the server SASL credentials.", LDAPException.UNAVAILABLE); } else if ((realm == null) || (realm.length() == 0)) { throw new LDAPException("Unable to decode the initial bind response " + "from the server: could not extract the realm " + "from the server SASL credentials.", LDAPException.UNAVAILABLE); } // At this point, we should have enough information to generate the // response. Create values for the remaining response fields. String cnonce = generateCNonce(Math.max(32, nonce.length())); String nonceCount = "00000001"; String qop = "auth"; String digestURI = "ldap/" + host; String response; try { response = generateResponse(authID, password, realm, nonce, cnonce, nonceCount, digestURI, charSet); } catch (Exception e) { throw new LDAPException("Internal failure while generating the " + "response value to send to the server: " + e, LDAPException.UNAVAILABLE); } // Assemble the full response to return to the server. String responseStr = "username=\"" + authID + "\",realm=\"" + realm + "\",nonce=\"" + nonce + "\",cnonce=\"" + cnonce + "\",nc=" + nonceCount + ",qop=" + qop + ",digest-uri=\"" + digestURI + "\",response=" + response; // Assemble the new bind request message. saslCredentialElements = new ASN1Element[] { new ASN1OctetString(SASL_MECHANISM_NAME), new ASN1OctetString(responseStr) }; bindRequestElements = new ASN1Element[] { new ASN1Integer(3), new ASN1OctetString(), new ASN1Sequence(LDAP_SASL_CREDENTIALS_TYPE, saslCredentialElements) }; ldapMessageElements = new ASN1Element[] { new ASN1Integer(2), new ASN1Sequence(LDAP_BIND_REQUEST_TYPE, bindRequestElements) }; messageElement = new ASN1Sequence(ldapMessageElements); // Send the bind request to the directory server. try { asn1Writer.writeElement(messageElement); } catch (IOException ioe) { throw new LDAPException("Unable to send the subsequent bind request to " + "the server: " + ioe, LDAPException.CONNECT_ERROR); } // Read the response from the server. try { responseElement = asn1Reader.readElement(Constants.MAX_BLOCKING_READ_TIME); } catch (ASN1Exception ae) { throw new LDAPException("Unable to decode the subsequent bind response " + "from the server: " + ae, LDAPException.UNAVAILABLE); } catch (IOException ioe) { throw new LDAPException("Unable to read the subsequent bind response " + "from the server: " + ioe, LDAPException.CONNECT_ERROR); } // Decode the element as a bind response. try { ASN1Element[] elements = responseElement.decodeAsSequence().getElements(); if (elements.length != 2) { throw new LDAPException("Unable to decode the subsequent bind " + "response from the server: response element " + "had an invalid number of elements.", LDAPException.UNAVAILABLE); } if (elements[1].getType() != LDAP_BIND_RESPONSE_TYPE) { throw new LDAPException("Unable to decode the subsequent bind " + "response from the server: response element " + "had an invalid protocol op type.", LDAPException.UNAVAILABLE); } elements = elements[1].decodeAsSequence().getElements(); int resultCode = elements[0].decodeAsEnumerated().getIntValue(); if (resultCode == LDAPException.SUCCESS) { return; } String matchedDN = elements[1].decodeAsOctetString().getStringValue(); String errorMessage = elements[2].decodeAsOctetString().getStringValue(); throw new LDAPException("The bind attempt was not successful.", resultCode, errorMessage, matchedDN); } catch (ASN1Exception ae) { throw new LDAPException("Unable to decode the subsequent bind response " + "from the server: " + ae, LDAPException.UNAVAILABLE); } } /** * Generates the cnonce string that will be used for a request. * * @param length The number of characters to include in the cnonce. * * @return The generated cnonce string. */ private String generateCNonce(int length) { char[] cnonceChars = new char[length]; for (int i=0; i < cnonceChars.length; i++) { cnonceChars[i] = CNONCE_ALPHABET[(random.nextInt() & 0x7FFFFFFF) % CNONCE_ALPHABET.length]; } return new String(cnonceChars); } /** * Generates the appropriate DIGEST-MD5 response based on the provided * information, as per the specification in RFC 2831. * * @param authID The authentication ID for the user. * @param password The password for the user indicated by the auth ID. * @param realm The realm for the user indicated by the auth ID. * @param nonce The server-generated random string used in the digest. * @param cnonce The client-generated random string used in the digest. * @param nonceCount The number of times the provided nonce has been used by * the client. * @param digestURI The URI that specifies the principal name of the * service in which the authentication is being performed. * @param charset The character set to use when encoding the data. * * @return The generated DIGEST-MD5 response. * * @throws UnsupportedEncodingException If the specified character set is * unsupported. */ private String generateResponse(String authID, String password, String realm, String nonce, String cnonce, String nonceCount, String digestURI, String charset) throws UnsupportedEncodingException { String a1Str1 = authID + ':' + realm + ':' + password; byte[] a1bytes1 = md5Digest.digest(a1Str1.getBytes(charset)); String a1Str2 = ':' + nonce + ':' + cnonce; byte[] a1bytes2 = a1Str2.getBytes(charset); byte[] a1 = new byte[a1bytes1.length + a1bytes2.length]; System.arraycopy(a1bytes1, 0, a1, 0, a1bytes1.length); System.arraycopy(a1bytes2, 0, a1, a1bytes1.length, a1bytes2.length); byte[] a2 = ("AUTHENTICATE:" + digestURI).getBytes(charset); String hexHashA1 = getHexString(md5Digest.digest(a1)); String hexHashA2 = getHexString(md5Digest.digest(a2)); String kdStr = hexHashA1 + ':' + nonce + ':' + nonceCount + ':' + cnonce + ':' + QOP_AUTH + ':' + hexHashA2; return getHexString(md5Digest.digest(kdStr.getBytes(charset))); } /** * Encodes the provided byte array into a string of the hexadecimal digits * corresponding to the values in the array. All the alphabetic hex digits * (a through f) will be return in lowercase. * * @param bytes The byte array to be encoded. * * @return The hexadecimal string representation of the provided byte array. */ private String getHexString(byte[] bytes) { StringBuilder buffer = new StringBuilder(2 * bytes.length); for (int i=0; i < bytes.length; i++) { switch ((bytes[i] >> 4) & 0x0F) { case 0x00: buffer.append('0'); break; case 0x01: buffer.append('1'); break; case 0x02: buffer.append('2'); break; case 0x03: buffer.append('3'); break; case 0x04: buffer.append('4'); break; case 0x05: buffer.append('5'); break; case 0x06: buffer.append('6'); break; case 0x07: buffer.append('7'); break; case 0x08: buffer.append('8'); break; case 0x09: buffer.append('9'); break; case 0x0a: buffer.append('a'); break; case 0x0b: buffer.append('b'); break; case 0x0c: buffer.append('c'); break; case 0x0d: buffer.append('d'); break; case 0x0e: buffer.append('e'); break; case 0x0f: buffer.append('f'); break; } switch (bytes[i] & 0x0F) { case 0x00: buffer.append('0'); break; case 0x01: buffer.append('1'); break; case 0x02: buffer.append('2'); break; case 0x03: buffer.append('3'); break; case 0x04: buffer.append('4'); break; case 0x05: buffer.append('5'); break; case 0x06: buffer.append('6'); break; case 0x07: buffer.append('7'); break; case 0x08: buffer.append('8'); break; case 0x09: buffer.append('9'); break; case 0x0a: buffer.append('a'); break; case 0x0b: buffer.append('b'); break; case 0x0c: buffer.append('c'); break; case 0x0d: buffer.append('d'); break; case 0x0e: buffer.append('e'); break; case 0x0f: buffer.append('f'); break; } } return buffer.toString(); } }