/* * Copyright (c) 2000, 2006, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it * under the terms of the GNU General Public License version 2 only, as * published by the Free Software Foundation. Oracle designates this * particular file as subject to the "Classpath" exception as provided * by Oracle in the LICENSE file that accompanied this code. * * This code is distributed in the hope that it will be useful, but WITHOUT * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License * version 2 for more details (a copy is included in the LICENSE file that * accompanied this code). * * You should have received a copy of the GNU General Public License version * 2 along with this work; if not, write to the Free Software Foundation, * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. * * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA * or visit www.oracle.com if you need additional information or have any * questions. */ package com.sun.security.sasl.digest; import java.security.AccessController; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.io.ByteArrayOutputStream; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.UnsupportedEncodingException; import java.util.StringTokenizer; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Set; import java.util.Arrays; import java.util.logging.Logger; import java.util.logging.Level; import javax.security.sasl.*; import javax.security.auth.callback.CallbackHandler; import javax.security.auth.callback.PasswordCallback; import javax.security.auth.callback.NameCallback; import javax.security.auth.callback.Callback; import javax.security.auth.callback.UnsupportedCallbackException; /** * An implementation of the DIGEST-MD5 * (<a href="http://www.ietf.org/rfc/rfc2831.txt">RFC 2831</a>) SASL * (<a href="http://www.ietf.org/rfc/rfc2222.txt">RFC 2222</a>) mechanism. * * The DIGEST-MD5 SASL mechanism specifies two modes of authentication. * - Initial Authentication * - Subsequent Authentication - optional, (currently unsupported) * * Required callbacks: * - RealmChoiceCallback * shows user list of realms server has offered; handler must choose one * from list * - RealmCallback * shows user the only realm server has offered or none; handler must * enter realm to use * - NameCallback * handler must enter username to use for authentication * - PasswordCallback * handler must enter password for username to use for authentication * * Environment properties that affect behavior of implementation: * * javax.security.sasl.qop * quality of protection; list of auth, auth-int, auth-conf; default is "auth" * javax.security.sasl.strength * auth-conf strength; list of high, medium, low; default is highest * available on platform ["high,medium,low"]. * high means des3 or rc4 (128); medium des or rc4-56; low is rc4-40; * choice of cipher depends on its availablility on platform * javax.security.sasl.maxbuf * max receive buffer size; default is 65536 * javax.security.sasl.sendmaxbuffer * max send buffer size; default is 65536; (min with server max recv size) * * com.sun.security.sasl.digest.cipher * name a specific cipher to use; setting must be compatible with the * setting of the javax.security.sasl.strength property. * * @see <a href="http://www.ietf.org/rfc/rfc2222.txt">RFC 2222</a> * - Simple Authentication and Security Layer (SASL) * @see <a href="http://www.ietf.org/rfc/rfc2831.txt">RFC 2831</a> * - Using Digest Authentication as a SASL Mechanism * @see <a href="http://java.sun.com/products/jce">Java(TM) * Cryptography Extension 1.2.1 (JCE)</a> * @see <a href="http://java.sun.com/products/jaas">Java(TM) * Authentication and Authorization Service (JAAS)</a> * * @author Jonathan Bruce * @author Rosanna Lee */ final class DigestMD5Client extends DigestMD5Base implements SaslClient { private static final String MY_CLASS_NAME = DigestMD5Client.class.getName(); // Property for specifying cipher explicitly private static final String CIPHER_PROPERTY = "com.sun.security.sasl.digest.cipher"; /* Directives encountered in challenges sent by the server. */ private static final String[] DIRECTIVE_KEY = { "realm", // >= 0 times "qop", // atmost once; default is "auth" "algorithm", // exactly once "nonce", // exactly once "maxbuf", // atmost once; default is 65536 "charset", // atmost once; default is ISO 8859-1 "cipher", // exactly once if qop is "auth-conf" "rspauth", // exactly once in 2nd challenge "stale", // atmost once for in subsequent auth (not supported) }; /* Indices into DIRECTIVE_KEY */ private static final int REALM = 0; private static final int QOP = 1; private static final int ALGORITHM = 2; private static final int NONCE = 3; private static final int MAXBUF = 4; private static final int CHARSET = 5; private static final int CIPHER = 6; private static final int RESPONSE_AUTH = 7; private static final int STALE = 8; private int nonceCount; // number of times nonce has been used/seen /* User-supplied/generated information */ private String specifiedCipher; // cipher explicitly requested by user private byte[] cnonce; // client generated nonce private String username; private char[] passwd; private byte[] authzidBytes; // byte repr of authzid /** * Constructor for DIGEST-MD5 mechanism. * * @param authzid A non-null String representing the principal * for which authorization is being granted.. * @param digestURI A non-null String representing detailing the * combined protocol and host being used for authentication. * @param props The possibly null properties to be used by the SASL * mechanism to configure the authentication exchange. * @param cbh The non-null CallbackHanlder object for callbacks * @throws SaslException if no authentication ID or password is supplied */ DigestMD5Client(String authzid, String protocol, String serverName, Map props, CallbackHandler cbh) throws SaslException { super(props, MY_CLASS_NAME, 2, protocol + "/" + serverName, cbh); // authzID can only be encoded in UTF8 - RFC 2222 if (authzid != null) { this.authzid = authzid; try { authzidBytes = authzid.getBytes("UTF8"); } catch (UnsupportedEncodingException e) { throw new SaslException( "DIGEST-MD5: Error encoding authzid value into UTF-8", e); } } if (props != null) { specifiedCipher = (String)props.get(CIPHER_PROPERTY); logger.log(Level.FINE, "DIGEST60:Explicitly specified cipher: {0}", specifiedCipher); } } /** * DIGEST-MD5 has no initial response * * @return false */ public boolean hasInitialResponse() { return false; } /** * Process the challenge data. * * The server sends a digest-challenge which the client must reply to * in a digest-response. When the authentication is complete, the * completed field is set to true. * * @param challengeData A non-null byte array containing the challenge * data from the server. * @return A possibly null byte array containing the response to * be sent to the server. * * @throws SaslException If the platform does not have MD5 digest support * or if the server sends an invalid challenge. */ public byte[] evaluateChallenge(byte[] challengeData) throws SaslException { if (challengeData.length > MAX_CHALLENGE_LENGTH) { throw new SaslException( "DIGEST-MD5: Invalid digest-challenge length. Got: " + challengeData.length + " Expected < " + MAX_CHALLENGE_LENGTH); } /* Extract and process digest-challenge */ byte[][] challengeVal; switch (step) { case 2: /* Process server's first challenge (from Step 1) */ /* Get realm, qop, maxbuf, charset, algorithm, cipher, nonce directives */ List<byte[]> realmChoices = new ArrayList<byte[]>(3); challengeVal = parseDirectives(challengeData, DIRECTIVE_KEY, realmChoices, REALM); try { processChallenge(challengeVal, realmChoices); checkQopSupport(challengeVal[QOP], challengeVal[CIPHER]); ++step; return generateClientResponse(challengeVal[CHARSET]); } catch (SaslException e) { step = 0; clearPassword(); throw e; // rethrow } catch (IOException e) { step = 0; clearPassword(); throw new SaslException("DIGEST-MD5: Error generating " + "digest response-value", e); } case 3: try { /* Process server's step 3 (server response to digest response) */ /* Get rspauth directive */ challengeVal = parseDirectives(challengeData, DIRECTIVE_KEY, null, REALM); validateResponseValue(challengeVal[RESPONSE_AUTH]); /* Initialize SecurityCtx implementation */ if (integrity && privacy) { secCtx = new DigestPrivacy(true /* client */); } else if (integrity) { secCtx = new DigestIntegrity(true /* client */); } return null; // Mechanism has completed. } finally { clearPassword(); step = 0; // Set to invalid state completed = true; } default: // No other possible state throw new SaslException("DIGEST-MD5: Client at illegal state"); } } /** * Record information from the challengeVal array into variables/fields. * Check directive values that are multi-valued and ensure that mandatory * directives not missing from the digest-challenge. * * @throws SaslException if a sasl is a the mechanism cannot * correcly handle a callbacks or if a violation in the * digest challenge format is detected. */ private void processChallenge(byte[][] challengeVal, List<byte[]> realmChoices) throws SaslException, UnsupportedEncodingException { /* CHARSET: optional atmost once */ if (challengeVal[CHARSET] != null) { if (!"utf-8".equals(new String(challengeVal[CHARSET], encoding))) { throw new SaslException("DIGEST-MD5: digest-challenge format " + "violation. Unrecognised charset value: " + new String(challengeVal[CHARSET])); } else { encoding = "UTF8"; useUTF8 = true; } } /* ALGORITHM: required exactly once */ if (challengeVal[ALGORITHM] == null) { throw new SaslException("DIGEST-MD5: Digest-challenge format " + "violation: algorithm directive missing"); } else if (!"md5-sess".equals(new String(challengeVal[ALGORITHM], encoding))) { throw new SaslException("DIGEST-MD5: Digest-challenge format " + "violation. Invalid value for 'algorithm' directive: " + challengeVal[ALGORITHM]); } /* NONCE: required exactly once */ if (challengeVal[NONCE] == null) { throw new SaslException("DIGEST-MD5: Digest-challenge format " + "violation: nonce directive missing"); } else { nonce = challengeVal[NONCE]; } try { /* REALM: optional, if multiple, stored in realmChoices */ String[] realmTokens = null; if (challengeVal[REALM] != null) { if (realmChoices == null || realmChoices.size() <= 1) { // Only one realm specified negotiatedRealm = new String(challengeVal[REALM], encoding); } else { realmTokens = new String[realmChoices.size()]; for (int i = 0; i < realmTokens.length; i++) { realmTokens[i] = new String(realmChoices.get(i), encoding); } } } NameCallback ncb = authzid == null ? new NameCallback("DIGEST-MD5 authentication ID: ") : new NameCallback("DIGEST-MD5 authentication ID: ", authzid); PasswordCallback pcb = new PasswordCallback("DIGEST-MD5 password: ", false); if (realmTokens == null) { // Server specified <= 1 realm // If 0, RFC 2831: the client SHOULD solicit a realm from the user. RealmCallback tcb = (negotiatedRealm == null? new RealmCallback("DIGEST-MD5 realm: ") : new RealmCallback("DIGEST-MD5 realm: ", negotiatedRealm)); cbh.handle(new Callback[] {tcb, ncb, pcb}); /* Acquire realm from RealmCallback */ negotiatedRealm = tcb.getText(); if (negotiatedRealm == null) { negotiatedRealm = ""; } } else { RealmChoiceCallback ccb = new RealmChoiceCallback( "DIGEST-MD5 realm: ", realmTokens, 0, false); cbh.handle(new Callback[] {ccb, ncb, pcb}); /* Acquire realm from RealmChoiceCallback*/ negotiatedRealm = realmTokens[ccb.getSelectedIndexes()[0]]; } passwd = pcb.getPassword(); pcb.clearPassword(); username = ncb.getName(); } catch (UnsupportedCallbackException e) { throw new SaslException("DIGEST-MD5: Cannot perform callback to " + "acquire realm, authentication ID or password", e); } catch (IOException e) { throw new SaslException( "DIGEST-MD5: Error acquiring realm, authentication ID or password", e); } if (username == null || passwd == null) { throw new SaslException( "DIGEST-MD5: authentication ID and password must be specified"); } /* MAXBUF: optional atmost once */ int srvMaxBufSize = (challengeVal[MAXBUF] == null) ? DEFAULT_MAXBUF : Integer.parseInt(new String(challengeVal[MAXBUF], encoding)); sendMaxBufSize = (sendMaxBufSize == 0) ? srvMaxBufSize : Math.min(sendMaxBufSize, srvMaxBufSize); } /** * Parses the 'qop' directive. If 'auth-conf' is specified by * the client and offered as a QOP option by the server, then a check * is client-side supported ciphers is performed. * * @throws IOException */ private void checkQopSupport(byte[] qopInChallenge, byte[] ciphersInChallenge) throws IOException { /* QOP: optional; if multiple, merged earlier */ String qopOptions; if (qopInChallenge == null) { qopOptions = "auth"; } else { qopOptions = new String(qopInChallenge, encoding); } // process String[] serverQopTokens = new String[3]; byte[] serverQop = parseQop(qopOptions, serverQopTokens, true /* ignore unrecognized tokens */); byte serverAllQop = combineMasks(serverQop); switch (findPreferredMask(serverAllQop, qop)) { case 0: throw new SaslException("DIGEST-MD5: No common protection " + "layer between client and server"); case NO_PROTECTION: negotiatedQop = "auth"; // buffer sizes not applicable break; case INTEGRITY_ONLY_PROTECTION: negotiatedQop = "auth-int"; integrity = true; rawSendSize = sendMaxBufSize - 16; break; case PRIVACY_PROTECTION: negotiatedQop = "auth-conf"; privacy = integrity = true; rawSendSize = sendMaxBufSize - 26; checkStrengthSupport(ciphersInChallenge); break; } if (logger.isLoggable(Level.FINE)) { logger.log(Level.FINE, "DIGEST61:Raw send size: {0}", new Integer(rawSendSize)); } } /** * Processes the 'cipher' digest-challenge directive. This allows the * mechanism to check for client-side support against the list of * supported ciphers send by the server. If no match is found, * the mechanism aborts. * * @throws SaslException If an error is encountered in processing * the cipher digest-challenge directive or if no client-side * support is found. */ private void checkStrengthSupport(byte[] ciphersInChallenge) throws IOException { /* CIPHER: required exactly once if qop=auth-conf */ if (ciphersInChallenge == null) { throw new SaslException("DIGEST-MD5: server did not specify " + "cipher to use for 'auth-conf'"); } // First determine ciphers that server supports String cipherOptions = new String(ciphersInChallenge, encoding); StringTokenizer parser = new StringTokenizer(cipherOptions, ", \t\n"); int tokenCount = parser.countTokens(); String token = null; byte[] serverCiphers = { UNSET, UNSET, UNSET, UNSET, UNSET }; String[] serverCipherStrs = new String[serverCiphers.length]; // Parse ciphers in challenge; mark each that server supports for (int i = 0; i < tokenCount; i++) { token = parser.nextToken(); for (int j = 0; j < CIPHER_TOKENS.length; j++) { if (token.equals(CIPHER_TOKENS[j])) { serverCiphers[j] |= CIPHER_MASKS[j]; serverCipherStrs[j] = token; // keep for replay to server logger.log(Level.FINE, "DIGEST62:Server supports {0}", token); } } } // Determine which ciphers are available on client byte[] clntCiphers = getPlatformCiphers(); // Take intersection of server and client supported ciphers byte inter = 0; for (int i = 0; i < serverCiphers.length; i++) { serverCiphers[i] &= clntCiphers[i]; inter |= serverCiphers[i]; } if (inter == UNSET) { throw new SaslException( "DIGEST-MD5: Client supports none of these cipher suites: " + cipherOptions); } // now have a clear picture of user / client; client / server // cipher options. Leverage strength array against what is // supported to choose a cipher. negotiatedCipher = findCipherAndStrength(serverCiphers, serverCipherStrs); if (negotiatedCipher == null) { throw new SaslException("DIGEST-MD5: Unable to negotiate " + "a strength level for 'auth-conf'"); } logger.log(Level.FINE, "DIGEST63:Cipher suite: {0}", negotiatedCipher); } /** * Steps through the ordered 'strength' array, and compares it with * the 'supportedCiphers' array. The cipher returned represents * the best possible cipher based on the strength preference and the * available ciphers on both the server and client environments. * * @param tokens The array of cipher tokens sent by server * @return The agreed cipher. */ private String findCipherAndStrength(byte[] supportedCiphers, String[] tokens) { byte s; for (int i = 0; i < strength.length; i++) { if ((s=strength[i]) != 0) { for (int j = 0; j < supportedCiphers.length; j++) { // If user explicitly requested cipher, then it // must be the one we choose if (s == supportedCiphers[j] && (specifiedCipher == null || specifiedCipher.equals(tokens[j]))) { switch (s) { case HIGH_STRENGTH: negotiatedStrength = "high"; break; case MEDIUM_STRENGTH: negotiatedStrength = "medium"; break; case LOW_STRENGTH: negotiatedStrength = "low"; break; } return tokens[j]; } } } } return null; // none found } /** * Returns digest-response suitable for an initial authentication. * * The following are qdstr-val (quoted string values) as per RFC 2831, * which means that any embedded quotes must be escaped. * realm-value * nonce-value * username-value * cnonce-value * authzid-value * @returns <tt>digest-response</tt> in a byte array * @throws SaslException if there is an error generating the * response value or the cnonce value. */ private byte[] generateClientResponse(byte[] charset) throws IOException { ByteArrayOutputStream digestResp = new ByteArrayOutputStream(); if (useUTF8) { digestResp.write("charset=".getBytes(encoding)); digestResp.write(charset); digestResp.write(','); } digestResp.write(("username=\"" + quotedStringValue(username) + "\",").getBytes(encoding)); if (negotiatedRealm.length() > 0) { digestResp.write(("realm=\"" + quotedStringValue(negotiatedRealm) + "\",").getBytes(encoding)); } digestResp.write("nonce=\"".getBytes(encoding)); writeQuotedStringValue(digestResp, nonce); digestResp.write('"'); digestResp.write(','); nonceCount = getNonceCount(nonce); digestResp.write(("nc=" + nonceCountToHex(nonceCount) + ",").getBytes(encoding)); cnonce = generateNonce(); digestResp.write("cnonce=\"".getBytes(encoding)); writeQuotedStringValue(digestResp, cnonce); digestResp.write("\",".getBytes(encoding)); digestResp.write(("digest-uri=\"" + digestUri + "\",").getBytes(encoding)); digestResp.write("maxbuf=".getBytes(encoding)); digestResp.write(String.valueOf(recvMaxBufSize).getBytes(encoding)); digestResp.write(','); try { digestResp.write("response=".getBytes(encoding)); digestResp.write(generateResponseValue("AUTHENTICATE", digestUri, negotiatedQop, username, negotiatedRealm, passwd, nonce, cnonce, nonceCount, authzidBytes)); digestResp.write(','); } catch (Exception e) { throw new SaslException( "DIGEST-MD5: Error generating response value", e); } digestResp.write(("qop=" + negotiatedQop).getBytes(encoding)); if (negotiatedCipher != null) { digestResp.write((",cipher=\"" + negotiatedCipher + "\"").getBytes(encoding)); } if (authzidBytes != null) { digestResp.write(",authzid=\"".getBytes(encoding)); writeQuotedStringValue(digestResp, authzidBytes); digestResp.write("\"".getBytes(encoding)); } if (digestResp.size() > MAX_RESPONSE_LENGTH) { throw new SaslException ("DIGEST-MD5: digest-response size too " + "large. Length: " + digestResp.size()); } return digestResp.toByteArray(); } /** * From RFC 2831, Section 2.1.3: Step Three * [Server] sends a message formatted as follows: * response-auth = "rspauth" "=" response-value * where response-value is calculated as above, using the values sent in * step two, except that if qop is "auth", then A2 is * * A2 = { ":", digest-uri-value } * * And if qop is "auth-int" or "auth-conf" then A2 is * * A2 = { ":", digest-uri-value, ":00000000000000000000000000000000" } */ private void validateResponseValue(byte[] fromServer) throws SaslException { if (fromServer == null) { throw new SaslException("DIGEST-MD5: Authenication failed. " + "Expecting 'rspauth' authentication success message"); } try { byte[] expected = generateResponseValue("", digestUri, negotiatedQop, username, negotiatedRealm, passwd, nonce, cnonce, nonceCount, authzidBytes); if (!Arrays.equals(expected, fromServer)) { /* Server's rspauth value does not match */ throw new SaslException( "Server's rspauth value does not match what client expects"); } } catch (NoSuchAlgorithmException e) { throw new SaslException( "Problem generating response value for verification", e); } catch (IOException e) { throw new SaslException( "Problem generating response value for verification", e); } } /** * Returns the number of requests (including current request) * that the client has sent in response to nonceValue. * This is 1 the first time nonceValue is seen. * * We don't cache nonce values seen, and we don't support subsequent * authentication, so the value is always 1. */ private static int getNonceCount(byte[] nonceValue) { return 1; } private void clearPassword() { if (passwd != null) { for (int i = 0; i < passwd.length; i++) { passwd[i] = 0; } passwd = null; } } }