001/**
002 *
003 * Copyright 2014 Florian Schmaus
004 *
005 * Licensed under the Apache License, Version 2.0 (the "License");
006 * you may not use this file except in compliance with the License.
007 * You may obtain a copy of the License at
008 *
009 *     http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.jivesoftware.smack.sasl.provided;
018
019import javax.security.auth.callback.CallbackHandler;
020
021import org.jivesoftware.smack.SmackException;
022import org.jivesoftware.smack.sasl.SASLMechanism;
023import org.jivesoftware.smack.util.ByteUtils;
024import org.jivesoftware.smack.util.MD5;
025import org.jivesoftware.smack.util.StringUtils;
026
027public class SASLDigestMD5Mechanism extends SASLMechanism {
028
029    public static final String NAME = DIGESTMD5;
030
031    private static final String INITAL_NONCE = "00000001";
032
033    /**
034     * The only 'qop' value supported by this implementation
035     */
036    private static final String QOP_VALUE = "auth";
037
038    private enum State {
039        INITIAL,
040        RESPONSE_SENT,
041        VALID_SERVER_RESPONSE,
042    }
043
044    private static boolean verifyServerResponse = true;
045
046    public static void setVerifyServerResponse(boolean verifyServerResponse) {
047        SASLDigestMD5Mechanism.verifyServerResponse = verifyServerResponse;
048    }
049
050    /**
051     * The state of the this instance of SASL DIGEST-MD5 authentication.
052     */
053    private State state = State.INITIAL;
054
055    private String nonce;
056    private String cnonce;
057    private String digestUri;
058    private String hex_hashed_a1;
059
060    @Override
061    protected void authenticateInternal(CallbackHandler cbh) throws SmackException {
062        throw new UnsupportedOperationException("CallbackHandler not (yet) supported");
063    }
064
065    @Override
066    protected byte[] getAuthenticationText() throws SmackException {
067        // DIGEST-MD5 has no initial response, return null
068        return null;
069    }
070
071    @Override
072    public String getName() {
073        return NAME;
074    }
075
076    @Override
077    public int getPriority() {
078        return 210;
079    }
080
081    @Override
082    public SASLDigestMD5Mechanism newInstance() {
083        return new SASLDigestMD5Mechanism();
084    }
085
086
087    @Override
088    public void checkIfSuccessfulOrThrow() throws SmackException {
089        if (verifyServerResponse && state != State.VALID_SERVER_RESPONSE) {
090            throw new SmackException(NAME + " no valid server response");
091        }
092    }
093
094    @Override
095    protected byte[] evaluateChallenge(byte[] challenge) throws SmackException {
096        if (challenge.length == 0) {
097            throw new SmackException("Initial challenge has zero length");
098        }
099        String[] challengeParts = (new String(challenge)).split(",");
100        byte[] response = null;
101        switch (state) {
102        case INITIAL:
103            for (String part : challengeParts) {
104                String[] keyValue = part.split("=");
105                assert (keyValue.length == 2);
106                String key = keyValue[0];
107                // RFC 2831 ยง 7.1 about the formating of the digest-challenge:
108                // "The full form is "<n>#<m>element" indicating at least <n> and
109                // at most <m> elements, each separated by one or more commas
110                // (",") and OPTIONAL linear white space (LWS)."
111                // Which means the key value may be preceded by whitespace,
112                // which is what we remove: *Only the preceding whitespace*.
113                key = key.replaceFirst("^\\s+", "");
114                String value = keyValue[1];
115                if ("nonce".equals(key)) {
116                    if (nonce != null) {
117                        throw new SmackException("Nonce value present multiple times");
118                    }
119                    nonce = value.replace("\"", "");
120                }
121                else if ("qop".equals(key)) {
122                    value = value.replace("\"", "");
123                    if (!value.equals("auth")) {
124                        throw new SmackException("Unsupported qop operation: " + value);
125                    }
126                }
127            }
128            if (nonce == null) {
129                // RFC 2831 2.1.1 about nonce "This directive is required and MUST appear exactly
130                // once; if not present, or if multiple instances are present, the client should
131                // abort the authentication exchange."
132                throw new SmackException("nonce value not present in initial challenge");
133            }
134            // RFC 2831 2.1.2.1 defines A1, A2, KD and response-value
135            byte[] a1FirstPart = MD5.bytes(authenticationId + ':' + serviceName + ':'
136                            + password);
137            cnonce = StringUtils.randomString(32);
138            byte[] a1 = ByteUtils.concact(a1FirstPart, toBytes(':' + nonce + ':' + cnonce));
139            digestUri = "xmpp/" + serviceName;
140            hex_hashed_a1 = StringUtils.encodeHex(MD5.bytes(a1));
141            String responseValue = calcResponse(DigestType.ClientResponse);
142            // @formatter:off
143            // See RFC 2831 2.1.2 digest-response
144            String saslString = "username=\"" + authenticationId + '"'
145                               + ",realm=\"" + serviceName + '"'
146                               + ",nonce=\"" + nonce + '"'
147                               + ",cnonce=\"" + cnonce + '"'
148                               + ",nc=" + INITAL_NONCE
149                               + ",qop=auth"
150                               + ",digest-uri=\"" + digestUri + '"'
151                               + ",response=" + responseValue
152                               + ",charset=utf-8";
153            // @formatter:on
154            response = toBytes(saslString);
155            state = State.RESPONSE_SENT;
156            break;
157        case RESPONSE_SENT:
158            if (verifyServerResponse) {
159                String serverResponse = null;
160                for (String part : challengeParts) {
161                    String[] keyValue = part.split("=");
162                    assert (keyValue.length == 2);
163                    String key = keyValue[0];
164                    String value = keyValue[1];
165                    if ("rspauth".equals(key)) {
166                        serverResponse = value;
167                        break;
168                    }
169                }
170                if (serverResponse == null) {
171                    throw new SmackException("No server response received while performing " + NAME
172                                    + " authentication");
173                }
174                String expectedServerResponse = calcResponse(DigestType.ServerResponse);
175                if (!serverResponse.equals(expectedServerResponse)) {
176                    throw new SmackException("Invalid server response  while performing " + NAME
177                                    + " authentication");
178                }
179            }
180            state = State.VALID_SERVER_RESPONSE;
181            break;
182        default:
183            throw new IllegalStateException();
184        }
185        return response;
186    }
187
188    private enum DigestType {
189        ClientResponse,
190        ServerResponse
191    }
192
193    private String calcResponse(DigestType digestType) {
194        StringBuilder a2 = new StringBuilder();
195        if (digestType == DigestType.ClientResponse) {
196            a2.append("AUTHENTICATE");
197        }
198        a2.append(':');
199        a2.append(digestUri);
200        String hex_hashed_a2 = StringUtils.encodeHex(MD5.bytes(a2.toString()));
201
202        StringBuilder kd_argument = new StringBuilder();
203        kd_argument.append(hex_hashed_a1);
204        kd_argument.append(':');
205        kd_argument.append(nonce);
206        kd_argument.append(':');
207        kd_argument.append(INITAL_NONCE);
208        kd_argument.append(':');
209        kd_argument.append(cnonce);
210        kd_argument.append(':');
211        kd_argument.append(QOP_VALUE);
212        kd_argument.append(':');
213        kd_argument.append(hex_hashed_a2);
214        byte[] kd = MD5.bytes(kd_argument.toString());
215        String responseValue = StringUtils.encodeHex(kd);
216        return responseValue;
217    }
218
219}