blob: 449284258c3b566c19a73c296c2a1a82b725cb20 [file] [log] [blame]
/*
* Copyright (c) 2003, 2012, 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.NoSuchAlgorithmException;
import java.io.ByteArrayOutputStream;
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.Arrays;
import java.util.logging.Level;
import javax.security.sasl.*;
import javax.security.auth.callback.*;
/**
* An implementation of the DIGEST-MD5 server SASL mechanism.
* (<a href="http://www.ietf.org/rfc/rfc2831.txt">RFC 2831</a>)
* <p>
* The DIGEST-MD5 SASL mechanism specifies two modes of authentication.
* <ul><li>Initial Authentication
* <li>Subsequent Authentication - optional, (currently not supported)
* </ul>
*
* Required callbacks:
* - RealmCallback
* used as key by handler to fetch password
* - NameCallback
* used as key by handler to fetch password
* - PasswordCallback
* handler must enter password for username/realm supplied
* - AuthorizeCallback
* handler must verify that authid/authzids are allowed and set
* authorized ID to be the canonicalized authzid (if applicable).
*
* Environment properties that affect the implementation:
* javax.security.sasl.qop:
* specifies list of qops; default is "auth"; typically, caller should set
* this to "auth, auth-int, auth-conf".
* javax.security.sasl.strength
* specifies low/medium/high strength of encryption; default is all available
* ciphers [high,medium,low]; high means des3 or rc4 (128); medium des or
* rc4-56; low is rc4-40.
* javax.security.sasl.maxbuf
* specifies max receive buf size; default is 65536
* javax.security.sasl.sendmaxbuffer
* specifies max send buf size; default is 65536 (min of this and client's max
* recv size)
*
* com.sun.security.sasl.digest.utf8:
* "true" means to use UTF-8 charset; "false" to use ISO-8859-1 encoding;
* default is "true".
* com.sun.security.sasl.digest.realm:
* space-separated list of realms; default is server name (fqdn parameter)
*
* @author Rosanna Lee
*/
final class DigestMD5Server extends DigestMD5Base implements SaslServer {
private static final String MY_CLASS_NAME = DigestMD5Server.class.getName();
private static final String UTF8_DIRECTIVE = "charset=utf-8,";
private static final String ALGORITHM_DIRECTIVE = "algorithm=md5-sess";
/*
* Always expect nonce count value to be 1 because we support only
* initial authentication.
*/
private static final int NONCE_COUNT_VALUE = 1;
/* "true" means use UTF8; "false" ISO 8859-1; default is "true" */
private static final String UTF8_PROPERTY =
"com.sun.security.sasl.digest.utf8";
/* List of space-separated realms used for authentication */
private static final String REALM_PROPERTY =
"com.sun.security.sasl.digest.realm";
/* Directives encountered in responses sent by the client. */
private static final String[] DIRECTIVE_KEY = {
"username", // exactly once
"realm", // exactly once if sent by server
"nonce", // exactly once
"cnonce", // exactly once
"nonce-count", // atmost once; default is 00000001
"qop", // atmost once; default is "auth"
"digest-uri", // atmost once; (default?)
"response", // exactly once
"maxbuf", // atmost once; default is 65536
"charset", // atmost once; default is ISO-8859-1
"cipher", // exactly once if qop is "auth-conf"
"authzid", // atmost once; default is none
"auth-param", // >= 0 times (ignored)
};
/* Indices into DIRECTIVE_KEY */
private static final int USERNAME = 0;
private static final int REALM = 1;
private static final int NONCE = 2;
private static final int CNONCE = 3;
private static final int NONCE_COUNT = 4;
private static final int QOP = 5;
private static final int DIGEST_URI = 6;
private static final int RESPONSE = 7;
private static final int MAXBUF = 8;
private static final int CHARSET = 9;
private static final int CIPHER = 10;
private static final int AUTHZID = 11;
private static final int AUTH_PARAM = 12;
/* Server-generated/supplied information */
private String specifiedQops;
private byte[] myCiphers;
private List<String> serverRealms;
DigestMD5Server(String protocol, String serverName, Map<String, ?> props,
CallbackHandler cbh) throws SaslException {
super(props, MY_CLASS_NAME, 1,
protocol + "/" + (serverName==null?"*":serverName),
cbh);
serverRealms = new ArrayList<String>();
useUTF8 = true; // default
if (props != null) {
specifiedQops = (String) props.get(Sasl.QOP);
if ("false".equals((String) props.get(UTF8_PROPERTY))) {
useUTF8 = false;
logger.log(Level.FINE, "DIGEST80:Server supports ISO-Latin-1");
}
String realms = (String) props.get(REALM_PROPERTY);
if (realms != null) {
StringTokenizer parser = new StringTokenizer(realms, ", \t\n");
int tokenCount = parser.countTokens();
String token = null;
for (int i = 0; i < tokenCount; i++) {
token = parser.nextToken();
logger.log(Level.FINE, "DIGEST81:Server supports realm {0}",
token);
serverRealms.add(token);
}
}
}
encoding = (useUTF8 ? "UTF8" : "8859_1");
// By default, use server name as realm
if (serverRealms.isEmpty()) {
if (serverName == null) {
throw new SaslException(
"A realm must be provided in props or serverName");
} else {
serverRealms.add(serverName);
}
}
}
public byte[] evaluateResponse(byte[] response) throws SaslException {
if (response.length > MAX_RESPONSE_LENGTH) {
throw new SaslException(
"DIGEST-MD5: Invalid digest response length. Got: " +
response.length + " Expected < " + MAX_RESPONSE_LENGTH);
}
byte[] challenge;
switch (step) {
case 1:
if (response.length != 0) {
throw new SaslException(
"DIGEST-MD5 must not have an initial response");
}
/* Generate first challenge */
String supportedCiphers = null;
if ((allQop&PRIVACY_PROTECTION) != 0) {
myCiphers = getPlatformCiphers();
StringBuilder sb = new StringBuilder();
// myCipher[i] is a byte that indicates whether CIPHER_TOKENS[i]
// is supported
for (int i = 0; i < CIPHER_TOKENS.length; i++) {
if (myCiphers[i] != 0) {
if (sb.length() > 0) {
sb.append(',');
}
sb.append(CIPHER_TOKENS[i]);
}
}
supportedCiphers = sb.toString();
}
try {
challenge = generateChallenge(serverRealms, specifiedQops,
supportedCiphers);
step = 3;
return challenge;
} catch (UnsupportedEncodingException e) {
throw new SaslException(
"DIGEST-MD5: Error encoding challenge", e);
} catch (IOException e) {
throw new SaslException(
"DIGEST-MD5: Error generating challenge", e);
}
// Step 2 is performed by client
case 3:
/* Validates client's response and generate challenge:
* response-auth = "rspauth" "=" response-value
*/
try {
byte[][] responseVal = parseDirectives(response, DIRECTIVE_KEY,
null, REALM);
challenge = validateClientResponse(responseVal);
} catch (SaslException e) {
throw e;
} catch (UnsupportedEncodingException e) {
throw new SaslException(
"DIGEST-MD5: Error validating client response", e);
} finally {
step = 0; // Set to invalid state
}
completed = true;
/* Initialize SecurityCtx implementation */
if (integrity && privacy) {
secCtx = new DigestPrivacy(false /* not client */);
} else if (integrity) {
secCtx = new DigestIntegrity(false /* not client */);
}
return challenge;
default:
// No other possible state
throw new SaslException("DIGEST-MD5: Server at illegal state");
}
}
/**
* Generates challenge to be sent to client.
* digest-challenge =
* 1#( realm | nonce | qop-options | stale | maxbuf | charset
* algorithm | cipher-opts | auth-param )
*
* realm = "realm" "=" <"> realm-value <">
* realm-value = qdstr-val
* nonce = "nonce" "=" <"> nonce-value <">
* nonce-value = qdstr-val
* qop-options = "qop" "=" <"> qop-list <">
* qop-list = 1#qop-value
* qop-value = "auth" | "auth-int" | "auth-conf" |
* token
* stale = "stale" "=" "true"
* maxbuf = "maxbuf" "=" maxbuf-value
* maxbuf-value = 1*DIGIT
* charset = "charset" "=" "utf-8"
* algorithm = "algorithm" "=" "md5-sess"
* cipher-opts = "cipher" "=" <"> 1#cipher-value <">
* cipher-value = "3des" | "des" | "rc4-40" | "rc4" |
* "rc4-56" | token
* auth-param = token "=" ( token | quoted-string )
*/
private byte[] generateChallenge(List<String> realms, String qopStr,
String cipherStr) throws UnsupportedEncodingException, IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
// Realms (>= 0)
for (int i = 0; realms != null && i < realms.size(); i++) {
out.write("realm=\"".getBytes(encoding));
writeQuotedStringValue(out, realms.get(i).getBytes(encoding));
out.write('"');
out.write(',');
}
// Nonce - required (1)
out.write(("nonce=\"").getBytes(encoding));
nonce = generateNonce();
writeQuotedStringValue(out, nonce);
out.write('"');
out.write(',');
// QOP - optional (1) [default: auth]
// qop="auth,auth-conf,auth-int"
if (qopStr != null) {
out.write(("qop=\"").getBytes(encoding));
// Check for quotes in case of non-standard qop options
writeQuotedStringValue(out, qopStr.getBytes(encoding));
out.write('"');
out.write(',');
}
// maxbuf - optional (1) [default: 65536]
if (recvMaxBufSize != DEFAULT_MAXBUF) {
out.write(("maxbuf=\"" + recvMaxBufSize + "\",").getBytes(encoding));
}
// charset - optional (1) [default: ISO 8859_1]
if (useUTF8) {
out.write(UTF8_DIRECTIVE.getBytes(encoding));
}
if (cipherStr != null) {
out.write("cipher=\"".getBytes(encoding));
// Check for quotes in case of custom ciphers
writeQuotedStringValue(out, cipherStr.getBytes(encoding));
out.write('"');
out.write(',');
}
// algorithm - required (1)
out.write(ALGORITHM_DIRECTIVE.getBytes(encoding));
return out.toByteArray();
}
/**
* Validates client's response.
* digest-response = 1#( username | realm | nonce | cnonce |
* nonce-count | qop | digest-uri | response |
* maxbuf | charset | cipher | authzid |
* auth-param )
*
* username = "username" "=" <"> username-value <">
* username-value = qdstr-val
* cnonce = "cnonce" "=" <"> cnonce-value <">
* cnonce-value = qdstr-val
* nonce-count = "nc" "=" nc-value
* nc-value = 8LHEX
* qop = "qop" "=" qop-value
* digest-uri = "digest-uri" "=" <"> digest-uri-value <">
* digest-uri-value = serv-type "/" host [ "/" serv-name ]
* serv-type = 1*ALPHA
* host = 1*( ALPHA | DIGIT | "-" | "." )
* serv-name = host
* response = "response" "=" response-value
* response-value = 32LHEX
* LHEX = "0" | "1" | "2" | "3" |
* "4" | "5" | "6" | "7" |
* "8" | "9" | "a" | "b" |
* "c" | "d" | "e" | "f"
* cipher = "cipher" "=" cipher-value
* authzid = "authzid" "=" <"> authzid-value <">
* authzid-value = qdstr-val
* sets:
* negotiatedQop
* negotiatedCipher
* negotiatedRealm
* negotiatedStrength
* digestUri (checked and set to clients to account for case diffs)
* sendMaxBufSize
* authzid (gotten from callback)
* @return response-value ('rspauth') for client to validate
*/
private byte[] validateClientResponse(byte[][] responseVal)
throws SaslException, UnsupportedEncodingException {
/* CHARSET: optional atmost once */
if (responseVal[CHARSET] != null) {
// The client should send this directive only if the server has
// indicated it supports UTF-8.
if (!useUTF8 ||
!"utf-8".equals(new String(responseVal[CHARSET], encoding))) {
throw new SaslException("DIGEST-MD5: digest response format " +
"violation. Incompatible charset value: " +
new String(responseVal[CHARSET]));
}
}
// maxbuf: atmost once
int clntMaxBufSize =
(responseVal[MAXBUF] == null) ? DEFAULT_MAXBUF
: Integer.parseInt(new String(responseVal[MAXBUF], encoding));
// Max send buf size is min of client's max recv buf size and
// server's max send buf size
sendMaxBufSize = ((sendMaxBufSize == 0) ? clntMaxBufSize :
Math.min(sendMaxBufSize, clntMaxBufSize));
/* username: exactly once */
String username;
if (responseVal[USERNAME] != null) {
username = new String(responseVal[USERNAME], encoding);
logger.log(Level.FINE, "DIGEST82:Username: {0}", username);
} else {
throw new SaslException("DIGEST-MD5: digest response format " +
"violation. Missing username.");
}
/* realm: exactly once if sent by server */
negotiatedRealm = ((responseVal[REALM] != null) ?
new String(responseVal[REALM], encoding) : "");
logger.log(Level.FINE, "DIGEST83:Client negotiated realm: {0}",
negotiatedRealm);
if (!serverRealms.contains(negotiatedRealm)) {
// Server had sent at least one realm
// Check that response is one of these
throw new SaslException("DIGEST-MD5: digest response format " +
"violation. Nonexistent realm: " + negotiatedRealm);
}
// Else, client specified realm was one of server's or server had none
/* nonce: exactly once */
if (responseVal[NONCE] == null) {
throw new SaslException("DIGEST-MD5: digest response format " +
"violation. Missing nonce.");
}
byte[] nonceFromClient = responseVal[NONCE];
if (!Arrays.equals(nonceFromClient, nonce)) {
throw new SaslException("DIGEST-MD5: digest response format " +
"violation. Mismatched nonce.");
}
/* cnonce: exactly once */
if (responseVal[CNONCE] == null) {
throw new SaslException("DIGEST-MD5: digest response format " +
"violation. Missing cnonce.");
}
byte[] cnonce = responseVal[CNONCE];
/* nonce-count: atmost once */
if (responseVal[NONCE_COUNT] != null &&
NONCE_COUNT_VALUE != Integer.parseInt(
new String(responseVal[NONCE_COUNT], encoding), 16)) {
throw new SaslException("DIGEST-MD5: digest response format " +
"violation. Nonce count does not match: " +
new String(responseVal[NONCE_COUNT]));
}
/* qop: atmost once; default is "auth" */
negotiatedQop = ((responseVal[QOP] != null) ?
new String(responseVal[QOP], encoding) : "auth");
logger.log(Level.FINE, "DIGEST84:Client negotiated qop: {0}",
negotiatedQop);
// Check that QOP is one sent by server
byte cQop;
switch (negotiatedQop) {
case "auth":
cQop = NO_PROTECTION;
break;
case "auth-int":
cQop = INTEGRITY_ONLY_PROTECTION;
integrity = true;
rawSendSize = sendMaxBufSize - 16;
break;
case "auth-conf":
cQop = PRIVACY_PROTECTION;
integrity = privacy = true;
rawSendSize = sendMaxBufSize - 26;
break;
default:
throw new SaslException("DIGEST-MD5: digest response format " +
"violation. Invalid QOP: " + negotiatedQop);
}
if ((cQop&allQop) == 0) {
throw new SaslException("DIGEST-MD5: server does not support " +
" qop: " + negotiatedQop);
}
if (privacy) {
negotiatedCipher = ((responseVal[CIPHER] != null) ?
new String(responseVal[CIPHER], encoding) : null);
if (negotiatedCipher == null) {
throw new SaslException("DIGEST-MD5: digest response format " +
"violation. No cipher specified.");
}
int foundCipher = -1;
logger.log(Level.FINE, "DIGEST85:Client negotiated cipher: {0}",
negotiatedCipher);
// Check that cipher is one that we offered
for (int j = 0; j < CIPHER_TOKENS.length; j++) {
if (negotiatedCipher.equals(CIPHER_TOKENS[j]) &&
myCiphers[j] != 0) {
foundCipher = j;
break;
}
}
if (foundCipher == -1) {
throw new SaslException("DIGEST-MD5: server does not " +
"support cipher: " + negotiatedCipher);
}
// Set negotiatedStrength
if ((CIPHER_MASKS[foundCipher]&HIGH_STRENGTH) != 0) {
negotiatedStrength = "high";
} else if ((CIPHER_MASKS[foundCipher]&MEDIUM_STRENGTH) != 0) {
negotiatedStrength = "medium";
} else {
// assume default low
negotiatedStrength = "low";
}
logger.log(Level.FINE, "DIGEST86:Negotiated strength: {0}",
negotiatedStrength);
}
// atmost once
String digestUriFromResponse = ((responseVal[DIGEST_URI]) != null ?
new String(responseVal[DIGEST_URI], encoding) : null);
if (digestUriFromResponse != null) {
logger.log(Level.FINE, "DIGEST87:digest URI: {0}",
digestUriFromResponse);
}
// serv-type "/" host [ "/" serv-name ]
// e.g.: smtp/mail3.example.com/example.com
// e.g.: ftp/ftp.example.com
// e.g.: ldap/ldapserver.example.com
// host should match one of service's configured service names
// Check against digest URI that mech was created with
if (uriMatches(digestUri, digestUriFromResponse)) {
digestUri = digestUriFromResponse; // account for case-sensitive diffs
} else {
throw new SaslException("DIGEST-MD5: digest response format " +
"violation. Mismatched URI: " + digestUriFromResponse +
"; expecting: " + digestUri);
}
// response: exactly once
byte[] responseFromClient = responseVal[RESPONSE];
if (responseFromClient == null) {
throw new SaslException("DIGEST-MD5: digest response format " +
" violation. Missing response.");
}
// authzid: atmost once
byte[] authzidBytes;
String authzidFromClient = ((authzidBytes=responseVal[AUTHZID]) != null?
new String(authzidBytes, encoding) : username);
if (authzidBytes != null) {
logger.log(Level.FINE, "DIGEST88:Authzid: {0}",
new String(authzidBytes));
}
// Ignore auth-param
// Get password need to generate verifying response
char[] passwd;
try {
// Realm and Name callbacks are used to provide info
RealmCallback rcb = new RealmCallback("DIGEST-MD5 realm: ",
negotiatedRealm);
NameCallback ncb = new NameCallback("DIGEST-MD5 authentication ID: ",
username);
// PasswordCallback is used to collect info
PasswordCallback pcb =
new PasswordCallback("DIGEST-MD5 password: ", false);
cbh.handle(new Callback[] {rcb, ncb, pcb});
passwd = pcb.getPassword();
pcb.clearPassword();
} catch (UnsupportedCallbackException e) {
throw new SaslException(
"DIGEST-MD5: Cannot perform callback to acquire password", e);
} catch (IOException e) {
throw new SaslException(
"DIGEST-MD5: IO error acquiring password", e);
}
if (passwd == null) {
throw new SaslException(
"DIGEST-MD5: cannot acquire password for " + username +
" in realm : " + negotiatedRealm);
}
try {
// Validate response value sent by client
byte[] expectedResponse;
try {
expectedResponse = generateResponseValue("AUTHENTICATE",
digestUri, negotiatedQop, username, negotiatedRealm,
passwd, nonce /* use own nonce */,
cnonce, NONCE_COUNT_VALUE, authzidBytes);
} catch (NoSuchAlgorithmException e) {
throw new SaslException(
"DIGEST-MD5: problem duplicating client response", e);
} catch (IOException e) {
throw new SaslException(
"DIGEST-MD5: problem duplicating client response", e);
}
if (!Arrays.equals(responseFromClient, expectedResponse)) {
throw new SaslException("DIGEST-MD5: digest response format " +
"violation. Mismatched response.");
}
// Ensure that authzid mapping is OK
try {
AuthorizeCallback acb =
new AuthorizeCallback(username, authzidFromClient);
cbh.handle(new Callback[]{acb});
if (acb.isAuthorized()) {
authzid = acb.getAuthorizedID();
} else {
throw new SaslException("DIGEST-MD5: " + username +
" is not authorized to act as " + authzidFromClient);
}
} catch (SaslException e) {
throw e;
} catch (UnsupportedCallbackException e) {
throw new SaslException(
"DIGEST-MD5: Cannot perform callback to check authzid", e);
} catch (IOException e) {
throw new SaslException(
"DIGEST-MD5: IO error checking authzid", e);
}
return generateResponseAuth(username, passwd, cnonce,
NONCE_COUNT_VALUE, authzidBytes);
} finally {
// Clear password
for (int i = 0; i < passwd.length; i++) {
passwd[i] = 0;
}
}
}
private static boolean uriMatches(String thisUri, String incomingUri) {
// Full match
if (thisUri.equalsIgnoreCase(incomingUri)) {
return true;
}
// Unbound match
if (thisUri.endsWith("/*")) {
int protoAndSlash = thisUri.length() - 1;
String thisProtoAndSlash = thisUri.substring(0, protoAndSlash);
String incomingProtoAndSlash = incomingUri.substring(0, protoAndSlash);
return thisProtoAndSlash.equalsIgnoreCase(incomingProtoAndSlash);
}
return false;
}
/**
* 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" }
*
* Clears password afterwards.
*/
private byte[] generateResponseAuth(String username, char[] passwd,
byte[] cnonce, int nonceCount, byte[] authzidBytes) throws SaslException {
// Construct response value
try {
byte[] responseValue = generateResponseValue("",
digestUri, negotiatedQop, username, negotiatedRealm,
passwd, nonce, cnonce, nonceCount, authzidBytes);
byte[] challenge = new byte[responseValue.length + 8];
System.arraycopy("rspauth=".getBytes(encoding), 0, challenge, 0, 8);
System.arraycopy(responseValue, 0, challenge, 8,
responseValue.length );
return challenge;
} catch (NoSuchAlgorithmException e) {
throw new SaslException("DIGEST-MD5: problem generating response", e);
} catch (IOException e) {
throw new SaslException("DIGEST-MD5: problem generating response", e);
}
}
public String getAuthorizationID() {
if (completed) {
return authzid;
} else {
throw new IllegalStateException(
"DIGEST-MD5 server negotiation not complete");
}
}
}