A closer look at the security of React Native biometric libraries

Many applications require the user to authenticate inside the application before they can access any content. Depending on the sensitivity of the information contained within, applications usually have two approaches:

  • The user authenticates once, then stays authenticated until they manually log out;
  • The user does not stay logged in for too long and has to re-authenticate after a period of inactivity.

The first strategy, while very convenient for the user, is obviously not very secure. The second approach is pretty secure but is a burden for the users as they have to enter their credentials every time. Implementing biometric authentication reduces this burden as the authentication method becomes quite easy and fast for the user.

Developers typically don’t write these integrations with the OS from scratch, and will typically use libraries either provided by the framework or by a third-party. This is especially true when working with cross-platform mobile application framework such as Flutter, Xamarin or React Native, where such integration needs to be implemented in the platform specific code. As authentication is a security-critical feature, it is important to verify if those third-party libraries have securely implemented the required functionality.

In this blog post, we will first take a look at the basic concept of biometric authentication, so that we can then investigate the security of several React Native libraries that provide support for biometric authentication.

TLDR;

We analyzed five React Native libraries that provide biometric authentication. For each of these libraries, we analyzed how the biometric authentication is implemented and whether it correctly uses the cryptographic primitives provided by the OS to secure sensitive data.

Our analysis showed that only one of the five analyzed libraries provides a secure result-based biometric authentication. The other libraries only offer event-based authentication, which is insecure as the biometric authentication is only validated without actually protecting any data in a cryptographic fashion.

The table below provides a summary of the type of biometric authentication offered by each analyzed library:

LibraryEvent-based*Result-based*
react-native-touch-id
expo-local-authentication
react-native-fingerprint-scanner
react-native-fingerprint-android
react-native-biometrics
* See below for definitions

Biometric authentication

Biometric authentication allows the user to authenticate to an application using their biometric data (fingerprint or face recognition). In general, biometric authentication can be implemented in two different ways:

  • Event-based: the biometric API simply returns the result of the authentication attempt to the application (“Success” or “Failure”). This method is considered insecure;
  • Result-based: upon a successful authentication, the biometric API retrieves some cryptographic object (such as a decryption key) and returns it to the application. Upon failure, no cryptographic object is returned.

Event-based authentication is insecure as it only consists of boolean value (or similar) being returned. It can therefore be bypassed using code instrumentation (e.g. Frida) by modifying the return value or by manually triggering the success flow. If an implementation is event-based, it also means that sensitive information is stored somewhere in an insecure fashion: After the application has received “success” from the biometric API, it will still need to authenticate the user to the back-end using some kind of credentials, which will be retrieved from local storage. This will be done without the need of a decryption key (otherwise the implementation wouldn’t be event-based) which means the credentials are stored somewhere on local storage without proper encryption.

A well-implemented result-based biometric authentication, on the other hand, will not be bypassable with tools such as Frida. To implement a secure result-based biometric authentication, the application must use hardware-backed biometric APIs.

A small note about storing credentials

While we use the term “credentials” in this blog post, we are not advocating for the storage of the user’s credentials (i.e. username and password). Storing the user’s credentials on the device is never a good idea for high-security applications, regardless of the way they are stored. Instead, the “credentials” mentioned above should be credentials dedicated to the biometric authentication (such as a high entropy string), which are generated during the activation of the biometric authentication.

To implement a secure result-based biometric authentication on Android, a cryptographic key requiring user authentication must be generated. This can be achieved by using the setUserAuthenticationRequired method when generating the key. Whenever the application will try to access the key, Android will ensure that valid biometrics are provided. The key must then be used to perform a cryptographic operation that unlocks credentials that can then be sent to the back-end. This is done by supplying a CryptoObject, initiated with the previous key, to the biometric API. For example, the BiometricPrompt class provides an authenticate method which takes a CryptoObject as an argument. A reference to the key can then be obtained in the success callback method, through the result argument. More information on implementing secure biometric authentication on Android can be found in this very nice blogpost by f-secure.

On iOS, a cryptographic key must be generated and stored in the Keychain. The entry in the Keychain must be set with the access control flag biometryAny. The key must then be used to perform cryptographic operation that unlocks credentials that can be sent to the back-end. By querying the Keychain for a key protected by biometryAny, iOS will make sure that the user unlocks the required key using their biometric data. Alternatively, instead of storing the cryptographic key in the Keychain, we could directly store the credentials themselves with the biometryAny protection.

