Search code examples
androiddalvikandroid-versionandroid-api-levelsandroid-fingerprint-api

How to Use Unsupported Exception for Lower Platform Version


I have a DialogFragment that handles login and fingerprint authentication for my application. This fragment uses two classes that are exclusive to API 23, KeyGenParameterSpec and KeyPermanentlyInvalidatedException. I had been under the impression that I could use these classes, as long as I check the build version before I try to initialize the classes (outlined here):

if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
    ...
} else {
    ...
}

But it appears that this is not the case. If I try to run this code on a version prior to API 20, the Dalvik VM rejects the entire class and throws a VerifyError. Though, the code does work for API 20 and greater. How can I use these methods in my code while still allowing the code to be used for previous API levels?

The full stack trace is as follows:

05-31 14:35:50.924 11941-11941/com.example.app E/dalvikvm: Could not find class 'android.security.keystore.KeyGenParameterSpec$Builder', referenced from method com.example.app.ui.fragment.util.LoginFragment.createKeyPair
05-31 14:35:50.924 11941-11941/com.example.app W/dalvikvm: VFY: unable to resolve new-instance 263 (Landroid/security/keystore/KeyGenParameterSpec$Builder;) in Lcom/example/app/ui/fragment/util/LoginFragment;
05-31 14:35:50.924 11941-11941/com.example.app D/dalvikvm: VFY: replacing opcode 0x22 at 0x000c
05-31 14:35:50.924 11941-11941/com.example.app W/dalvikvm: VFY: unable to resolve exception class 265 (Landroid/security/keystore/KeyPermanentlyInvalidatedException;)
05-31 14:35:50.924 11941-11941/com.example.app W/dalvikvm: VFY: unable to find exception handler at addr 0x3f
05-31 14:35:50.924 11941-11941/com.example.app W/dalvikvm: VFY:  rejected Lcom/example/app/ui/fragment/util/LoginFragment;.initializeCipher (I)Z
05-31 14:35:50.924 11941-11941/cp W/dalvikvm: VFY:  rejecting opcode 0x0d at 0x003f
05-31 14:35:50.924 11941-11941/com.example.app W/dalvikvm: VFY:  rejected Lcom/example/app/ui/fragment/util/LoginFragment;.initializeCipher (I)Z
05-31 14:35:50.924 11941-11941/com.example.app W/dalvikvm: Verifier rejected class Lcom/example/app/ui/fragment/util/LoginFragment;
05-31 14:35:50.924 11941-11941/com.example.app D/AndroidRuntime: Shutting down VM
05-31 14:35:50.924 11941-11941/com.example.app W/dalvikvm: threadid=1: thread exiting with uncaught exception (group=0x9cca9b20)
05-31 14:35:50.934 11941-11941/com.example.app E/AndroidRuntime: FATAL EXCEPTION: main
        Process: com.example.app, PID: 11941 java.lang.VerifyError: com/example/app/ui/fragment/util/LoginFragment
            at com.example.app.util.NetworkUtility.login(NetworkUtility.java:41)
            at com.example.app.ui.activity.AbstractNavActivity.onOptionsItemSelected(AbstractNavActivity.java:68)
            at android.app.Activity.onMenuItemSelected(Activity.java:2600)
            at android.support.v4.app.FragmentActivity.onMenuItemSelected(FragmentActivity.java:403)
            at android.support.v7.app.AppCompatActivity.onMenuItemSelected(AppCompatActivity.java:189)
            at android.support.v7.view.WindowCallbackWrapper.onMenuItemSelected(WindowCallbackWrapper.java:100)
            at android.support.v7.view.WindowCallbackWrapper.onMenuItemSelected(WindowCallbackWrapper.java:100)
            at android.support.v7.app.ToolbarActionBar$2.onMenuItemClick(ToolbarActionBar.java:69)
            at android.support.v7.widget.Toolbar$1.onMenuItemClick(Toolbar.java:169)
            at android.support.v7.widget.ActionMenuView$MenuBuilderCallback.onMenuItemSelected(ActionMenuView.java:760)
            at android.support.v7.view.menu.MenuBuilder.dispatchMenuItemSelected(MenuBuilder.java:811)
            at android.support.v7.view.menu.MenuItemImpl.invoke(MenuItemImpl.java:152)
            at android.support.v7.view.menu.MenuBuilder.performItemAction(MenuBuilder.java:958)
            at android.support.v7.view.menu.MenuBuilder.performItemAction(MenuBuilder.java:948)
            at android.support.v7.view.menu.MenuPopupHelper.onItemClick(MenuPopupHelper.java:191)
            at android.widget.AdapterView.performItemClick(AdapterView.java:299)
            at android.widget.AbsListView.performItemClick(AbsListView.java:1113)
            at android.widget.AbsListView$PerformClick.run(AbsListView.java:2904)
            at android.widget.AbsListView$3.run(AbsListView.java:3638)
            at android.os.Handler.handleCallback(Handler.java:733)
            at android.os.Handler.dispatchMessage(Handler.java:95)
            at android.os.Looper.loop(Looper.java:136)
            at android.app.ActivityThread.main(ActivityThread.java:5017)
            at java.lang.reflect.Method.invokeNative(Native Method)
            at java.lang.reflect.Method.invoke(Method.java:515)
            at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:779)
            at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:595)
            at dalvik.system.NativeStart.main(Native Method)

