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