Being even more secure with fingerprints

Android and iOS allow you to either trust ‘all fingerprints enrolled on the device’, or ‘all fingerprints currently enrolled on the device’. In the latter case, the cryptographic object becomes unusable in case a fingerprint is added or removed.
For Android, the default is ‘all fingerprints’, while you can use setInvalidatedByBiometricEnrollment to delete a CryptoObject in case a fingerprint is added to the device.
For iOS, the choice is between biometryAny and biometryCurrentSet.
While the ‘currently enrolled‘ option is the most secure, we will not put any weight on this distinction in this blogpost.

Is event-based authentication really insecure?

Yes and no. This fully depends on the threat model of your mobile application. The requirement for applications to provide result-based authentication is a Level 2 requirement in the OWASP MASVS (MSTG-AUTH-8). Level 2 means that your application is handling sensitive information and is typically used for applications in the financial, medical or government sector.

OWASP MASVS Verification Levels (source)

If your application uses event-based biometric authentication, there are specific attacks that will make the user’s credentials available to the attacker:

  • Physical extraction using forensics software
  • Extraction of data from backup files (e.g. iTunes backups or adb backups)
  • Malware with root access to the device

This last example would also be able to attack an application that uses result-based biometric authentication, as it would be possible to inject into the application right after the credentials have been decrypted in memory, but the bar for such an attack is much higher than simply copying the application’s local storage.

React Native

React Native is an open-source mobile application framework created by Facebook. The framework, built on top of ReactJS, allows for cross-platform mobile application development in JavaScript. This allows developer to develop mobile applications on different platforms at once, using HTML, CSS and JavaScript. Over the past few years it has gained quite some traction and is now used by many developers.

While being a cross-platform framework, some feature still require developing in native Android (Java or Kotlin) or iOS (Objective-C or Swift). To get rid of that need, many libraries have seen the light of the day to take care of the platform specific code and provide a JavaScript API that can be used directly in React Native.

Biometric authentication is one such feature that still requires platform specific code to be implemented. It is therefore no surprise that many libraries have been created in an attempt to spare developers the burden of having to implement them separately on the different platforms.

A closer look at several React Native biometric authentication libraries

In this section, we will take a look at five libraries that provide biometric authentication for React Native applications. Rather than only focusing on the documentation, we will examine the source code to verify if the implementation is secure. Based on the top results on Google for ‘biometric API react native’ we have chosen the following libraries:

For each library, we have linked to the specific commit that was the latest while writing this blogpost. Please use the latest versions of the libraries in case you want to use them.

React-native-touch-id

GitHub: https://github.com/naoufal/react-native-touch-id/ (Reviewed version)

Library no longer maintained

The library is no longer maintained by its developers and should therefore not be used anymore regardless of the conclusions of our analysis.

In the Readme file, we can already find some hints that the library does not support result-based biometric authentication. The example code given in the documentation contains the following lines of code:

TouchID.authenticate('to demo this react-native component', optionalConfigObject)
    .then(success => {
        AlertIOS.alert('Authenticated Successfully');
    })
    .catch(error => {
        AlertIOS.alert('Authentication Failed');
    });

In the above example, it is clear that it is an event-based biometric authentication as the success method does not verify the state of the authentication, nor does it provide a way for the developers to verify it.

The more astute among you will notice the optionalConfigObject parameter, which could very well contain data that would be used in a result-based authentication, right? Unfortunately, that’s not the case. If we look a bit further in the documentation, we will find the following:

authenticate(reason, config)
Attempts to authenticate with Face ID/Touch ID. Returns a Promise object.
Arguments
    - reason - An optional String that provides a clear reason for requesting authentication.
    - config - optional - Android only (does nothing on iOS) - an object that specifies the title and color to present in the confirmation dialog.

As we can see, the authenticate method only takes the two parameters that were used in the example. In addition, the optional parameter config (optionalConfigObject in the example code), which does nothing on iOS, is used for UI information.

Ok, enough with the documentation, let’s now dive into the source code to see if the library provides a way to perform a result-based biometric authentication.

