From 77b938845aa909a70f896b759b04ba7c1b1d9aa6 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Wed, 15 Jun 2022 17:40:18 -0500 Subject: Polyfills for native crypto --- README.md | 9 ++- babel.config.js | 15 +++++ ios/Podfile.lock | 6 ++ metro.config.js | 13 +++- package.json | 4 ++ src/App.native.tsx | 3 +- src/api/auth.ts | 68 ------------------- src/env.ts | 6 +- src/platform/polyfills.native.ts | 21 ++++++ src/platform/polyfills.web.ts | 1 + src/state/auth.ts | 142 +++++++++++++++++++++++++++++++++++++++ src/state/env.ts | 6 +- src/state/index.ts | 2 +- src/state/models/session.ts | 2 +- yarn.lock | 26 ++++++- 15 files changed, 243 insertions(+), 81 deletions(-) delete mode 100644 src/api/auth.ts create mode 100644 src/platform/polyfills.native.ts create mode 100644 src/platform/polyfills.web.ts create mode 100644 src/state/auth.ts diff --git a/README.md b/README.md index 779a78556..ba8b3cf25 100644 --- a/README.md +++ b/README.md @@ -24,10 +24,17 @@ Uses: - Web: `yarn web` - Tips - `npx react-native info` Checks what has been installed. + - On M1 macs, you need to exclude "arm64" from the target architectures + - Annoyingly this must be re-set via XCode after every pod install ## Various notes - ["SSO" flows on mobile](https://developer.okta.com/blog/2022/01/13/mobile-sso) - Suggests we might want to use `ASWebAuthenticationSession` on iOS - [react-native-inappbrowser-reborn](https://www.npmjs.com/package/react-native-inappbrowser-reborn) with `openAuth: true` might be worth exploring - - We might even [get rejected by the app store](https://community.auth0.com/t/react-native-ios-app-rejected-on-appstore-for-using-react-native-auth0/36793) if we don't \ No newline at end of file + - We might even [get rejected by the app store](https://community.auth0.com/t/react-native-ios-app-rejected-on-appstore-for-using-react-native-auth0/36793) if we don't +- Cryptography + - We rely on [isomorphic-webcrypto](https://github.com/kevlened/isomorphic-webcrypto) + - For the CRNG this uses [react-native-securerandom](https://github.com/robhogan/react-native-securerandom) which provides proper random on mobile + - For the crypto this uses [msrcrypto](https://github.com/kevlened/msrCrypto) - but we should consider switching to [the MS maintained version](https://github.com/microsoft/MSR-JavaScript-Crypto) + - In the future it might be preferable to move off of msrcrypto and use iOS and Android native modules, but nothing is available right now \ No newline at end of file diff --git a/babel.config.js b/babel.config.js index cf1f9fbbc..e11e3a9df 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,3 +1,18 @@ module.exports = { presets: ['module:metro-react-native-babel-preset'], + plugins: [ + [ + 'module:react-native-dotenv', + { + // envName: 'APP_ENV', + moduleName: '@env', + path: '.env', + blocklist: null, + allowlist: null, + safe: false, + allowUndefined: true, + verbose: false, + }, + ], + ], } diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 8be1857f2..63cc844f3 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -296,6 +296,8 @@ PODS: - RNScreens (3.13.1): - React-Core - React-RCTImage + - RNSecureRandom (1.0.0): + - React - Yoga (1.14.0) DEPENDENCIES: @@ -335,6 +337,7 @@ DEPENDENCIES: - "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)" - RNInAppBrowser (from `../node_modules/react-native-inappbrowser-reborn`) - RNScreens (from `../node_modules/react-native-screens`) + - RNSecureRandom (from `../node_modules/react-native-securerandom`) - Yoga (from `../node_modules/react-native/ReactCommon/yoga`) SPEC REPOS: @@ -410,6 +413,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-inappbrowser-reborn" RNScreens: :path: "../node_modules/react-native-screens" + RNSecureRandom: + :path: "../node_modules/react-native-securerandom" Yoga: :path: "../node_modules/react-native/ReactCommon/yoga" @@ -449,6 +454,7 @@ SPEC CHECKSUMS: RNCAsyncStorage: 466b9df1a14bccda91da86e0b7d9a345d78e1673 RNInAppBrowser: 3ff3a3b8f458aaf25aaee879d057352862edf357 RNScreens: 40a2cb40a02a609938137a1e0acfbf8fc9eebf19 + RNSecureRandom: 0dcee021fdb3d50cd5cee5db0ebf583c42f5af0e Yoga: 99652481fcd320aefa4a7ef90095b95acd181952 PODFILE CHECKSUM: cf94853ebcb0d8e0d027dca9ab7a4ede886a8f20 diff --git a/metro.config.js b/metro.config.js index 9c99c9e98..9dfaece89 100644 --- a/metro.config.js +++ b/metro.config.js @@ -11,9 +11,11 @@ console.log(metroResolver) module.exports = { resolver: { resolveRequest: (context, moduleName, platform) => { + // HACK // metro doesn't support the "exports" directive in package.json // so we have to manually fix some imports // see https://github.com/facebook/metro/issues/670 + // -prf if (moduleName.startsWith('ucans')) { const subpath = moduleName.split('/').slice(1) if (subpath.length === 0) { @@ -34,14 +36,19 @@ module.exports = { filePath, } } + // HACK + // this module has the same problem with the "exports" module + // but also we need modules to use our version of webcrypto + // so here we're routing to a module we define + // -prf if (moduleName === 'one-webcrypto') { return { type: 'sourceFile', filePath: path.join( context.projectRoot, - 'node_modules', - 'one-webcrypto', - 'browser.mjs', + 'src', + 'platform', + 'polyfills.native.ts', ), } } diff --git a/package.json b/package.json index 635bc7fe3..af95df9eb 100644 --- a/package.json +++ b/package.json @@ -20,15 +20,18 @@ "@react-navigation/native": "^6.0.10", "@react-navigation/native-stack": "^6.6.2", "@react-navigation/stack": "^6.2.1", + "@zxing/text-encoding": "^0.9.0", "mobx": "^6.6.0", "mobx-react-lite": "^3.4.0", "mobx-state-tree": "^5.1.5", + "msrcrypto": "^1.5.8", "react": "17.0.2", "react-dom": "17.0.2", "react-native": "0.68.2", "react-native-inappbrowser-reborn": "^3.6.3", "react-native-safe-area-context": "^4.3.1", "react-native-screens": "^3.13.1", + "react-native-securerandom": "^1.0.0", "react-native-web": "^0.17.7", "ucans": "0.9.1" }, @@ -49,6 +52,7 @@ "eslint": "^7.32.0", "jest": "^26.6.3", "metro-react-native-babel-preset": "^0.67.0", + "react-native-dotenv": "^3.3.1", "react-scripts": "^5.0.1", "react-test-renderer": "17.0.2", "typescript": "^4.4.4" diff --git a/src/App.native.tsx b/src/App.native.tsx index 2fadf993f..511a8401a 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -1,4 +1,5 @@ import React, {useState, useEffect} from 'react' +import {whenWebCrypto} from './platform/polyfills.native' import {RootStore, setupState, RootStoreProvider} from './state' import * as Routes from './routes' @@ -7,7 +8,7 @@ function App() { // init useEffect(() => { - setupState().then(setRootStore) + whenWebCrypto.then(() => setupState()).then(setRootStore) }, []) // show nothing prior to init diff --git a/src/api/auth.ts b/src/api/auth.ts deleted file mode 100644 index 60ff1a3f2..000000000 --- a/src/api/auth.ts +++ /dev/null @@ -1,68 +0,0 @@ -import {Linking} from 'react-native' -import * as auth from '@adxp/auth' -import {InAppBrowser} from 'react-native-inappbrowser-reborn' -import {isWeb} from '../platform/detection' -import {makeAppUrl} from '../platform/urls' -import * as env from '../env' - -const SCOPE = auth.writeCap( - 'did:key:z6MkfRiFMLzCxxnw6VMrHK8pPFt4QAHS3jX3XM87y9rta6kP', - 'did:example:microblog', -) - -export async function isAuthed(authStore: auth.BrowserStore) { - return await authStore.hasUcan(SCOPE) -} - -export async function logout(authStore: auth.BrowserStore) { - await authStore.reset() -} - -export async function parseUrlForUcan() { - // @ts-ignore window is defined -prf - const fragment = window.location.hash - if (fragment.length < 1) { - return undefined - } - try { - const ucan = await auth.parseLobbyResponseHashFragment(fragment) - // @ts-ignore window is defined -prf - window.location.hash = '' - return ucan - } catch (err) { - return undefined - } -} - -export async function requestAppUcan(authStore: auth.BrowserStore) { - const did = await authStore.getDid() - const returnUrl = makeAppUrl() - const fragment = auth.requestAppUcanHashFragment(did, SCOPE, returnUrl) - const url = `${env.AUTH_LOBBY}#${fragment}` - - if (isWeb) { - // @ts-ignore window is defined -prf - window.location.href = url - return false - } - - if (await InAppBrowser.isAvailable()) { - const res = await InAppBrowser.openAuth(url, returnUrl, { - // iOS Properties - ephemeralWebSession: false, - // Android Properties - showTitle: false, - enableUrlBarHiding: true, - enableDefaultShare: false, - }) - if (res.type === 'success' && res.url) { - Linking.openURL(res.url) - } else { - console.error('Bad response', res) - return false - } - } else { - Linking.openURL(url) - } - return true -} diff --git a/src/env.ts b/src/env.ts index 78fb88acc..47bceac77 100644 --- a/src/env.ts +++ b/src/env.ts @@ -1,5 +1,7 @@ -if (typeof process.env.REACT_APP_AUTH_LOBBY !== 'string') { +import {REACT_APP_AUTH_LOBBY} from '@env' + +if (typeof REACT_APP_AUTH_LOBBY !== 'string') { throw new Error('ENV: No auth lobby provided') } -export const AUTH_LOBBY = process.env.REACT_APP_AUTH_LOBBY +export const AUTH_LOBBY = REACT_APP_AUTH_LOBBY diff --git a/src/platform/polyfills.native.ts b/src/platform/polyfills.native.ts new file mode 100644 index 000000000..b4d38f04f --- /dev/null +++ b/src/platform/polyfills.native.ts @@ -0,0 +1,21 @@ +import {generateSecureRandom} from 'react-native-securerandom' +import crypto from 'msrcrypto' +import '@zxing/text-encoding' // TextEncoder / TextDecoder + +export const whenWebCrypto = new Promise(async (resolve, reject) => { + try { + const bytes = await generateSecureRandom(48) + crypto.initPrng(Array.from(bytes)) + + // @ts-ignore global.window exists -prf + if (!global.window.crypto) { + // @ts-ignore global.window exists -prf + global.window.crypto = crypto + } + resolve(true) + } catch (e: any) { + reject(e) + } +}) + +export const webcrypto = crypto diff --git a/src/platform/polyfills.web.ts b/src/platform/polyfills.web.ts new file mode 100644 index 000000000..c6035e5e3 --- /dev/null +++ b/src/platform/polyfills.web.ts @@ -0,0 +1 @@ +// do nothing diff --git a/src/state/auth.ts b/src/state/auth.ts new file mode 100644 index 000000000..b49a11d90 --- /dev/null +++ b/src/state/auth.ts @@ -0,0 +1,142 @@ +import {Linking} from 'react-native' +import * as auth from '@adxp/auth' +import * as ucan from 'ucans' +import {InAppBrowser} from 'react-native-inappbrowser-reborn' +import {isWeb} from '../platform/detection' +import {makeAppUrl} from '../platform/urls' +import * as storage from './storage' +import * as env from '../env' + +const SCOPE = auth.writeCap( + 'did:key:z6MkfRiFMLzCxxnw6VMrHK8pPFt4QAHS3jX3XM87y9rta6kP', + 'did:example:microblog', +) + +export async function isAuthed(authStore: ReactNativeStore) { + return await authStore.hasUcan(SCOPE) +} + +export async function logout(authStore: ReactNativeStore) { + await authStore.reset() +} + +export async function parseUrlForUcan() { + if (isWeb) { + // @ts-ignore window is defined -prf + const fragment = window.location.hash + if (fragment.length < 1) { + return undefined + } + try { + const ucan = await auth.parseLobbyResponseHashFragment(fragment) + // @ts-ignore window is defined -prf + window.location.hash = '' + return ucan + } catch (err) { + return undefined + } + } else { + // TODO + } +} + +export async function requestAppUcan(authStore: ReactNativeStore) { + const did = await authStore.getDid() + const returnUrl = makeAppUrl() + const fragment = auth.requestAppUcanHashFragment(did, SCOPE, returnUrl) + const url = `${env.AUTH_LOBBY}#${fragment}` + + if (isWeb) { + // @ts-ignore window is defined -prf + window.location.href = url + return false + } + + if (await InAppBrowser.isAvailable()) { + const res = await InAppBrowser.openAuth(url, returnUrl, { + // iOS Properties + ephemeralWebSession: false, + // Android Properties + showTitle: false, + enableUrlBarHiding: true, + enableDefaultShare: false, + }) + if (res.type === 'success' && res.url) { + Linking.openURL(res.url) + } else { + console.error('Bad response', res) + return false + } + } else { + Linking.openURL(url) + } + return true +} + +export class ReactNativeStore extends auth.AuthStore { + private keypair: ucan.EdKeypair + private ucanStore: ucan.Store + + constructor(keypair: ucan.EdKeypair, ucanStore: ucan.Store) { + super() + this.keypair = keypair + this.ucanStore = ucanStore + } + + static async load(): Promise { + const keypair = await ReactNativeStore.loadOrCreateKeypair() + + const storedUcans = await ReactNativeStore.getStoredUcanStrs() + const ucanStore = await ucan.Store.fromTokens(storedUcans) + + return new ReactNativeStore(keypair, ucanStore) + } + + static async loadOrCreateKeypair(): Promise { + const storedKey = await storage.loadString('adxKey') + if (storedKey) { + return ucan.EdKeypair.fromSecretKey(storedKey) + } else { + // @TODO: again just stand in since no actual root keys + const keypair = await ucan.EdKeypair.create({exportable: true}) + storage.saveString('adxKey', await keypair.export()) + return keypair + } + } + + static async getStoredUcanStrs(): Promise { + const storedStr = await storage.loadString('adxUcans') + if (!storedStr) { + return [] + } + return storedStr.split(',') + } + + static setStoredUcanStrs(ucans: string[]): void { + storage.saveString('adxUcans', ucans.join(',')) + } + + protected async getKeypair(): Promise { + return this.keypair + } + + async addUcan(token: ucan.Chained): Promise { + this.ucanStore.add(token) + const storedUcans = await ReactNativeStore.getStoredUcanStrs() + ReactNativeStore.setStoredUcanStrs([...storedUcans, token.encoded()]) + } + + async getUcanStore(): Promise { + return this.ucanStore + } + + async clear(): Promise { + storage.clear() + } + + async reset(): Promise { + this.clear() + this.keypair = await ReactNativeStore.loadOrCreateKeypair() + this.ucanStore = await ucan.Store.fromTokens([]) + } +} diff --git a/src/state/env.ts b/src/state/env.ts index f47a7037f..c1e11ebd0 100644 --- a/src/state/env.ts +++ b/src/state/env.ts @@ -4,17 +4,17 @@ */ import {getEnv, IStateTreeNode} from 'mobx-state-tree' -import * as auth from '@adxp/auth' +import {ReactNativeStore} from './auth' import {API} from '../api' export class Environment { api = new API() - authStore?: auth.BrowserStore + authStore?: ReactNativeStore constructor() {} async setup() { - this.authStore = await auth.BrowserStore.load() + this.authStore = await ReactNativeStore.load() } } diff --git a/src/state/index.ts b/src/state/index.ts index 0e70055e0..fa7c9518d 100644 --- a/src/state/index.ts +++ b/src/state/index.ts @@ -6,7 +6,7 @@ import { } from './models/root-store' import {Environment} from './env' import * as storage from './storage' -import * as auth from '../api/auth' +import * as auth from './auth' const ROOT_STATE_STORAGE_KEY = 'root' diff --git a/src/state/models/session.ts b/src/state/models/session.ts index 06c4bb1aa..c032d7594 100644 --- a/src/state/models/session.ts +++ b/src/state/models/session.ts @@ -1,6 +1,6 @@ import {Instance, SnapshotOut, types, flow} from 'mobx-state-tree' // import {UserConfig} from '../../api' -import * as auth from '../../api/auth' +import * as auth from '../auth' import {withEnvironment} from '../env' export const SessionModel = types diff --git a/yarn.lock b/yarn.lock index e6d92d256..b0eb1149e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3067,6 +3067,11 @@ resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d" integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ== +"@zxing/text-encoding@^0.9.0": + version "0.9.0" + resolved "https://registry.yarnpkg.com/@zxing/text-encoding/-/text-encoding-0.9.0.tgz#fb50ffabc6c7c66a0c96b4c03e3d9be74864b70b" + integrity sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA== + abab@^2.0.3, abab@^2.0.5: version "2.0.6" resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291" @@ -3833,7 +3838,7 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== -base64-js@^1.1.2, base64-js@^1.3.1, base64-js@^1.5.1: +base64-js@*, base64-js@^1.1.2, base64-js@^1.3.1, base64-js@^1.5.1: version "1.5.1" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== @@ -9411,6 +9416,11 @@ ms@2.1.3, ms@^2.1.1, ms@^2.1.3: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== +msrcrypto@^1.5.8: + version "1.5.8" + resolved "https://registry.yarnpkg.com/msrcrypto/-/msrcrypto-1.5.8.tgz#be419be4945bf134d8af52e9d43be7fa261f4a1c" + integrity sha512-ujZ0TRuozHKKm6eGbKHfXef7f+esIhEckmThVnz7RNyiOJd7a6MXj2JGBoL9cnPDW+JMG16MoTUh5X+XXjI66Q== + multicast-dns@^7.2.5: version "7.2.5" resolved "https://registry.yarnpkg.com/multicast-dns/-/multicast-dns-7.2.5.tgz#77eb46057f4d7adbd16d9290fa7299f6fa64cced" @@ -11016,6 +11026,13 @@ react-native-codegen@^0.0.17: jscodeshift "^0.13.1" nullthrows "^1.1.1" +react-native-dotenv@^3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/react-native-dotenv/-/react-native-dotenv-3.3.1.tgz#8f399cf28ca77d860d8e7f7323e439fa60a8ca0b" + integrity sha512-gAKXout1XCwCqJ3QPGoQAF2eRzOHgOnwg3x19z+ssow8bDIksJeKBqvoHDyGziVilAIP1J0bEC9Jf+VF8nFang== + dependencies: + dotenv "^10.0.0" + react-native-gradle-plugin@^0.0.6: version "0.0.6" resolved "https://registry.yarnpkg.com/react-native-gradle-plugin/-/react-native-gradle-plugin-0.0.6.tgz#b61a9234ad2f61430937911003cddd7e15c72b45" @@ -11042,6 +11059,13 @@ react-native-screens@^3.13.1: react-freeze "^1.0.0" warn-once "^0.1.0" +react-native-securerandom@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/react-native-securerandom/-/react-native-securerandom-1.0.0.tgz#1cff2f727c90c9ec3318b42dbf825a628b53b49b" + integrity sha512-lnhcsWloFzMN/HffyDBlh4VlqdhDH8uxEzUIH3aJPgC1PxV6OKZkvAk409EwsAhcmG/z3yZuVKegKpUr5IM9ug== + dependencies: + base64-js "*" + react-native-web@^0.17.7: version "0.17.7" resolved "https://registry.yarnpkg.com/react-native-web/-/react-native-web-0.17.7.tgz#038899dbc94467a0ca0be214b88a30e0c117b176" -- cgit 1.4.1