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;
}