Technology
minutes read

# Dealing with Custom Cryptographic Systems in React Native

Written by
Paweł Mikutel
Published on
July 15, 2024
TL;DR

While our solution works and provides secure access to cryptographic building blocks from the React Native level, the testing aspect remains an open challenge. This method can also be shared across web versions, making it a versatile approach. Our use of manual tests, dedicated testing keys, and partial reliance on web-based testing methods has ensured stability so far.

Author
Paweł Mikutel
My LinkedIn
Download 2024 SaaS Report
By subscribing you agree to our Privacy Policy.
Thank you! Your submission has been received
Oops! Something went wrong while submitting the form.
Share

Have you ever found yourself entangled in the web of cryptographic complexity? We certainly did! Today, we’re sharing the story of how we navigated the treacherous waters of implementing a custom cryptographic system in React Native. The system required a hybrid of symmetric and asymmetric encryption and needed to be secure, efficient, and usable both on mobile and web platforms. Sounds simple? Well, let's dive in.

The Problem

Because of security reasons we cannot share entire implementation and all details of cryptographic system but on a high level we needed a custom cryptographic system which included following steps:

  1. Mobile App: Generate a one-time-use symmetric AES key and encrypt it with an asymmetric public RSA key.
  2. Backend: Decrypt the symmetric AES key using an asymmetric private RSA key.
  3. Backend: Encrypt sensitive data with the symmetric AES key and send it back to the mobile app.
  4. Mobile App: Decrypt the received data using the AES key from the first step.
  5. Mobile App: Delete the single-use AES key.

These operations required several cryptographic primitives like hashing and key generation functions. Since there was no trusted source for these primitives in React Native, unlike the Web, which has the Web Crypto API, we faced a significant hurdle. And then it hit us: could we use the Web Crypto API in a mobile environment?

Background Information

Before we delve into details, let’s understand some key concepts:

  • Symmetric Encryption: An encryption method where the same key is used for both encryption and decryption. In our case, we used AES.
  • Asymmetric Encryption: Involves a pair of keys—a public key for encryption and a private key for decryption. We used RSA here.
  • Web Crypto API: A standard JavaScript API for performing cryptographic operations in the browser.

The Solution: A Hidden WebView

To access the trusted cryptographic primitives of the Web Crypto API, we decided to use a hidden WebView. Why? Because the WebView could serve as our makeshift browser environment in which we could securely run our crypto operations. Here's how we went about it:

  1. Secure Setup: Ensure the WebView is hidden, uses HTTPS, and points to a secure local resource.
  2. Inject Code: Place all cryptographic JavaScript code within the WebView as an injected HTML string.
  3. Secure Sandbox: Remember, the Web Crypto API operates within a secure sandbox, meaning cryptographic operations must remain within this space.

Initial version showed this method can work and we can reach our goals. However as usually road from prototype to production is quite long and bumpy as when we tried to make the code more usable, adaptable to entire app and testable we faced new problems.

Exporting Methods

One major hurdle was the Web Crypto API's design. The Web Crypto API is meant to operate within the secure sandbox of the browser environment. It doesn't provide mechanisms for exporting raw keys or performing cryptographic operations outside of that context. Any attempt to access or manipulate the underlying key material or cryptographic primitives outside of the browser's secure execution environment would violate the security model of the Web Crypto API.

This means we couldn't simply export cryptographic methods or primitives to use them in the React Native JS context. The majority of cryptographic operations had to be performed inside the WebView's secure sandbox.

This led to a structure were each kind of secure operation consisted of 2 steps:

  1. Create Encrypted Package: In the mobile app, the AES key is generated and encrypted within the WebView, then sent to the trusted API.
  2. Decrypt Information: Data received from the API is decrypted, again within the WebView using the previously generated AES key.

Each step was starting in React Native app, then it was delegated to a crypto environment in the WebView and the result went back to React Native for further processing (display to user, send a backend request, etc.)

