ScramMechanism.java

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