Updated with Code

The login() method is just a convenience method to start the LoginFragment:

public static void login(FragmentManager manager) {
     manager.beginTransAction().add(LoginFragment.newInstance(), null).commit();
}

The relevant code is in the LoginFragment itself. Specifically the createKeyPair() and initializeCipher methods:

public class LoginFragment extends DialogFragment
        implements TextView.OnEditorActionListener, FingerprintCallback.Callback {

    ...

    public static LoginFragment newInstance() {
        return newInstance(null);
    }

    public static LoginFragment newInstance(Intent intent) {
        LoginFragment fragment = new LoginFragment();

        Bundle args = new Bundle();
        args.putParcelable(EXTRA_INTENT, intent);
        fragment.setArguments(args);

        return fragment;
    }

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        Injector.getContextComponent().inject(this);
        setStyle(STYLE_NO_TITLE, R.style.DialogTheme);
        setRetainInstance(true);
        setCancelable(false);

        mSaveUsernamePreference = mPreferences.getBoolean(getString(R.string.key_auth_username_retain));
        mUseFingerprintPreference = mPreferences.getBoolean(getString(R.string.key_auth_fingerprint));
        mUsernamePreference = mPreferences.getString(getString(R.string.key_auth_username));
        mPasswordPreference = mPreferences.getString(getString(R.string.key_auth_password));
    }

    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container,
                             Bundle savedInstanceState) {
        View view = inflater.inflate(R.layout.dialog_login_container, container, false);
        ButterKnife.bind(this, view);

        mPasswordView.setOnEditorActionListener(this);

        if(!mFingerprintManager.isHardwareDetected()) {
            mUseFingerprintToggle.setVisibility(View.GONE);
        } else {
            mGenerated = initializeKeyPair(false);
        }

        if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            setStage(isFingerprintAvailable() ? Stage.FINGERPRINT : Stage.CREDENTIALS);
        } else {
            setStage(Stage.CREDENTIALS);
        }

        return view;
    }

    @Override
    public void onResume() {
        super.onResume();

        ...

        if(mStage == Stage.FINGERPRINT && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            startListening(initializeCipher(Cipher.DECRYPT_MODE));
        }
    }

    @Override
    public void onPause() {
        super.onPause();
        stopListening();
    }

    ...

    @Override
    public void onAuthenticationSucceeded(FingerprintManagerCompat.AuthenticationResult result) {
        Timber.i("Fingerprint succeeded");
        showFingerprintSuccess();

        mSubscriptions.add(
            mGenerated.subscribeOn(Schedulers.newThread())
                    .observeOn(AndroidSchedulers.mainThread())
                    .doOnCompleted(() -> {
                        try {
                            mUsername = mUsernamePreference.get();
                            mPassword = decryptPassword(result.getCryptoObject().getCipher());
                            initLoginAttempt();
                        } catch (IllegalBlockSizeException | BadPaddingException exception) {
                            Timber.e(exception, "Failed to decrypt password");
                        }
                    }).subscribe());
    }

    @Override
    public void onAuthenticationHelp(int messageId, CharSequence message) {
        Timber.i("Fingerprint help id: " + messageId + " message: " + message);
        showFingerprintError(message);
    }

    @Override
    public void onAuthenticationError(int messageId, CharSequence message) {
        Timber.i("Fingerprint error id: " + messageId + " message: " + message);
        if(messageId != 5) {
            showFingerprintError(message);
        }
    }

    @Override
    public void onAuthenticationFailed() {
        Timber.i("Fingerprint failed");
        showFingerprintError(getResources().getString(R.string.msg_fingerprint_error_unknown));
    }

    @OnClick(R.id.button_cancel)
    public void onCancel() {
        dismiss();
    }

    @OnClick(R.id.button_continue)
    public void onContinue() {
        switch (mStage) {
            case CREDENTIALS:
                mUsername = mUsernameView.getText().toString();
                mPassword = mPasswordView.getText().toString();
                initLoginAttempt();
                break;
            case FINGERPRINT:
                setStage(Stage.CREDENTIALS);
                break;
        }
    }

    private void showFingerprintSuccess() {
        int colorAccent = ThemeUtil.getColorAttribute(getContext(), android.R.attr.colorAccent);
        mFingerprintIcon.setImageResource(R.drawable.ic_done_white_24dp);
        mFingerprintIcon.setCircleColor(colorAccent);
        mFingerprintStatus.setText(R.string.msg_fingerprint_success);
        mFingerprintStatus.setTextColor(colorAccent);
    }

    private void showFingerprintError(CharSequence message) {
        int colorError = ContextCompat.getColor(getContext(), R.color.material_deep_orange_600);
        mFingerprintIcon.setImageResource(R.drawable.ic_priority_high_white_24dp);
        mFingerprintIcon.setCircleColor(colorError);
        mFingerprintStatus.setText(message);
        mFingerprintStatus.setTextColor(colorError);
        resetFingerprintStatus();
    }

    private void resetFingerprintStatus() {
        mSubscriptions.add(Observable.timer(1600, TimeUnit.MILLISECONDS)
                .subscribeOn(Schedulers.newThread())
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe(finished -> {
                    mFingerprintIcon.setImageResource(R.drawable.ic_fingerprint_white_24dp);
                    mFingerprintIcon.setCircleColor(ContextCompat
                            .getColor(getContext(), R.color.material_blue_gray_500));
                    mFingerprintStatus.setText(R.string.msg_fingerprint_input);
                    mFingerprintStatus.setTextColor(ThemeUtil
                            .getColorAttribute(getContext(), android.R.attr.textColorHint));
                }));
    }

    private void onSaveUsernameChanged(boolean checked) {
        if(!checked) {
            mUseFingerprintToggle.setChecked(false);
        }
    }

    private void onUseFingerprintChanged(boolean checked) {
        if(checked) {
            mSaveUsernameToggle.setChecked(true);

            if(!mFingerprintManager.hasEnrolledFingerprints()) {
                displaySettingsDialog();
                mUseFingerprintToggle.setChecked(false);
            }
        }
    }

    public void setStage(Stage stage) {
        switch (stage) {
            case CREDENTIALS:
                Timber.d("Set stage Credentials");
                mPositiveButton.setText(R.string.btn_login);
                mFingerprintContent.setVisibility(View.GONE);
                mCredentialContent.setVisibility(View.VISIBLE);
                setForm();
                break;
            case FINGERPRINT:
                mPositiveButton.setText(R.string.btn_password);
                mCredentialContent.setVisibility(View.GONE);
                mFingerprintContent.setVisibility(View.VISIBLE);
                break;
        } mStage = stage;
    }

    private void startListening(boolean cipher) {
        Timber.v("Start listening for fingerprint input");
        mCancellationSignal = new CancellationSignal();
        if(cipher) {
            mFingerprintManager.authenticate(new FingerprintManagerCompat.CryptoObject(mCipher),
                    0, mCancellationSignal, new FingerprintCallback(this), null);
        } else {
            setStage(Stage.CREDENTIALS);
        }
    }

    private void stopListening() {
        if(mCancellationSignal != null) {
            mCancellationSignal.cancel();
            mCancellationSignal = null;
        }
    }

    private void setForm() {
        if(mSaveUsernamePreference.isSet() && mSaveUsernamePreference.get()
                && mUsernamePreference.isSet()) {
            mUsernameView.setText(mUsernamePreference.get());
            mUsernameView.setSelectAllOnFocus(true);
            mPasswordView.requestFocus();
        } else {
            mUsernameView.requestFocus();
        }
    }

    public void initLoginAttempt() {
        mProgressBar.setVisibility(View.VISIBLE);
        mAuthenticationService.getLoginForm().subscribeOn(Schedulers.newThread())
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe(this::onLoginFormResponse, this::onError);
    }

    private void onLoginFormResponse(ResponseBody response) {
        try {
            attemptLogin(LoginForm.parse(response.string()));
        } catch (IOException exception) {
            Timber.w(exception, "Failed to parse login form");
        }
    }

    private void attemptLogin(LoginForm loginForm) {
        mAuthenticationService
                .login(loginForm.getLoginTicket(), loginForm.getExecution(), loginForm.getEventIdentifier(),
                        mUsername, mPassword, loginForm.getSubmitValue())
                .subscribeOn(Schedulers.newThread())
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe(this::onLoginResponse, this::onError);
    }

    public void onLoginResponse(ResponseBody response) {
        Timber.d("LOGIN RESPONSE");
        try {
            Timber.d(response.string());
        } catch (IOException exception) {
            Timber.w(exception, "Failed to retrieve attemptLogin response");
        }

        mSubscriptions.add(NetworkUtility.getAuthentication()
                .subscribeOn(Schedulers.newThread())
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe(this::onAuthenticationChanged, this::onError));
    }

    public void onAuthenticationChanged(Boolean authenticated) {
        if(authenticated) {
            Timber.d("Authentication success");

            if(mStage == Stage.CREDENTIALS) {
                if (mSaveUsernameToggle.isChecked()) {
                    storeUsername();
                } else {
                    clearUsername();
                }

                if (mUseFingerprintToggle.isChecked()) {
                    mGenerated = initializeKeyPair(true);
                    storePassword();
                } else {
                    clearPassword();
                    finishIntent();
                }
            } else {
                finishIntent();
            }
        } else {
            Timber.d("Authentication failed");
            setStage(Stage.CREDENTIALS);
            mCaptionView.setTextColor(ContextCompat.getColor(getContext(), R.color.material_deep_orange_600));
            mCaptionView.setText(getString(R.string.msg_login_failed));
            mPasswordView.setText("");
        }
    }

    private void finishIntent() {
        mProgressBar.setVisibility(View.INVISIBLE);
        Intent intent = getArguments().getParcelable(EXTRA_INTENT);
        if(intent != null) {
            startActivity(intent);
        } dismiss();
    }

    private void onError(Throwable throwable) {
        Timber.w(throwable, "Login attempt failed");
        mProgressBar.setVisibility(View.INVISIBLE);
        mCaptionView.setTextColor(ContextCompat.getColor(getContext(), R.color.material_deep_orange_600));
        mCaptionView.setText("Login attempt failed\nPlease check your internet connection and try again");
        mPasswordView.setText("");
    }

    private void storeUsername() {
        String username = mUsernameView.getText().toString();
        mUsernamePreference.set(username);
        if(mPreferences.getBoolean(getString(R.string.key_auth_push), false).get()) {
            UAirship.shared().getPushManager().getNamedUser().setId(username);
        }
    }

    private void clearUsername() {
        UAirship.shared().getPushManager().getNamedUser().setId(null);
        mUsernamePreference.delete();
    }

    private void storePassword() {
        Timber.d("STORE PASSWORD");
        mSubscriptions.add(mGenerated.subscribeOn(Schedulers.newThread())
                .observeOn(AndroidSchedulers.mainThread())
                .doOnCompleted(() -> {
                    try {
                        Timber.d("Store password");
                        initializeCipher(Cipher.ENCRYPT_MODE);

                        String password = mPasswordView.getText().toString();
                        byte[] bytes = password.getBytes();
                        byte[] encrypted = mCipher.doFinal(bytes);
                        String encoded = Base64.encodeToString(encrypted, Base64.NO_WRAP);

                        mPasswordPreference.set(encoded);

                        finishIntent();

                    } catch (IllegalBlockSizeException | BadPaddingException exception) {
                        Timber.e(exception, "Failed to encrypt password");
                    }
                }).subscribe());
    }

    private String decryptPassword(Cipher cipher) throws IllegalBlockSizeException, BadPaddingException {
        String encoded = mPasswordPreference.get();

        Timber.d("ENCODED STRING " + encoded);

        byte[] encrypted = Base64.decode(encoded, Base64.NO_WRAP);

        byte[] bytes = cipher.doFinal(encrypted);

        return new String(bytes);
    }

    private void clearPassword() {
        mPasswordPreference.delete();
    }

    private boolean isFingerprintAvailable() {
        return mUseFingerprintPreference.isSet() && mUseFingerprintPreference.get()
                && mFingerprintManager.hasEnrolledFingerprints()
                && mSaveUsernamePreference.isSet()
                && mPasswordPreference.isSet();
    }

    private void displaySettingsDialog() {
        new AlertDialog.Builder(getContext())
                .setTitle(R.string.title_dialog_secure_lock)
                .setMessage(R.string.msg_fingerprint_unavailable)
                .setPositiveButton(R.string.btn_settings, (dialog, which) -> {
                    startActivity(new Intent(android.provider.Settings.ACTION_SECURITY_SETTINGS));
                    dialog.dismiss();
                }).setNegativeButton(R.string.btn_cancel, (dialog, which) -> {
            dialog.dismiss();
        }).create().show();
    }

    @TargetApi(Build.VERSION_CODES.M)
    private boolean initializeCipher(int opmode) {
        try {
            mKeyStore.load(null);

            /**
             * A known bug in the Android 6.0 (API Level 23) implementation of Bouncy Castle
             * RSA OAEP causes the cipher to default to an SHA-1 certificate, making the SHA-256
             * certificate of the public key incompatible
             * To work around this issue, explicitly provide a new OAEP specification upon
             * initialization
             * @see <a href="https://code.google.com/p/android/issues/detail?id=197719">Issue 197719</a>
             */
            AlgorithmParameterSpec spec = generateOAEPParameterSpec();
            Key key;

            if(opmode == Cipher.ENCRYPT_MODE) {
                Key publicKey = mKeyStore.getCertificate(CIPHER_KEY_ALIAS).getPublicKey();

                /**
                 * A known bug in Android 6.0 (API Level 23) causes user authentication-related
                 * authorizations to be enforced even for public keys
                 * To work around this issue, extract the public key material to use outside of
                 * the Android Keystore
                 * @see <a href="http://developer.android.com/reference/android/security/keystore/KeyGenParameterSpec.html">KeyGenParameterSpec Known Issues</a>
                 */
                key = KeyFactory.getInstance(publicKey.getAlgorithm())
                        .generatePublic(new X509EncodedKeySpec(publicKey.getEncoded()));
            } else {
                key = mKeyStore.getKey(CIPHER_KEY_ALIAS, null);
            }

            mCipher.init(opmode, key, spec);
            return true;
        } catch (KeyPermanentlyInvalidatedException exception) {
            Timber.w(exception, "Failed to initialize Cipher");
            handleKeyPermanentlyInvalidated();
            return false;
        } catch (IOException | KeyStoreException | UnrecoverableEntryException
                | InvalidKeySpecException | CertificateException | InvalidKeyException
                | NoSuchAlgorithmException | InvalidAlgorithmParameterException exception) {
            throw new RuntimeException("Failed to initialize Cipher", exception);
        }
    }

    private OAEPParameterSpec generateOAEPParameterSpec() {
        return new OAEPParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA1, PSource.PSpecified.DEFAULT);
    }

    private void handleKeyPermanentlyInvalidated() {
        mCaptionView.setText(getString(R.string.msg_fingerprint_invalidated));
        mGenerated = initializeKeyPair(true);
        clearPassword();
    }

    private Observable<KeyPair> initializeKeyPair(boolean generate) {
        return Observable.create(subscriber -> {
            try {
                mKeyStore.load(null);

                if(!generate || mKeyStore.containsAlias(CIPHER_KEY_ALIAS)) {
                    PublicKey publicKey = mKeyStore.getCertificate(CIPHER_KEY_ALIAS).getPublicKey();
                    PrivateKey privateKey = (PrivateKey) mKeyStore.getKey(CIPHER_KEY_ALIAS, null);
                    subscriber.onNext(new KeyPair(publicKey, privateKey));
                } else {
                    subscriber.onNext(createKeyPair());
                }

                subscriber.onCompleted();
            } catch (IOException | KeyStoreException | UnrecoverableKeyException
                    | CertificateException | NoSuchAlgorithmException
                    | InvalidAlgorithmParameterException exception) {
                Timber.e(exception, "Failed to generate key pair");
                subscriber.onError(exception);
            }
        });
    }

    @TargetApi(Build.VERSION_CODES.M)
    private KeyPair createKeyPair() throws InvalidAlgorithmParameterException {
        // Set the alias of the entry in Android KeyStore where the key will appear
        // and the constrains (purposes) in the constructor of the Builder
        Timber.d("Initialize key pair");
        mKeyPairGenerator.initialize(
                new KeyGenParameterSpec.Builder(CIPHER_KEY_ALIAS, KeyProperties.PURPOSE_DECRYPT)
                    .setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512)
                    .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP)
                        .setUserAuthenticationRequired(true)
                        .build());

        return mKeyPairGenerator.generateKeyPair();
    }

}

