aboutsummaryrefslogtreecommitdiff
path: root/app/src/main/java/moe/yuuta/gplicense/LicenseChecker.java
blob: e909aecd9d429e500cf860ee6a7ba1e79ea4e503 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
package moe.yuuta.gplicense;

import android.annotation.SuppressLint;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager.NameNotFoundException;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.IBinder;
import android.os.RemoteException;
import android.provider.Settings.Secure;
import com.elvishew.xlog.Logger;
import com.elvishew.xlog.XLog;
import moe.yuuta.ext.*;
import moe.yuuta.gplicense.util.Base64;
import moe.yuuta.gplicense.util.Base64DecoderException;
import moe.yuuta.workmode.BuildConfig;

import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.PublicKey;
import java.security.SecureRandom;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.X509EncodedKeySpec;
import java.util.*;

/**
 * Client library for Google Play license verifications.
 * <p>
 * The LicenseChecker is configured via a {@link Policy} which contains the logic to determine
 * whether a user should have access to the application. For example, the Policy can define a
 * threshold for allowable number of server or client failures before the library reports the user
 * as not having access.
 * <p>
 * Must also provide the Base64-encoded RSA public key associated with your developer account. The
 * public key is obtainable from the publisher site.
 */
public class LicenseChecker implements ConnCallback {
    private static final Logger logger = XLog.tag("LCK").build();

    private static final String KEY_FACTORY_ALGORITHM = "RSA";

    // Timeout value (in milliseconds) for calls to service.
    private static final int TIMEOUT_MS = 10 * 1000;

    private static final SecureRandom RANDOM = new SecureRandom();
    private static final boolean DEBUG_LICENSE_ERROR = BuildConfig.DEBUG;

    private IService mService;

    private PublicKey mPublicKey;
    private final Context mContext;
    private final Policy mPolicy;
    /**
     * A handler for running tasks on a background thread. We don't want license processing to block
     * the UI thread.
     */
    private Handler mHandler;
    private final String mPackageName;
    private final String mVersionCode;
    private final Set<LicenseValidator> mChecksInProgress = new HashSet<LicenseValidator>();
    private final Queue<LicenseValidator> mPendingChecks = new LinkedList<LicenseValidator>();

    private LicServiceConn mConn;

    /**
     * @param context a Context
     * @param policy implementation of Policy
     * @param encodedPublicKey Base64-encoded RSA public key
     * @throws IllegalArgumentException if encodedPublicKey is invalid
     */
    public LicenseChecker(Context context, Policy policy, String encodedPublicKey) {
        mContext = context;
        mPolicy = policy;
        mPublicKey = generatePublicKey(encodedPublicKey);
        mPackageName = mContext.getPackageName();
        mVersionCode = getVersionCode(context, mPackageName);
        HandlerThread handlerThread = new HandlerThread("background thread");
        handlerThread.start();
        mHandler = new Handler(handlerThread.getLooper());
    }

    /**
     * Generates a PublicKey instance from a string containing the Base64-encoded public key.
     *
     * @param encodedPublicKey Base64-encoded public key
     * @throws IllegalArgumentException if encodedPublicKey is invalid
     */
    private static PublicKey generatePublicKey(String encodedPublicKey) {
        try {
            byte[] decodedKey = Base64.decode(encodedPublicKey);
            KeyFactory keyFactory = KeyFactory.getInstance(KEY_FACTORY_ALGORITHM);

            return keyFactory.generatePublic(new X509EncodedKeySpec(decodedKey));
        } catch (NoSuchAlgorithmException e) {
            // This won't happen in an Android-compatible environment.
            throw new RuntimeException(e);
        } catch (Base64DecoderException e) {
            logger.e("Could not decode from Base64.");
            throw new IllegalArgumentException(e);
        } catch (InvalidKeySpecException e) {
            logger.e("Invalid key specification.");
            throw new IllegalArgumentException(e);
        }
    }

