-
Notifications
You must be signed in to change notification settings - Fork 15
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
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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` |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export class KeychainError extends Error {} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export class WalletUnlockError extends Error {} |
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 | ||
} |
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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. paradym specific identifier There was a problem hiding this comment. Choose a reason for hiding this commentThe 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' } | ||
} |
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 | ||
} | ||
} | ||
} |
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 | ||
} |
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}` | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Paradym specific again There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
} |
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('') | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is ths from aries askar of react-native-random-values? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. React Native Random Values |
||
} |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.