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}