    /**
     * Checks if the user should have access to the app. Binds the service if necessary.
     * <p>
     * NOTE: This call uses a trivially obfuscated string (base64-encoded). For best security, we
     * recommend obfuscating the string that is passed into bindService using another method of your
     * own devising.
     * <p>
     * source string: "com.android.vending.licensing.IService"
     * <p>
     *
     * @param callback
     */
    public synchronized void checkAccess(LicenseCheckerCallback callback) {
        // If we have a valid recent LICENSED response, we can skip asking
        // Market.
        if (mPolicy.allowAccess()) {
            logger.i("Using cached response");
            callback.allow(Policy.LICENSED);
        } else {
            LicenseValidator validator = new LicenseValidator(mPolicy, new NullDeviceLimiter(),
                    callback, generateNonce(), mPackageName, mVersionCode);

            if (mService == null) {
                logger.i("Binding to service.");
                try {
                    mConn = new LicServiceConn(this);
                    boolean bindResult = mContext
                            .bindService(
                                    new Intent(
                                            new String(
                                                    // Base64 encoded -
                                                    // com.android.vending.licensing.IService
                                                    // Consider encoding this in another way in your
                                                    // code to improve security
                                                    Base64.decode(
                                                            "Y29tLmFuZHJvaWQudmVuZGluZy5saWNlbnNpbmcuSUxpY2Vuc2luZ1NlcnZpY2U=")))
                                            // As of Android 5.0, implicit
                                            // Service Intents are no longer
                                            // allowed because it's not
                                            // possible for the user to
                                            // participate in disambiguating
                                            // them. This does mean we break
                                            // compatibility with Android
                                            // Cupcake devices with this
                                            // release, since setPackage was
                                            // added in Donut.
                                            .setPackage(
                                                    new String(
                                                            // Base64
                                                            // encoded -
                                                            // com.android.vending
                                                            Base64.decode(
                                                                    "Y29tLmFuZHJvaWQudmVuZGluZw=="))),
                                    mConn, // ServiceConnection.
                                    Context.BIND_AUTO_CREATE);
                    if (bindResult) {
                        mPendingChecks.offer(validator);
                    } else {
                        logger.e("Could not bind to service.");
                        handleServiceConnectionError(validator);
                    }
                } catch (SecurityException e) {
                    callback.applicationError(LicenseCheckerCallback.ERROR_MISSING_PERMISSION);
                } catch (Base64DecoderException e) {
                    e.printStackTrace();
                }
            } else {
                mPendingChecks.offer(validator);
                runChecks();
            }
        }
    }

    private void runChecks() {
        LicenseValidator validator;
        while ((validator = mPendingChecks.poll()) != null) {
            try {
                logger.i("Executing on service for " + validator.getPackageName());
                mService.exec(
                        validator.getNonce(), validator.getPackageName(),
                        new IPCResultListener(new ResultListener(validator)));
                mChecksInProgress.add(validator);
            } catch (RemoteException e) {
                logger.w("RemoteException in exec call.", e);
                handleServiceConnectionError(validator);
            }
        }
    }

    private synchronized void finishCheck(LicenseValidator validator) {
        mChecksInProgress.remove(validator);
        if (mChecksInProgress.isEmpty()) {
            cleanupService();
        }
    }

    private class ResultListener implements ResultCallback {
        private final LicenseValidator mValidator;
        private Runnable mOnTimeout;

        public ResultListener(LicenseValidator validator) {
            mValidator = validator;
            mOnTimeout = () -> {
                logger.i("Check timed out.");
                handleServiceConnectionError(mValidator);
                finishCheck(mValidator);
            };
            startTimeout();
        }

        private static final int ERROR_CONTACTING_SERVER = 0x101;
        private static final int ERROR_INVALID_PACKAGE_NAME = 0x102;
        private static final int ERROR_NON_MATCHING_UID = 0x103;

