com.jrummyapps.android.safetynet.SafetyNetHelper.java Source code

Java tutorial

Introduction

Here is the source code for com.jrummyapps.android.safetynet.SafetyNetHelper.java

Source

/*
 * Copyright (C) 2016 JRummy Apps Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 * Raw
 */

package com.jrummyapps.android.safetynet;

import android.annotation.SuppressLint;
import android.content.Context;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.content.pm.Signature;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.support.annotation.IntDef;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.text.TextUtils;
import android.util.Base64;
import android.util.Log;
import com.google.android.gms.common.ConnectionResult;
import com.google.android.gms.common.api.GoogleApiClient;
import com.google.android.gms.common.api.GoogleApiClient.ConnectionCallbacks;
import com.google.android.gms.common.api.GoogleApiClient.OnConnectionFailedListener;
import com.google.android.gms.safetynet.SafetyNet;
import com.google.android.gms.safetynet.SafetyNetApi;
import java.io.BufferedReader;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.URL;
import java.security.DigestInputStream;
import java.security.KeyStore;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Set;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.TrustManagerFactory;
import javax.net.ssl.X509TrustManager;
import org.json.JSONArray;
import org.json.JSONObject;

/**
 * <a href="https://developer.android.com/training/safetynet/index.html">SafetyNet: Google's tamper detection.</a>
 */
public class SafetyNetHelper implements Runnable, OnConnectionFailedListener, ConnectionCallbacks {

    /**
     * The error code used when connecting with Google Play Services failed.
     */
    public static final int RESPONSE_FAILED_CONNECTION = 1;

    /**
     * The error code used when the SafetyNet Service failed.
     */
    public static final int RESPONSE_FAILED_ATTESTATION = 2;

    /**
     * The error code used when parsing the JSON Web Signature failed.
     */
    public static final int RESPONSE_FAILED_PARSING_JWS = 3;

    /**
     * An unknown error occurred.
     */
    public static final int UNKNOWN_ERROR = 4;

    /**
     * URL to use the Android Device Verification API which only validates that the provided JWS message was received
     * from the SafetyNet service. The API allows for 10,000 requests per day.
     */
    private static final String GOOGLE_VERIFICATION_URL = "https://www.googleapis.com/androidcheck/v1/attestations/verify?key=";

    /**
     * This is used to validate the payload response from the SafetyNet.API, if it exceeds this duration, the response is
     * considered invalid.
     */
    private static final int MAX_TIMESTAMP_DURATION = 3 * 60 * 1000;

    private static final String SHA_256 = "SHA-256";
    private static final String TAG = "SafetyNetHelper";

    private static SecureRandom secureRandom;

    /**
     * Create a request to the {@link SafetyNet}.
     *
     * @param context
     *     the application context
     * @return A {@link Builder} object to create the {@link SafetyNetHelper}.
     */
    public static Builder with(Context context) {
        return new Builder(context);
    }

    /**
     * Validate the SafetyNet response using the Android Device Verification API. This API performs a validation check on
     * the JWS message returned from the SafetyNet service.
     *
     * <b>Important:</b> This use of the Android Device Verification API only validates that the provided JWS message was
     * received from the SafetyNet service. It <i>does not</i> verify that the payload data matches your original
     * compatibility check request.
     *
     * @param jws
     *     The output of {@link SafetyNetApi.AttestationResult#getJwsResult()}.
     * @param apiKey
     *     The Android Device Verification API key
     * @return {@code true} if the provided JWS message was received from the SafetyNet service.
     * @throws SafetyNetError
     *     if an error occurs while verifying the JSON Web Signature.
     */
    public static boolean validate(@NonNull String jws, @NonNull String apiKey) throws SafetyNetError {
        try {
            URL verifyApiUrl = new URL(GOOGLE_VERIFICATION_URL + apiKey);

            TrustManagerFactory trustManagerFactory = TrustManagerFactory
                    .getInstance(TrustManagerFactory.getDefaultAlgorithm());
            trustManagerFactory.init((KeyStore) null);
            TrustManager[] defaultTrustManagers = trustManagerFactory.getTrustManagers();
            TrustManager[] trustManagers = Arrays.copyOf(defaultTrustManagers, defaultTrustManagers.length + 1);
            trustManagers[defaultTrustManagers.length] = new GoogleApisTrustManager();

            SSLContext sslContext = SSLContext.getInstance("TLS");
            sslContext.init(null, trustManagers, null);

            HttpsURLConnection urlConnection = (HttpsURLConnection) verifyApiUrl.openConnection();
            urlConnection.setSSLSocketFactory(sslContext.getSocketFactory());
            urlConnection.setRequestMethod("POST");
            urlConnection.setRequestProperty("Content-Type", "application/json");

            JSONObject requestJson = new JSONObject();
            requestJson.put("signedAttestation", jws);
            byte[] outputInBytes = requestJson.toString().getBytes("UTF-8");
            OutputStream os = urlConnection.getOutputStream();
            os.write(outputInBytes);
            os.close();

            urlConnection.connect();
            InputStream is = urlConnection.getInputStream();
            BufferedReader reader = new BufferedReader(new InputStreamReader(is));
            StringBuilder sb = new StringBuilder();
            for (String line = reader.readLine(), nl = ""; line != null; line = reader.readLine(), nl = "\n") {
                sb.append(nl).append(line);
            }

            return new JSONObject(sb.toString()).getBoolean("isValidSignature");
        } catch (Exception e) {
            throw new SafetyNetError(e);
        }
    }