Android

Let’s first take a look at the Android implementation. We can find the React Native authenticate method in the TouchID.android.js file, which is used to perform the biometric authentication. This method is the only method to perform biometric authentication provided by the library. The following code can be found in the method:

authenticate(reason, config) {
  //...
  return new Promise((resolve, reject) => {
    NativeTouchID.authenticate(
      authReason,
      authConfig,
      error => {
        return reject(typeof error == 'String' ? createError(error, error) : createError(error));
      },
      success => {
        return resolve(true);
      }
    );
  });
}

We can already see in the above code snippet that the success callback does not verify the result of the authentication and only returns a boolean value. The Android implementation is therefore event-based.

iOS

Let’s now take look at the iOS implementation. Once again, the TouchID.ios.js file only contains one method for biometric authentication, authenticate, which contains the following code:

authenticate(reason, config) {
  //...
  return new Promise((resolve, reject) => {
    NativeTouchID.authenticate(authReason, authConfig, error => {
      // Return error if rejected
      if (error) {
        return reject(createError(authConfig, error.message));
      }

      resolve(true);
    });
  });
}

As we can see, authentication will fail if the error object is set, and will return a boolean value if not. The library does not provide a way for the application to verify the state of the authentication. The iOS implementation is therefore event-based.

As we saw, react-native-touch-id only supports event-based biometric authentication. Applications using this library will therefore not be able to implement a secure biometric authentication.

Result: Insecure event-based authentication

Expo-local-authentication

GitHub: https://github.com/expo/expo (Reviewed version)

The library only provides one JavaScript method for biometric authentication, authenticateAsync, which can be found in the LocalAuthentication.ts file. The following code is responsible for the biometric authentication:

export async function authenticateAsync(
    options: LocalAuthenticationOptions = {}
): Promise<LocalAuthenticationResult> {
    //...
    const promptMessage = options.promptMessage || 'Authenticate';
    const result = await ExpoLocalAuthentication.authenticateAsync({ ...options, promptMessage });

    if (result.warning) {
        console.warn(result.warning);
    }
    return result;
}

The method performs a call to the native ExpoLocalAuthentication.authenticateAsync method and returns the resulting object. To see which data is included in the result object, we will have to dive into the platform specific part of the library.

Android

The authenticateAsync method called from JavaScript can be found in the LocalAuthenticationModule.java file. The following code snippet is the part that we are interested in:

public void authenticateAsync(final Map<String, Object> options, final Promise promise) {
  
      //...
      Executor executor = Executors.newSingleThreadExecutor();
      mBiometricPrompt = new BiometricPrompt(fragmentActivity, executor, mAuthenticationCallback);

      BiometricPrompt.PromptInfo.Builder promptInfoBuilder = new BiometricPrompt.PromptInfo.Builder()
              .setDeviceCredentialAllowed(!disableDeviceFallback)
              .setTitle(promptMessage);
      if (cancelLabel != null && disableDeviceFallback) {
        promptInfoBuilder.setNegativeButtonText(cancelLabel);
      }
      BiometricPrompt.PromptInfo promptInfo = promptInfoBuilder.build();
      mBiometricPrompt.authenticate(promptInfo);
    }
  });
}

Right away, we can see that the call to BiometricPrompt.authenticate is performed without supplying a BiometricPrompt.CryptoObject. The biometric authentication can therefore only be event-based rather than result-based. For the sake of completeness, let’s verify this assertion by looking at the success callback method:

new BiometricPrompt.AuthenticationCallback () {
  @Override
  public void onAuthenticationSucceeded(BiometricPrompt.AuthenticationResult result) {
    mIsAuthenticating = false;
    mBiometricPrompt = null;
    Bundle successResult = new Bundle();
    successResult.putBoolean("success", true);
    safeResolve(successResult);
  }
};

As expected, the onAuthenticationSucceeded callback method does not verify the value of result and returns a boolean value, which shows that the Android implementation is event-based.

iOS

Let’s now look at the iOS implementation.

The authenticateAsync method called from JavaScript can be found in the EXLocalAuthentication.m file. The following code snippet is the part that we are interested in:

