I am facing a weird issue which has started to happen after a recent Android Update on my Galaxy S8 phone.
I have a React Native Module for encrypting values using a key stored in Android KeyStore in which was working perfectly fine and suddenly started to fail with the following exception during decryption:
android.security.KeyStoreException: Signature/MAC verification failed
I did a bit of investigation and figured out that if I call my decrypt method instantly after encrypting a value, it works fine but if I try to do the decryption in another call from the JS side, it fails.
Also if I prompt the user for biometrics, the decryption works fine regardless of being the same call or another call.
Here is my module code:
public class BiometricsModule extends ReactContextBaseJavaModule {
private Promise _promise;
private ReactApplicationContext _context;
private String CIPHER_IV = "CIPHER_IV";
private SettingsStore settingsStore;
public FVBiometricsModule(@NonNull ReactApplicationContext reactContext) {
super(reactContext);
_context = reactContext;
settingsStore = new SettingsStore(_context);
}
@NonNull
@Override
public String getName() {
return "Biometrics";
}
@ReactMethod
public void isEnrolledAsync(final Promise promise) {
_promise = promise;
try {
WritableMap resultData = new WritableNativeMap();
Integer biometricsCheckResult = BiometricManager.from(_context).canAuthenticate();
String reason = parseResult(biometricsCheckResult);
resultData.putBoolean("result", reason == "SUCCESS");
if (reason != "SUCCESS") {
resultData.putString("reason", reason);
}
promise.resolve(resultData);
} catch (Exception e) {
promise.reject(e);
}
}
@ReactMethod
public void promptForBiometricsAsync(String prompt, String title, String cancelButtonText, final Promise promise) {
_promise = promise;
prompt(null, null, prompt, title, cancelButtonText, false, null);
}
@ReactMethod
public void setPasswordAsync(String username, String password, Boolean biometricsProtected, String prompt, String title, String cancelButtonText, final Promise promise) {
_promise = promise;
try {
generateKey(biometricsProtected);
Cipher cipher = getCipher(biometricsProtected);
SecretKey secretKey = getSecretKey();
cipher.init(Cipher.ENCRYPT_MODE, secretKey);
settingsStore.setValue(CIPHER_IV, Base64.encodeToString(cipher.getIV(), Base64.DEFAULT));
if (biometricsProtected) {
prompt(username, password, prompt, title, cancelButtonText, false, cipher);
} else {
encrypt(username, password, cipher);
}
} catch (Exception e) {
promise.reject(e);
}
}
@ReactMethod
public void getPasswordAsync(String username, Boolean biometricsProtected, String prompt, String title, String cancelButtonText, final Promise promise) {
_promise = promise;
try {
Cipher cipher = getCipher(biometricsProtected);
SecretKey secretKey = getSecretKey();
byte[] _iv = Base64.decode(settingsStore.getValue(CIPHER_IV, null), Base64.DEFAULT);
cipher.init(Cipher.DECRYPT_MODE, secretKey, biometricsProtected ? new IvParameterSpec(_iv) : new GCMParameterSpec(128, _iv));
if (biometricsProtected) {
prompt(username, null, prompt, title, cancelButtonText, true, cipher);
} else {
decrypt(username, cipher);
}
} catch (Exception e) {
promise.reject(e);
}
}
private String parseResult(Integer biometricsCheckResult) {
String result = "SUCCESS";
switch (biometricsCheckResult) {
case BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE:
result = "NOT_AVAILABLE";
break;
case BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE:
result = "NOT_AVAILABLE";
break;
case BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED:
result = "NOT_ENROLLED";
break;
}
return result;
}
private void generateKey(Boolean biometricsProtected) {
generateSecretKey(new KeyGenParameterSpec.Builder(
_context.getPackageName(),
KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(biometricsProtected ? KeyProperties.BLOCK_MODE_CBC : KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(biometricsProtected ? KeyProperties.ENCRYPTION_PADDING_PKCS7 : KeyProperties.ENCRYPTION_PADDING_NONE)
.setUserAuthenticationRequired(biometricsProtected)
.setInvalidatedByBiometricEnrollment(biometricsProtected)
.build());
}
private void generateSecretKey(KeyGenParameterSpec keyGenParameterSpec) {
try {
KeyGenerator keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore");
keyGenerator.init(keyGenParameterSpec);
keyGenerator.generateKey();
} catch (Exception e) {
_promise.reject(e);
}
}
private SecretKey getSecretKey() {
try {
KeyStore keyStore = KeyStore.getInstance("AndroidKeyStore");
keyStore.load(null);
return ((SecretKey)keyStore.getKey(_context.getPackageName(), null));
} catch (Exception e) {
_promise.reject(e);
return null;
}
}
private Cipher getCipher(Boolean biometricsProtected) {
try {
return Cipher.getInstance(KeyProperties.KEY_ALGORITHM_AES + "/"
+ (biometricsProtected ? KeyProperties.BLOCK_MODE_CBC : KeyProperties.BLOCK_MODE_GCM) + "/"
+ (biometricsProtected ? KeyProperties.ENCRYPTION_PADDING_PKCS7 : KeyProperties.ENCRYPTION_PADDING_NONE));
} catch (Exception e) {
_promise.reject(e);
return null;
}
}
private void prompt(String username, String password, String prompt, String title, String cancelBButtonText, Boolean decrypt, Cipher cipher) {
MainActivity.mainActivity.runOnUiThread(new Runnable() {
public void run() {
WritableMap resultData = new WritableNativeMap();
Executor _executor = ContextCompat.getMainExecutor(MainActivity.mainActivity);
BiometricPrompt _biometricPrompt = new BiometricPrompt(MainActivity.mainActivity,
_executor, new BiometricPrompt.AuthenticationCallback() {
@Override
public void onAuthenticationError(int errorCode,
@NonNull CharSequence errString) {
super.onAuthenticationError(errorCode, errString);
try {
resultData.putBoolean("result", false);
_promise.resolve(resultData);
} catch (Exception e) {
_promise.reject(e);
}
}
@Override
public void onAuthenticationSucceeded(
@NonNull BiometricPrompt.AuthenticationResult result) {
super.onAuthenticationSucceeded(result);
try {
if (password != null && !decrypt) {
byte[] encryptedInfo = result.getCryptoObject().getCipher().doFinal(password.getBytes(Charset.defaultCharset()));
settingsStore.setValue(username, Base64.encodeToString(encryptedInfo, Base64.DEFAULT));
resultData.putBoolean("result", true);
} else if (decrypt) {
String decryptedInfo = new String(result.getCryptoObject().getCipher().doFinal(Base64.decode(settingsStore.getValue(username, null), Base64.DEFAULT)));
resultData.putString("password", decryptedInfo);
}
_promise.resolve(resultData);
} catch (Exception e) {
_promise.reject(e);
}
}
@Override
public void onAuthenticationFailed() {
super.onAuthenticationFailed();
try {
resultData.putBoolean("result", false);
_promise.resolve(resultData);
} catch (Exception e) {
_promise.reject(e);
}
}
});
BiometricPrompt.PromptInfo promptInfo = new BiometricPrompt.PromptInfo.Builder()
.setTitle(title)
.setSubtitle(prompt)
.setNegativeButtonText(cancelBButtonText)
.setConfirmationRequired(false)
.build();
if (cipher != null)
_biometricPrompt.authenticate(promptInfo, new BiometricPrompt.CryptoObject(cipher));
else
_biometricPrompt.authenticate(promptInfo);
}
});
}
private void encrypt(String username, String password, Cipher cipher) {
try {
WritableMap resultData = new WritableNativeMap();
byte[] encryptedInfo = cipher.doFinal(password.getBytes(Charset.defaultCharset()));
settingsStore.setValue(username, Base64.encodeToString(encryptedInfo, Base64.DEFAULT));
resultData.putBoolean("result", true);
_promise.resolve(resultData);
} catch (Exception e) {
_promise.reject(e);
}
}
private void decrypt(String username, Cipher cipher) {
try {
WritableMap resultData = new WritableNativeMap();
String decryptedInfo = new String(cipher.doFinal(Base64.decode(settingsStore.getValue(username, null), Base64.DEFAULT)));
resultData.putString("password", decryptedInfo);
_promise.resolve(resultData);
} catch (Exception e) {
_promise.reject(e);
}
}
}
At some point I though it is because of the conversion between byte[] and string but that's not the issue.