    /**
     * Parse the JSON Web Signature (JWS) response from the {@link SafetyNet} response.
     *
     * @param jws
     *     The output of {@link SafetyNetApi.AttestationResult#getJwsResult()}.
     * @return The {@link SafetyNetResponse}.
     * @throws SafetyNetError
     *     If an error occurs while parsing the JWS.
     */
    public static SafetyNetResponse getSafetyNetResponseFromJws(@NonNull String jws) throws SafetyNetError {
        try {
            String[] parts = jws.split("\\.");

            HashMap<String, Object> header = new HashMap<>();

            try {
                JSONObject json = new JSONObject(new String(Base64.decode(parts[0], Base64.DEFAULT)));
                for (Iterator<String> iterator = json.keys(); iterator.hasNext();) {
                    String key = iterator.next();
                    header.put(key, json.get(key));
                }
            } catch (Exception ignored) {
            }

            JSONObject json = new JSONObject(new String(Base64.decode(parts[1], Base64.DEFAULT)));
            String nonce = json.optString("nonce");
            long timestampMs = json.optLong("timestampMs");
            String apkPackageName = json.optString("apkPackageName");

            JSONArray jsonArray = json.optJSONArray("apkCertificateDigestSha256");
            String[] apkCertificateDigestSha256 = null;
            if (jsonArray != null) {
                int length = jsonArray.length();
                apkCertificateDigestSha256 = new String[length];
                for (int i = 0; i < length; i++) {
                    apkCertificateDigestSha256[i] = jsonArray.getString(i);
                }
            }

            String apkDigestSha256 = json.optString("apkDigestSha256");
            boolean ctsProfileMatch = json.optBoolean("ctsProfileMatch");
            String signature = parts[2];

            return new SafetyNetResponse(jws, header, nonce, timestampMs, apkPackageName,
                    apkCertificateDigestSha256, apkDigestSha256, ctsProfileMatch, signature);

        } catch (Exception e) {
            throw new SafetyNetError(e);
        }
    }

    /**
     * Generates a random token.
     *
     * @return A nonce, with a length of 32, to be used with the {@link SafetyNet} request.
     */
    public static byte[] generateOneTimeNonce() {
        if (secureRandom == null) {
            secureRandom = new SecureRandom();
        }
        byte[] nonce = new byte[32];
        secureRandom.nextBytes(nonce);
        return nonce;
    }

    private final Context context;
    private final Handler handler;
    private final Set<SafetyNetListener> listeners;
    private final byte[] nonce;
    private final String apiKey;
    private GoogleApiClient googleApiClient;
    private long requestTimestamp;
    private boolean running;
    private boolean cancel;

    private SafetyNetHelper(Builder builder) {
        this.context = builder.context;
        this.handler = builder.handler;
        this.listeners = builder.listeners;
        this.nonce = builder.nonce;
        this.apiKey = builder.apiKey;
    }

    @Override
    public void run() {
        running = true;
        googleApiClient = new GoogleApiClient.Builder(context).addOnConnectionFailedListener(this)
                .addConnectionCallbacks(this).addApi(SafetyNet.API).build();
        googleApiClient.connect();
    }

    @Override
    public void onConnectionFailed(@NonNull ConnectionResult connectionResult) {
        onError(RESPONSE_FAILED_CONNECTION, "An error occurred while connecting with Google Play Services.");
    }

