diff --git a/wallets/rn_cli_wallet/__tests__/mmkvEncryptionKey.test.ts b/wallets/rn_cli_wallet/__tests__/mmkvEncryptionKey.test.ts new file mode 100644 index 00000000..013510f0 --- /dev/null +++ b/wallets/rn_cli_wallet/__tests__/mmkvEncryptionKey.test.ts @@ -0,0 +1,75 @@ +const mockSecureStoreGet = jest.fn(); +const mockSecureStoreSet = jest.fn(); +let mockTestMode: string | undefined; + +jest.mock('expo-secure-store', () => ({ + getItemAsync: mockSecureStoreGet, + setItemAsync: mockSecureStoreSet, +})); + +jest.mock('../src/utils/env', () => ({ + ENV: { TEST_MODE: mockTestMode }, +})); + +function loadGetEncryptionKey() { + let result: typeof import('../src/utils/mmkvEncryptionKey').getEncryptionKey; + jest.isolateModules(() => { + result = ( + require('../src/utils/mmkvEncryptionKey') as typeof import('../src/utils/mmkvEncryptionKey') + ).getEncryptionKey; + }); + return result!; +} + +describe('MMKV encryption key', () => { + beforeEach(() => { + mockSecureStoreGet.mockReset(); + mockSecureStoreSet.mockReset(); + mockTestMode = undefined; + }); + + it('reuses an existing Keychain key', async () => { + mockSecureStoreGet.mockResolvedValue('existing-key'); + + await expect(loadGetEncryptionKey()()).resolves.toBe('existing-key'); + expect(mockSecureStoreSet).not.toHaveBeenCalled(); + }); + + it('generates and persists a valid MMKV key on first use', async () => { + mockSecureStoreGet.mockResolvedValue(null); + mockSecureStoreSet.mockResolvedValue(undefined); + + const key = await loadGetEncryptionKey()(); + + expect(key).toHaveLength(16); + expect(mockSecureStoreSet).toHaveBeenCalledWith( + 'mmkv_encryption_key', + key, + ); + }); + + it('fails closed when Keychain is unavailable in a normal build', async () => { + mockSecureStoreGet.mockRejectedValue(new Error('missing entitlement')); + + await expect(loadGetEncryptionKey()()).rejects.toThrow( + 'refusing to access wallet secrets: missing entitlement', + ); + }); + + it('does not replace a missing key when encrypted wallet data exists', async () => { + mockSecureStoreGet.mockResolvedValue(null); + + await expect(loadGetEncryptionKey()(true)).rejects.toThrow( + 'the encryption key is missing for existing wallet data', + ); + expect(mockSecureStoreSet).not.toHaveBeenCalled(); + }); + + it('uses an encrypted disposable store only in explicit E2E mode', async () => { + mockTestMode = 'true'; + mockSecureStoreGet.mockRejectedValue(new Error('missing entitlement')); + + await expect(loadGetEncryptionKey()()).resolves.toBe('wallet-e2e-key!!'); + expect(mockSecureStoreGet).not.toHaveBeenCalled(); + }); +}); diff --git a/wallets/rn_cli_wallet/__tests__/storage.test.ts b/wallets/rn_cli_wallet/__tests__/storage.test.ts new file mode 100644 index 00000000..f43f2a37 --- /dev/null +++ b/wallets/rn_cli_wallet/__tests__/storage.test.ts @@ -0,0 +1,165 @@ +const mockStores = new Map>(); +const mockDroppedWrites = new Set(); +const mockConfigurations: Array<{ + id: string; + encryptionKey?: string; +}> = []; + +jest.mock('react-native-mmkv', () => ({ + MMKV: class { + private values: Map; + + constructor(config: { id?: string; encryptionKey?: string } = {}) { + const id = config.id ?? 'default'; + if (!mockStores.has(id)) { + mockStores.set(id, new Map()); + } + this.values = mockStores.get(id)!; + mockConfigurations.push({ id, encryptionKey: config.encryptionKey }); + } + + getString(key: string) { + return this.values.get(key); + } + + set(key: string, value: string) { + if (mockDroppedWrites.has(key)) { + return; + } + this.values.set(key, value); + } + + delete(key: string) { + this.values.delete(key); + } + + getAllKeys() { + return [...this.values.keys()]; + } + }, +})); + +const mockGetEncryptionKey = jest.fn(); +jest.mock('../src/utils/mmkvEncryptionKey', () => ({ + getEncryptionKey: mockGetEncryptionKey, +})); + +function getMockStore(id: string): Map { + if (!mockStores.has(id)) { + mockStores.set(id, new Map()); + } + return mockStores.get(id)!; +} + +const defaultStore = () => getMockStore('default'); +const encryptedMmkv = () => getMockStore('wallet-secure'); +function loadStorage() { + let result: typeof import('../src/utils/storage').storage; + jest.isolateModules(() => { + result = ( + require('../src/utils/storage') as typeof import('../src/utils/storage') + ).storage; + }); + return result!; +} + +describe('wallet secret storage', () => { + beforeEach(() => { + mockStores.forEach(store => store.clear()); + mockDroppedWrites.clear(); + mockConfigurations.length = 0; + mockGetEncryptionKey.mockReset().mockResolvedValue('test-secret-key'); + }); + + it('migrates a legacy mnemonic before deleting the plaintext value', async () => { + const storage = loadStorage(); + defaultStore().set('EIP155_MNEMONIC_1', 'seed phrase'); + + await expect(storage.getItem('EIP155_MNEMONIC_1')).resolves.toBe( + 'seed phrase', + ); + + expect(encryptedMmkv().get('EIP155_MNEMONIC_1')).toBe('seed phrase'); + expect(defaultStore().has('EIP155_MNEMONIC_1')).toBe(false); + expect(mockConfigurations).toContainEqual({ + id: 'wallet-secure', + encryptionKey: 'test-secret-key', + }); + }); + + it('prefers an encrypted value and cleans up an interrupted migration', async () => { + const storage = loadStorage(); + encryptedMmkv().set('SOLANA_MNEMONIC_1', 'new phrase'); + defaultStore().set('SOLANA_MNEMONIC_1', 'old phrase'); + + await expect(storage.getItem('SOLANA_MNEMONIC_1')).resolves.toBe( + 'new phrase', + ); + expect(defaultStore().has('SOLANA_MNEMONIC_1')).toBe(false); + }); + + it('leaves WalletConnect and preference records in the default store', async () => { + const storage = loadStorage(); + await storage.setItem('wc@2:client:0.3//session', { topic: 'abc' }); + await storage.setItem('TEST_NETS', 'YES'); + + expect(defaultStore().get('wc@2:client:0.3//session')).toBe( + JSON.stringify({ topic: 'abc' }), + ); + expect(defaultStore().get('TEST_NETS')).toBe('YES'); + await expect(storage.getKeys()).resolves.toEqual([ + 'wc@2:client:0.3//session', + 'TEST_NETS', + ]); + expect(mockGetEncryptionKey).not.toHaveBeenCalled(); + }); + + it('keeps legacy secrets out of WalletConnect storage scans', async () => { + const storage = loadStorage(); + defaultStore().set('BITCOIN_MNEMONIC_1', 'seed phrase'); + defaultStore().set('wc@2:core:0.3//pairing', JSON.stringify({ topic: 'abc' })); + + await expect(storage.getKeys()).resolves.toEqual([ + 'wc@2:core:0.3//pairing', + ]); + await expect(storage.getEntries()).resolves.toEqual([ + ['wc@2:core:0.3//pairing', { topic: 'abc' }], + ]); + expect(defaultStore().has('BITCOIN_MNEMONIC_1')).toBe(true); + }); + + it('keeps the plaintext value when encrypted-write verification fails', async () => { + const storage = loadStorage(); + defaultStore().set('TON_SECRET_KEY_1', 'legacy secret'); + mockDroppedWrites.add('TON_SECRET_KEY_1'); + + await expect(storage.getItem('TON_SECRET_KEY_1')).rejects.toThrow( + 'Failed to verify encrypted wallet storage', + ); + expect(defaultStore().get('TON_SECRET_KEY_1')).toBe('legacy secret'); + }); + + it('requires the existing encryption key after encrypted data was written', async () => { + encryptedMmkv().set('STELLAR_SECRET_KEY_1', 'secret'); + getMockStore('wallet-secure-metadata').set( + 'has-encrypted-wallet-data', + 'true', + ); + const storage = loadStorage(); + + await expect(storage.getItem('STELLAR_SECRET_KEY_1')).resolves.toBe( + 'secret', + ); + expect(mockGetEncryptionKey).toHaveBeenCalledWith(true); + }); + + it('does not write a secret to plaintext when encryption-key storage fails', async () => { + const storage = loadStorage(); + mockGetEncryptionKey.mockRejectedValueOnce(new Error('Keychain unavailable')); + + await expect( + storage.setItem('STELLAR_SECRET_KEY_1', 'secret'), + ).rejects.toThrow('Keychain unavailable'); + expect(defaultStore().has('STELLAR_SECRET_KEY_1')).toBe(false); + }); +}); diff --git a/wallets/rn_cli_wallet/package.json b/wallets/rn_cli_wallet/package.json index 619a123c..939c6e70 100644 --- a/wallets/rn_cli_wallet/package.json +++ b/wallets/rn_cli_wallet/package.json @@ -72,6 +72,7 @@ "expo-application": "~56.0.3", "expo-clipboard": "~56.0.4", "expo-navigation-bar": "~56.0.3", + "expo-secure-store": "~56.0.4", "expo-system-ui": "56.0.5", "lottie-react-native": "7.3.5", "pressto": "0.7.0", diff --git a/wallets/rn_cli_wallet/src/utils/BitcoinWalletUtil.ts b/wallets/rn_cli_wallet/src/utils/BitcoinWalletUtil.ts index 44d30710..54d528a4 100644 --- a/wallets/rn_cli_wallet/src/utils/BitcoinWalletUtil.ts +++ b/wallets/rn_cli_wallet/src/utils/BitcoinWalletUtil.ts @@ -73,7 +73,7 @@ export async function loadBitcoinWallet(input: string): Promise<{ if (__DEV__) { console.warn( - '[SECURITY] Bitcoin key material stored unencrypted. Use secure enclave in production.', + '[SECURITY] Bitcoin mnemonic stored in encrypted MMKV on native (key in Keychain/Keystore); unencrypted localStorage on web.', ); } diff --git a/wallets/rn_cli_wallet/src/utils/CantonWalletUtil.ts b/wallets/rn_cli_wallet/src/utils/CantonWalletUtil.ts index c094fd13..96e9f78e 100644 --- a/wallets/rn_cli_wallet/src/utils/CantonWalletUtil.ts +++ b/wallets/rn_cli_wallet/src/utils/CantonWalletUtil.ts @@ -72,7 +72,7 @@ export async function loadCantonWallet(input: string): Promise<{ await storage.setItem('CANTON_SECRET_KEY_1', newWallet.getSecretKey()); if (__DEV__) { console.warn( - '[SECURITY] Canton secret key stored unencrypted. Use secure enclave in production.', + '[SECURITY] Canton secret key stored in encrypted MMKV on native (key in Keychain/Keystore); unencrypted localStorage on web.', ); } diff --git a/wallets/rn_cli_wallet/src/utils/SolanaWalletUtil.ts b/wallets/rn_cli_wallet/src/utils/SolanaWalletUtil.ts index d74f831c..119663d5 100644 --- a/wallets/rn_cli_wallet/src/utils/SolanaWalletUtil.ts +++ b/wallets/rn_cli_wallet/src/utils/SolanaWalletUtil.ts @@ -96,7 +96,7 @@ export async function loadSolanaWallet(input: string): Promise<{ if (__DEV__) { console.warn( - '[SECURITY] Solana key material stored unencrypted. Use secure enclave in production.', + '[SECURITY] Solana key material stored in encrypted MMKV on native (key in Keychain/Keystore); unencrypted localStorage on web.', ); } diff --git a/wallets/rn_cli_wallet/src/utils/StellarWalletUtil.ts b/wallets/rn_cli_wallet/src/utils/StellarWalletUtil.ts index cb55e5ad..8ef158c6 100644 --- a/wallets/rn_cli_wallet/src/utils/StellarWalletUtil.ts +++ b/wallets/rn_cli_wallet/src/utils/StellarWalletUtil.ts @@ -81,7 +81,7 @@ export async function loadStellarWallet(input: string): Promise<{ if (__DEV__) { console.warn( - '[SECURITY] Stellar key material stored unencrypted. Use secure enclave in production.', + '[SECURITY] Stellar key material stored in encrypted MMKV on native (key in Keychain/Keystore); unencrypted localStorage on web.', ); } diff --git a/wallets/rn_cli_wallet/src/utils/SuiWalletUtil.ts b/wallets/rn_cli_wallet/src/utils/SuiWalletUtil.ts index 55d3a382..1b3b64f2 100644 --- a/wallets/rn_cli_wallet/src/utils/SuiWalletUtil.ts +++ b/wallets/rn_cli_wallet/src/utils/SuiWalletUtil.ts @@ -64,7 +64,7 @@ export async function loadSuiWallet(input: string): Promise<{ await storage.setItem('SUI_MNEMONIC_1', trimmedInput); if (__DEV__) { console.warn( - '[SECURITY] SUI mnemonic stored unencrypted. Use secure enclave in production.', + '[SECURITY] SUI mnemonic stored in encrypted MMKV on native (key in Keychain/Keystore); unencrypted localStorage on web.', ); } diff --git a/wallets/rn_cli_wallet/src/utils/TonWalletUtil.ts b/wallets/rn_cli_wallet/src/utils/TonWalletUtil.ts index c18b559d..e951734c 100644 --- a/wallets/rn_cli_wallet/src/utils/TonWalletUtil.ts +++ b/wallets/rn_cli_wallet/src/utils/TonWalletUtil.ts @@ -79,7 +79,7 @@ export async function loadTonWallet(input: string): Promise<{ await storage.setItem('TON_SECRET_KEY_1', newWallet.getSecretKey()); if (__DEV__) { console.warn( - '[SECURITY] TON secret key stored unencrypted. Use secure enclave in production.', + '[SECURITY] TON secret key stored in encrypted MMKV on native (key in Keychain/Keystore); unencrypted localStorage on web.', ); } diff --git a/wallets/rn_cli_wallet/src/utils/TronWalletUtil.ts b/wallets/rn_cli_wallet/src/utils/TronWalletUtil.ts index 06638a1f..37ea5f3c 100644 --- a/wallets/rn_cli_wallet/src/utils/TronWalletUtil.ts +++ b/wallets/rn_cli_wallet/src/utils/TronWalletUtil.ts @@ -68,7 +68,7 @@ export async function loadTronWallet(input: string): Promise<{ storage.setItem('TRON_PrivateKey_1', trimmedInput); if (__DEV__) { console.warn( - '[SECURITY] TRON private key stored unencrypted. Use secure enclave in production.', + '[SECURITY] TRON private key stored in encrypted MMKV on native (key in Keychain/Keystore); unencrypted localStorage on web.', ); } diff --git a/wallets/rn_cli_wallet/src/utils/mmkvEncryptionKey.ts b/wallets/rn_cli_wallet/src/utils/mmkvEncryptionKey.ts new file mode 100644 index 00000000..eab76707 --- /dev/null +++ b/wallets/rn_cli_wallet/src/utils/mmkvEncryptionKey.ts @@ -0,0 +1,96 @@ +import { Platform } from 'react-native'; +import * as SecureStore from 'expo-secure-store'; +import { ENV } from './env'; + +// Owns the lifecycle of the MMKV encryption key. The key is generated once and +// persisted in the OS Keychain (iOS) / Keystore (Android), so the key that +// protects wallet secrets never sits in plaintext MMKV. + +const ENCRYPTION_KEY_ENTRY = 'mmkv_encryption_key'; +const EXPO_SECURE_STORE_TIMEOUT_MS = 4000; +const E2E_ENCRYPTION_KEY = 'wallet-e2e-key!!'; + +function withTimeout(promise: Promise, ms: number): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error(`SecureStore timed out after ${ms}ms`)), + ms, + ); + + promise.then( + value => { + clearTimeout(timeout); + resolve(value); + }, + error => { + clearTimeout(timeout); + reject(error); + }, + ); + }); +} + +// MMKV encryption keys cannot exceed 16 bytes. Twelve random bytes encode to +// exactly 16 base64 characters, providing 96 bits of entropy. +function generateKey(): string { + const bytes = crypto.getRandomValues(new Uint8Array(12)); + // Buffer is installed globally by @walletconnect/react-native-compat. + return Buffer.from(bytes).toString('base64'); +} + +let keyPromise: Promise | undefined; + +async function loadEncryptionKey( + mustHaveExistingKey: boolean, +): Promise { + // SecureStore and MMKV encryption are unavailable in the browser. The web + // storage shim remains explicitly best-effort and unencrypted. + if (Platform.OS === 'web') { + return undefined; + } + + // The unsigned iOS E2E app has no Keychain entitlement. Test mode uses a + // known key for its disposable simulator data and never attempts to persist + // production wallet material. + if (ENV.TEST_MODE === 'true') { + return E2E_ENCRYPTION_KEY; + } + + try { + const existing = await withTimeout( + SecureStore.getItemAsync(ENCRYPTION_KEY_ENTRY), + EXPO_SECURE_STORE_TIMEOUT_MS, + ); + if (existing) { + return existing; + } + + if (mustHaveExistingKey) { + throw new Error('the encryption key is missing for existing wallet data'); + } + + const key = generateKey(); + await withTimeout( + SecureStore.setItemAsync(ENCRYPTION_KEY_ENTRY, key), + EXPO_SECURE_STORE_TIMEOUT_MS, + ); + return key; + } catch (error) { + // Never downgrade wallet secrets to plaintext. WalletConnect itself no + // longer waits for this path, and wallet restoration can surface the + // secure-storage error independently. + const reason = error instanceof Error ? `: ${error.message}` : ''; + throw new Error( + `OS-backed MMKV encryption-key storage is unavailable; refusing to access wallet secrets${reason}`, + ); + } +} + +export function getEncryptionKey( + mustHaveExistingKey = false, +): Promise { + if (!keyPromise) { + keyPromise = loadEncryptionKey(mustHaveExistingKey); + } + return keyPromise; +} diff --git a/wallets/rn_cli_wallet/src/utils/storage.ts b/wallets/rn_cli_wallet/src/utils/storage.ts index 6e8f1ee3..4977c578 100644 --- a/wallets/rn_cli_wallet/src/utils/storage.ts +++ b/wallets/rn_cli_wallet/src/utils/storage.ts @@ -1,33 +1,155 @@ import { MMKV } from 'react-native-mmkv'; import { safeJsonParse, safeJsonStringify } from '@walletconnect/safe-json'; +import { getEncryptionKey } from './mmkvEncryptionKey'; -const mmkv = new MMKV(); +// WalletConnect Core and ordinary app preferences must remain in the default +// MMKV store. Several modules open that store synchronously without an +// encryption key, so encrypting it in place makes those readers fail. +const defaultStore = new MMKV(); + +// The persisted ids must stay stable across upgrades even though the constant +// names now make it explicit that these are MMKV databases, not SecureStore. +const ENCRYPTED_MMKV_ID = 'wallet-secure'; +const ENCRYPTED_MMKV_METADATA_ID = 'wallet-secure-metadata'; +const ENCRYPTED_DATA_MARKER = 'has-encrypted-wallet-data'; +const encryptionMetadata = new MMKV({ id: ENCRYPTED_MMKV_METADATA_ID }); + +// Keep this list explicit: only wallet key material belongs in the encrypted +// store. Everything else, including WalletConnect sessions, retains its +// existing storage location and upgrade behavior. +const SECRET_KEYS = new Set([ + 'EIP155_MNEMONIC_1', + 'EIP155_PRIVATE_KEY_1', + 'SUI_MNEMONIC_1', + 'TON_SECRET_KEY_1', + 'TRON_PrivateKey_1', + 'CANTON_SECRET_KEY_1', + 'SOLANA_MNEMONIC_1', + 'SOLANA_SECRET_KEY_1', + 'BITCOIN_MNEMONIC_1', + 'STELLAR_MNEMONIC_1', + 'STELLAR_SECRET_KEY_1', +]); + +let encryptedMmkvPromise: Promise | undefined; + +function isSecretKey(key: string): boolean { + return SECRET_KEYS.has(key); +} + +function getNonSecretKeys(): string[] { + return defaultStore.getAllKeys().filter(key => !isSecretKey(key)); +} + +function getEncryptedMmkv(): Promise { + if (!encryptedMmkvPromise) { + const mustHaveExistingKey = + encryptionMetadata.getString(ENCRYPTED_DATA_MARKER) === 'true'; + encryptedMmkvPromise = getEncryptionKey(mustHaveExistingKey).then(key => { + // The web MMKV shim is localStorage-backed and does not support + // encryption. A separate id still keeps secret and ordinary data apart. + if (!key) { + return new MMKV({ id: ENCRYPTED_MMKV_ID }); + } + + return new MMKV({ id: ENCRYPTED_MMKV_ID, encryptionKey: key }); + }); + } + return encryptedMmkvPromise; +} + +function parseItem(item: string | undefined): T | undefined { + if (typeof item === 'undefined' || item === null) { + return undefined; + } + return safeJsonParse(item) as T; +} + +function setAndVerify(store: MMKV, key: string, serialized: string): void { + store.set(key, serialized); + if (store.getString(key) !== serialized) { + throw new Error(`Failed to verify encrypted wallet storage for ${key}`); + } +} + +function markEncryptedDataPresent(): void { + encryptionMetadata.set(ENCRYPTED_DATA_MARKER, 'true'); + if (encryptionMetadata.getString(ENCRYPTED_DATA_MARKER) !== 'true') { + throw new Error('Failed to persist encrypted MMKV metadata'); + } +} + +// Migrate one secret at a time. Writing and verifying before deleting makes +// this safe to retry if the app exits at any point during an upgrade. +async function getSecretItem(key: string): Promise { + const encryptedMmkv = await getEncryptedMmkv(); + const encryptedValue = encryptedMmkv.getString(key); + const legacy = defaultStore.getString(key); + + if (typeof encryptedValue !== 'undefined') { + const parsed = parseItem(encryptedValue); + markEncryptedDataPresent(); + // Clean up a legacy copy left by an interrupted migration. + if (typeof legacy !== 'undefined') { + defaultStore.delete(key); + } + return parsed; + } + + if (typeof legacy === 'undefined') { + return undefined; + } + + const parsed = parseItem(legacy); + setAndVerify(encryptedMmkv, key, legacy); + markEncryptedDataPresent(); + defaultStore.delete(key); + return parsed; +} export const storage = { - getKeys: async () => { - return mmkv.getAllKeys(); - }, + // WalletConnect uses these methods to hydrate its own records. Returning the + // default store preserves existing sessions. Filtering also prevents legacy + // plaintext secrets from entering WalletConnect's in-memory storage cache + // while their background migration is still pending. + getKeys: async () => getNonSecretKeys(), getEntries: async (): Promise<[string, T][]> => { function parseEntry(key: string): [string, any] { - const value = mmkv.getString(key); - return [key, safeJsonParse(value ?? '')]; + return [key, safeJsonParse(defaultStore.getString(key) ?? '')]; } - const keys = mmkv.getAllKeys(); - return keys.map(parseEntry); + return getNonSecretKeys().map(parseEntry); }, setItem: async (key: string, value: T) => { - return mmkv.set(key, safeJsonStringify(value)); + const serialized = safeJsonStringify(value); + if (!isSecretKey(key)) { + return defaultStore.set(key, serialized); + } + + const encryptedMmkv = await getEncryptedMmkv(); + setAndVerify(encryptedMmkv, key, serialized); + markEncryptedDataPresent(); + // Remove a previous plaintext value only after the encrypted write has + // been verified. + defaultStore.delete(key); }, getItem: async (key: string): Promise => { - const item = mmkv.getString(key); - if (typeof item === 'undefined' || item === null) { - return undefined; + if (isSecretKey(key)) { + return getSecretItem(key); } - - return safeJsonParse(item) as T; + return parseItem(defaultStore.getString(key)); }, removeItem: async (key: string) => { - return mmkv.delete(key); + if (!isSecretKey(key)) { + return defaultStore.delete(key); + } + + // Do not report success while a secret may still exist in either store. + const encryptedMmkv = await getEncryptedMmkv(); + encryptedMmkv.delete(key); + if (typeof encryptedMmkv.getString(key) !== 'undefined') { + throw new Error(`Failed to remove ${key} from encrypted wallet storage`); + } + defaultStore.delete(key); }, }; diff --git a/wallets/rn_cli_wallet/yarn.lock b/wallets/rn_cli_wallet/yarn.lock index 56239db0..42e0176f 100644 --- a/wallets/rn_cli_wallet/yarn.lock +++ b/wallets/rn_cli_wallet/yarn.lock @@ -5333,6 +5333,7 @@ __metadata: expo-application: ~56.0.3 expo-clipboard: ~56.0.4 expo-navigation-bar: ~56.0.3 + expo-secure-store: ~56.0.4 expo-system-ui: 56.0.5 jest: ^29.2.1 jest-expo: 56.0.5 @@ -8494,6 +8495,15 @@ __metadata: languageName: node linkType: hard +"expo-secure-store@npm:~56.0.4": + version: 56.0.4 + resolution: "expo-secure-store@npm:56.0.4" + peerDependencies: + expo: "*" + checksum: a47a5c0378fdc7676df9d11b3f2417bac4106fcc9b7b461ee4774c8ffb0277a52e6c35545a0dff82de2f48dc9c112bd14060cc20a019cca1ea7507286a6822ac + languageName: node + linkType: hard + "expo-server@npm:^56.0.5": version: 56.0.5 resolution: "expo-server@npm:56.0.5"