UM_EXPORT_METHOD_AS(authenticateAsync,
                    authenticateWithOptions:(NSDictionary *)options
                    resolve:(UMPromiseResolveBlock)resolve
                    reject:(UMPromiseRejectBlock)reject)
{
    //...
    [context evaluatePolicy:LAPolicyDeviceOwnerAuthenticationWithBiometrics
      localizedReason:reason
        reply:^(BOOL success, NSError *error) {
          resolve(@{
            @"success": @(success),
            @"error": error == nil ? [NSNull null] : [self convertErrorCode:error],
            @"warning": UMNullIfNil(warningMessage),
          });
        }];
}

Just like the Android implementation, the library returns a boolean value indicating whether the authentication succeeded or not. The iOS implementation is therefore event-based.

It is worth noting that the library allows for other authentication methods to be used on iOS (device PIN code, Apple Watch, …). Unfortunately, the implementation of the authentication for the other methods suffers from the same issue as the biometric authentication as can be seen in the following code snippet:

UM_EXPORT_METHOD_AS(authenticateAsync,
                    authenticateWithOptions:(NSDictionary *)options
                    resolve:(UMPromiseResolveBlock)resolve
                    reject:(UMPromiseRejectBlock)reject)
{
  NSString *disableDeviceFallback = options[@"disableDeviceFallback"];
  //...
  if ([disableDeviceFallback boolValue]) {
    // biometric authentication
  } else {
    [context evaluatePolicy:LAPolicyDeviceOwnerAuthentication
      localizedReason:reason
        reply:^(BOOL success, NSError *error) {
          resolve(@{
            @"success": @(success),
            @"error": error == nil ? [NSNull null] : [self convertErrorCode:error],
            @"warning": UMNullIfNil(warningMessage),
          });
        }];
  }
}

As we just saw, the expo-local-authentication library only supports event-based biometric authentication. Developer using this library will therefore not be able to implement a secure biometric authentication.

Result: Insecure event-based authentication

React-native-fingerprint-scanner

Source: https://github.com/hieuvp/react-native-fingerprint-scanner (Reviewed version)

The library provides two different implementations for the two platforms. Let’s start with Android.

Android

The library provides one JavaScript method to authenticate using biometric, authenticate, that can be found in the authenticate.android.js file. On Android 6.0 and above, the authenticate method will be the following:

const authCurrent = (title, subTitle, description, cancelButton, resolve, reject) => {
  ReactNativeFingerprintScanner.authenticate(title, subTitle, description, cancelButton)
    .then(() => {
      resolve(true);
    })
    .catch((error) => {
      reject(createError(error.code, error.message));
    });
}

On Android versions before Android 6.0, the authenticate method will be the following:

const authLegacy = (onAttempt, resolve, reject) => {
  //...
  ReactNativeFingerprintScanner.authenticate()
    .then(() => {
      DeviceEventEmitter.removeAllListeners('FINGERPRINT_SCANNER_AUTHENTICATION');
      resolve(true);
    })
    .catch((error) => {
      DeviceEventEmitter.removeAllListeners('FINGERPRINT_SCANNER_AUTHENTICATION');
      reject(createError(error.code, error.message));
    });
}

In both cases, the method will return a boolean value if the call to ReactNativeFingerprintScanner.authenticate did not throw an error, and will raise an exception otherwise. The Android implementation is therefore event-based.

iOS

Just like Android, the library provides one JavaScript method to authenticate using biometric: authenticate. The implementation of the method can be found in the authenticate.ios.js file and can also be found in the following code snippet:

export default ({ description = ' ', fallbackEnabled = true }) => {
  return new Promise((resolve, reject) => {
    ReactNativeFingerprintScanner.authenticate(description, fallbackEnabled, error => {
      if (error) {
        return reject(createError(error.code, error.message))
      }

      return resolve(true);
    });
  });
}

Once again, the method will return a boolean value if the call to ReactNativeFingerprintScanner.authenticate did not return an error. The iOS implementation is therefore event-based.

Similarly to expo-local-authentication, react-native-fingerprint-scanner also supports other authentication methods on iOS. These can be used as fallback methods if the fallbackEnabled parameter is set to true when calling the authenticate method, which is the case by default. As the authenticate method is used for these fallback methods as well, they also suffer from the same issue as the biometric authentication provided by the library.

