Polyfills for native crypto

zio/stable
Paul Frazee 2022-06-15 17:40:18 -05:00
parent b2dd8d4f44
commit 77b938845a
15 changed files with 243 additions and 81 deletions

View File

@ -24,10 +24,17 @@ Uses:
- Web: `yarn web` - Web: `yarn web`
- Tips - Tips
- `npx react-native info` Checks what has been installed. - `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 ## Various notes
- ["SSO" flows on mobile](https://developer.okta.com/blog/2022/01/13/mobile-sso) - ["SSO" flows on mobile](https://developer.okta.com/blog/2022/01/13/mobile-sso)
- Suggests we might want to use `ASWebAuthenticationSession` on iOS - 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 - [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 - 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

View File

@ -1,3 +1,18 @@
module.exports = { module.exports = {
presets: ['module:metro-react-native-babel-preset'], 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,
},
],
],
} }

View File

@ -296,6 +296,8 @@ PODS:
- RNScreens (3.13.1): - RNScreens (3.13.1):
- React-Core - React-Core
- React-RCTImage - React-RCTImage
- RNSecureRandom (1.0.0):
- React
- Yoga (1.14.0) - Yoga (1.14.0)
DEPENDENCIES: DEPENDENCIES:
@ -335,6 +337,7 @@ DEPENDENCIES:
- "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)" - "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)"
- RNInAppBrowser (from `../node_modules/react-native-inappbrowser-reborn`) - RNInAppBrowser (from `../node_modules/react-native-inappbrowser-reborn`)
- RNScreens (from `../node_modules/react-native-screens`) - RNScreens (from `../node_modules/react-native-screens`)
- RNSecureRandom (from `../node_modules/react-native-securerandom`)
- Yoga (from `../node_modules/react-native/ReactCommon/yoga`) - Yoga (from `../node_modules/react-native/ReactCommon/yoga`)
SPEC REPOS: SPEC REPOS:
@ -410,6 +413,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-inappbrowser-reborn" :path: "../node_modules/react-native-inappbrowser-reborn"
RNScreens: RNScreens:
:path: "../node_modules/react-native-screens" :path: "../node_modules/react-native-screens"
RNSecureRandom:
:path: "../node_modules/react-native-securerandom"
Yoga: Yoga:
:path: "../node_modules/react-native/ReactCommon/yoga" :path: "../node_modules/react-native/ReactCommon/yoga"
@ -449,6 +454,7 @@ SPEC CHECKSUMS:
RNCAsyncStorage: 466b9df1a14bccda91da86e0b7d9a345d78e1673 RNCAsyncStorage: 466b9df1a14bccda91da86e0b7d9a345d78e1673
RNInAppBrowser: 3ff3a3b8f458aaf25aaee879d057352862edf357 RNInAppBrowser: 3ff3a3b8f458aaf25aaee879d057352862edf357
RNScreens: 40a2cb40a02a609938137a1e0acfbf8fc9eebf19 RNScreens: 40a2cb40a02a609938137a1e0acfbf8fc9eebf19
RNSecureRandom: 0dcee021fdb3d50cd5cee5db0ebf583c42f5af0e
Yoga: 99652481fcd320aefa4a7ef90095b95acd181952 Yoga: 99652481fcd320aefa4a7ef90095b95acd181952
PODFILE CHECKSUM: cf94853ebcb0d8e0d027dca9ab7a4ede886a8f20 PODFILE CHECKSUM: cf94853ebcb0d8e0d027dca9ab7a4ede886a8f20

View File

@ -11,9 +11,11 @@ console.log(metroResolver)
module.exports = { module.exports = {
resolver: { resolver: {
resolveRequest: (context, moduleName, platform) => { resolveRequest: (context, moduleName, platform) => {
// HACK
// metro doesn't support the "exports" directive in package.json // metro doesn't support the "exports" directive in package.json
// so we have to manually fix some imports // so we have to manually fix some imports
// see https://github.com/facebook/metro/issues/670 // see https://github.com/facebook/metro/issues/670
// -prf
if (moduleName.startsWith('ucans')) { if (moduleName.startsWith('ucans')) {
const subpath = moduleName.split('/').slice(1) const subpath = moduleName.split('/').slice(1)
if (subpath.length === 0) { if (subpath.length === 0) {
@ -34,14 +36,19 @@ module.exports = {
filePath, 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') { if (moduleName === 'one-webcrypto') {
return { return {
type: 'sourceFile', type: 'sourceFile',
filePath: path.join( filePath: path.join(
context.projectRoot, context.projectRoot,
'node_modules', 'src',
'one-webcrypto', 'platform',
'browser.mjs', 'polyfills.native.ts',
), ),
} }
} }

View File

@ -20,15 +20,18 @@
"@react-navigation/native": "^6.0.10", "@react-navigation/native": "^6.0.10",
"@react-navigation/native-stack": "^6.6.2", "@react-navigation/native-stack": "^6.6.2",
"@react-navigation/stack": "^6.2.1", "@react-navigation/stack": "^6.2.1",
"@zxing/text-encoding": "^0.9.0",
"mobx": "^6.6.0", "mobx": "^6.6.0",
"mobx-react-lite": "^3.4.0", "mobx-react-lite": "^3.4.0",
"mobx-state-tree": "^5.1.5", "mobx-state-tree": "^5.1.5",
"msrcrypto": "^1.5.8",
"react": "17.0.2", "react": "17.0.2",
"react-dom": "17.0.2", "react-dom": "17.0.2",
"react-native": "0.68.2", "react-native": "0.68.2",
"react-native-inappbrowser-reborn": "^3.6.3", "react-native-inappbrowser-reborn": "^3.6.3",
"react-native-safe-area-context": "^4.3.1", "react-native-safe-area-context": "^4.3.1",
"react-native-screens": "^3.13.1", "react-native-screens": "^3.13.1",
"react-native-securerandom": "^1.0.0",
"react-native-web": "^0.17.7", "react-native-web": "^0.17.7",
"ucans": "0.9.1" "ucans": "0.9.1"
}, },
@ -49,6 +52,7 @@
"eslint": "^7.32.0", "eslint": "^7.32.0",
"jest": "^26.6.3", "jest": "^26.6.3",
"metro-react-native-babel-preset": "^0.67.0", "metro-react-native-babel-preset": "^0.67.0",
"react-native-dotenv": "^3.3.1",
"react-scripts": "^5.0.1", "react-scripts": "^5.0.1",
"react-test-renderer": "17.0.2", "react-test-renderer": "17.0.2",
"typescript": "^4.4.4" "typescript": "^4.4.4"

View File

@ -1,4 +1,5 @@
import React, {useState, useEffect} from 'react' import React, {useState, useEffect} from 'react'
import {whenWebCrypto} from './platform/polyfills.native'
import {RootStore, setupState, RootStoreProvider} from './state' import {RootStore, setupState, RootStoreProvider} from './state'
import * as Routes from './routes' import * as Routes from './routes'
@ -7,7 +8,7 @@ function App() {
// init // init
useEffect(() => { useEffect(() => {
setupState().then(setRootStore) whenWebCrypto.then(() => setupState()).then(setRootStore)
}, []) }, [])
// show nothing prior to init // show nothing prior to init

View File

@ -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
}

View File

@ -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') 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

View File

@ -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

View File

@ -0,0 +1 @@
// do nothing

142
src/state/auth.ts 100644
View File

@ -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<ReactNativeStore> {
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<ucan.EdKeypair> {
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<string[]> {
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<ucan.EdKeypair> {
return this.keypair
}
async addUcan(token: ucan.Chained): Promise<void> {
this.ucanStore.add(token)
const storedUcans = await ReactNativeStore.getStoredUcanStrs()
ReactNativeStore.setStoredUcanStrs([...storedUcans, token.encoded()])
}
async getUcanStore(): Promise<ucan.Store> {
return this.ucanStore
}
async clear(): Promise<void> {
storage.clear()
}
async reset(): Promise<void> {
this.clear()
this.keypair = await ReactNativeStore.loadOrCreateKeypair()
this.ucanStore = await ucan.Store.fromTokens([])
}
}

View File

@ -4,17 +4,17 @@
*/ */
import {getEnv, IStateTreeNode} from 'mobx-state-tree' import {getEnv, IStateTreeNode} from 'mobx-state-tree'
import * as auth from '@adxp/auth' import {ReactNativeStore} from './auth'
import {API} from '../api' import {API} from '../api'
export class Environment { export class Environment {
api = new API() api = new API()
authStore?: auth.BrowserStore authStore?: ReactNativeStore
constructor() {} constructor() {}
async setup() { async setup() {
this.authStore = await auth.BrowserStore.load() this.authStore = await ReactNativeStore.load()
} }
} }

View File

@ -6,7 +6,7 @@ import {
} from './models/root-store' } from './models/root-store'
import {Environment} from './env' import {Environment} from './env'
import * as storage from './storage' import * as storage from './storage'
import * as auth from '../api/auth' import * as auth from './auth'
const ROOT_STATE_STORAGE_KEY = 'root' const ROOT_STATE_STORAGE_KEY = 'root'

View File

@ -1,6 +1,6 @@
import {Instance, SnapshotOut, types, flow} from 'mobx-state-tree' import {Instance, SnapshotOut, types, flow} from 'mobx-state-tree'
// import {UserConfig} from '../../api' // import {UserConfig} from '../../api'
import * as auth from '../../api/auth' import * as auth from '../auth'
import {withEnvironment} from '../env' import {withEnvironment} from '../env'
export const SessionModel = types export const SessionModel = types

View File

@ -3067,6 +3067,11 @@
resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d" resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d"
integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ== 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: abab@^2.0.3, abab@^2.0.5:
version "2.0.6" version "2.0.6"
resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291" 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" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== 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" version "1.5.1"
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== 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" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== 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: multicast-dns@^7.2.5:
version "7.2.5" version "7.2.5"
resolved "https://registry.yarnpkg.com/multicast-dns/-/multicast-dns-7.2.5.tgz#77eb46057f4d7adbd16d9290fa7299f6fa64cced" 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" jscodeshift "^0.13.1"
nullthrows "^1.1.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: react-native-gradle-plugin@^0.0.6:
version "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" 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" react-freeze "^1.0.0"
warn-once "^0.1.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: react-native-web@^0.17.7:
version "0.17.7" version "0.17.7"
resolved "https://registry.yarnpkg.com/react-native-web/-/react-native-web-0.17.7.tgz#038899dbc94467a0ca0be214b88a30e0c117b176" resolved "https://registry.yarnpkg.com/react-native-web/-/react-native-web-0.17.7.tgz#038899dbc94467a0ca0be214b88a30e0c117b176"