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}