As we just saw, the react-native-fingerprint-scanner library only supports event-based biometric authentication. Developer using this library will therefore not be able to implement a secure biometric authentication.

Result: Insecure event-based authentication

React-native-fingerprint-android

GitHub: https://github.com/jariz/react-native-fingerprint-android (Reviewed version)

As the name of the library suggests, the library only implements biometric authentication on the Android platform.

The library provides one method for biometric authentication, authenticate, which can be found in the index.android.js file. The part that we are interested in is the following:

static async authenticate(warningCallback:?(response:FingerprintError) => {}):Promise<null> {
  //..
  let err;
  try {
    await FingerprintAndroidNative.authenticate();
  } catch(ex) {
    err = ex
  }
  finally {
    //remove the subscriptions and crash if needed
    DeviceEventEmitter.removeAllListeners("fingerPrintAuthenticationHelp");
    if(err) {
      throw err
    }
  }
}

Right away, we can see in the method prototype that the method returns a Promise<null>. This is similar to returning a boolean value, indicating therefore that the biometric authentication provided by the library is event-based.

However, let’s still dive into the Java implementation of FingerprintAndroidNative.authenticate just to be sure.

The implementation of the method can be found in the FingerprintModule.java file. The relevant lines of the method can be found below:

public void authenticate(Promise promise) {
    //...
    fingerprintManager.authenticate(null, 0, cancellationSignal, new AuthenticationCallback(promise), null); 
    //..
}

As we can see, the method performs a call to the FingerprintManager.authenticate method without providing a FingerprintManager.CryptoObject. The biometric authentication can therefore only be event-based rather than result-based. We could convince ourselves even further by inspecting the OnAuthenticationSucceeded callback method, but this should be enough already.

As we just saw, the react-native-fingerprint-android library only supports event-based biometric authentication. Developer using this library will therefore not be able to implement a secure biometric authentication.

Result: Insecure event-based authentication

React-native-biometrics

GitHub: https://github.com/SelfLender/react-native-biometrics (Reviewed version)

Last, but certainly not least! The library provides two methods to authenticate using biometrics. This looks promising already!

The first method to perform biometric authentication is the simplePrompt method, available in the index.ts file. However, it is clearly mentioned in the documentation that this method only validates the user’s biometrics and that it should not be used for security sensitive features:

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.

**NOTE: This only validates a user's biometrics. This should not be used to log a user in or authenticate with a server, instead use createSignature. It should only be used to gate certain user actions within an app.

We will therefore not investigate this method as it should already be clear to the reader that it is an event-based biometric authentication.

The second method to perform biometric authentication in the library is the createSignature method, available in the index.ts file. According to the documentation, to use this method, a key pair must first be created, using the createKeys method, and the public key must be sent to the server. The authentication process consists in a cryptographic signature sent and verified on the server. The diagram below, taken from the Readme file, illustrates this process.

https://camo.githubusercontent.com/8558a1a8617482d43d4ea3fc6d872adcc6c2c42644cdfef994b4ac3b2790907b/68747470733a2f2f322e62702e626c6f6773706f742e636f6d2f2d4c70327a61415a696574772f566935396862366b3653492f4141414141414141424c6b2f48735858425969497771552f73313630302f696d61676530312e706e67
Authentication flow (source)

Alright! On paper, this looks pretty secure: a cryptographic signature being verified on the server is a proper way to perform biometric authentication. However, we still need to verify if the cryptographic operations are done properly in the library.

Let’s analyze the platform specific implementations.

Android

To verify that the library uses a secure implementation, we have to verify that:

  • The private key used to perform the signature requires user authentication;
  • The success callback uses the result of the biometric authentication to perform cryptographic operations;
  • The library returns the result of the above cryptographic operations to the application.

So first, let’s analyze the createSignature method from the ReactNativeBiometrics class:

