diff --git a/README.md b/README.md index b30b189..4793335 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,8 @@ rnBiometrics.isSensorAvailable() Generates a public private RSA 2048 key pair that will be stored in the device keystore. Returns a `Promise` that resolves to an object providing details about the keys. +⚠️ **Warning**: Subsequent calls to `createKeys()` will delete existing keys from the device keystore. + __Result Object__ | Property | Type | Description | @@ -185,7 +187,30 @@ rnBiometrics.createKeys() }) ``` -### biometricKeysExist() +### createEncryptionKeys() + +Performs platform dependent setup for symmetric encryption of local-only secrets that will be stored in the device keystore (AES-GCM on Android, RSA-OAEP-SHA512-wrapped AES-GCM on iOS.) Returns a promise that resolves to the success/failure status. + +⚠️⚠️ **Warning**: Subsequent calls to `createEncryptionKeys()` will delete existing keys from the device keystore. As the keys are not designed to be extracted from the store, this will render all existing encrypted data inaccessible. Consider guarding calls with `biometricEncryptionKeysExist()`. + +__Result Object__ + +| Property | Type | Description | +| --- | --- | --- | +| success | bool | A boolean indicating if the biometric prompt succeeded, `false` if the users cancels the biometrics prompt | + +```js +import ReactNativeBiometrics from 'react-native-biometrics' + +ReactNativeBiometrics.createEncryptionKeys('Confirm fingerprint') + .then((resultObject) => { + const { success } = resultObject + console.log(success) + }) +``` + + +### biometricKeysExist(), biometricEncryptionKeysExist() Detects if keys have already been generated and exist in the keystore. Returns a `Promise` that resolves to an object indicating details about the keys. @@ -211,11 +236,22 @@ rnBiometrics.biometricKeysExist() console.log('Keys do not exist or were deleted') } }) + +ReactNativeBiometrics.biometricEncryptionKeysExist() + .then((resultObject) => { + const { keysExist } = resultObject + + if (keysExist) { + console.log('Encryption key exist') + } else { + console.log('Encryption key does not exist or was deleted') + } + }) ``` -### deleteKeys() +### deleteKeys() , deleteEncryptionKeys() -Deletes the generated keys from the device keystore. Returns a `Promise` that resolves to an object indicating details about the deletion. +Deletes the generated key(s) from the device keystore. Returns a `Promise` that resolves to an object indicating details about the deletion. __Result Object__ @@ -240,13 +276,24 @@ rnBiometrics.deleteKeys() console.log('Unsuccessful deletion because there were no keys to delete') } }) + +ReactNativeBiometrics.deleteEncryptionKeys() + .then((resultObject) => { + const { keysDeleted } = resultObject + + if (keysDeleted) { + console.log('Successful deletion') + } else { + console.log('Unsuccessful deletion because there were no keys to delete') + } + }) ``` ### createSignature(options) Prompts the user for their fingerprint or face id in order to retrieve the private key from the keystore, then uses the private key to generate a RSA PKCS#1v1.5 SHA 256 signature. Returns a `Promise` that resolves to an object with details about the signature. -**NOTE: No biometric prompt is displayed in iOS simulators when attempting to retrieve keys for signature generation, it only occurs on actual devices. +**NOTE: No biometric prompt is displayed in iOS simulators when attempting to retrieve keys for signature generation, it only occurs on actual devices.** __Options Object__ @@ -288,6 +335,102 @@ rnBiometrics.createSignature({ }) ``` +### encryptData(options) + +Prompts the user for their fingerprint or face id in order to retrieve the key from the keystore, then uses it to encrypt the data. Returns a `Promise` that resolves to an object with the encrypted data and IV. + +**NOTE: No biometric prompt is displayed in iOS simulators when attempting to retrieve keys for signature generation, it only occurs on actual devices.** + +> `allowDeviceCredentials` is not supported for data encryption. + +__Options Object__ + +| Parameter | Type | Description | iOS | Android | +| --- | --- | --- | --- | --- | +| promptMessage | string | Message that will be displayed in the fingerprint or face id prompt | ✔ | ✔ | +| payload | string | String of data to be encrypted | ✔ | ✔ | +| cancelButtonText | string | Text to be displayed for the cancel button on biometric prompts, defaults to `Cancel` | ✖ | ✔ | + +__Result Object__ + +| Property | Type | Description | +| --- | --- | --- | +| success | bool | A boolean indicating if the process was successful, `false` if the users cancels the biometrics prompt | +| encrypted | string | A base64 encoded string representing the encrypted data. `undefined` if the process was not successful. | +| iv | string | A base64 encoded string representing the AES initialization vector. `undefined` if the process was not successful. | +| error | string | An error message indicating reasons why signature creation failed. `undefined` if there is no error. | + +__Example__ + +```js +import ReactNativeBiometrics from 'react-native-biometrics' + +let payload = 'hunter2' + +ReactNativeBiometrics.encryptData({ + promptMessage: 'Save password', + payload: payload + }) + .then((resultObject) => { + const { success, encrypted, iv } = resultObject + + if (success) { + console.log(encrypted, iv) + myStorageApi.set("encryptedPassword", encrypted) + myStorageApi.set("passwordIV", iv) + } + }) +``` + + +### decryptData(options) + +Prompts the user for their fingerprint or face id in order to retrieve the key from the keystore, then uses it to decrypt the payload with the supplied IV. Returns a `Promise` that resolves to an object with the decrypted data. + +**NOTE: No biometric prompt is displayed in iOS simulators when attempting to retrieve keys for decryption, it only occurs on actual devices.** + +> `allowDeviceCredentials` is not supported for data encryption. + +__Options Object__ + +| Parameter | Type | Description | iOS | Android | +| --- | --- | --- | --- | --- | +| promptMessage | string | Message that will be displayed in the fingerprint or face id prompt | ✔ | ✔ | +| payload | string | Base64 encoded data to decrypt | ✔ | ✔ | +| iv | string | Base64 encoded iv used to encrypt the data | ✔ | ✔ | +| cancelButtonText | string | Text to be displayed for the cancel button on biometric prompts, defaults to `Cancel` | ✖ | ✔ | + +__Result Object__ + +| Property | Type | Description | +| --- | --- | --- | +| success | bool | A boolean indicating if the process was successful, `false` if the users cancels the biometrics prompt | +| decrypted | string | A string representing the decrypted data. `undefined` if the process was not successful. | +| error | string | An error message indicating reasons why signature creation failed. `undefined` if there is no error. | + +__Example__ + +```js +import ReactNativeBiometrics from 'react-native-biometrics' + +let payload = myStorageApi.get("encryptedPassword") +let iv = myStorageApi.get("passwordIV") + +ReactNativeBiometrics.decryptData({ + promptMessage: 'Load password', + payload: payload, + iv: iv + }) + .then((resultObject) => { + const { success, decrypted } = resultObject + + if (success) { + console.log(decrypted) + //use password to log in + } + }) +``` + ### simplePrompt(options) Prompts the user for their fingerprint or face id. Returns a `Promise` that resolves if the user provides a valid biometrics or cancel the prompt, otherwise the promise rejects. diff --git a/android/src/main/java/com/rnbiometrics/DecryptDataCallback.java b/android/src/main/java/com/rnbiometrics/DecryptDataCallback.java new file mode 100644 index 0000000..170070d --- /dev/null +++ b/android/src/main/java/com/rnbiometrics/DecryptDataCallback.java @@ -0,0 +1,57 @@ +package com.rnbiometrics; + +import android.util.Base64; + +import androidx.annotation.NonNull; +import androidx.biometric.BiometricPrompt; + +import com.facebook.react.bridge.Promise; +import com.facebook.react.bridge.WritableMap; +import com.facebook.react.bridge.WritableNativeMap; + +import java.nio.charset.Charset; + +import javax.crypto.Cipher; + +public class DecryptDataCallback extends BiometricPrompt.AuthenticationCallback { + private Promise promise; + private String payload; + + public DecryptDataCallback(Promise promise, String payload) { + super(); + this.promise = promise; + this.payload = payload; + } + + @Override + public void onAuthenticationError(int errorCode, @NonNull CharSequence errString) { + super.onAuthenticationError(errorCode, errString); + if (errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON || errorCode == BiometricPrompt.ERROR_USER_CANCELED ) { + WritableMap resultMap = new WritableNativeMap(); + resultMap.putBoolean("success", false); + resultMap.putString("error", "User cancellation"); + this.promise.resolve(resultMap); + } else { + this.promise.reject(errString.toString(), errString.toString()); + } + } + + @Override + public void onAuthenticationSucceeded(@NonNull BiometricPrompt.AuthenticationResult result) { + super.onAuthenticationSucceeded(result); + + try { + BiometricPrompt.CryptoObject cryptoObject = result.getCryptoObject(); + Cipher cryptoCipher = cryptoObject.getCipher(); + byte[] decrypted = cryptoCipher.doFinal(Base64.decode(payload, Base64.DEFAULT)); + String encoded = new String(decrypted, Charset.forName("UTF-8")); + + WritableMap resultMap = new WritableNativeMap(); + resultMap.putBoolean("success", true); + resultMap.putString("decrypted", encoded); + promise.resolve(resultMap); + } catch (Exception e) { + promise.reject("Error decrypting data: " + e.getMessage(), "Error decrypting data"); + } + } +} diff --git a/android/src/main/java/com/rnbiometrics/EncryptDataCallback.java b/android/src/main/java/com/rnbiometrics/EncryptDataCallback.java new file mode 100644 index 0000000..f6e7ba9 --- /dev/null +++ b/android/src/main/java/com/rnbiometrics/EncryptDataCallback.java @@ -0,0 +1,61 @@ +package com.rnbiometrics; + +import android.util.Base64; + +import androidx.annotation.NonNull; +import androidx.biometric.BiometricPrompt; + +import com.facebook.react.bridge.Promise; +import com.facebook.react.bridge.WritableMap; +import com.facebook.react.bridge.WritableNativeMap; + +import java.security.Signature; + +import javax.crypto.Cipher; + +public class EncryptDataCallback extends BiometricPrompt.AuthenticationCallback { + private Promise promise; + private String payload; + + public EncryptDataCallback(Promise promise, String payload) { + super(); + this.promise = promise; + this.payload = payload; + } + + @Override + public void onAuthenticationError(int errorCode, @NonNull CharSequence errString) { + super.onAuthenticationError(errorCode, errString); + if (errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON || errorCode == BiometricPrompt.ERROR_USER_CANCELED ) { + WritableMap resultMap = new WritableNativeMap(); + resultMap.putBoolean("success", false); + resultMap.putString("error", "User cancellation"); + this.promise.resolve(resultMap); + } else { + this.promise.reject(errString.toString(), errString.toString()); + } + } + + @Override + public void onAuthenticationSucceeded(@NonNull BiometricPrompt.AuthenticationResult result) { + super.onAuthenticationSucceeded(result); + + try { + BiometricPrompt.CryptoObject cryptoObject = result.getCryptoObject(); + Cipher cryptoCipher = cryptoObject.getCipher(); + byte[] encrypted = cryptoCipher.doFinal(this.payload.getBytes()); + String encryptedString = Base64.encodeToString(encrypted, Base64.DEFAULT) + .replaceAll("\r", "") + .replaceAll("\n", ""); + String encodedIV = Base64.encodeToString(cryptoCipher.getIV(), Base64.DEFAULT); + + WritableMap resultMap = new WritableNativeMap(); + resultMap.putBoolean("success", true); + resultMap.putString("encrypted", encryptedString); + resultMap.putString("iv", encodedIV); + promise.resolve(resultMap); + } catch (Exception e) { + promise.reject("Error encrypting data: " + e.getMessage(), "Error encrypting data"); + } + } +} diff --git a/android/src/main/java/com/rnbiometrics/ReactNativeBiometrics.java b/android/src/main/java/com/rnbiometrics/ReactNativeBiometrics.java index 00bf6ad..dfd0222 100644 --- a/android/src/main/java/com/rnbiometrics/ReactNativeBiometrics.java +++ b/android/src/main/java/com/rnbiometrics/ReactNativeBiometrics.java @@ -30,6 +30,11 @@ import java.util.concurrent.Executor; import java.util.concurrent.Executors; +import javax.crypto.Cipher; +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; + /** * Created by brandon on 4/5/18. */ @@ -37,6 +42,8 @@ public class ReactNativeBiometrics extends ReactContextBaseJavaModule { protected String biometricKeyAlias = "biometric_key"; + protected String biometricEncryptionKeyAlias = "biometric_encryption_key"; + protected int encryptionKeySize = 256; public ReactNativeBiometrics(ReactApplicationContext reactContext) { super(reactContext); @@ -128,6 +135,34 @@ private boolean isCurrentSDKMarshmallowOrLater() { return Build.VERSION.SDK_INT >= Build.VERSION_CODES.M; } + @ReactMethod + public void createEncryptionKeys(Promise promise) { + try { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + throw new Exception("Cannot generate keys on android versions below 6.0"); + } + deleteBiometricEncryptionKey(); + KeyGenParameterSpec keyGenParameterSpec = new KeyGenParameterSpec.Builder( + biometricEncryptionKeyAlias, + KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(encryptionKeySize) + .setUserAuthenticationRequired(true) + .build(); + KeyGenerator keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore"); + keyGenerator.init(keyGenParameterSpec); + keyGenerator.generateKey(); + + WritableMap resultMap = new WritableNativeMap(); + resultMap.putBoolean("success", true); //Not returning the key itself since it's symmetric + promise.resolve(resultMap); + } catch (Exception e) { + ReactNativeBiometricsPackage.rejectWithThrowable(promise, "Error generating encryption key", e); + } + } + @ReactMethod public void deleteKeys(Promise promise) { if (doesBiometricKeyExist()) { @@ -147,6 +182,99 @@ public void deleteKeys(Promise promise) { } } + @ReactMethod + public void deleteEncryptionKeys(Promise promise) { + boolean deletionSuccessful = false; + if (doesBiometricEncryptionKeyExist()) { + deletionSuccessful = deleteBiometricEncryptionKey(); + if (!deletionSuccessful) { + promise.reject("Error deleting biometric encryption key", "Error deleting biometric encryption key"); + } + } + WritableMap resultMap = new WritableNativeMap(); + resultMap.putBoolean("keysDeleted", deletionSuccessful); + promise.resolve(resultMap); + } + + @ReactMethod + public void encryptData(final ReadableMap params, final Promise promise) { + UiThreadUtil.runOnUiThread( + new Runnable() { + @Override + public void run() { + try { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + throw new Exception("Cannot encrypt data on android versions below 6.0"); + } + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + KeyStore keyStore = KeyStore.getInstance("AndroidKeyStore"); + keyStore.load(null); + + cipher.init(Cipher.ENCRYPT_MODE, (SecretKey) keyStore.getKey(biometricEncryptionKeyAlias, null)); + + BiometricPrompt biometricPrompt = new BiometricPrompt( + (FragmentActivity) getCurrentActivity(), + Executors.newSingleThreadExecutor(), + new EncryptDataCallback(promise, params.getString("payload")) + ); + + PromptInfo promptInfo = new PromptInfo.Builder() + .setDeviceCredentialAllowed(false) + .setNegativeButtonText(params.getString("cancelButtonText")) + .setTitle(params.getString("promptMessage")) + .build(); + biometricPrompt.authenticate(promptInfo, new BiometricPrompt.CryptoObject(cipher)); + } catch (Exception e) { + ReactNativeBiometricsPackage.rejectWithThrowable(promise, "Error encrypting data", e); + } + } + } + ); + } + + /* + Decrypts a base64 encoded `payload` with the given base64 encoded `iv`, using the provided `cancelButtonText` and `promptMessage` strings on the biometric prompt + */ + @ReactMethod + public void decryptData(final ReadableMap params, final Promise promise) { + UiThreadUtil.runOnUiThread( + new Runnable() { + @Override + public void run() { + try { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + throw new Exception("Cannot decrypt data on android versions below 6.0"); + } + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + KeyStore keyStore = KeyStore.getInstance("AndroidKeyStore"); + keyStore.load(null); + + cipher.init( + Cipher.DECRYPT_MODE, + (SecretKey) keyStore.getKey(biometricEncryptionKeyAlias, null), + new GCMParameterSpec(128, Base64.decode(params.getString("iv"), Base64.DEFAULT)) + ); + + BiometricPrompt biometricPrompt = new BiometricPrompt( + (FragmentActivity) getCurrentActivity(), + Executors.newSingleThreadExecutor(), + new DecryptDataCallback(promise, params.getString("payload")) + ); + + PromptInfo promptInfo = new PromptInfo.Builder() + .setDeviceCredentialAllowed(false) + .setNegativeButtonText(params.getString("cancelButtonText")) + .setTitle(params.getString("promptMessage")) + .build(); + biometricPrompt.authenticate(promptInfo, new BiometricPrompt.CryptoObject(cipher)); + } catch (Exception e) { + ReactNativeBiometricsPackage.rejectWithThrowable(promise, "Error decrypting data", e); + } + } + } + ); + } + @ReactMethod public void createSignature(final ReadableMap params, final Promise promise) { if (isCurrentSDKMarshmallowOrLater()) { @@ -248,6 +376,28 @@ public void biometricKeysExist(Promise promise) { } } + @ReactMethod + public void biometricEncryptionKeysExist(Promise promise) { + try { + WritableMap resultMap = new WritableNativeMap(); + resultMap.putBoolean("keysExist", doesBiometricEncryptionKeyExist()); + promise.resolve(resultMap); + } catch (Exception e) { + ReactNativeBiometricsPackage.rejectWithThrowable(promise, "Error checking for key", e); + } + } + + protected boolean doesBiometricEncryptionKeyExist() { + try { + KeyStore keyStore = KeyStore.getInstance("AndroidKeyStore"); + keyStore.load(null); + + return keyStore.containsAlias(biometricEncryptionKeyAlias); + } catch (Exception e) { + return false; + } + } + protected boolean doesBiometricKeyExist() { try { KeyStore keyStore = KeyStore.getInstance("AndroidKeyStore"); @@ -259,6 +409,18 @@ protected boolean doesBiometricKeyExist() { } } + protected boolean deleteBiometricEncryptionKey() { + try { + KeyStore keyStore = KeyStore.getInstance("AndroidKeyStore"); + keyStore.load(null); + + keyStore.deleteEntry(biometricEncryptionKeyAlias); + return true; + } catch (Exception e) { + return false; + } + } + protected boolean deleteBiometricKey() { try { KeyStore keyStore = KeyStore.getInstance("AndroidKeyStore"); diff --git a/android/src/main/java/com/rnbiometrics/ReactNativeBiometricsPackage.java b/android/src/main/java/com/rnbiometrics/ReactNativeBiometricsPackage.java index 92174e1..0f69294 100644 --- a/android/src/main/java/com/rnbiometrics/ReactNativeBiometricsPackage.java +++ b/android/src/main/java/com/rnbiometrics/ReactNativeBiometricsPackage.java @@ -2,9 +2,12 @@ import com.facebook.react.ReactPackage; import com.facebook.react.bridge.NativeModule; +import com.facebook.react.bridge.Promise; import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.uimanager.ViewManager; +import java.io.PrintWriter; +import java.io.StringWriter; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -28,4 +31,12 @@ public List createNativeModules( return modules; } + + public static void rejectWithThrowable(final Promise promise, final String message, final Throwable e) { + StringWriter sw = new StringWriter(); + PrintWriter pw = new PrintWriter(sw); + e.printStackTrace(pw); + String error = String.format("%s: %s\n%s", message, e.getMessage(), sw.toString()); + promise.reject(error, error); + } } diff --git a/index.ts b/index.ts index 32528b4..185fa27 100644 --- a/index.ts +++ b/index.ts @@ -35,6 +35,13 @@ interface CreateSignatureOptions { cancelButtonText?: string } +interface DecryptDataOptions { + promptMessage: string + payload: string + iv: string + cancelButtonText?: string +} + interface CreateSignatureResult { success: boolean signature?: string @@ -47,6 +54,19 @@ interface SimplePromptOptions { cancelButtonText?: string } +interface EncryptionResult { + success: boolean + encrypted?: string + iv?: string + error?: string +} + +interface DecryptionResult { + success: boolean + decrypted?: string + error?: string +} + interface SimplePromptResult { success: boolean error?: string @@ -86,7 +106,24 @@ export module ReactNativeBiometricsLegacy { * @returns {Promise} Promise that resolves to object with details about the newly generated public key */ export function createKeys(): Promise { - return new ReactNativeBiometrics().createKeys() + return new ReactNativeBiometrics().createKeys(); + } + + /** + * Creates a symmetric encryption key, returns promise that resolves to a boolean indicating success/failure. + * @returns {Promise} Promise that resolves to object with success status + */ + export function createEncryptionKeys(): Promise { + return new ReactNativeBiometrics().createEncryptionKeys() + } + + /** + * Returns promise that resolves to an object with object.keysExists = true | false + * indicating if the keys were found to exist or not + * @returns {Promise} Promise that resolves to object with details aobut the existence of keys + */ + export function biometricEncryptionKeysExist(): Promise { + return new ReactNativeBiometrics().biometricEncryptionKeysExist() } /** @@ -98,6 +135,15 @@ export module ReactNativeBiometricsLegacy { return new ReactNativeBiometrics().biometricKeysExist() } + /** + * Returns promise that resolves to an object with true | false + * indicating if the encryption key properly deleted + * @returns {Promise} Promise that resolves to an object with details about the deletion + */ + export function deleteEncryptionKeys(): Promise { + return new ReactNativeBiometrics().deleteEncryptionKeys() + } + /** * Returns promise that resolves to an object with true | false * indicating if the keys were properly deleted @@ -107,6 +153,34 @@ export module ReactNativeBiometricsLegacy { return new ReactNativeBiometrics().deleteKeys() } + /** + * Prompts user with biometrics dialog using the passed in prompt message and + * returns promise that resolves to an object with object.decrypted as UTF-8 string + * @param {Object} decryptDataOptions + * @param {string} decryptDataOptions.promptMessage + * @param {string} decryptDataOptions.payload: base64 encoded data + * @param {string} decryptDataOptions.iv: base64 encoded IV + * @param {string} createSignatureOptions.cancelButtonText (Android only) + * @returns {Promise} Promise that resolves to an object containing encryption details + */ + export function decryptData(decryptDataOptions: DecryptDataOptions): Promise { + return new ReactNativeBiometrics().decryptData(decryptDataOptions) + } + + /** + * Prompts user with biometrics dialog using the passed in prompt message and + * returns promise that resolves to an object with object.encrypted and object.iv, + * which is the base64 encoded encrypted payload and its encryption IV respectively. + * @param {Object} createSignatureOptions + * @param {string} createSignatureOptions.promptMessage + * @param {string} createSignatureOptions.payload: Should be ASCII or UTF-8 + * @param {string} createSignatureOptions.cancelButtonText (Android only) + * @returns {Promise} Promise that resolves to an object containing encryption details + */ + export function encryptData(createSignatureOptions: CreateSignatureOptions): Promise { + return new ReactNativeBiometrics().encryptData(createSignatureOptions) + } + /** * Prompts user with biometrics dialog using the passed in prompt message and * returns promise that resolves to an object with object.signature, @@ -135,90 +209,152 @@ export module ReactNativeBiometricsLegacy { } export default class ReactNativeBiometrics { - allowDeviceCredentials = false - - /** - * @param {Object} rnBiometricsOptions - * @param {boolean} rnBiometricsOptions.allowDeviceCredentials - */ - constructor(rnBiometricsOptions?: RNBiometricsOptions) { - const allowDeviceCredentials = rnBiometricsOptions?.allowDeviceCredentials ?? false - this.allowDeviceCredentials = allowDeviceCredentials - } + allowDeviceCredentials = false - /** - * Returns promise that resolves to an object with object.biometryType = Biometrics | TouchID | FaceID - * @returns {Promise} Promise that resolves to an object with details about biometrics available - */ - isSensorAvailable(): Promise { - return bridge.isSensorAvailable({ - allowDeviceCredentials: this.allowDeviceCredentials - }) - } + /** + * @param {Object} rnBiometricsOptions + * @param {boolean} rnBiometricsOptions.allowDeviceCredentials + */ + constructor(rnBiometricsOptions?: RNBiometricsOptions) { + const allowDeviceCredentials = rnBiometricsOptions?.allowDeviceCredentials ?? false + this.allowDeviceCredentials = allowDeviceCredentials + } - /** - * Creates a public private key pair,returns promise that resolves to - * an object with object.publicKey, which is the public key of the newly generated key pair - * @returns {Promise} Promise that resolves to object with details about the newly generated public key - */ - createKeys(): Promise { - return bridge.createKeys({ - allowDeviceCredentials: this.allowDeviceCredentials - }) - } + /** + * Returns promise that resolves to an object with object.biometryType = Biometrics | TouchID | FaceID + * @returns {Promise} Promise that resolves to an object with details about biometrics available + */ + isSensorAvailable(): Promise { + return bridge.isSensorAvailable({ + allowDeviceCredentials: this.allowDeviceCredentials + }) + } - /** - * Returns promise that resolves to an object with object.keysExists = true | false - * indicating if the keys were found to exist or not - * @returns {Promise} Promise that resolves to object with details aobut the existence of keys - */ - biometricKeysExist(): Promise { - return bridge.biometricKeysExist() - } + /** + * Creates a public private key pair,returns promise that resolves to + * an object with object.publicKey, which is the public key of the newly generated key pair + * @returns {Promise} Promise that resolves to object with details about the newly generated public key + */ + createKeys(): Promise { + return bridge.createKeys({ + allowDeviceCredentials: this.allowDeviceCredentials + }) + } - /** - * Returns promise that resolves to an object with true | false - * indicating if the keys were properly deleted - * @returns {Promise} Promise that resolves to an object with details about the deletion - */ - deleteKeys(): Promise { - return bridge.deleteKeys() - } + /** + * Creates a symmetric encryption key, returns promise that resolves to a boolean indicating success/failure. + * @returns {Promise} Promise that resolves to object with success status + */ + createEncryptionKeys(): Promise { + return bridge.createEncryptionKeys() + } + + /** + * Returns promise that resolves to an object with object.keysExists = true | false + * indicating if the keys were found to exist or not + * @returns {Promise} Promise that resolves to object with details aobut the existence of keys + */ + biometricEncryptionKeysExist(): Promise { + return bridge.biometricEncryptionKeysExist() + } + + /** + * Returns promise that resolves to an object with object.keysExists = true | false + * indicating if the keys were found to exist or not + * @returns {Promise} Promise that resolves to object with details aobut the existence of keys + */ + biometricKeysExist(): Promise { + return bridge.biometricKeysExist() + } - /** - * Prompts user with biometrics dialog using the passed in prompt message and - * returns promise that resolves to an object with object.signature, - * which is cryptographic signature of the payload - * @param {Object} createSignatureOptions - * @param {string} createSignatureOptions.promptMessage - * @param {string} createSignatureOptions.payload - * @returns {Promise} Promise that resolves to an object cryptographic signature details - */ - createSignature(createSignatureOptions: CreateSignatureOptions): Promise { - createSignatureOptions.cancelButtonText = createSignatureOptions.cancelButtonText ?? 'Cancel' - - return bridge.createSignature({ - allowDeviceCredentials: this.allowDeviceCredentials, - ...createSignatureOptions - }) + /** + * Returns promise that resolves to an object with true | false + * indicating if the encryption key properly deleted + * @returns {Promise} Promise that resolves to an object with details about the deletion + */ + deleteEncryptionKeys(): Promise { + return bridge.deleteEncryptionKeys() + } + + /** + * Returns promise that resolves to an object with true | false + * indicating if the keys were properly deleted + * @returns {Promise} Promise that resolves to an object with details about the deletion + */ + deleteKeys(): Promise { + return bridge.deleteKeys() + } + + /** + * Prompts user with biometrics dialog using the passed in prompt message and + * returns promise that resolves to an object with object.decrypted as UTF-8 string + * @param {Object} decryptDataOptions + * @param {string} decryptDataOptions.promptMessage + * @param {string} decryptDataOptions.payload: base64 encoded data + * @param {string} decryptDataOptions.iv: base64 encoded IV + * @param {string} createSignatureOptions.cancelButtonText (Android only) + * @returns {Promise} Promise that resolves to an object containing encryption details + */ + decryptData(decryptDataOptions: DecryptDataOptions): Promise { + if (!decryptDataOptions.cancelButtonText) { + decryptDataOptions.cancelButtonText = 'Cancel' } - /** - * Prompts user with biometrics dialog using the passed in prompt message and - * returns promise that resolves to an object with object.success = true if the user passes, - * object.success = false if the user cancels, and rejects if anything fails - * @param {Object} simplePromptOptions - * @param {string} simplePromptOptions.promptMessage - * @param {string} simplePromptOptions.fallbackPromptMessage - * @returns {Promise} Promise that resolves an object with details about the biometrics result - */ - simplePrompt(simplePromptOptions: SimplePromptOptions): Promise { - simplePromptOptions.cancelButtonText = simplePromptOptions.cancelButtonText ?? 'Cancel' - simplePromptOptions.fallbackPromptMessage = simplePromptOptions.fallbackPromptMessage ?? 'Use Passcode' - - return bridge.simplePrompt({ - allowDeviceCredentials: this.allowDeviceCredentials, - ...simplePromptOptions - }) + return bridge.decryptData(decryptDataOptions) + } + + /** + * Prompts user with biometrics dialog using the passed in prompt message and + * returns promise that resolves to an object with object.encrypted and object.iv, + * which is the base64 encoded encrypted payload and its encryption IV respectively. + * @param {Object} createSignatureOptions + * @param {string} createSignatureOptions.promptMessage + * @param {string} createSignatureOptions.payload: Should be ASCII or UTF-8 + * @param {string} createSignatureOptions.cancelButtonText (Android only) + * @returns {Promise} Promise that resolves to an object containing encryption details + */ + encryptData(createSignatureOptions: CreateSignatureOptions): Promise { + if (!createSignatureOptions.cancelButtonText) { + createSignatureOptions.cancelButtonText = 'Cancel' } + + return bridge.encryptData(createSignatureOptions) } + + /** + * Prompts user with biometrics dialog using the passed in prompt message and + * returns promise that resolves to an object with object.signature, + * which is cryptographic signature of the payload + * @param {Object} createSignatureOptions + * @param {string} createSignatureOptions.promptMessage + * @param {string} createSignatureOptions.payload + * @returns {Promise} Promise that resolves to an object cryptographic signature details + */ + createSignature(createSignatureOptions: CreateSignatureOptions): Promise { + createSignatureOptions.cancelButtonText = createSignatureOptions.cancelButtonText ?? 'Cancel' + + return bridge.createSignature({ + allowDeviceCredentials: this.allowDeviceCredentials, + ...createSignatureOptions + }) + } + + /** + * Prompts user with biometrics dialog using the passed in prompt message and + * returns promise that resolves to an object with object.success = true if the user passes, + * object.success = false if the user cancels, and rejects if anything fails + * @param {Object} simplePromptOptions + * @param {string} simplePromptOptions.promptMessage + * @param {string} simplePromptOptions.fallbackPromptMessage + * @returns {Promise} Promise that resolves an object with details about the biometrics result + */ + simplePrompt(simplePromptOptions: SimplePromptOptions): Promise { + simplePromptOptions.cancelButtonText = simplePromptOptions.cancelButtonText ?? 'Cancel' + simplePromptOptions.fallbackPromptMessage = simplePromptOptions.fallbackPromptMessage ?? 'Use Passcode' + + return bridge.simplePrompt({ + allowDeviceCredentials: this.allowDeviceCredentials, + ...simplePromptOptions + }) + } +} diff --git a/ios/ReactNativeBiometrics.m b/ios/ReactNativeBiometrics.m index 03ec388..be3b236 100644 --- a/ios/ReactNativeBiometrics.m +++ b/ios/ReactNativeBiometrics.m @@ -77,7 +77,7 @@ @implementation ReactNativeBiometrics } }; - [self deleteBiometricKey]; + [self deleteBiometricKey:biometricKeyTag]; NSError *gen_error = nil; id privateKey = CFBridgingRelease(SecKeyCreateRandomKey((__bridge CFDictionaryRef)keyAttributes, (void *)&gen_error)); @@ -99,29 +99,91 @@ @implementation ReactNativeBiometrics }); } -RCT_EXPORT_METHOD(deleteKeys: (RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { - dispatch_async(dispatch_get_global_queue( DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - BOOL biometricKeyExists = [self doesBiometricKeyExist]; +RCT_EXPORT_METHOD(createEncryptionKeys: (RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { + dispatch_async(dispatch_get_global_queue( DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + if ([UIDevice currentDevice].systemVersion.floatValue < 11) { + reject(@"storage_error", @"iOS 11 or higher is required to encrypt data", nil); + return; + } + + CFErrorRef error = NULL; + SecAccessControlRef sacObject = SecAccessControlCreateWithFlags(kCFAllocatorDefault, + kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, + kSecAccessControlBiometryAny|kSecAccessControlPrivateKeyUsage, &error); + if (sacObject == NULL || error != NULL) { + NSString *errorString = [NSString stringWithFormat:@"SecItemAdd can't create sacObject: %@", error]; + reject(@"storage_error", errorString, nil); + return; + } + + NSData *biometricKeyTag = [self getBiometricEncryptionKeyTag]; + NSDictionary *keyAttributes = @{ + (id)kSecAttrKeyType: (id)kSecAttrKeyTypeECSECPrimeRandom, + (id)kSecAttrKeySizeInBits: @256, + (id)kSecAttrTokenID: (id)kSecAttrTokenIDSecureEnclave, + (id)kSecPrivateKeyAttrs: + @{ (id)kSecAttrIsPermanent: @YES, + (id)kSecAttrApplicationTag: biometricKeyTag, + (id)kSecAttrAccessControl: (__bridge_transfer id)sacObject, + }, + }; + + [self deleteBiometricKey: biometricKeyTag]; + + NSError *gen_error = nil; + SecKeyRef privateKey = (__bridge SecKeyRef) CFBridgingRelease(SecKeyCreateRandomKey((__bridge CFDictionaryRef)keyAttributes, (void *)&gen_error)); + + if (privateKey == nil) { + NSString *message = [NSString stringWithFormat:@"Key generation error: %@", gen_error]; + reject(@"storage_error", message, nil); + return; + } + SecKeyRef publicKey = SecKeyCopyPublicKey(privateKey); - if (biometricKeyExists) { - OSStatus status = [self deleteBiometricKey]; - - if (status == noErr) { NSDictionary *result = @{ - @"keysDeleted": @(YES), + @"success": @(YES), + @"pubkey": [(NSData*)CFBridgingRelease(SecKeyCopyExternalRepresentation(publicKey, nil)) base64EncodedStringWithOptions:0], }; resolve(result); - } else { - NSString *message = [NSString stringWithFormat:@"Key not found: %@",[self keychainErrorToString:status]]; - reject(@"deletion_error", message, nil); + }); +} + +RCT_EXPORT_METHOD(deleteKeys: (RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { + dispatch_async(dispatch_get_global_queue( DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + NSData *biometricKeyTag = [self getBiometricKeyTag]; + BOOL success = false; + if ([self doesBiometricKeyExist:biometricKeyTag]) { + OSStatus status = [self deleteBiometricKey:biometricKeyTag]; + success = status == noErr; + if (!success) { + reject(@"deletion_error", [NSString stringWithFormat:@"Key not found: %@", [self keychainErrorToString:status]], nil); + return; + } } - } else { + NSDictionary *result = @{ + @"keysDeleted": @(success), + }; + resolve(result); + }); +} + +RCT_EXPORT_METHOD(deleteEncryptionKeys: (RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + NSData *biometricKeyTag = [self getBiometricEncryptionKeyTag]; + BOOL success = false; + if ([self doesBiometricKeyExist:biometricKeyTag]) { + OSStatus status = [self deleteBiometricKey:biometricKeyTag]; + success = status == noErr; + if (!success) { + reject(@"deletion_error", [NSString stringWithFormat:@"Key not found: %@", [self keychainErrorToString:status]], nil); + return; + } + } NSDictionary *result = @{ - @"keysDeleted": @(NO), + @"keysDeleted": @(success), }; resolve(result); - } - }); + }); } RCT_EXPORT_METHOD(createSignature: (NSDictionary *)params resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { @@ -169,6 +231,106 @@ @implementation ReactNativeBiometrics }); } + + +RCT_EXPORT_METHOD(encryptData: (NSDictionary *)params resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + NSString *promptMessage = [RCTConvert NSString:params[@"promptMessage"]]; + NSString *payload = [RCTConvert NSString:params[@"payload"]]; + + SecKeyRef privateKey; + OSStatus status = [self getEncryptionPrivateKey:promptMessage key:&privateKey]; + if (status != errSecSuccess) { + reject(@"storage_error", [NSString stringWithFormat:@"Key not found: %@", [self keychainErrorToString:status]], nil); + return; + } + + SecKeyRef publicKey = SecKeyCopyPublicKey(privateKey); + + Boolean algorithmSupported = SecKeyIsAlgorithmSupported(publicKey, kSecKeyOperationTypeEncrypt, kSecKeyAlgorithmECIESEncryptionCofactorVariableIVX963SHA256AESGCM); + + if (!algorithmSupported) { + CFRelease(privateKey); + CFRelease(publicKey); + reject(@"storage_error", @"Encryption algorithm not supported", nil); + return; + } + + NSData* plainText = [payload dataUsingEncoding:NSUTF8StringEncoding]; + NSData* cipherText = nil; + CFErrorRef encryptError = NULL; + cipherText = (NSData*)CFBridgingRelease(SecKeyCreateEncryptedData(publicKey, kSecKeyAlgorithmECIESEncryptionCofactorVariableIVX963SHA256AESGCM, (__bridge CFDataRef)plainText, &encryptError)); + CFRelease(privateKey); + CFRelease(publicKey); + + if (!cipherText) { + NSError *err = CFBridgingRelease(encryptError); + NSString *message = [NSString stringWithFormat:@"Encryption error: %@", err]; + reject(@"encryption_error", message, nil); + } + NSString *ciphertextString = [cipherText base64EncodedStringWithOptions:0]; + NSDictionary *result = @{ + @"success": @(YES), + @"encrypted": ciphertextString, + @"iv": @"", // Not needed on iOS, encoded in the cipherText blob. Returned empty for android interoperability + }; + resolve(result); + }); +} + +RCT_EXPORT_METHOD(decryptData: (NSDictionary *)params resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + NSString *promptMessage = [RCTConvert NSString:params[@"promptMessage"]]; + NSString *payload = [RCTConvert NSString:params[@"payload"]]; + + SecKeyRef privateKey; + OSStatus status = [self getEncryptionPrivateKey:promptMessage key:&privateKey]; + if (status != errSecSuccess) { + reject(@"storage_error", [NSString stringWithFormat:@"Key not found: %@", [self keychainErrorToString:status]], nil); + return; + } + + Boolean algorithmSupported = SecKeyIsAlgorithmSupported(privateKey, kSecKeyOperationTypeDecrypt, kSecKeyAlgorithmECIESEncryptionCofactorVariableIVX963SHA256AESGCM); + + if (!algorithmSupported) { + CFRelease(privateKey); + reject(@"storage_error", @"Encryption algorithm not supported", nil); + return; + } + + NSData *cipherText = [[NSData alloc] initWithBase64EncodedString:payload options:0]; + if (!cipherText) { + reject(@"decoding_error", @"Base64 decode failed", nil); + return; + } + + NSData *plainText = nil; + CFErrorRef decryptError = NULL; + plainText = (NSData*)CFBridgingRelease(SecKeyCreateDecryptedData(privateKey, kSecKeyAlgorithmECIESEncryptionCofactorVariableIVX963SHA256AESGCM, (__bridge CFDataRef)cipherText, &decryptError)); + CFRelease(privateKey); + + if (!plainText) { + NSError *err = CFBridgingRelease(decryptError); + NSString *message = [NSString stringWithFormat:@"Decryption error: %@", err]; + reject(@"decryption_error", message, nil); + return; + } + + NSString *plaintextString = [[NSString alloc] initWithData:plainText encoding:NSUTF8StringEncoding]; + if (!plaintextString) { + reject(@"encoding_error", @"UTF8 encode failed", nil); + return; + } + NSDictionary *result = @{ + @"success": @(YES), + @"decrypted": plaintextString, + }; + resolve(result); + }); +} + + + RCT_EXPORT_METHOD(simplePrompt: (NSDictionary *)params resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { dispatch_async(dispatch_get_global_queue( DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ NSString *promptMessage = [RCTConvert NSString:params[@"promptMessage"]]; @@ -207,34 +369,50 @@ @implementation ReactNativeBiometrics RCT_EXPORT_METHOD(biometricKeysExist: (RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { dispatch_async(dispatch_get_global_queue( DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - BOOL biometricKeyExists = [self doesBiometricKeyExist]; - - if (biometricKeyExists) { NSDictionary *result = @{ - @"keysExist": @(YES) + @"keysExist": @([self doesBiometricKeyExist: [self getBiometricKeyTag]]) }; resolve(result); - } else { + }); +} + +RCT_EXPORT_METHOD(biometricEncryptionKeysExist: (RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { + dispatch_async(dispatch_get_global_queue( DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ NSDictionary *result = @{ - @"keysExist": @(NO) + @"keysExist": @([self doesBiometricKeyExist: [self getBiometricEncryptionKeyTag]]) }; resolve(result); - } }); } +- (OSStatus) getEncryptionPrivateKey: (NSString *) promptMessage key: (SecKeyRef *) key { + NSDictionary *query = @{ + (id)kSecClass: (id)kSecClassKey, + (id)kSecAttrApplicationTag: [self getBiometricEncryptionKeyTag], + (id)kSecAttrKeyType: (id)kSecAttrKeyTypeEC, + (id)kSecReturnRef: @YES, + (id)kSecUseOperationPrompt: promptMessage + }; + OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)query, (CFTypeRef *)key); + return status; +} + - (NSData *) getBiometricKeyTag { NSString *biometricKeyAlias = @"com.rnbiometrics.biometricKey"; NSData *biometricKeyTag = [biometricKeyAlias dataUsingEncoding:NSUTF8StringEncoding]; return biometricKeyTag; } -- (BOOL) doesBiometricKeyExist { - NSData *biometricKeyTag = [self getBiometricKeyTag]; +- (NSData *) getBiometricEncryptionKeyTag { + NSString *biometricKeyAlias = @"com.rnbiometrics.encryptionKey"; + NSData *biometricKeyTag = [biometricKeyAlias dataUsingEncoding:NSUTF8StringEncoding]; + return biometricKeyTag; +} + +- (BOOL) doesBiometricKeyExist: (NSData *) tag { NSDictionary *searchQuery = @{ (id)kSecClass: (id)kSecClassKey, - (id)kSecAttrApplicationTag: biometricKeyTag, - (id)kSecAttrKeyType: (id)kSecAttrKeyTypeRSA, + (id)kSecAttrApplicationTag: tag, (id)kSecUseAuthenticationUI: (id)kSecUseAuthenticationUIFail }; @@ -242,12 +420,10 @@ - (BOOL) doesBiometricKeyExist { return status == errSecSuccess || status == errSecInteractionNotAllowed; } --(OSStatus) deleteBiometricKey { - NSData *biometricKeyTag = [self getBiometricKeyTag]; +-(OSStatus) deleteBiometricKey: (NSData *) tag { NSDictionary *deleteQuery = @{ (id)kSecClass: (id)kSecClassKey, - (id)kSecAttrApplicationTag: biometricKeyTag, - (id)kSecAttrKeyType: (id)kSecAttrKeyTypeRSA + (id)kSecAttrApplicationTag: tag, }; OSStatus status = SecItemDelete((__bridge CFDictionaryRef)deleteQuery);