1package eu.siacs.conversations.crypto.sasl;
2
3import android.util.Base64;
4
5import com.google.common.base.Objects;
6import com.google.common.cache.Cache;
7import com.google.common.cache.CacheBuilder;
8
9import org.bouncycastle.crypto.Digest;
10import org.bouncycastle.crypto.macs.HMac;
11import org.bouncycastle.crypto.params.KeyParameter;
12
13import java.nio.charset.Charset;
14import java.security.InvalidKeyException;
15import java.util.concurrent.ExecutionException;
16
17import eu.siacs.conversations.entities.Account;
18import eu.siacs.conversations.utils.CryptoHelper;
19
20abstract class ScramMechanism extends SaslMechanism {
21 // TODO: When channel binding (SCRAM-SHA1-PLUS) is supported in future, generalize this to
22 // indicate support and/or usage.
23 private static final String GS2_HEADER = "n,,";
24 private static final byte[] CLIENT_KEY_BYTES = "Client Key".getBytes();
25 private static final byte[] SERVER_KEY_BYTES = "Server Key".getBytes();
26 private static final Cache<CacheKey, KeyPair> CACHE =
27 CacheBuilder.newBuilder().maximumSize(10).build();
28 private final String clientNonce;
29 protected State state = State.INITIAL;
30 private String clientFirstMessageBare;
31 private byte[] serverSignature = null;
32
33 ScramMechanism(final Account account) {
34 super(account);
35
36 // This nonce should be different for each authentication attempt.
37 this.clientNonce = CryptoHelper.random(100);
38 clientFirstMessageBare = "";
39 }
40
41 protected abstract HMac getHMAC();
42
43 protected abstract Digest getDigest();
44
45 private KeyPair getKeyPair(final String password, final String salt, final int iterations)
46 throws ExecutionException {
47 return CACHE.get(
48 new CacheKey(getHMAC().getAlgorithmName(), password, salt, iterations),
49 () -> {
50 final byte[] saltedPassword, serverKey, clientKey;
51 saltedPassword =
52 hi(
53 password.getBytes(),
54 Base64.decode(salt, Base64.DEFAULT),
55 iterations);
56 serverKey = hmac(saltedPassword, SERVER_KEY_BYTES);
57 clientKey = hmac(saltedPassword, CLIENT_KEY_BYTES);
58 return new KeyPair(clientKey, serverKey);
59 });
60 }
61
62 private byte[] hmac(final byte[] key, final byte[] input) throws InvalidKeyException {
63 final HMac hMac = getHMAC();
64 hMac.init(new KeyParameter(key));
65 hMac.update(input, 0, input.length);
66 final byte[] out = new byte[hMac.getMacSize()];
67 hMac.doFinal(out, 0);
68 return out;
69 }
70
71 public byte[] digest(byte[] bytes) {
72 final Digest digest = getDigest();
73 digest.reset();
74 digest.update(bytes, 0, bytes.length);
75 final byte[] out = new byte[digest.getDigestSize()];
76 digest.doFinal(out, 0);
77 return out;
78 }
79
80 /*
81 * Hi() is, essentially, PBKDF2 [RFC2898] with HMAC() as the
82 * pseudorandom function (PRF) and with dkLen == output length of
83 * HMAC() == output length of H().
84 */
85 private byte[] hi(final byte[] key, final byte[] salt, final int iterations)
86 throws InvalidKeyException {
87 byte[] u = hmac(key, CryptoHelper.concatenateByteArrays(salt, CryptoHelper.ONE));
88 byte[] out = u.clone();
89 for (int i = 1; i < iterations; i++) {
90 u = hmac(key, u);
91 for (int j = 0; j < u.length; j++) {
92 out[j] ^= u[j];
93 }
94 }
95 return out;
96 }
97
98 @Override
99 public String getClientFirstMessage() {
100 if (clientFirstMessageBare.isEmpty() && state == State.INITIAL) {
101 clientFirstMessageBare =
102 "n="
103 + CryptoHelper.saslEscape(CryptoHelper.saslPrep(account.getUsername()))
104 + ",r="
105 + this.clientNonce;
106 state = State.AUTH_TEXT_SENT;
107 }
108 return Base64.encodeToString(
109 (GS2_HEADER + clientFirstMessageBare).getBytes(Charset.defaultCharset()),
110 Base64.NO_WRAP);
111 }
112
113 @Override
114 public String getResponse(final String challenge) throws AuthenticationException {
115 switch (state) {
116 case AUTH_TEXT_SENT:
117 if (challenge == null) {
118 throw new AuthenticationException("challenge can not be null");
119 }
120 byte[] serverFirstMessage;
121 try {
122 serverFirstMessage = Base64.decode(challenge, Base64.DEFAULT);
123 } catch (IllegalArgumentException e) {
124 throw new AuthenticationException("Unable to decode server challenge", e);
125 }
126 final Tokenizer tokenizer = new Tokenizer(serverFirstMessage);
127 String nonce = "";
128 int iterationCount = -1;
129 String salt = "";
130 for (final String token : tokenizer) {
131 if (token.charAt(1) == '=') {
132 switch (token.charAt(0)) {
133 case 'i':
134 try {
135 iterationCount = Integer.parseInt(token.substring(2));
136 } catch (final NumberFormatException e) {
137 throw new AuthenticationException(e);
138 }
139 break;
140 case 's':
141 salt = token.substring(2);
142 break;
143 case 'r':
144 nonce = token.substring(2);
145 break;
146 case 'm':
147 /*
148 * RFC 5802:
149 * m: This attribute is reserved for future extensibility. In this
150 * version of SCRAM, its presence in a client or a server message
151 * MUST cause authentication failure when the attribute is parsed by
152 * the other end.
153 */
154 throw new AuthenticationException(
155 "Server sent reserved token: `m'");
156 }
157 }
158 }
159
160 if (iterationCount < 0) {
161 throw new AuthenticationException("Server did not send iteration count");
162 }
163 if (nonce.isEmpty() || !nonce.startsWith(clientNonce)) {
164 throw new AuthenticationException(
165 "Server nonce does not contain client nonce: " + nonce);
166 }
167 if (salt.isEmpty()) {
168 throw new AuthenticationException("Server sent empty salt");
169 }
170
171 final String clientFinalMessageWithoutProof =
172 "c="
173 + Base64.encodeToString(GS2_HEADER.getBytes(), Base64.NO_WRAP)
174 + ",r="
175 + nonce;
176 final byte[] authMessage =
177 (clientFirstMessageBare
178 + ','
179 + new String(serverFirstMessage)
180 + ','
181 + clientFinalMessageWithoutProof)
182 .getBytes();
183
184 final KeyPair keys;
185 try {
186 keys =
187 getKeyPair(
188 CryptoHelper.saslPrep(account.getPassword()),
189 salt,
190 iterationCount);
191 } catch (ExecutionException e) {
192 throw new AuthenticationException("Invalid keys generated");
193 }
194 final byte[] clientSignature;
195 try {
196 serverSignature = hmac(keys.serverKey, authMessage);
197 final byte[] storedKey = digest(keys.clientKey);
198
199 clientSignature = hmac(storedKey, authMessage);
200
201 } catch (final InvalidKeyException e) {
202 throw new AuthenticationException(e);
203 }
204
205 final byte[] clientProof = new byte[keys.clientKey.length];
206
207 if (clientSignature.length < keys.clientKey.length) {
208 throw new AuthenticationException(
209 "client signature was shorter than clientKey");
210 }
211
212 for (int i = 0; i < clientProof.length; i++) {
213 clientProof[i] = (byte) (keys.clientKey[i] ^ clientSignature[i]);
214 }
215
216 final String clientFinalMessage =
217 clientFinalMessageWithoutProof
218 + ",p="
219 + Base64.encodeToString(clientProof, Base64.NO_WRAP);
220 state = State.RESPONSE_SENT;
221 return Base64.encodeToString(clientFinalMessage.getBytes(), Base64.NO_WRAP);
222 case RESPONSE_SENT:
223 try {
224 final String clientCalculatedServerFinalMessage =
225 "v=" + Base64.encodeToString(serverSignature, Base64.NO_WRAP);
226 if (!clientCalculatedServerFinalMessage.equals(
227 new String(Base64.decode(challenge, Base64.DEFAULT)))) {
228 throw new Exception();
229 }
230 state = State.VALID_SERVER_RESPONSE;
231 return "";
232 } catch (Exception e) {
233 throw new AuthenticationException(
234 "Server final message does not match calculated final message");
235 }
236 default:
237 throw new InvalidStateException(state);
238 }
239 }
240
241 private static class CacheKey {
242 final String algorithm;
243 final String password;
244 final String salt;
245 final int iterations;
246
247 private CacheKey(String algorithm, String password, String salt, int iterations) {
248 this.algorithm = algorithm;
249 this.password = password;
250 this.salt = salt;
251 this.iterations = iterations;
252 }
253
254 @Override
255 public boolean equals(Object o) {
256 if (this == o) return true;
257 if (o == null || getClass() != o.getClass()) return false;
258 CacheKey cacheKey = (CacheKey) o;
259 return iterations == cacheKey.iterations
260 && Objects.equal(algorithm, cacheKey.algorithm)
261 && Objects.equal(password, cacheKey.password)
262 && Objects.equal(salt, cacheKey.salt);
263 }
264
265 @Override
266 public int hashCode() {
267 return Objects.hashCode(algorithm, password, salt, iterations);
268 }
269 }
270
271 private static class KeyPair {
272 final byte[] clientKey;
273 final byte[] serverKey;
274
275 KeyPair(final byte[] clientKey, final byte[] serverKey) {
276 this.clientKey = clientKey;
277 this.serverKey = serverKey;
278 }
279 }
280}