        // Runs in IPC thread pool. Post it to the Handler, so we can guarantee
        // either this or the timeout runs.
        @Override
        public void verifyLicense(final int responseCode, final String signedData,
                                  final String signature) {
            mHandler.post(() -> {
                logger.i("Received response. (code: " + responseCode + ")");
                // Make sure it hasn't already timed out.
                if (mChecksInProgress.contains(mValidator)) {
                    clearTimeout();
                    mValidator.verify(mPublicKey, responseCode, signedData, signature);
                    finishCheck(mValidator);
                }
                if (DEBUG_LICENSE_ERROR) {
                    boolean logResponse;
                    String stringError = null;
                    switch (responseCode) {
                        case ERROR_CONTACTING_SERVER:
                            logResponse = true;
                            stringError = "ERROR_CONTACTING_SERVER";
                            break;
                        case ERROR_INVALID_PACKAGE_NAME:
                            logResponse = true;
                            stringError = "ERROR_INVALID_PACKAGE_NAME";
                            break;
                        case ERROR_NON_MATCHING_UID:
                            logResponse = true;
                            stringError = "ERROR_NON_MATCHING_UID";
                            break;
                        default:
                            logResponse = false;
                    }

                    if (logResponse) {
                        @SuppressLint("HardwareIds") String android_id = Secure.getString(mContext.getContentResolver(),
                                Secure.ANDROID_ID);
                        Date date = new Date();
                        logger.d("Server Failure: " + stringError);
                        logger.d("Android ID: " + android_id);
                        logger.d("Time: " + date.toGMTString());
                    }
                }

            });
        }

        private void startTimeout() {
            logger.i("Start monitoring timeout.");
            mHandler.postDelayed(mOnTimeout, TIMEOUT_MS);
        }

        private void clearTimeout() {
            logger.i("Clearing timeout.");
            mHandler.removeCallbacks(mOnTimeout);
        }
    }

    @Override
    public synchronized void onServiceConnected(ComponentName name, IBinder service) {
        mService = IService.Stub.asInterface(service);
        runChecks();
    }

    @Override
    public synchronized void onServiceDisconnected(ComponentName name) {
        // Called when the connection with the service has been
        // unexpectedly disconnected. That is, Market crashed.
        // If there are any checks in progress, the timeouts will handle them.
        logger.w("Service unexpectedly disconnected.");
        mService = null;
    }

    /**
     * Generates policy response for service connection errors, as a result of disconnections or
     * timeouts.
     */
    private synchronized void handleServiceConnectionError(LicenseValidator validator) {
        mPolicy.processServerResponse(Policy.RETRY, null);

        if (mPolicy.allowAccess()) {
            validator.getCallback().allow(Policy.RETRY);
        } else {
            validator.getCallback().dontAllow(Policy.RETRY);
        }
    }

    /** Unbinds service if necessary and removes reference to it. */
    private void cleanupService() {
        if (mService != null) {
            try {
                mContext.unbindService(mConn);
            } catch (IllegalArgumentException e) {
                // Somehow we've already been unbound. This is a non-fatal
                // error.
                logger.e("Unable to unbind from licensing service (already unbound)");
            }
            mService = null;
        }
    }

    /**
     * Inform the library that the hostContext is about to be destroyed, so that any open connections
     * can be cleaned up.
     * <p>
     * Failure to call this method can result in a crash under certain circumstances, such as during
     * screen rotation if an Activity requests the license check or when the user exits the
     * application.
     */
    public synchronized void onDestroy() {
        cleanupService();
        mHandler.getLooper().quit();
    }

    /** Generates a nonce (number used once). */
    private int generateNonce() {
        return RANDOM.nextInt();
    }

    /**
     * Get version code for the application package name.
     *
     * @param context
     * @param packageName application package name
     * @return the version code or empty string if package not found
     */
    private static String getVersionCode(Context context, String packageName) {
        try {
            return String.valueOf(
                    context.getPackageManager().getPackageInfo(packageName, 0).versionCode);
        } catch (NameNotFoundException e) {
            logger.e("Package not found. could not get version code.");
            return "";
        }
    }
}