    @Override
    public void onConnected(@Nullable Bundle bundle) {
        Runnable runnable = new Runnable() {

            @Override
            public void run() {
                try {
                    requestTimestamp = System.currentTimeMillis();
                    SafetyNetApi.AttestationResult result = SafetyNet.SafetyNetApi.attest(googleApiClient, nonce)
                            .await();
                    if (cancel) {
                        return;
                    }
                    if (!result.getStatus().isSuccess()) {
                        onError(RESPONSE_FAILED_ATTESTATION,
                                "An error occurred while communicating with SafetyNet.");
                        return;
                    }
                    try {
                        SafetyNetResponse response = getSafetyNetResponseFromJws(result.getJwsResult());
                        SafetyNetVerification verification = verify(response);
                        onFinished(response, verification);
                    } catch (SafetyNetError e) {
                        onError(RESPONSE_FAILED_PARSING_JWS, e.getLocalizedMessage());
                    }
                } catch (Exception e) {
                    onError(UNKNOWN_ERROR, e.getLocalizedMessage());
                }
            }
        };

        if (Looper.getMainLooper() == Looper.myLooper()) {
            new Thread(runnable).start();
        } else {
            runnable.run();
        }
    }

    @Override
    public void onConnectionSuspended(int reason) {
        onError(RESPONSE_FAILED_CONNECTION, "An error occurred while connecting with Google Play Services.");
    }

    /**
     * Check if the process is running in the background.
     *
     * @return {@code true} if the {@link SafetyNet} API is currently being queried.
     */
    public boolean isRunning() {
        return running;
    }

    /**
     * Cancel running or posting the results
     */
    public void cancel() {
        cancel = true;
    }

    private void onError(@SafetyNetErrorCode final int errorCode, final String reason) {
        running = false;
        if (!cancel) {
            handler.post(new Runnable() {

                @Override
                public void run() {
                    try {
                        for (SafetyNetListener listener : listeners) {
                            listener.onError(errorCode, reason);
                        }
                    } catch (IllegalStateException e) {
                        Log.e(TAG, "Error calling listener", e);
                    }
                }
            });
        }
    }

    private void onFinished(final SafetyNetResponse response, final SafetyNetVerification verification) {
        running = false;
        if (!cancel) {
            handler.post(new Runnable() {

                @Override
                public void run() {
                    try {
                        for (SafetyNetListener listener : listeners) {
                            listener.onFinished(response, verification);
                        }
                    } catch (IllegalStateException e) {
                        Log.e(TAG, "Error calling listener", e);
                    }
                }
            });
        }
    }

    private SafetyNetVerification verify(SafetyNetResponse response) {
        Boolean isValidSignature = null;
        if (!TextUtils.isEmpty(apiKey)) {
            try {
                isValidSignature = validate(response.jws, apiKey);
            } catch (SafetyNetError e) {
                Log.d(TAG, "An error occurred while using the Android Device Verification API", e);
            }
        }

        String nonce = Base64.encodeToString(this.nonce, Base64.DEFAULT).trim();
        boolean isValidNonce = TextUtils.equals(nonce, response.nonce);

        long durationOfReq = response.timestampMs - requestTimestamp;
        boolean isValidResponseTime = durationOfReq < MAX_TIMESTAMP_DURATION;

        boolean isValidApkSignature = true;
        if (response.apkCertificateDigestSha256 != null && response.apkCertificateDigestSha256.length > 0) {
            isValidApkSignature = Arrays.equals(getApkCertificateDigests().toArray(),
                    response.apkCertificateDigestSha256);
        }

        boolean isValidApkDigest = true;
        if (!TextUtils.isEmpty(response.apkDigestSha256)) {
            isValidApkDigest = TextUtils.equals(getApkDigestSha256(), response.apkDigestSha256);
        }

        return new SafetyNetVerification(isValidSignature, isValidNonce, isValidResponseTime, isValidApkSignature,
                isValidApkDigest);
    }

    @Nullable
    private String getApkDigestSha256() {
        try {
            FileInputStream fis = new FileInputStream(context.getPackageCodePath());
            MessageDigest md = MessageDigest.getInstance(SHA_256);
            try {
                DigestInputStream dis = new DigestInputStream(fis, md);
                byte[] buffer = new byte[2048];
                while (dis.read(buffer) != -1) {
                    //
                }
                dis.close();
            } finally {
                fis.close();
            }
            return Base64.encodeToString(md.digest(), Base64.NO_WRAP);
        } catch (IOException | NoSuchAlgorithmException e) {
            return null;
        }
    }

