001/*
002 * CDDL HEADER START
003 *
004 * The contents of this file are subject to the terms of the
005 * Common Development and Distribution License, Version 1.0 only
006 * (the "License").  You may not use this file except in compliance
007 * with the License.
008 *
009 * You can obtain a copy of the license at legal-notices/CDDLv1_0.txt
010 * or http://forgerock.org/license/CDDLv1.0.html.
011 * See the License for the specific language governing permissions
012 * and limitations under the License.
013 *
014 * When distributing Covered Code, include this CDDL HEADER in each
015 * file and include the License file at legal-notices/CDDLv1_0.txt.
016 * If applicable, add the following below this CDDL HEADER, with the
017 * fields enclosed by brackets "[]" replaced with your own identifying
018 * information:
019 *      Portions Copyright [yyyy] [name of copyright owner]
020 *
021 * CDDL HEADER END
022 *
023 *
024 *      Copyright 2006-2009 Sun Microsystems, Inc.
025 *      Portions Copyright 2011-2015 ForgeRock AS.
026 */
027package org.opends.server.extensions;
028
029import java.security.MessageDigest;
030import java.security.SecureRandom;
031import java.text.ParseException;
032import java.util.Arrays;
033import java.util.List;
034import org.forgerock.i18n.LocalizableMessage;
035import org.opends.server.admin.server.ConfigurationChangeListener;
036import org.opends.server.admin.std.server.CramMD5SASLMechanismHandlerCfg;
037import org.opends.server.admin.std.server.SASLMechanismHandlerCfg;
038import org.opends.server.api.*;
039import org.forgerock.opendj.config.server.ConfigChangeResult;
040import org.forgerock.opendj.config.server.ConfigException;
041import org.opends.server.core.BindOperation;
042import org.opends.server.core.DirectoryServer;
043import org.opends.server.core.PasswordPolicyState;
044import org.forgerock.i18n.slf4j.LocalizedLogger;
045import org.opends.server.types.*;
046import org.forgerock.opendj.ldap.ResultCode;
047import org.forgerock.opendj.ldap.ByteString;
048import static org.opends.messages.ExtensionMessages.*;
049import static org.opends.server.util.ServerConstants.*;
050import static org.opends.server.util.StaticUtils.*;
051
052/**
053 * This class provides an implementation of a SASL mechanism that uses digest
054 * authentication via CRAM-MD5.  This is a password-based mechanism that does
055 * not expose the password itself over the wire but rather uses an MD5 hash that
056 * proves the client knows the password.  This is similar to the DIGEST-MD5
057 * mechanism, and the primary differences are that CRAM-MD5 only obtains random
058 * data from the server (whereas DIGEST-MD5 uses random data from both the
059 * server and the client), CRAM-MD5 does not allow for an authorization ID in
060 * addition to the authentication ID where DIGEST-MD5 does, and CRAM-MD5 does
061 * not define any integrity and confidentiality mechanisms where DIGEST-MD5
062 * does.  This implementation is  based on the proposal defined in
063 * draft-ietf-sasl-crammd5-05.
064 */
065public class CRAMMD5SASLMechanismHandler
066       extends SASLMechanismHandler<CramMD5SASLMechanismHandlerCfg>
067       implements ConfigurationChangeListener<
068                       CramMD5SASLMechanismHandlerCfg>
069{
070  private static final LocalizedLogger logger = LocalizedLogger.getLoggerForThisClass();
071
072  /** An array filled with the inner pad byte. */
073  private byte[] iPad;
074
075  /** An array filled with the outer pad byte. */
076  private byte[] oPad;
077
078  /** The current configuration for this SASL mechanism handler. */
079  private CramMD5SASLMechanismHandlerCfg currentConfig;
080
081  /** The identity mapper that will be used to map ID strings to user entries. */
082  private IdentityMapper<?> identityMapper;
083
084  /** The message digest engine that will be used to create the MD5 digests. */
085  private MessageDigest md5Digest;
086
087  /**
088   * The lock that will be used to provide threadsafe access to the message
089   * digest.
090   */
091  private Object digestLock;
092
093  /**
094   * The random number generator that we will use to create the server challenge.
095   */
096  private SecureRandom randomGenerator;
097
098
099
100  /**
101   * Creates a new instance of this SASL mechanism handler.  No initialization
102   * should be done in this method, as it should all be performed in the
103   * <CODE>initializeSASLMechanismHandler</CODE> method.
104   */
105  public CRAMMD5SASLMechanismHandler()
106  {
107    super();
108  }
109
110
111
112  /** {@inheritDoc} */
113  @Override
114  public void initializeSASLMechanismHandler(
115                   CramMD5SASLMechanismHandlerCfg configuration)
116         throws ConfigException, InitializationException
117  {
118    configuration.addCramMD5ChangeListener(this);
119    currentConfig = configuration;
120
121    // Initialize the variables needed for the MD5 digest creation.
122    digestLock      = new Object();
123    randomGenerator = new SecureRandom();
124
125    try
126    {
127      md5Digest = MessageDigest.getInstance("MD5");
128    }
129    catch (Exception e)
130    {
131      logger.traceException(e);
132
133      LocalizableMessage message =
134          ERR_SASLCRAMMD5_CANNOT_GET_MESSAGE_DIGEST.get(getExceptionMessage(e));
135      throw new InitializationException(message, e);
136    }
137
138
139    // Create and fill the iPad and oPad arrays.
140    iPad = new byte[HMAC_MD5_BLOCK_LENGTH];
141    oPad = new byte[HMAC_MD5_BLOCK_LENGTH];
142    Arrays.fill(iPad, CRAMMD5_IPAD_BYTE);
143    Arrays.fill(oPad, CRAMMD5_OPAD_BYTE);
144
145
146    // Get the identity mapper that should be used to find users.
147    DN identityMapperDN = configuration.getIdentityMapperDN();
148    identityMapper = DirectoryServer.getIdentityMapper(identityMapperDN);
149
150    DirectoryServer.registerSASLMechanismHandler(SASL_MECHANISM_CRAM_MD5, this);
151  }
152
153
154
155  /** {@inheritDoc} */
156  @Override
157  public void finalizeSASLMechanismHandler()
158  {
159    currentConfig.removeCramMD5ChangeListener(this);
160    DirectoryServer.deregisterSASLMechanismHandler(SASL_MECHANISM_CRAM_MD5);
161  }
162
163
164
165
166  /** {@inheritDoc} */
167  @Override
168  public void processSASLBind(BindOperation bindOperation)
169  {
170    // The CRAM-MD5 bind process uses two stages.  See if the client provided
171    // any credentials.  If not, then we're in the first stage so we'll send the
172    // challenge to the client.
173    ByteString       clientCredentials = bindOperation.getSASLCredentials();
174    ClientConnection clientConnection  = bindOperation.getClientConnection();
175    if (clientCredentials == null)
176    {
177      // The client didn't provide any credentials, so this is the initial
178      // request.  Generate some random data to send to the client as the
179      // challenge and store it in the client connection so we can verify the
180      // credentials provided by the client later.
181      byte[] challengeBytes = new byte[16];
182      randomGenerator.nextBytes(challengeBytes);
183      StringBuilder challengeString = new StringBuilder(18);
184      challengeString.append('<');
185      for (byte b : challengeBytes)
186      {
187        challengeString.append(byteToLowerHex(b));
188      }
189      challengeString.append('>');
190
191      final ByteString challenge = ByteString.valueOfUtf8(challengeString);
192      clientConnection.setSASLAuthStateInfo(challenge);
193      bindOperation.setServerSASLCredentials(challenge);
194      bindOperation.setResultCode(ResultCode.SASL_BIND_IN_PROGRESS);
195      return;
196    }
197
198
199    // If we've gotten here, then the client did provide credentials.  First,
200    // make sure that we have a stored version of the credentials associated
201    // with the client connection.  If not, then it likely means that the client
202    // is trying to pull a fast one on us.
203    Object saslStateInfo = clientConnection.getSASLAuthStateInfo();
204    if (saslStateInfo == null)
205    {
206      bindOperation.setResultCode(ResultCode.INVALID_CREDENTIALS);
207
208      LocalizableMessage message = ERR_SASLCRAMMD5_NO_STORED_CHALLENGE.get();
209      bindOperation.setAuthFailureReason(message);
210      return;
211    }
212
213    if (! (saslStateInfo instanceof  ByteString))
214    {
215      bindOperation.setResultCode(ResultCode.INVALID_CREDENTIALS);
216
217      LocalizableMessage message = ERR_SASLCRAMMD5_INVALID_STORED_CHALLENGE.get();
218      bindOperation.setAuthFailureReason(message);
219      return;
220    }
221
222    ByteString  challenge = (ByteString) saslStateInfo;
223
224    // Wipe out the stored challenge so it can't be used again.
225    clientConnection.setSASLAuthStateInfo(null);
226
227
228    // Now look at the client credentials and make sure that we can decode them.
229    // It should be a username followed by a space and a digest string.  Since
230    // the username itself may contain spaces but the digest string may not,
231    // look for the last space and use it as the delimiter.
232    String credString = clientCredentials.toString();
233    int spacePos = credString.lastIndexOf(' ');
234    if (spacePos < 0)
235    {
236      bindOperation.setResultCode(ResultCode.INVALID_CREDENTIALS);
237
238      LocalizableMessage message = ERR_SASLCRAMMD5_NO_SPACE_IN_CREDENTIALS.get();
239      bindOperation.setAuthFailureReason(message);
240      return;
241    }
242
243    String userName = credString.substring(0, spacePos);
244    String digest   = credString.substring(spacePos+1);
245
246
247    // Look at the digest portion of the provided credentials.  It must have a
248    // length of exactly 32 bytes and be comprised only of hex characters.
249    if (digest.length() != 2*MD5_DIGEST_LENGTH)
250    {
251      bindOperation.setResultCode(ResultCode.INVALID_CREDENTIALS);
252
253      LocalizableMessage message = ERR_SASLCRAMMD5_INVALID_DIGEST_LENGTH.get(
254              digest.length(),
255              2*MD5_DIGEST_LENGTH);
256      bindOperation.setAuthFailureReason(message);
257      return;
258    }
259
260    byte[] digestBytes;
261    try
262    {
263      digestBytes = hexStringToByteArray(digest);
264    }
265    catch (ParseException pe)
266    {
267      logger.traceException(pe);
268
269      bindOperation.setResultCode(ResultCode.INVALID_CREDENTIALS);
270
271      LocalizableMessage message = ERR_SASLCRAMMD5_INVALID_DIGEST_CONTENT.get(
272              pe.getMessage());
273      bindOperation.setAuthFailureReason(message);
274      return;
275    }
276
277
278    // Get the user entry for the authentication ID.  Allow for an
279    // authentication ID that is just a username (as per the CRAM-MD5 spec), but
280    // also allow a value in the authzid form specified in RFC 2829.
281    Entry  userEntry    = null;
282    String lowerUserName = toLowerCase(userName);
283    if (lowerUserName.startsWith("dn:"))
284    {
285      // Try to decode the user DN and retrieve the corresponding entry.
286      DN userDN;
287      try
288      {
289        userDN = DN.valueOf(userName.substring(3));
290      }
291      catch (DirectoryException de)
292      {
293        logger.traceException(de);
294
295        bindOperation.setResultCode(ResultCode.INVALID_CREDENTIALS);
296
297        LocalizableMessage message = ERR_SASLCRAMMD5_CANNOT_DECODE_USERNAME_AS_DN.get(
298                userName, de.getMessageObject());
299        bindOperation.setAuthFailureReason(message);
300        return;
301      }
302
303      if (userDN.isRootDN())
304      {
305        bindOperation.setResultCode(ResultCode.INVALID_CREDENTIALS);
306
307        LocalizableMessage message = ERR_SASLCRAMMD5_USERNAME_IS_NULL_DN.get();
308        bindOperation.setAuthFailureReason(message);
309        return;
310      }
311
312      DN rootDN = DirectoryServer.getActualRootBindDN(userDN);
313      if (rootDN != null)
314      {
315        userDN = rootDN;
316      }
317
318      try
319      {
320        userEntry = DirectoryServer.getEntry(userDN);
321      }
322      catch (DirectoryException de)
323      {
324        logger.traceException(de);
325
326        bindOperation.setResultCode(ResultCode.INVALID_CREDENTIALS);
327
328        LocalizableMessage message = ERR_SASLCRAMMD5_CANNOT_GET_ENTRY_BY_DN.get(userDN, de.getMessageObject());
329        bindOperation.setAuthFailureReason(message);
330        return;
331      }
332    }
333    else
334    {
335      // Use the identity mapper to resolve the username to an entry.
336      if (lowerUserName.startsWith("u:"))
337      {
338        userName = userName.substring(2);
339      }
340
341      try
342      {
343        userEntry = identityMapper.getEntryForID(userName);
344      }
345      catch (DirectoryException de)
346      {
347        logger.traceException(de);
348
349        bindOperation.setResultCode(ResultCode.INVALID_CREDENTIALS);
350
351        LocalizableMessage message = ERR_SASLCRAMMD5_CANNOT_MAP_USERNAME.get(userName, de.getMessageObject());
352        bindOperation.setAuthFailureReason(message);
353        return;
354      }
355    }
356
357
358    // At this point, we should have a user entry.  If we don't then fail.
359    if (userEntry == null)
360    {
361      bindOperation.setResultCode(ResultCode.INVALID_CREDENTIALS);
362
363      LocalizableMessage message = ERR_SASLCRAMMD5_NO_MATCHING_ENTRIES.get(userName);
364      bindOperation.setAuthFailureReason(message);
365      return;
366    }
367    else
368    {
369      bindOperation.setSASLAuthUserEntry(userEntry);
370    }
371
372
373    // Get the clear-text passwords from the user entry, if there are any.
374    List<ByteString> clearPasswords;
375    try
376    {
377      AuthenticationPolicyState authState = AuthenticationPolicyState.forUser(
378          userEntry, false);
379
380      if (!authState.isPasswordPolicy())
381      {
382        bindOperation.setResultCode(ResultCode.INAPPROPRIATE_AUTHENTICATION);
383        LocalizableMessage message = ERR_SASL_ACCOUNT_NOT_LOCAL
384            .get(SASL_MECHANISM_CRAM_MD5, userEntry.getName());
385        bindOperation.setAuthFailureReason(message);
386        return;
387      }
388
389      PasswordPolicyState pwPolicyState = (PasswordPolicyState) authState;
390      clearPasswords = pwPolicyState.getClearPasswords();
391      if (clearPasswords == null || clearPasswords.isEmpty())
392      {
393        bindOperation.setResultCode(ResultCode.INVALID_CREDENTIALS);
394
395        LocalizableMessage message = ERR_SASLCRAMMD5_NO_REVERSIBLE_PASSWORDS.get(userEntry.getName());
396        bindOperation.setAuthFailureReason(message);
397        return;
398      }
399    }
400    catch (Exception e)
401    {
402      bindOperation.setResultCode(ResultCode.INVALID_CREDENTIALS);
403
404      LocalizableMessage message = ERR_SASLCRAMMD5_CANNOT_GET_REVERSIBLE_PASSWORDS.get( userEntry.getName(), e);
405      bindOperation.setAuthFailureReason(message);
406      return;
407    }
408
409
410    // Iterate through the clear-text values and see if any of them can be used
411    // in conjunction with the challenge to construct the provided digest.
412    boolean matchFound = false;
413    for (ByteString clearPassword : clearPasswords)
414    {
415      byte[] generatedDigest = generateDigest(clearPassword, challenge);
416      if (Arrays.equals(digestBytes, generatedDigest))
417      {
418        matchFound = true;
419        break;
420      }
421    }
422
423    if (! matchFound)
424    {
425      bindOperation.setResultCode(ResultCode.INVALID_CREDENTIALS);
426
427      LocalizableMessage message = ERR_SASLCRAMMD5_INVALID_PASSWORD.get();
428      bindOperation.setAuthFailureReason(message);
429      return;
430    }
431
432
433    // If we've gotten here, then the authentication was successful.
434    bindOperation.setResultCode(ResultCode.SUCCESS);
435
436    AuthenticationInfo authInfo = new AuthenticationInfo(userEntry,
437        SASL_MECHANISM_CRAM_MD5, DirectoryServer.isRootDN(userEntry.getName()));
438    bindOperation.setAuthenticationInfo(authInfo);
439  }
440
441
442
443  /**
444   * Generates the appropriate HMAC-MD5 digest for a CRAM-MD5 authentication
445   * with the given information.
446   *
447   * @param  password   The clear-text password to use when generating the
448   *                    digest.
449   * @param  challenge  The server-supplied challenge to use when generating the
450   *                    digest.
451   *
452   * @return  The generated HMAC-MD5 digest for CRAM-MD5 authentication.
453   */
454  private byte[] generateDigest(ByteString password, ByteString challenge)
455  {
456    // Get the byte arrays backing the password and challenge.
457    byte[] p = password.toByteArray();
458    byte[] c = challenge.toByteArray();
459
460
461    // Grab a lock to protect the MD5 digest generation.
462    synchronized (digestLock)
463    {
464      // If the password is longer than the HMAC-MD5 block length, then use an
465      // MD5 digest of the password rather than the password itself.
466      if (p.length > HMAC_MD5_BLOCK_LENGTH)
467      {
468        p = md5Digest.digest(p);
469      }
470
471
472      // Create byte arrays with data needed for the hash generation.
473      byte[] iPadAndData = new byte[HMAC_MD5_BLOCK_LENGTH + c.length];
474      System.arraycopy(iPad, 0, iPadAndData, 0, HMAC_MD5_BLOCK_LENGTH);
475      System.arraycopy(c, 0, iPadAndData, HMAC_MD5_BLOCK_LENGTH, c.length);
476
477      byte[] oPadAndHash = new byte[HMAC_MD5_BLOCK_LENGTH + MD5_DIGEST_LENGTH];
478      System.arraycopy(oPad, 0, oPadAndHash, 0, HMAC_MD5_BLOCK_LENGTH);
479
480
481      // Iterate through the bytes in the key and XOR them with the iPad and
482      // oPad as appropriate.
483      for (int i=0; i < p.length; i++)
484      {
485        iPadAndData[i] ^= p[i];
486        oPadAndHash[i] ^= p[i];
487      }
488
489
490      // Copy an MD5 digest of the iPad-XORed key and the data into the array to
491      // be hashed.
492      System.arraycopy(md5Digest.digest(iPadAndData), 0, oPadAndHash,
493                       HMAC_MD5_BLOCK_LENGTH, MD5_DIGEST_LENGTH);
494
495
496      // Return an MD5 digest of the resulting array.
497      return md5Digest.digest(oPadAndHash);
498    }
499  }
500
501
502
503  /** {@inheritDoc} */
504  @Override
505  public boolean isPasswordBased(String mechanism)
506  {
507    // This is a password-based mechanism.
508    return true;
509  }
510
511
512
513  /** {@inheritDoc} */
514  @Override
515  public boolean isSecure(String mechanism)
516  {
517    // This may be considered a secure mechanism.
518    return true;
519  }
520
521
522
523  /** {@inheritDoc} */
524  @Override
525  public boolean isConfigurationAcceptable(
526                      SASLMechanismHandlerCfg configuration,
527                      List<LocalizableMessage> unacceptableReasons)
528  {
529    CramMD5SASLMechanismHandlerCfg config =
530         (CramMD5SASLMechanismHandlerCfg) configuration;
531    return isConfigurationChangeAcceptable(config, unacceptableReasons);
532  }
533
534
535
536  /** {@inheritDoc} */
537  @Override
538  public boolean isConfigurationChangeAcceptable(
539                      CramMD5SASLMechanismHandlerCfg configuration,
540                      List<LocalizableMessage> unacceptableReasons)
541  {
542    return true;
543  }
544
545
546
547  /** {@inheritDoc} */
548  @Override
549  public ConfigChangeResult applyConfigurationChange(
550              CramMD5SASLMechanismHandlerCfg configuration)
551  {
552    final ConfigChangeResult ccr = new ConfigChangeResult();
553
554    DN identityMapperDN = configuration.getIdentityMapperDN();
555    identityMapper = DirectoryServer.getIdentityMapper(identityMapperDN);
556    currentConfig  = configuration;
557
558    return ccr;
559  }
560}