DigestMd5.java

  1package eu.siacs.conversations.crypto.sasl;
  2
  3import android.util.Log;
  4import androidx.annotation.NonNull;
  5import com.google.common.base.Preconditions;
  6import com.google.common.base.Splitter;
  7import com.google.common.base.Strings;
  8import com.google.common.collect.ImmutableMap;
  9import com.google.common.hash.Hashing;
 10import com.google.common.io.BaseEncoding;
 11import eu.siacs.conversations.Config;
 12import eu.siacs.conversations.entities.Account;
 13import eu.siacs.conversations.utils.CryptoHelper;
 14import java.nio.charset.Charset;
 15import java.util.Map;
 16import javax.net.ssl.SSLSocket;
 17
 18public class DigestMd5 extends SaslMechanism {
 19
 20    public static final String MECHANISM = "DIGEST-MD5";
 21    private State state = State.INITIAL;
 22    private String precalculatedRSPAuth;
 23
 24    public DigestMd5(final Account account) {
 25        super(account);
 26    }
 27
 28    @Override
 29    public int getPriority() {
 30        return 10;
 31    }
 32
 33    @Override
 34    public String getMechanism() {
 35        return MECHANISM;
 36    }
 37
 38    @Override
 39    public String getClientFirstMessage(final SSLSocket sslSocket) {
 40        Preconditions.checkState(
 41                this.state == State.INITIAL, "Calling getClientFirstMessage from invalid state");
 42        this.state = State.AUTH_TEXT_SENT;
 43        return "";
 44    }
 45
 46    @Override
 47    public String getResponse(final String challenge, final SSLSocket socket)
 48            throws AuthenticationException {
 49        return switch (state) {
 50            case AUTH_TEXT_SENT -> processChallenge(challenge, socket);
 51            case RESPONSE_SENT -> validateServerResponse(challenge);
 52            case VALID_SERVER_RESPONSE -> validateUnnecessarySuccessMessage(challenge);
 53            default -> throw new InvalidStateException(state);
 54        };
 55    }
 56
 57    // ejabberd sends the RSPAuth response as a challenge and then an empty success
 58    // technically this is allowed as per https://datatracker.ietf.org/doc/html/rfc2222#section-5.2
 59    // although it says to do that only if the profile of the protocol does not allow data to be put
 60    // into success. which xmpp does allow. obviously
 61    private String validateUnnecessarySuccessMessage(final String challenge)
 62            throws AuthenticationException {
 63        if (Strings.isNullOrEmpty(challenge)) {
 64            return "";
 65        }
 66        throw new AuthenticationException("Success message must be empty");
 67    }
 68
 69    private String validateServerResponse(final String challenge) throws AuthenticationException {
 70        Log.d(Config.LOGTAG, "DigestMd5.validateServerResponse(" + challenge + ")");
 71        final var attributes = messageToAttributes(challenge);
 72        Log.d(Config.LOGTAG, "attributes: " + attributes);
 73        final var rspauth = attributes.get("rspauth");
 74        if (Strings.isNullOrEmpty(rspauth)) {
 75            throw new AuthenticationException("no rspauth in server finish message");
 76        }
 77        final var expected = this.precalculatedRSPAuth;
 78        if (Strings.isNullOrEmpty(expected) || !this.precalculatedRSPAuth.equals(rspauth)) {
 79            throw new AuthenticationException("RSPAuth mismatch");
 80        }
 81        this.state = State.VALID_SERVER_RESPONSE;
 82        return "";
 83    }
 84
 85    private String processChallenge(final String challenge, final SSLSocket socket)
 86            throws AuthenticationException {
 87        Log.d(Config.LOGTAG, "DigestMd5.processChallenge()");
 88        this.state = State.RESPONSE_SENT;
 89        final var attributes = messageToAttributes(challenge);
 90
 91        final var nonce = attributes.get("nonce");
 92
 93        if (Strings.isNullOrEmpty(nonce)) {
 94            throw new AuthenticationException("Server nonce missing");
 95        }
 96        final String digestUri = "xmpp/" + account.getServer();
 97        final String nonceCount = "00000001";
 98        final String x =
 99                account.getUsername() + ":" + account.getServer() + ":" + account.getPassword();
100        final byte[] y = Hashing.md5().hashBytes(x.getBytes(Charset.defaultCharset())).asBytes();
101        final String cNonce = CryptoHelper.random(100);
102        final byte[] a1 =
103                CryptoHelper.concatenateByteArrays(
104                        y, (":" + nonce + ":" + cNonce).getBytes(Charset.defaultCharset()));
105        final String a2 = "AUTHENTICATE:" + digestUri;
106        final String ha1 = CryptoHelper.bytesToHex(Hashing.md5().hashBytes(a1).asBytes());
107        final String ha2 =
108                CryptoHelper.bytesToHex(
109                        Hashing.md5().hashBytes(a2.getBytes(Charset.defaultCharset())).asBytes());
110        final String kd = ha1 + ":" + nonce + ":" + nonceCount + ":" + cNonce + ":auth:" + ha2;
111
112        final String a2ForResponse = ":" + digestUri;
113        final String ha2ForResponse =
114                CryptoHelper.bytesToHex(
115                        Hashing.md5()
116                                .hashBytes(a2ForResponse.getBytes(Charset.defaultCharset()))
117                                .asBytes());
118        final String kdForResponseInput =
119                ha1 + ":" + nonce + ":" + nonceCount + ":" + cNonce + ":auth:" + ha2ForResponse;
120
121        this.precalculatedRSPAuth =
122                CryptoHelper.bytesToHex(
123                        Hashing.md5()
124                                .hashBytes(kdForResponseInput.getBytes(Charset.defaultCharset()))
125                                .asBytes());
126
127        final String response =
128                CryptoHelper.bytesToHex(
129                        Hashing.md5().hashBytes(kd.getBytes(Charset.defaultCharset())).asBytes());
130
131        final String saslString =
132                "username=\""
133                        + account.getUsername()
134                        + "\",realm=\""
135                        + account.getServer()
136                        + "\",nonce=\""
137                        + nonce
138                        + "\",cnonce=\""
139                        + cNonce
140                        + "\",nc="
141                        + nonceCount
142                        + ",qop=auth,digest-uri=\""
143                        + digestUri
144                        + "\",response="
145                        + response
146                        + ",charset=utf-8";
147        return BaseEncoding.base64().encode(saslString.getBytes());
148    }
149
150    private static Map<String, String> messageToAttributes(final String message)
151            throws AuthenticationException {
152        byte[] asBytes;
153        try {
154            asBytes = BaseEncoding.base64().decode(message);
155        } catch (final IllegalArgumentException e) {
156            throw new AuthenticationException("Unable to decode server challenge", e);
157        }
158        try {
159            return splitToAttributes(new String(asBytes));
160        } catch (final IllegalArgumentException e) {
161            throw new AuthenticationException("Duplicate attributes");
162        }
163    }
164
165    private static Map<String, String> splitToAttributes(final String message) {
166        final ImmutableMap.Builder<String, String> builder = new ImmutableMap.Builder<>();
167        for (final String token : Splitter.on(',').split(message)) {
168            final var tuple = Splitter.on('=').limit(2).splitToList(token);
169            if (tuple.size() == 2) {
170                final var value = tuple.get(1);
171                builder.put(tuple.get(0), trimQuotes(value));
172            }
173        }
174        return builder.buildOrThrow();
175    }
176
177    public static String trimQuotes(@NonNull final String input) {
178        if (input.length() >= 2
179                && input.charAt(0) == '"'
180                && input.charAt(input.length() - 1) == '"') {
181            return input.substring(1, input.length() - 1);
182        }
183        return input;
184    }
185}