    @SuppressLint("PackageManagerGetSignatures")
    private List<String> getApkCertificateDigests() {
        List<String> apkCertificateDigests = new ArrayList<>();
        PackageManager pm = context.getPackageManager();
        PackageInfo packageInfo;
        try {
            packageInfo = pm.getPackageInfo(context.getPackageName(), PackageManager.GET_SIGNATURES);
        } catch (PackageManager.NameNotFoundException wtf) {
            return apkCertificateDigests;
        }
        Signature[] signatures = packageInfo.signatures;
        for (Signature signature : signatures) {
            try {
                MessageDigest md = MessageDigest.getInstance(SHA_256);
                md.update(signature.toByteArray());
                byte[] digest = md.digest();
                apkCertificateDigests.add(Base64.encodeToString(digest, Base64.NO_WRAP));
            } catch (NoSuchAlgorithmException ignored) {
            }
        }
        return apkCertificateDigests;
    }

    /**
     * The {@link SafetyNet} API payload response (decoded from the JSON Web Token).
     */
    public static class SafetyNetResponse {

        /** The value of {@link SafetyNetApi.AttestationResult#getJwsResult()} */
        public final String jws;
        /** The headers from the JSON Web Signature */
        public final HashMap<String, Object> header;
        /** The requested nonce */
        public final String nonce;
        /** The timestamp of the request */
        public final long timestampMs;
        /** The package name of the requesting app */
        public final String apkPackageName;
        /** The APK signature(s) of the requesting app */
        public final String[] apkCertificateDigestSha256;
        /** The APK digest of the requesting app */
        public final String apkDigestSha256;
        /** {@code true} if the device passed the compatibility test */
        public final boolean ctsProfileMatch;
        /** The JWS signature */
        public final String signature;

        SafetyNetResponse(String jws, HashMap<String, Object> header, String nonce, long timestampMs,
                String apkPackageName, String[] apkCertificateDigestSha256, String apkDigestSha256,
                boolean ctsProfileMatch, String signature) {
            this.jws = jws;
            this.header = header;
            this.nonce = nonce;
            this.timestampMs = timestampMs;
            this.apkPackageName = apkPackageName;
            this.apkCertificateDigestSha256 = apkCertificateDigestSha256;
            this.apkDigestSha256 = apkDigestSha256;
            this.ctsProfileMatch = ctsProfileMatch;
            this.signature = signature;
        }

    }

    /**
     * Validates the {@link SafetyNet} response.
     */
    public static class SafetyNetVerification {

        /**
         * The response from the Android Device Verification API. If the apiKey was not set then this is {@code null}
         */
        @Nullable
        public final Boolean isValidSignature;

        /**
         * {@code true} if the request nonce matches the nonce returned from the {@link SafetyNet} result.
         */
        public final boolean isValidNonce;

        /**
         * {@code true} if the payload response took more than {@value MAX_TIMESTAMP_DURATION} milliseconds.
         */
        public final boolean isValidResponseTime;

        /**
         * {@code true} if the payload's "apkCertificateDigestSha256" matches the signature of this APK.
         */
        public final boolean isValidApkSignature;

        /**
         * {@code true} if the payload's "apkDigestSha256" matches the digest of this APK.
         */
        public final boolean isValidApkDigest;

        SafetyNetVerification(@Nullable Boolean isValidSignature, boolean isValidNonce, boolean isValidResponseTime,
                boolean isValidApkSignature, boolean isValidApkDigest) {
            this.isValidSignature = isValidSignature;
            this.isValidNonce = isValidNonce;
            this.isValidResponseTime = isValidResponseTime;
            this.isValidApkSignature = isValidApkSignature;
            this.isValidApkDigest = isValidApkDigest;
        }

        /**
         * Check if the {@link SafetyNet} response is valid.
         *
         * @return {@code true} if the response from {@link SafetyNet} is verified and valid.
         */
        public boolean isValid() {
            return (isValidSignature == null ? true
                    /* No API key to check the response. Assume true. */ : isValidSignature) && isValidNonce
                    && isValidResponseTime && isValidApkSignature && isValidApkDigest;
        }

    }

    /**
     * An exception used when retrieving or parsing a response from the {@link SafetyNet} API failed.
     */
    public static class SafetyNetError extends Exception {

        public SafetyNetError(Throwable cause) {
            super(cause);
        }

    }

    @IntDef({ RESPONSE_FAILED_ATTESTATION, RESPONSE_FAILED_CONNECTION, RESPONSE_FAILED_PARSING_JWS, UNKNOWN_ERROR })
    public @interface SafetyNetErrorCode {

    }

