When building local-first apps, we must be specially careful with how we protect data. That is because we keep the data locally within the user’s device. All the encryption and decryption must be local and guarantee that unauthorized people do not have access to sensitive data.
Say we have an user that’s signed into the app with some provider (such as Google) and that provider has sent us the user’s private key so we could do our authentication bit. What if we used that private key and its corresponding public key to encrypt/decrypt this user’s data on the device?
Well, that’s what I set out to do and here’s what I found out:
Expo does not do well with third-party encryption modules and also does not provide the tools for it.
The main problem is that being a react-native workflow, Expo doesn’t support node specific modules (such as crypto
) and its own expo-crypto
(see the docs) implementation has no encryption algorithms — only hashing, which for a local-first app is not nearly enough. On top of that, any implementation that’s native will be rejected by Expo.
There are solutions such as crypto-browserify
(see the docs) for making the node crypto
module accessible outside of node, but I needed to do some sort of hacking before I could get an encryption algorithm working with Expo and I’ll show below how I got it done.
My pick for an Elliptic Curve implementation was eth-crypto
’s (see the docs). With it, having the user’s private key, I can derive their public key with the publicKeyByPrivateKey
method. Then, I can encrypt the data with encryptWithPublicKey
and decrypt it later with decryptWithPrivateKey
. It all should be very straight-forward.
But then, we run into two main problems. The eth-crypto
depends on:
- The node module
crypto
we already know won’t work on Expo; - The
atob
function that cannot be run natively with Expo.
To deal with issue #1, I found out about the patch-package
package. With it, we can patch the dependencies from requiring crypto
to requiring crypto-browserify
and make these patches reproducible within the github repo. I gotta say here that I’m aware that patching modules is not the ideal way, and I agree that it should be avoided when possible, but so far it has been the one solution out of four others I’ve tried that’s actually worked.
So, to get it done, all I did was:
-
Install
crypto-browserify
,eth-crypto
,react-native-get-random-values
,@ethersproject/shims
andpatch-packages
:npm i crypto-browserify eth-crypto react-native-get-random-values @ethersproject/shims patch-packages
-
Add this to
package.json
:"scripts": { "postinstall": "patch-package", }
-
Open the
eccrypto/browser.js
file inside of the project’snode_module
folder and replacerequire('crypto')
withrequire('crypto-browserify)
-
Run
npx patch-package eccrypt
(this will create apatches
folder in the project’s root to keep track of the patches, so they’re commitable and reproducible) -
In whichever file you intend on calling importing the
eth-crypto
module, always import this first (and at this very order):// Import the crypto getRandomValues shim (**BEFORE** the shims) import "react-native-get-random-values"; // Import the the ethers shims (**BEFORE** ethers) import "@ethersproject/shims"; // now import the eth-crypto
This will keep the
atob
problem away.
And that’s pretty much all the hacking needed. If you run into any stream
module resolving issue (or any other node specific modules), install their browserified versions and patch the dependencies as needed such as in the steps 3-4 above.
Now, to use the elliptic curve encryption algorithm, as mentioned before, we can simply follow the eth-crypto
docs and do things like:
const publicKey = EthCrypto.publicKeyByPrivateKey( '0x107be946709e41b7895eea9f2dacf998a0a9124acbb786f0fd1a826101581a07' ); // > 'bf1cc3154424dc22191941d9f4f50b063a2b663a2337e5548abea633c1d06ece...'
const encrypted = await EthCrypto.encryptWithPublicKey( // beware that there's no x0 at public key start 'bf1cc3154424dc22191941d9f4f50b063a2b663a2337e5548abea633c1d06ece...', // publicKey 'foobar' // message ); /* > { iv: '02aeac54cb45283b427bd1a5028552c1', ephemPublicKey: '044acf39ed83c304f19f41ea66615d7a6c0068d5fc48ee181f2fb1091...', ciphertext: '5fbbcc1a44ee19f7499dbc39cfc4ce96', mac: '96490b293763f49a371d3a2040a2d2cb57f246ee88958009fe3c7ef2a38264a1' } */
const message = await EthCrypto.decryptWithPrivateKey( '0x107be946709e41b7895eea9f2dacf998a0a9124acbb786f0fd1a826101581a07', // privateKey { iv: '02aeac54cb45283b427bd1a5028552c1', ephemPublicKey: '044acf39ed83c304f19f41ea66615d7a6c0068d5fc48ee181f2fb1091...', ciphertext: '5fbbcc1a44ee19f7499dbc39cfc4ce96', mac: '96490b293763f49a371d3a2040a2d2cb57f246ee88958009fe3c7ef2a38264a1' } // encrypted-data ); // 'foobar'
And those should work fine with Expo.
UPDATE: While the steps above have worked for me in order to encrypt/decrypt strings given that a private key already exists, I had trouble calling the EthCrypto.createIdentity()
method to create both the private and the public keys from scratch. To bypass it, I ended up going all the way up to the @noble/curves
module to get a function that would generate the keys. All I did was:
-
Install the
@noble/curves
package: -
Import
secp256k1
:import { secp256k1 } from "@noble/curves/secp256k1";
-
Create the random private key with:
const uInt8ArrayKey = secp256k1.utils.randomPrivateKey();
As explicitly named, the return of randomPrivateKey
is a UInt8Array. To be able to use it along with the eth-crypto
package functions, I turned it into an hex-string and prepended a “0x”
to it.
This is an open learning experience for me. If you have any suggestions or questions, feel free to reach me at frederico@mulungood.com