Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: react native keychain setup #119

Merged
merged 4 commits into from
Jul 25, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions apps/funke/app.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,14 @@ const config = {
updates: {
fallbackToCacheTimeout: 0,
},
plugins: ['expo-font', 'expo-secure-store', 'expo-router'],
plugins: ['expo-font', 'expo-router'],
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the plugin be removed if the PR mentions to keep it in for now?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the funke app it doesn't really matter yet. Also -- the plugin is for configuration of the Face id description, it doesn't impact the bundling.

assetBundlePatterns: ['**/*'],
ios: {
supportsTablet: false,
bundleIdentifier: `id.animo.funke.wallet${variant.bundle}`,
infoPlist: {
NSCameraUsageDescription: 'This app uses the camera to scan QR-codes.',
NSCameraUsageDescription: 'Funke Wallet uses the camera to initiate receiving and sharing of credentials.',
NSFaceIDUsageDescription: 'Funke Wallet uses FaceID to securely unlock the wallet and share credentials.',
ITSAppUsesNonExemptEncryption: false,
// Add schemes for deep linking
CFBundleURLTypes: [
Expand Down
4 changes: 2 additions & 2 deletions apps/funke/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import {
useFonts,
useTransparentNavigationBar,
} from '@package/app'
import { getLegacySecureWalletKey } from '@package/secure-store/legacyUnlock'
import { Heading, Page, Paragraph, XStack, YStack, config, useToastController } from '@package/ui'
import { getSecureWalletKey } from '@package/utils'
import { DefaultTheme, ThemeProvider } from '@react-navigation/native'
import { Stack } from 'expo-router'
import * as SplashScreen from 'expo-splash-screen'
Expand Down Expand Up @@ -40,7 +40,7 @@ export default function HomeLayout() {
if (agent) return

const startAgent = async () => {
const walletKey = await getSecureWalletKey().catch(() => {
const walletKey = await getLegacySecureWalletKey().catch(() => {
toast.show('Could not load wallet key from secure storage.')
setAgentInitializationFailed(true)
})
Expand Down
3 changes: 2 additions & 1 deletion apps/funke/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,17 @@
"expo-linking": "~6.3.1",
"expo-navigation-bar": "~3.0.6",
"expo-router": "~3.5.16",
"expo-secure-store": "~13.0.1",
"expo-splash-screen": "~0.27.5",
"expo-status-bar": "~1.12.1",
"expo-system-ui": "~3.0.6",
"expo-updates": "~0.25.16",
"react": "*",
"react-native": "0.74.2",
"react-native-argon2": "^2.0.1",
"react-native-fs": "^2.20.0",
"react-native-gesture-handler": "~2.16.2",
"react-native-get-random-values": "~1.11.0",
"react-native-keychain": "^8.2.0",
"react-native-safe-area-context": "4.10.1",
"react-native-screens": "~3.31.1",
"react-native-svg": "15.2.0"
Expand Down
5 changes: 3 additions & 2 deletions apps/paradym/app.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,14 @@ const config = {
updates: {
fallbackToCacheTimeout: 0,
},
plugins: ['expo-font', 'expo-secure-store'],
plugins: ['expo-font'],
assetBundlePatterns: ['**/*'],
ios: {
supportsTablet: false,
bundleIdentifier: `id.paradym.wallet${variant.bundle}`,
infoPlist: {
NSCameraUsageDescription: 'This app uses the camera to scan QR-codes.',
NSCameraUsageDescription: 'Paradym Wallet uses the camera to initiate receiving and sharing of credentials.',
NSFaceIDUsageDescription: 'Paradym Wallet uses FaceID to securely unlock the wallet and share credentials.',
ITSAppUsesNonExemptEncryption: false,
// Add schemes for deep linking
CFBundleURLTypes: [
Expand Down
4 changes: 2 additions & 2 deletions apps/paradym/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import {
useHasInternetConnection,
useTransparentNavigationBar,
} from '@package/app'
import { getLegacySecureWalletKey } from '@package/secure-store/legacyUnlock'
import { Heading, Page, Paragraph, XStack, YStack, config, useToastController } from '@package/ui'
import { getSecureWalletKey } from '@package/utils'
import { DefaultTheme, ThemeProvider } from '@react-navigation/native'
import { useFonts } from 'expo-font'
import { Stack } from 'expo-router'
Expand Down Expand Up @@ -60,7 +60,7 @@ export default function HomeLayout() {
if (agent) return

const startAgent = async () => {
const walletKey = await getSecureWalletKey().catch(() => {
const walletKey = await getLegacySecureWalletKey().catch(() => {
toast.show('Could not load wallet key from secure storage.')
setAgentInitializationFailed(true)
})
Expand Down
19 changes: 19 additions & 0 deletions packages/secure-store/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<div align="center">
<img src="../../assets/icon.png" alt="Paradym Logo" height="176px" />
</div>

<h1 align="center"><b>Secure Store</b></h1>

This package contains methods to securely store, derive and retrieve the wallet key. It contains functionality using both React Native Keychain and Expo Secure Store. For this reason, there's no generic package export, but you should import from the specific files to only import the needed dependencies.

## Using React Native Keychain

Using `react-native-keychain` is the recommended approach and provides the best security. If you want to use this in an app, you need to make sure `react-native-keychain` and `react-native-argon2` are installed in the app.

You should import from `@package/secure-store/secureUnlock`.

## Using Expo Secure Store

Expo Secure Store was used previously to store the wallet key, however no PIN or integration with device biometrics has been implemented. This is kept as legacy behavior until the Paradym Wallet can be updated to use the new (more secure) approach. You need to make sure `expo-secure-store` is installed in the app.

You should import from `@package/secure-store/legacyUnlock`
1 change: 1 addition & 0 deletions packages/secure-store/error/KeychainError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export class KeychainError extends Error {}
1 change: 1 addition & 0 deletions packages/secure-store/error/WalletUnlockError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export class WalletUnlockError extends Error {}
47 changes: 47 additions & 0 deletions packages/secure-store/keychain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import * as Keychain from 'react-native-keychain'
import { KeychainError } from './error/KeychainError'

export type KeychainOptions = Omit<Keychain.Options, 'service'>

/**
* Store the value with id in the keychain.
*
* @throws {KeychainError} if an unexpected error occurs
*/
export async function storeKeychainItem(id: string, value: string, options: KeychainOptions): Promise<void> {
const result = await Keychain.setGenericPassword(id, value, {
...options,
service: value,
}).catch((error) => {
throw new KeychainError(`Error storing value for id '${id}' in keychain`, {
cause: error,
})
})

if (!result) {
throw new KeychainError(`Error storing value for id '${id}' in keychain`)
}
}

/**
* Retrieve a value by id from the keychcain
*
* @returns {string | null} the value or null if it doesn't exist
* @throws {KeychainError} if an unexpected error occurs
*/
export async function getKeychainItemById(id: string, options: KeychainOptions): Promise<string | null> {
const result = await Keychain.getGenericPassword({
...options,
service: id,
}).catch((error) => {
throw new KeychainError(`Error retrieving value with id '${id}' from keychain`, {
cause: error,
})
})

if (!result) {
return null
}

return result.password
}
41 changes: 41 additions & 0 deletions packages/secure-store/legacyUnlock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { ariesAskar } from '@hyperledger/aries-askar-shared'
import * as SecureStore from 'expo-secure-store'

const STORE_KEY_LEGACY = 'wallet-key' as const
const STORE_KEY_RAW = 'paradym-wallet-key-raw' as const
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

paradym specific identifier

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but this need to stay the same as this it the legacy unlock (currnetly used by paradym wallet). We can't just change the key


function generateNewWalletKey(): string {
return ariesAskar.storeGenerateRawKey({})
}

/**
* Retrieve the wallet key from expo secure store. This is the legacy method as we now have a more secure way to unlock the wallet
* using React Native Keychain, protected by biometrics and a deriving the wallet key from a PIN.
*
* This methods allows us to keep the Paradym Wallet as before, until we can update it to also use the PIN and biometric unlock capabilities.
*
* Once we are ready to upgrade we should ask the user to set up a PIN, and derive a new wallet key from it, and rotate the wallet key to this
* new key. That will be able to overwrite both the raw wallet key and derived wallet key methods used in this function.
*/
export async function getLegacySecureWalletKey(): Promise<{
walletKey: string
keyDerivation: 'raw' | 'derive'
}> {
const secureStoreAvailable = await SecureStore.isAvailableAsync()
if (!secureStoreAvailable) throw new Error('SecureStore is not available on this device.')

// New method: raw wallet key
let walletKey = await SecureStore.getItemAsync(STORE_KEY_RAW)
if (walletKey) return { walletKey, keyDerivation: 'raw' }

// TODO: rotate the old wallet key to a new raw key
// Old method: derived wallet key
walletKey = await SecureStore.getItemAsync(STORE_KEY_LEGACY)
if (walletKey) return { walletKey, keyDerivation: 'derive' }

// No wallet key found, generate new method: raw wallet key
const newWalletKey = generateNewWalletKey()
await SecureStore.setItemAsync(STORE_KEY_RAW, newWalletKey)

return { walletKey: newWalletKey, keyDerivation: 'raw' }
}
24 changes: 24 additions & 0 deletions packages/secure-store/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "@package/secure-store",
"version": "0.0.0",
"private": true,
"peerDependencies": {
"@hyperledger/aries-askar-react-native": "^0.2.0",
"expo-secure-store": "~13.0.1",
"react-native": "0.74.2",
"react-native-argon2": "^2.0.1",
"react-native-get-random-values": "~1.11.0",
"react-native-keychain": "^8.2.0"
},
"peerDependenciesMeta": {
"expo-secure-store": {
"optional": true
},
"react-native-keychain": {
"optional": true
},
"react-native-argon2": {
"optional": true
}
}
}
18 changes: 18 additions & 0 deletions packages/secure-store/secure-unlock/react-native-argon2.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
declare module 'react-native-argon2' {
export interface Argon2Config {
iterations?: number
memory?: number
parallelism?: number
hashLength?: number
mode?: string
}

export interface Argon2Result {
rawHash: string
encodedHash: string
}

function argon2(password: string, salt: string, config: Argon2Config): Promise<Argon2Result>

export default argon2
}
35 changes: 35 additions & 0 deletions packages/secure-store/secure-unlock/saltStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import * as Keychain from 'react-native-keychain'
import { type KeychainOptions, getKeychainItemById, storeKeychainItem } from '../keychain'

const saltStoreBaseOptions: KeychainOptions = {
/* Salt can be accessed on this device */
accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
}

const SALT_ID = (version: number) => `PARADYM_WALLET_SALT_${version}`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Paradym specific again

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel it is ok to use PARADYM_WALLET as a prefix here. As the 'core' I'd see as paradym wallet. I didn't want to go fully generic "WALLET_SALT"


/**
* Store the salt in the keychain.
*
* @throws {KeychainError} if an unexpected error occurs
*/
export async function storeSalt(salt: string, version = 1): Promise<void> {
const saltId = SALT_ID(version)

await storeKeychainItem(saltId, salt, saltStoreBaseOptions)
}

/**
* Retrieve the salt from the keychain.
*
* @returns {string | null} the salt or null if it doesn't exist
* @throws {KeychainError} if an unexpected error occurs
*/
export async function getSalt(version = 1): Promise<string | null> {
const saltId = SALT_ID(version)

// TODO: should probably throw error if not found
const salt = await getKeychainItemById(saltId, saltStoreBaseOptions)

return salt
}
29 changes: 29 additions & 0 deletions packages/secure-store/secure-unlock/walletKeyDerivation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import argon2 from 'react-native-argon2'

/**
* Derive key from pin and salt.
*
* Configuration based on recommended parameters defined in RFC 9106
* @see https://www.rfc-editor.org/rfc/rfc9106.html#name-parameter-choice
*/
export const deriveWalletKey = async (pin: string, salt: string) => {
const { rawHash } = await argon2(pin, salt, {
hashLength: 32,
mode: 'argon2id',
parallelism: 4,
iterations: 1,
memory: 21,
})

return rawHash
}

/**
* Generate 32 byte key crypto getRandomValues.
*
* @see https://github.com/LinusU/react-native-get-random-values
* @see https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues
*/
export function generateSalt(): string {
return crypto.getRandomValues(new Uint8Array(32)).join('')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is ths from aries askar of react-native-random-values?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Native Random Values

}
Loading