public void createSignature(final ReadableMap params, final Promise promise) {
    //...
    Signature signature = Signature.getInstance("SHA256withRSA");
    KeyStore keyStore = KeyStore.getInstance("AndroidKeyStore");
    keyStore.load(null);

    PrivateKey privateKey = (PrivateKey) keyStore.getKey(biometricKeyAlias, null);
    signature.initSign(privateKey);

    BiometricPrompt.CryptoObject cryptoObject = new BiometricPrompt.CryptoObject(signature);

    AuthenticationCallback authCallback = new CreateSignatureCallback(promise, payload);
    //...
    BiometricPrompt biometricPrompt = new BiometricPrompt(fragmentActivity, executor, authCallback);

    PromptInfo promptInfo = new PromptInfo.Builder()
            .setDeviceCredentialAllowed(false)
            .setNegativeButtonText(cancelButtomText)
            .setTitle(promptMessage)
            .build();
    biometricPrompt.authenticate(promptInfo, cryptoObject);
}

In the above code, we can see that a Signature object is initiated with the private key biometricKeyAlias. A CryptoObject is then initiated with the signature. Finally, we can see that the CryptoObject is correctly given to the BiometricPrompt.authenticate method. Ok, so far so good.

Let’s now take a look at how the used key pair is created:

public void createKeys(Promise promise) {
    //...  
    KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_RSA, "AndroidKeyStore");
    KeyGenParameterSpec keyGenParameterSpec = new KeyGenParameterSpec.Builder(biometricKeyAlias, KeyProperties.PURPOSE_SIGN)
            .setDigests(KeyProperties.DIGEST_SHA256)
            .setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1)
            .setAlgorithmParameterSpec(new RSAKeyGenParameterSpec(2048, RSAKeyGenParameterSpec.F4))
            .setUserAuthenticationRequired(true)
            .build();
    keyPairGenerator.initialize(keyGenParameterSpec);
    //...
}

We can see in the code snippet above that the AndroidKeystore is used and that the key pair is configured to require user authentication using the setUserAuthenticationRequired method.

We now only need to verify that the success callback properly handles and returns the result of the authentication. Let’s take a look at the onAuthenticationSucceeded method of the CreateSignatureCallback class:

public void onAuthenticationSucceeded(@NonNull BiometricPrompt.AuthenticationResult result) {
    //...
    BiometricPrompt.CryptoObject cryptoObject = result.getCryptoObject();
    Signature cryptoSignature = cryptoObject.getSignature();
    cryptoSignature.update(this.payload.getBytes());
    byte[] signed = cryptoSignature.sign();
    String signedString = Base64.encodeToString(signed, Base64.DEFAULT);
    signedString = signedString.replaceAll("\r", "").replaceAll("\n", "");

    WritableMap resultMap = new WritableNativeMap();
    resultMap.putBoolean("success", true);
    resultMap.putString("signature", signedString);
    promise.resolve(resultMap);
    //... 
}

The success callback uses the authentication result to get the Signature object and to sign the provided payload. The signature is then encoded in base64 and returned in the promise.

The application can therefore provide a payload to the library, which will be signed after the user successfully provided their biometric data. The signature is then returned to the application, which can finally be sent to the server for verification and complete the authentication.

The Android implementation therefore allows for a secure result-based biometric authentication.

iOS

Like for Android, to verify that the library uses a secure implementation, we have to verify that:

  • The private key requires user authentication;
  • The private key is used to perform cryptographic operations;
  • The library returns the result of the above cryptographic operations to the application.

So, let’s dive right in. The following code snippet shows the relevant part of the createSignature method, available in the ReactNativeBiometrics.m file:

RCT_EXPORT_METHOD(createSignature: (NSDictionary *)params resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) {
    //...
    NSData *biometricKeyTag = [self getBiometricKeyTag];
    NSDictionary *query = @{
                            (id)kSecClass: (id)kSecClassKey,
                            (id)kSecAttrApplicationTag: biometricKeyTag,
                            (id)kSecAttrKeyType: (id)kSecAttrKeyTypeRSA,
                            (id)kSecReturnRef: @YES,
                            (id)kSecUseOperationPrompt: promptMessage
                            };
    SecKeyRef privateKey;
    OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)query, (CFTypeRef *)&privateKey);

    if (status == errSecSuccess) {
      NSError *error;
      NSData *dataToSign = [payload dataUsingEncoding:NSUTF8StringEncoding];
      NSData *signature = CFBridgingRelease(SecKeyCreateSignature(privateKey, kSecKeyAlgorithmRSASignatureMessagePKCS1v15SHA256, (CFDataRef)dataToSign, (void *)&error));

      if (signature != nil) {
        NSString *signatureString = [signature base64EncodedStringWithOptions:0];
        NSDictionary *result = @{
          @"success": @(YES),
          @"signature": signatureString
        };
        resolve(result);
      }
      //...
    }
}