    /**
     * Interface definition for a callback to be invoked during/after the {@link SafetyNet} API is queried.
     */
    public interface SafetyNetListener {

        /**
         * Called when an error occurs while trying to receive a response from the {@link SafetyNet} API.
         *
         * @param errorCode
         *     The error code
         * @param reason
         *     The error reason
         */
        void onError(@SafetyNetErrorCode int errorCode, String reason);

        /**
         * Called when the {@link SafetyNet} API returns a valid response.
         *
         * @param response
         *     The {@link SafetyNet} API response
         * @param verification
         *     Contains info about the validity of the response.
         */
        void onFinished(SafetyNetResponse response, SafetyNetVerification verification);

    }

    /**
     * Custom TrustManager to use SSL public key Pinning to verify connections to www.googleapis.com
     * Created by scottab on 27/05/2015.
     */
    public static class GoogleApisTrustManager implements X509TrustManager {

        private final static String[] GOOGLEAPIS_COM_PINS = { "sha1/f2QjSla9GtnwpqhqreDLIkQNFu8=",
                "sha1/Q9rWMO5T+KmAym79hfRqo3mQ4Oo=", "sha1/wHqYaI2J+6sFZAwRfap9ZbjKzE4=" };

        @SuppressLint("TrustAllX509TrustManager")
        @Override
        public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
            // No-Op
        }

        @Override
        public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
            for (X509Certificate cert : chain) {
                boolean expected = validateCertificatePin(cert);
                if (!expected) {
                    throw new CertificateException(
                            "could not find a valid SSL public key pin for www.googleapis.com");
                }
            }
        }

        @Override
        public X509Certificate[] getAcceptedIssuers() {
            return new X509Certificate[0];
        }

        private boolean validateCertificatePin(X509Certificate certificate) throws CertificateException {

            MessageDigest digest;
            try {
                digest = MessageDigest.getInstance("SHA1");
            } catch (NoSuchAlgorithmException e) {
                throw new CertificateException(e);
            }

            byte[] pubKeyInfo = certificate.getPublicKey().getEncoded();
            byte[] pin = digest.digest(pubKeyInfo);
            String pinAsBase64 = "sha1/" + Base64.encodeToString(pin, Base64.DEFAULT);
            for (String validPin : GOOGLEAPIS_COM_PINS) {
                if (validPin.equalsIgnoreCase(pinAsBase64)) {
                    return true;
                }
            }
            return false;
        }

    }

    public static class Builder {

        final Set<SafetyNetListener> listeners = new HashSet<>();
        final Context context;
        Handler handler;
        String apiKey;
        byte[] nonce;

        Builder(@NonNull Context context) {
            this.context = context.getApplicationContext();
        }

        /**
         * Set the {@link SafetyNetListener}.
         *
         * @param listener
         *     The {@link SafetyNetListener} to receive callbacks on the UI thread.
         * @return this {@link Builder} object for chaining method calls.
         */
        public Builder addSafetyNetListener(@NonNull SafetyNetListener listener) {
            this.listeners.add(listener);
            return this;
        }

        /**
         * Set the nonce used in the {@link SafetyNet} request.
         *
         * @param nonce
         *     A nonce used with a SafetyNet request should be at least 16 bytes in length.
         * @return this {@link Builder} object for chaining method calls.
         */
        public Builder setNonce(@NonNull byte[] nonce) {
            this.nonce = nonce;
            return this;
        }

        /**
         * Set the {@link Handler} that is used to post callbacks.
         *
         * @param handler
         *     The {@link Handler}
         * @return this {@link Builder} object for chaining method calls.
         */
        public Builder setHandler(@NonNull Handler handler) {
            this.handler = handler;
            return this;
        }

        /**
         * Set the Android Device Verification API key. If set to {@code null} then the request will not be validated.
         *
         * @param apiKey
         *     The API key for the Android Device Verification API.
         * @return this {@link Builder} object for chaining method calls.
         */
        public Builder setApiKey(@NonNull String apiKey) {
            this.apiKey = apiKey;
            return this;
        }

        /**
         * Run the {@link SafetyNet} request.
         */
        public SafetyNetHelper run() {
            if (nonce == null) {
                nonce = generateOneTimeNonce();
            }
            if (handler == null) {
                handler = new Handler(Looper.getMainLooper());
            }
            SafetyNetHelper safetyNetHelper = new SafetyNetHelper(this);
            safetyNetHelper.run();
            return safetyNetHelper;
        }

    }

}