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}