The library attempts to retrieve the private key, identified by biometricKeyTag, from the Keychain and then uses it to sign a provided payload. When the signature succeeds, the library returns the encrypted data to the application. This looks very good already!

Let’s now take a look at how the private key is generated, to ensure that proper user authentication is needed to access it. The key pair is created in the createKeys method, in the same file. The following code snippet show the relevant part of the method:

RCT_EXPORT_METHOD(createKeys: (RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) {
    //...
    SecAccessControlRef sacObject = SecAccessControlCreateWithFlags(kCFAllocatorDefault,
                                                                    kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
                                                                    kSecAccessControlBiometryAny, &error);
    //...
    NSDictionary *keyAttributes = @{
        (id)kSecClass: (id)kSecClassKey,
        (id)kSecAttrKeyType: (id)kSecAttrKeyTypeRSA,
        (id)kSecAttrKeySizeInBits: @2048,
        (id)kSecPrivateKeyAttrs: @{
        (id)kSecAttrIsPermanent: @YES,
        (id)kSecUseAuthenticationUI: (id)kSecUseAuthenticationUIAllow,
        (id)kSecAttrApplicationTag: biometricKeyTag,
        (id)kSecAttrAccessControl: (__bridge_transfer id)sacObject
        }
    };
    //...
    id privateKey = CFBridgingRelease(SecKeyCreateRandomKey((__bridge CFDictionaryRef)keyAttributes, (void *)&gen_error));
    //...
}

In the above code snippet, we can see that the key pair is generated and added to the Keychain using the kSecAccessControlBiometryAny access control flag. Retrieving the key from the Keychain will therefore require a successful biometric authentication.

The application can therefore provide a payload to the library, which will be signed after the user successfully authenticated. The signature is then returned to the application, which can then be submitted to the server for verification.

The iOS implementation therefore allows for a secure result-based biometric authentication.

As we saw, the react-native-biometrics library provides two biometric authentication methods, one of which, createSignature, offers a secure result-based biometric authentication.

It should be noted that the way the library perform biometric authentication requires the server to implement the signature verification, which is harder and requires more changes on the server than just decrypting a token on the local device and sending it to the server for verification. However, while it is a bit harder to integrate into an application, it has the advantage of preventing replay attacks as the authentication payload sent to the server will be different for every authentication.

Result: Secure result-based authentication

Conclusion

Out of the five libraries we analyzed, only one of them, react-native-biometrics, provides a secure result-based biometric authentication which allows for a non-bypassable authentication implementation. The other four libraries only provide event-based biometric authentication, which only allows for a client-side authentication implementation which would therefore be bypassable.

The table below provides a summary of the type of biometric authentication offered by each analyzed library:

LibraryEvent-basedResult-based
react-native-touch-id
expo-local-authentication
react-native-fingerprint-scanner
react-native-fingerprint-android
react-native-biometrics

Usage of third party libraries and mobile development frameworks can certainly decrease the needed development effort, and for applications that don’t require a high level of security, there’s not too much that can go wrong. However, if your application does contain sensitive data or functionality, such as applications from the financial, government or healthcare sector, security should be included in each step of the SDLC. In that case, choosing the correct mobile development framework to use (if any) and which external libraries to trust (if any) is a very important step.

About the authors

Simon Lardinois
Simon Lardinois

Simon Lardinois is a Security Consultant in the Software and Security assessment team at NVISO. His main area of focus is mobile application security, but is also interested in web and reverse engineering. In addition to mobile applications security, he also enjoys developing mobile applications.

Jeroen Beckers
Jeroen Beckers

Jeroen Beckers is a mobile security expert working in the NVISO Software and Security assessment team. He is a SANS instructor and SANS lead author of the SEC575 course. Jeroen is also a co-author of OWASP Mobile Security Testing Guide (MSTG) and the OWASP Mobile Application Security Verification Standard (MASVS). He loves to both program and reverse engineer stuff.

One thought on “A closer look at the security of React Native biometric libraries

Leave a Reply