Communication with WebView

Communication between the React Native app and the WebView was crucial. We used postMessage for sending data to the WebView and onMessage for receiving responses.

Here's a simplified flow of communication:

  • React Native to WebView (in React Native):
    webviewRef.current.postMessage(JSON.stringify(encryptPackage));
  • React Native to WebView (in WebView):
    const handleDataFromRN = async (message) => {
       const method = JSON.parse(message.data).method
         const data = JSON.parse(message.data).data
       if (method === 'ENCRYPTION_METHOD1') {
           await executeMethod1(data)
         }
         if (method === 'ENCRYPTION_METHOD2') {
           await encryptionMethod2(data.pinData)
         }
         if (method === 'ENCRYPTION_METHOD3') {
           await decryptionMethod3(data)
         }
         
         // other cases ....
     }
     
     window.addEventListener("message", message => handleDataFromRN(message))
  • WebView to React Native:
    const handleMessage = useCallback(
       (data: WebViewMessageEvent) => {
         const response = JSON.parse(data.nativeEvent.data)
         switch (response.method) {
           case ENCRYPTION_METHOD1:
             // further processing with (response.data)
             break
           case ENCRYPTION_METHOD2:
             // further processing with (response.data)
             break
           case ENCRYPTION_METHOD3:
             // further processing with (response.data)
             break
           default:
             break
         }
       },[]
     )
    <WebView source={{ html: injectedHTML }} onMessage={handleMessage} />

Challenges Encountered: Testing the Injected Code

When it came to testing, we hit a few roadblocks that proved to be quite challenging. Here are the main issues we faced and the solutions we explored:

The Conundrum of Testing Injected Strings

One of the significant challenges was the fact that the JavaScript code for the mobile app had to be stored as an injected string. Unlike regular JavaScript files, snippets, or modules, which can be individually tested and validated, injecting code as a string complicates the testing pipeline.

Testing JavaScript code that's embedded as a string in a web view’s source is a unique beast. Most traditional testing frameworks work seamlessly with isolated and distinct code files. However, trying to unit test a string-injected code proved to be a bit of a riddle. To be honest, we didn’t indulge in a deep dive to find a robust solution because the overall implementation had already consumed more time than we had initially estimated.

Relying on Web Version Tests

Given the intricacies of testing string-injected JS, we had to rely heavily on the tests designed for our Web version. This approach inherently comes with its caveats. While the Web version’s tests were comprehensive and gave us a good degree of confidence, the code isn’t a one-to-one match with the mobile implementation due to differences in context (WebView vs. standard web environment).

Why this isn’t perfect:

  • RSA keys and AES keys are uniquely generated each time, rendering deterministic testing nearly impossible.
  • Even though the majority of the logic remains the same, context-specific differences mean that some edge cases might slip through undetected.

This means our primary safety net was an almost-the-same shared codebase monitored by our trusty JEST unit tests.

End-to-End Testing: Manual and Automated

For end-to-end (E2E) testing, the challenges were even steeper. Every cryptographic action employed its unique AES key, and we couldn’t use the original RSA keys for testing purposes.

Our strategy:

  • We introduced dedicated testing RSA keys to mimic real-world cryptographic operations.
  • We had to involve additional manual tests to ensure every aspect of the cryptographic operations processed accurately. This was critical to ensure that the real-world cryptographic functionality aligns with our expectations.

Conclusion

In the end, while our solution works and provides secure access to cryptographic building blocks from the React Native level, the testing aspect remains an open challenge. This method can also be shared across web versions, making it a versatile approach. Our use of manual tests, dedicated testing keys, and partial reliance on web-based testing methods has ensured stability so far.

We would absolutely love to hear if you have any ingenious solutions or tools that can help address the intricacies of testing injected JavaScript strings and non deterministic operations in a WebView. Feel free to share your thoughts and methodologies in the comments. Happy coding!