ScramMechanism.java

  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}