Update

Okay, so I figured out it is the KeyPermanentlyInvalidatedException that is causing the error. If I comment out the catch block that handles that exception, the code runs fine on any device. The problem is that I need to be able to handle that exception on devices on API 23+:

catch (KeyPermanentlyInvalidatedException exception) {
    Timber.w(exception, "A new fingerprint was added to the device");
    handleKeyPermanentlyInvalidated();
    return false;
}

Solution

  • My guess is that either FingerprintCallback.Callback extends an API Level 23+ interface or that LoginFragment has fields that reference API Level 23+ stuff.

    Your rule about being able to call API Level 23+ methods safely inside the version guard block is correct. However, you cannot:

    • inherit from classes that do not exist on the device
    • implement interfaces that do not exist on the device
    • have fields whose types do not exist on the device
    • accept constructor or method parameters whose types do not exist on the device (where we actually call these)
    • have method return values whose types do not exist on the device (where we actually call these)

    In many cases, we don't need any of that, in which case just checking Build.VERSION.SDK_INT before calling API Level 23+ methods is sufficient.

    If you need to do some of the things in the bulleted list, that's fine, but then you need to isolate those into classes that you only use on API Level 23+ devices.

    So, for example, let's pretend that the problem is that FingerprintCallback.Callback extends some API Level 23+ interface. Rather than implementing FingerprintCallback.Callback on the LoginFragment, you might implement that as an anonymous inner class, and only execute the code creating that anonymous inner class instance if Build.VERSION.SDK_INT is high enough. Then, you only are referencing FingerprintCallback.Callback on the newer devices, and you should be safe.