diff --git a/.changeset/storage-plugin-keychain-adapter.md b/.changeset/storage-plugin-keychain-adapter.md new file mode 100644 index 000000000..e77b3d0f0 --- /dev/null +++ b/.changeset/storage-plugin-keychain-adapter.md @@ -0,0 +1,7 @@ +--- +'@rozenite/storage-plugin': minor +--- + +Add `createKeychainStorageAdapter` to inspect and edit react-native-keychain (iOS Keychain and Android Keystore) entries in the Storage panel. + +Storages can now be marked read-only with `capabilities.readOnly`. The panel disables adding, editing, deleting, purging and importing for them, and the device rejects those changes. diff --git a/.changeset/storage-plugin-purge-refresh.md b/.changeset/storage-plugin-purge-refresh.md new file mode 100644 index 000000000..12d43adbe --- /dev/null +++ b/.changeset/storage-plugin-purge-refresh.md @@ -0,0 +1,5 @@ +--- +'@rozenite/storage-plugin': patch +--- + +Refresh the Storage panel after a purge that fails partway, so it no longer shows entries that were already removed. diff --git a/apps/playground/package.json b/apps/playground/package.json index f41e25f06..77a0f04f3 100644 --- a/apps/playground/package.json +++ b/apps/playground/package.json @@ -69,6 +69,7 @@ "react-native-gesture-handler": "~2.32.0", "react-native-get-random-values": "^1.11.0", "react-native-harness": "^1.0.0-alpha.24", + "react-native-keychain": "^10.0.0", "react-native-mmkv": "^4.3.2", "react-native-nitro-fetch": "^1.5.1", "react-native-nitro-modules": "^0.36.1", diff --git a/apps/playground/src/app/screens/StoragePluginScreen.tsx b/apps/playground/src/app/screens/StoragePluginScreen.tsx index 298413ed8..9d1567493 100644 --- a/apps/playground/src/app/screens/StoragePluginScreen.tsx +++ b/apps/playground/src/app/screens/StoragePluginScreen.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { Alert, FlatList, Text } from 'react-native'; +import * as Keychain from 'react-native-keychain'; import * as SecureStore from 'expo-secure-store'; import { MMKV } from 'react-native-mmkv'; import { @@ -23,7 +24,7 @@ import { } from '../storage-plugin-adapters'; import { useTheme } from '../theme/useTheme'; -type AdapterTab = 'mmkv' | 'async' | 'secure'; +type AdapterTab = 'mmkv' | 'async' | 'secure' | 'keychain'; type AsyncStorageMode = 'v2-default' | 'v3-auth' | 'v3-cache'; type EntryType = 'string' | 'number' | 'boolean' | 'buffer'; type AsyncStorageLike = { @@ -125,6 +126,22 @@ export const StoragePluginScreen = () => { return; } + if (tab === 'keychain') { + const services = await Keychain.getAllGenericPasswordServices(); + const credentials = await Promise.all( + services.map(async (service) => ({ + service, + credentials: await Keychain.getGenericPassword({ service }), + })), + ); + setEntries( + credentials.flatMap(({ service, credentials: item }) => + item ? [{ key: service, type: 'string' as const, value: item.password }] : [], + ), + ); + return; + } + const keys = getKnownSecureStoreKeys(); const values = await Promise.all( keys.map(async (entryKey) => ({ @@ -178,6 +195,8 @@ export const StoragePluginScreen = () => { } } else if (tab === 'async') { await selectedAsyncStorage.setItem(key, value); + } else if (tab === 'keychain') { + await Keychain.setGenericPassword('playground', value, { service: key }); } else { rememberSecureStoreKey(key); await SecureStore.setItemAsync(key, value); @@ -207,6 +226,8 @@ export const StoragePluginScreen = () => { } } else if (tab === 'async') { await selectedAsyncStorage.removeItem(key); + } else if (tab === 'keychain') { + await Keychain.resetGenericPassword({ service: key }); } else { forgetSecureStoreKey(key); await SecureStore.deleteItemAsync(key); @@ -224,7 +245,10 @@ export const StoragePluginScreen = () => { return ( - + { { value: 'mmkv', label: 'MMKV' }, { value: 'async', label: 'Async' }, { value: 'secure', label: 'Secure' }, + { value: 'keychain', label: 'Keychain' }, ]} /> @@ -297,7 +322,7 @@ export const StoragePluginScreen = () => { label="Value" helperText={ !supportsTypedValues - ? 'AsyncStorage and SecureStore are string-only in this demo.' + ? 'AsyncStorage, SecureStore, and Keychain are string-only in this demo.' : undefined } > diff --git a/apps/playground/src/app/storage-plugin-adapters.ts b/apps/playground/src/app/storage-plugin-adapters.ts index d2f59ec33..69a021f30 100644 --- a/apps/playground/src/app/storage-plugin-adapters.ts +++ b/apps/playground/src/app/storage-plugin-adapters.ts @@ -1,8 +1,10 @@ import AsyncStorage, { createAsyncStorage } from '@react-native-async-storage/async-storage'; +import * as Keychain from 'react-native-keychain'; import * as SecureStore from 'expo-secure-store'; import { createAsyncStorageAdapter, createExpoSecureStorageAdapter, + createKeychainStorageAdapter, createMMKVStorageAdapter, } from '@rozenite/storage-plugin'; import { mmkvStorages } from './mmkv-storages'; @@ -64,4 +66,7 @@ export const storagePluginAdapters = [ storageId: 'secure-default', storageName: 'Default SecureStore', }), + createKeychainStorageAdapter({ + keychain: Keychain, + }), ]; diff --git a/packages/storage-plugin/README.md b/packages/storage-plugin/README.md index b78bda55a..71df920ca 100644 --- a/packages/storage-plugin/README.md +++ b/packages/storage-plugin/README.md @@ -2,7 +2,7 @@ ### A Rozenite plugin for inspecting multiple storage backends in React Native DevTools. -The Storage Plugin provides a single inspector for sync and async storages, including MMKV, AsyncStorage and Expo SecureStore via adapters. +The Storage Plugin provides a single inspector for sync and async storages, including MMKV, AsyncStorage, Expo SecureStore and react-native-keychain via adapters. ## Installation @@ -13,7 +13,7 @@ npm install @rozenite/storage-plugin Optional peers depending on adapters you use: ```bash -npm install react-native-mmkv @react-native-async-storage/async-storage expo-secure-store +npm install react-native-mmkv @react-native-async-storage/async-storage expo-secure-store react-native-keychain ``` ## Usage @@ -23,6 +23,7 @@ import { createAsyncStorageAdapter, createMMKVStorageAdapter, createExpoSecureStorageAdapter, + createKeychainStorageAdapter, useRozeniteStoragePlugin, } from '@rozenite/storage-plugin'; @@ -41,6 +42,9 @@ const storages = [ storage: SecureStore, keys: ['token', 'session'], }), + createKeychainStorageAdapter({ + keychain: Keychain, + }), ]; useRozeniteStoragePlugin({ storages }); diff --git a/packages/storage-plugin/package.json b/packages/storage-plugin/package.json index f18eb969e..ac8aac5b0 100644 --- a/packages/storage-plugin/package.json +++ b/packages/storage-plugin/package.json @@ -76,6 +76,7 @@ "expo-secure-store": "*", "react": "*", "react-native": "*", + "react-native-keychain": "*", "react-native-mmkv": "*" }, "peerDependenciesMeta": { @@ -85,6 +86,9 @@ "expo-secure-store": { "optional": true }, + "react-native-keychain": { + "optional": true + }, "react-native-mmkv": { "optional": true } diff --git a/packages/storage-plugin/react-native.ts b/packages/storage-plugin/react-native.ts index 5a46ae521..8a23b1a5f 100644 --- a/packages/storage-plugin/react-native.ts +++ b/packages/storage-plugin/react-native.ts @@ -4,7 +4,9 @@ export type { CreateAsyncStorageAdapterOptions, CreateExpoAsyncStorageAdapterOptions, CreateExpoSecureStorageAdapterOptions, + CreateKeychainStorageAdapterOptions, CreateMMKVStorageAdapterOptions, + KeychainLike, SecureStorageLike, } from './src/react-native/adapters'; @@ -22,6 +24,7 @@ export type { export let createAsyncStorageAdapter: typeof import('./src/react-native/adapters').createAsyncStorageAdapter; export let createExpoAsyncStorageAdapter: typeof import('./src/react-native/adapters').createExpoAsyncStorageAdapter; export let createExpoSecureStorageAdapter: typeof import('./src/react-native/adapters').createExpoSecureStorageAdapter; +export let createKeychainStorageAdapter: typeof import('./src/react-native/adapters').createKeychainStorageAdapter; export let createMMKVStorageAdapter: typeof import('./src/react-native/adapters').createMMKVStorageAdapter; export let useRozeniteStoragePlugin: typeof import('./src/react-native/useRozeniteStoragePlugin').useRozeniteStoragePlugin; @@ -46,6 +49,13 @@ const createNoopStorageAdapter = ( storages: [], }); +const createNoopKeychainStorageAdapter = ((options: { adapterId?: string; adapterName?: string }) => + createNoopStorageAdapter( + options, + 'react-native-keychain', + 'React Native Keychain', + )) as typeof createKeychainStorageAdapter; + if (!isDev || isServer) { createAsyncStorageAdapter = ((options: { adapterId?: string; adapterName?: string }) => createNoopStorageAdapter( @@ -60,6 +70,7 @@ if (!isDev || isServer) { 'expo-secure-store', 'Expo SecureStore', )) as typeof createExpoSecureStorageAdapter; + createKeychainStorageAdapter = createNoopKeychainStorageAdapter; createMMKVStorageAdapter = ((options: { adapterId?: string; adapterName?: string }) => createNoopStorageAdapter(options, 'mmkv', 'MMKV')) as typeof createMMKVStorageAdapter; useRozeniteStoragePlugin = () => null; @@ -70,6 +81,7 @@ if (!isDev || isServer) { createAsyncStorageAdapter = asyncStorageModule.createAsyncStorageAdapter; createExpoAsyncStorageAdapter = asyncStorageModule.createExpoAsyncStorageAdapter; createExpoSecureStorageAdapter = secureStorageModule.createExpoSecureStorageAdapter; + createKeychainStorageAdapter = createNoopKeychainStorageAdapter; createMMKVStorageAdapter = ((options: { adapterId?: string; adapterName?: string }) => createNoopStorageAdapter(options, 'mmkv', 'MMKV')) as typeof createMMKVStorageAdapter; useRozeniteStoragePlugin = @@ -80,6 +92,8 @@ if (!isDev || isServer) { require('./src/react-native/adapters').createExpoAsyncStorageAdapter; createExpoSecureStorageAdapter = require('./src/react-native/adapters').createExpoSecureStorageAdapter; + createKeychainStorageAdapter = + require('./src/react-native/adapters').createKeychainStorageAdapter; createMMKVStorageAdapter = require('./src/react-native/adapters').createMMKVStorageAdapter; useRozeniteStoragePlugin = require('./src/react-native/useRozeniteStoragePlugin').useRozeniteStoragePlugin; diff --git a/packages/storage-plugin/src/react-native/__tests__/keychain-adapter.test.ts b/packages/storage-plugin/src/react-native/__tests__/keychain-adapter.test.ts new file mode 100644 index 000000000..1e8ed384a --- /dev/null +++ b/packages/storage-plugin/src/react-native/__tests__/keychain-adapter.test.ts @@ -0,0 +1,297 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createKeychainStorageAdapter } from '../adapters/keychain'; +import type { KeychainLike } from '../adapters/keychain'; + +type FakeItem = { username: string; password: string; storage?: string }; + +const createFakeKeychain = (initial: Record = {}) => { + const items = new Map(Object.entries(initial)); + + const keychain = { + getAllGenericPasswordServices: vi.fn( + async () => [...items.keys()], + ), + getGenericPassword: vi.fn( + async ({ service }: { service: string }) => items.get(service) ?? false, + ), + setGenericPassword: vi.fn( + async ( + username: string, + password: string, + options?: { service?: string; [option: string]: unknown }, + ) => { + items.set(options?.service ?? 'default', { username, password }); + }, + ), + resetGenericPassword: vi.fn(async ({ service }: { service: string }) => { + items.delete(service); + }), + } satisfies KeychainLike; + + return { keychain, items }; +}; + +const getStorage = (adapter: ReturnType) => { + const [node] = adapter.storages; + if (node.storage.kind !== 'async') { + throw new Error('Expected an async storage.'); + } + + return node.storage; +}; + +describe('createKeychainStorageAdapter', () => { + it('exposes one string-only storage with sensible defaults', () => { + const { keychain } = createFakeKeychain(); + const adapter = createKeychainStorageAdapter({ keychain }); + + expect(adapter.id).toBe('react-native-keychain'); + expect(adapter.name).toBe('React Native Keychain'); + expect(adapter.storages).toHaveLength(1); + expect(adapter.storages[0].capabilities).toEqual({ + supportedTypes: ['string'], + readOnly: false, + }); + }); + + it('exposes no storages when react-native-keychain is missing', () => { + expect(createKeychainStorageAdapter({ keychain: undefined }).storages).toEqual([]); + expect(createKeychainStorageAdapter({ keychain: null }).storages).toEqual([]); + }); + + it('lists every keychain service by default', async () => { + const { keychain } = createFakeKeychain({ + token: { username: 'a', password: '1' }, + session: { username: 'b', password: '2' }, + }); + const storage = getStorage(createKeychainStorageAdapter({ keychain })); + + expect(await storage.getAllKeys()).toEqual(['token', 'session']); + }); + + it('skips services that need authentication when listing, unless disabled', async () => { + const { keychain } = createFakeKeychain(); + + await getStorage(createKeychainStorageAdapter({ keychain })).getAllKeys(); + await getStorage(createKeychainStorageAdapter({ keychain, skipUIAuth: false })).getAllKeys(); + + expect(keychain.getAllGenericPasswordServices).toHaveBeenNthCalledWith(1, { + skipUIAuth: true, + }); + expect(keychain.getAllGenericPasswordServices).toHaveBeenNthCalledWith(2, { + skipUIAuth: false, + }); + }); + + it('uses the services option, either as a list or as a function', async () => { + const { keychain } = createFakeKeychain(); + + const fromList = getStorage(createKeychainStorageAdapter({ keychain, services: ['a'] })); + const fromFunction = getStorage( + createKeychainStorageAdapter({ keychain, services: async () => ['b'] }), + ); + + expect(await fromList.getAllKeys()).toEqual(['a']); + expect(await fromFunction.getAllKeys()).toEqual(['b']); + expect(keychain.getAllGenericPasswordServices).not.toHaveBeenCalled(); + }); + + it('reads the password as the entry value and returns undefined for missing services', async () => { + const { keychain } = createFakeKeychain({ + token: { username: 'alice', password: 'secret' }, + }); + const storage = getStorage(createKeychainStorageAdapter({ keychain })); + + expect(await storage.get('token')).toEqual({ + key: 'token', + type: 'string', + value: 'secret', + }); + expect(await storage.get('missing')).toBeUndefined(); + }); + + it('returns undefined and warns for entries that cannot be read', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { keychain } = createFakeKeychain({ + token: { username: 'alice', password: 'secret' }, + }); + keychain.getGenericPassword.mockRejectedValueOnce(new Error('User canceled the operation.')); + const storage = getStorage(createKeychainStorageAdapter({ keychain })); + + expect(await storage.get('token')).toBeUndefined(); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('Could not read Keychain service "token"'), + expect.any(Error), + ); + warn.mockRestore(); + }); + + it('lists each service once', async () => { + const { keychain } = createFakeKeychain(); + keychain.getAllGenericPasswordServices.mockResolvedValueOnce(['token', 'token', 'session']); + const storage = getStorage(createKeychainStorageAdapter({ keychain })); + + expect(await storage.getAllKeys()).toEqual(['token', 'session']); + }); + + it('keeps the existing username when updating an entry', async () => { + const { keychain, items } = createFakeKeychain({ + token: { username: 'alice', password: 'old' }, + }); + const storage = getStorage( + createKeychainStorageAdapter({ + keychain, + setOptions: { accessible: 'AccessibleWhenUnlocked' }, + }), + ); + + await storage.set({ key: 'token', type: 'string', value: 'new' }); + + expect(items.get('token')).toEqual({ username: 'alice', password: 'new' }); + expect(keychain.setGenericPassword).toHaveBeenCalledWith('alice', 'new', { + accessible: 'AccessibleWhenUnlocked', + service: 'token', + }); + }); + + it('keeps the Android storage type of an existing entry', async () => { + const { keychain } = createFakeKeychain({ + token: { username: 'alice', password: 'old', storage: 'KeystoreAESGCM' }, + }); + const storage = getStorage(createKeychainStorageAdapter({ keychain })); + + await storage.set({ key: 'token', type: 'string', value: 'new' }); + + expect(keychain.setGenericPassword).toHaveBeenCalledWith('alice', 'new', { + storage: 'KeystoreAESGCM', + service: 'token', + }); + }); + + it('overwrites an unreadable entry with the defaults', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { keychain, items } = createFakeKeychain({ + token: { username: 'alice', password: 'old' }, + }); + keychain.getGenericPassword.mockRejectedValueOnce(new Error('Key permanently invalidated')); + const storage = getStorage(createKeychainStorageAdapter({ keychain })); + + await storage.set({ key: 'token', type: 'string', value: 'new' }); + + expect(items.get('token')).toEqual({ username: 'user', password: 'new' }); + warn.mockRestore(); + }); + + it('lets setOptions override the existing storage type', async () => { + const { keychain } = createFakeKeychain({ + token: { username: 'alice', password: 'old', storage: 'KeystoreAESCBC' }, + }); + const storage = getStorage( + createKeychainStorageAdapter({ keychain, setOptions: { storage: 'KeystoreAESGCM_NoAuth' } }), + ); + + await storage.set({ key: 'token', type: 'string', value: 'new' }); + + expect(keychain.setGenericPassword).toHaveBeenCalledWith('alice', 'new', { + storage: 'KeystoreAESGCM_NoAuth', + service: 'token', + }); + }); + + it('resolves setOptions per service when given a function', async () => { + const { keychain } = createFakeKeychain(); + const storage = getStorage( + createKeychainStorageAdapter({ + keychain, + setOptions: (service) => + service === 'token' ? { accessControl: 'BiometryCurrentSet' } : undefined, + }), + ); + + await storage.set({ key: 'token', type: 'string', value: 'a' }); + await storage.set({ key: 'other', type: 'string', value: 'b' }); + + expect(keychain.setGenericPassword).toHaveBeenNthCalledWith(1, 'user', 'a', { + accessControl: 'BiometryCurrentSet', + service: 'token', + }); + expect(keychain.setGenericPassword).toHaveBeenNthCalledWith(2, 'user', 'b', { + service: 'other', + }); + }); + + it('uses the default username for new entries', async () => { + const { keychain, items } = createFakeKeychain(); + const storage = getStorage( + createKeychainStorageAdapter({ keychain, defaultUsername: 'rozenite' }), + ); + + await storage.set({ key: 'fresh', type: 'string', value: 'value' }); + + expect(items.get('fresh')).toEqual({ + username: 'rozenite', + password: 'value', + }); + }); + + it('rejects non-string values', async () => { + const { keychain } = createFakeKeychain(); + const storage = getStorage(createKeychainStorageAdapter({ keychain })); + + await expect(storage.set({ key: 'n', type: 'number', value: 1 })).rejects.toThrow( + 'supports only string values', + ); + }); + + it('deletes a single service and clears all of them', async () => { + const { keychain, items } = createFakeKeychain({ + a: { username: 'u', password: '1' }, + b: { username: 'u', password: '2' }, + c: { username: 'u', password: '3' }, + }); + const storage = getStorage(createKeychainStorageAdapter({ keychain })); + + await storage.delete('a'); + expect([...items.keys()]).toEqual(['b', 'c']); + + await storage.clear(); + expect(items.size).toBe(0); + }); + + it('does not clear services hidden by the blacklist', async () => { + const { keychain, items } = createFakeKeychain({ + token: { username: 'u', password: '1' }, + 'auth-refresh': { username: 'u', password: '2' }, + }); + const storage = getStorage(createKeychainStorageAdapter({ keychain, blacklist: /auth/ })); + + await storage.clear(); + + expect([...items.keys()]).toEqual(['auth-refresh']); + }); + + it('clears every service it can and reports the ones that failed', async () => { + const { keychain, items } = createFakeKeychain({ + a: { username: 'u', password: '1' }, + b: { username: 'u', password: '2' }, + c: { username: 'u', password: '3' }, + }); + keychain.resetGenericPassword.mockImplementation(async ({ service }) => { + if (service === 'b') { + throw new Error('Keystore failure'); + } + items.delete(service); + }); + const storage = getStorage(createKeychainStorageAdapter({ keychain })); + + await expect(storage.clear()).rejects.toThrow('Could not clear Keychain services: b.'); + expect([...items.keys()]).toEqual(['b']); + }); + + it('marks the storage read-only', () => { + const { keychain } = createFakeKeychain(); + const adapter = createKeychainStorageAdapter({ keychain, readOnly: true }); + + expect(adapter.storages[0].capabilities.readOnly).toBe(true); + }); +}); diff --git a/packages/storage-plugin/src/react-native/__tests__/storage-view.test.ts b/packages/storage-plugin/src/react-native/__tests__/storage-view.test.ts index e749d9901..524f4d714 100644 --- a/packages/storage-plugin/src/react-native/__tests__/storage-view.test.ts +++ b/packages/storage-plugin/src/react-native/__tests__/storage-view.test.ts @@ -170,3 +170,37 @@ describe('StorageView subscriptions', () => { expect(onChange).not.toHaveBeenCalled(); }); }); + +describe('StorageView read-only storages', () => { + it('rejects set, delete and purge without touching the storage', async () => { + const storage = { + kind: 'sync' as const, + getAllKeys: vi.fn(() => ['key']), + get: vi.fn(), + set: vi.fn(), + delete: vi.fn(), + clear: vi.fn(), + }; + const [view] = createStorageViews([ + { + id: 'manual', + name: 'Manual', + storages: [ + { + id: 'storage', + name: 'Storage', + capabilities: { supportedTypes: ['string'], readOnly: true }, + storage, + }, + ], + }, + ]); + + await expect(view.set({ key: 'key', type: 'string', value: 'x' })).rejects.toThrow('read-only'); + await expect(view.delete('key')).rejects.toThrow('read-only'); + await expect(view.purge()).rejects.toThrow('read-only'); + expect(storage.set).not.toHaveBeenCalled(); + expect(storage.delete).not.toHaveBeenCalled(); + expect(storage.clear).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/storage-plugin/src/react-native/__tests__/use-rozenite-storage-plugin.test.tsx b/packages/storage-plugin/src/react-native/__tests__/use-rozenite-storage-plugin.test.tsx new file mode 100644 index 000000000..dbbf53317 --- /dev/null +++ b/packages/storage-plugin/src/react-native/__tests__/use-rozenite-storage-plugin.test.tsx @@ -0,0 +1,105 @@ +// @vitest-environment jsdom + +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { StorageAdapter } from '../../shared/types'; + +declare global { + var IS_REACT_ACT_ENVIRONMENT: boolean | undefined; +} + +type Listener = (payload: unknown) => void; + +const mocks = vi.hoisted(() => { + const listeners = new Map(); + return { + listeners, + client: { + send: vi.fn(), + onMessage: vi.fn((type: string, listener: Listener) => { + listeners.set(type, listener); + return { remove: () => listeners.delete(type) }; + }), + }, + }; +}); + +vi.mock('@rozenite/plugin-bridge', () => ({ + useRozeniteDevToolsClient: () => mocks.client, +})); +vi.mock('@rozenite/agent-bridge', () => ({ + useRozenitePluginAgentTool: vi.fn(), +})); + +import { useRozeniteStoragePlugin } from '../useRozeniteStoragePlugin'; + +const target = { adapterId: 'adapter', storageId: 'storage' }; + +const render = async (storages: StorageAdapter[]) => { + const Harness = () => { + useRozeniteStoragePlugin({ storages }); + return null; + }; + const root = createRoot(document.createElement('div')); + await act(async () => root.render()); + return root; +}; + +describe('useRozeniteStoragePlugin purge-storage', () => { + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + mocks.listeners.clear(); + mocks.client.send.mockReset(); + }); + afterEach(() => { + delete globalThis.IS_REACT_ACT_ENVIRONMENT; + }); + + it('refreshes the panel even when the purge fails partway', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const keys = ['a', 'b', 'c']; + const root = await render([ + { + id: target.adapterId, + name: 'Adapter', + storages: [ + { + id: target.storageId, + name: 'Storage', + capabilities: { supportedTypes: ['string'] }, + storage: { + kind: 'async', + getAllKeys: async () => keys, + get: async () => undefined, + set: async () => {}, + delete: async () => {}, + clear: async () => { + keys.splice(0, keys.length, 'b'); + throw new Error('Could not clear services: b.'); + }, + }, + }, + ], + }, + ]); + + await act(async () => + mocks.listeners.get('purge-storage')?.({ type: 'purge-storage', target }), + ); + + expect(mocks.client.send).toHaveBeenCalledWith('storage-invalidated', { + type: 'storage-invalidated', + target, + key: undefined, + operation: 'purge', + entryCount: 1, + }); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('Failed to purge storage in adapter/storage'), + expect.any(Error), + ); + warn.mockRestore(); + await act(async () => root.unmount()); + }); +}); diff --git a/packages/storage-plugin/src/react-native/__tests__/use-storage-agent-tools.test.ts b/packages/storage-plugin/src/react-native/__tests__/use-storage-agent-tools.test.ts index 08fb73ce4..01cc11d94 100644 --- a/packages/storage-plugin/src/react-native/__tests__/use-storage-agent-tools.test.ts +++ b/packages/storage-plugin/src/react-native/__tests__/use-storage-agent-tools.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; +import { useRozenitePluginAgentTool } from '@rozenite/agent-bridge'; import { MAX_PAGE_LIMIT } from '@rozenite/agent-shared'; import { storageToolDefinitions } from '../../shared/agent-tools'; import type { StorageAdapter, StorageEntry } from '../../shared/types'; @@ -7,7 +8,7 @@ vi.mock('@rozenite/agent-bridge', () => ({ useRozenitePluginAgentTool: vi.fn(), })); -import { createStorageAgentHandlers } from '../useStorageAgentTools'; +import { createStorageAgentHandlers, useStorageAgentTools } from '../useStorageAgentTools'; import { createStorageViews } from '../storage-view'; const createViews = ( @@ -58,6 +59,7 @@ describe('storage Agent tool handlers', () => { adapterName: 'Adapter one', storageName: 'Storage first', supportedTypes: ['string', 'buffer'], + readOnly: false, }, { adapterId: 'two', @@ -65,6 +67,7 @@ describe('storage Agent tool handlers', () => { adapterName: 'Adapter two', storageName: 'Storage second', supportedTypes: ['string', 'buffer'], + readOnly: false, }, ], }); @@ -290,4 +293,34 @@ describe('storage Agent tool handlers', () => { expect(adapters[0].storages[0].storage.get).toHaveBeenCalledTimes(1); expect(adapters[0].storages[0].storage.get).toHaveBeenCalledWith('large'); }); + + it('treats listed entries that cannot be read as existing in write tools', async () => { + const { views } = createViews([ + { adapterId: 'adapter', storageId: 'storage', entries: entries('readable') }, + ]); + // The key is listed, but reading it returns nothing (e.g. a locked Keychain item). + const getAllKeys = vi.spyOn(views[0], 'getAllKeys').mockResolvedValue(['readable', 'locked']); + vi.mocked(useRozenitePluginAgentTool).mockClear(); + useStorageAgentTools(views); + const handler = (name: string) => { + const call = vi + .mocked(useRozenitePluginAgentTool) + .mock.calls.find(([options]) => options.tool.name === name); + return call?.[0].handler as (input: Record) => Promise; + }; + + await expect( + handler('create-entry')({ key: 'locked', type: 'string', value: 'x' }), + ).rejects.toThrow('already exists'); + await expect( + handler('edit-entry')({ key: 'locked', type: 'string', value: 'x' }), + ).resolves.toMatchObject({ edited: true }); + await expect(handler('remove-entry')({ key: 'locked' })).resolves.toMatchObject({ + removed: true, + }); + await expect(handler('remove-entry')({ key: 'missing' })).resolves.toMatchObject({ + removed: false, + }); + getAllKeys.mockRestore(); + }); }); diff --git a/packages/storage-plugin/src/react-native/adapters/index.ts b/packages/storage-plugin/src/react-native/adapters/index.ts index 9fe05900a..68789f13d 100644 --- a/packages/storage-plugin/src/react-native/adapters/index.ts +++ b/packages/storage-plugin/src/react-native/adapters/index.ts @@ -9,5 +9,8 @@ export type { CreateExpoAsyncStorageAdapterOptions, } from './async-storage'; +export { createKeychainStorageAdapter } from './keychain'; +export type { CreateKeychainStorageAdapterOptions, KeychainLike } from './keychain'; + export { createExpoSecureStorageAdapter } from './secure-storage'; export type { CreateExpoSecureStorageAdapterOptions, SecureStorageLike } from './secure-storage'; diff --git a/packages/storage-plugin/src/react-native/adapters/keychain.ts b/packages/storage-plugin/src/react-native/adapters/keychain.ts new file mode 100644 index 000000000..88aa296a7 --- /dev/null +++ b/packages/storage-plugin/src/react-native/adapters/keychain.ts @@ -0,0 +1,164 @@ +import type { StorageAdapter, StorageNode } from '../../shared/types'; + +export type KeychainLike = { + getAllGenericPasswordServices(options?: { skipUIAuth?: boolean }): Promise; + getGenericPassword(options: { + service: string; + }): Promise; + setGenericPassword( + username: string, + password: string, + options?: { service?: string; [option: string]: unknown }, + ): Promise; + resetGenericPassword(options: { service: string }): Promise; +}; + +type ServiceSource = readonly string[] | (() => Promise); + +type SetOptionsSource = + | Record + | ((service: string) => Record | undefined); + +export type CreateKeychainStorageAdapterOptions = { + /** The `react-native-keychain` module. When it is missing the adapter exposes no storages. */ + keychain: KeychainLike | null | undefined; + /** Services to show. Defaults to every service reported by `getAllGenericPasswordServices`. */ + services?: ServiceSource; + /** + * Skip services that need user authentication when listing them, so the panel does not + * trigger biometric or passcode prompts (iOS). Ignored when `services` is set. + * @default true + */ + skipUIAuth?: boolean; + /** Username stored with entries created from the panel. Existing entries keep their username. */ + defaultUsername?: string; + /** + * Options forwarded to `setGenericPassword`, such as `accessible` or `accessControl`. + * The keychain does not report these for stored entries, so pass a function to return + * the options each service was written with; otherwise edits re-save with these options. + */ + setOptions?: SetOptionsSource; + /** Disable add, edit, delete, purge and import in the panel and reject them on the device. */ + readOnly?: boolean; + adapterId?: string; + adapterName?: string; + storageId?: string; + storageName?: string; + blacklist?: RegExp; +}; + +export const createKeychainStorageAdapter = ({ + keychain, + services, + skipUIAuth = true, + defaultUsername = 'user', + setOptions, + readOnly = false, + adapterId = 'react-native-keychain', + adapterName = 'React Native Keychain', + storageId = 'default', + storageName = 'Keychain / Keystore', + blacklist, +}: CreateKeychainStorageAdapterOptions): StorageAdapter => { + if (!keychain) { + return { id: adapterId, name: adapterName, storages: [] }; + } + + const resolveServices = async (): Promise => { + const resolved = + typeof services === 'function' + ? await services() + : (services ?? (await keychain.getAllGenericPasswordServices({ skipUIAuth }))); + + // iOS reports one service per Keychain item, so a service with several accounts repeats. + return [...new Set(resolved)]; + }; + + const readCredentials = async (service: string) => { + try { + return await keychain.getGenericPassword({ service }); + } catch (error) { + // Entries behind biometrics, or with an invalidated Keystore key, cannot be read. + console.warn( + `[Rozenite] Storage Plugin: Could not read Keychain service "${service}".`, + error, + ); + return false; + } + }; + + const isBlacklisted = (service: string) => { + if (!blacklist) { + return false; + } + + blacklist.lastIndex = 0; + return blacklist.test(service); + }; + + const resolveSetOptions = (service: string) => + typeof setOptions === 'function' ? setOptions(service) : setOptions; + + const storageNode: StorageNode = { + id: storageId, + name: storageName, + blacklist, + capabilities: { + supportedTypes: ['string'], + readOnly, + }, + storage: { + kind: 'async', + getAllKeys: resolveServices, + get: async (key) => { + // Unreadable entries are skipped instead of failing the whole listing. + const credentials = await readCredentials(key); + if (!credentials) { + return undefined; + } + + return { + key, + type: 'string', + value: credentials.password, + }; + }, + set: async (entry) => { + if (entry.type !== 'string') { + throw new Error('Keychain adapter supports only string values.'); + } + + // setGenericPassword replaces the whole entry, so keep the existing username and, + // on Android, the storage type it was encrypted with. An unreadable entry is + // overwritten with the defaults. + const existing = await readCredentials(entry.key); + const username = existing ? existing.username : defaultUsername; + await keychain.setGenericPassword(username, entry.value, { + ...(existing && existing.storage ? { storage: existing.storage } : {}), + ...resolveSetOptions(entry.key), + service: entry.key, + }); + }, + delete: async (key) => { + await keychain.resetGenericPassword({ service: key }); + }, + clear: async () => { + // Only clear what the panel shows: hidden services stay untouched. + const visible = (await resolveServices()).filter((service) => !isBlacklisted(service)); + const results = await Promise.allSettled( + visible.map((service) => keychain.resetGenericPassword({ service })), + ); + const failed = visible.filter((_, index) => results[index].status === 'rejected'); + if (failed.length > 0) { + throw new Error(`Could not clear Keychain services: ${failed.join(', ')}.`); + } + }, + }, + }; + + return { + id: adapterId, + name: adapterName, + storages: [storageNode], + }; +}; diff --git a/packages/storage-plugin/src/react-native/storage-view.ts b/packages/storage-plugin/src/react-native/storage-view.ts index 8f15c5fac..4dd72904e 100644 --- a/packages/storage-plugin/src/react-native/storage-view.ts +++ b/packages/storage-plugin/src/react-native/storage-view.ts @@ -1,5 +1,6 @@ import { getStorageViewId, + isReadOnly, supportsType, type AsyncStorage, type StorageAdapter, @@ -44,6 +45,14 @@ const checkTypeSupport = ( ); }; +const checkWritable = (capabilities: StorageCapabilities, target: StorageTarget) => { + if (!isReadOnly(capabilities)) { + return; + } + + throw new Error(`Storage "${target.storageId}" in adapter "${target.adapterId}" is read-only.`); +}; + const getAllKeys = async (storage: AsyncStorageLike) => { if (isAsyncStorage(storage)) { return storage.getAllKeys(); @@ -139,6 +148,7 @@ export const createStorageView = ( blacklist: storageNode.blacklist, get, set: async (entry) => { + checkWritable(storageNode.capabilities, target); checkTypeSupport(storageNode.capabilities, entry.type, target); locallyMutatingKeys.add(entry.key); try { @@ -148,6 +158,7 @@ export const createStorageView = ( } }, delete: async (key) => { + checkWritable(storageNode.capabilities, target); locallyMutatingKeys.add(key); try { await deleteEntry(storage, key); @@ -156,6 +167,7 @@ export const createStorageView = ( } }, purge: async () => { + checkWritable(storageNode.capabilities, target); isLocallyPurging = true; try { await clearStorage(storage); diff --git a/packages/storage-plugin/src/react-native/useRozeniteStoragePlugin.ts b/packages/storage-plugin/src/react-native/useRozeniteStoragePlugin.ts index 31590331e..ae2685979 100644 --- a/packages/storage-plugin/src/react-native/useRozeniteStoragePlugin.ts +++ b/packages/storage-plugin/src/react-native/useRozeniteStoragePlugin.ts @@ -158,13 +158,22 @@ export const useRozeniteStoragePlugin = ({ storages }: RozeniteStoragePluginOpti try { await view.purge(); - await sendInvalidation(view.target, undefined, 'purge'); } catch (error) { console.warn( `[Rozenite] Storage Plugin: Failed to purge storage in ${target.adapterId}/${target.storageId}.`, error, ); } + + // A failed purge may still have removed some entries, so refresh the panel either way. + try { + await sendInvalidation(view.target, undefined, 'purge'); + } catch (error) { + console.warn( + `[Rozenite] Storage Plugin: Failed to refresh storage in ${target.adapterId}/${target.storageId}.`, + error, + ); + } }), client.onMessage('discover-storages', async (event: StorageDiscoverStoragesRequestEvent) => { const response = await handleStorageDiscoveryRequest(views, event); diff --git a/packages/storage-plugin/src/react-native/useStorageAgentTools.ts b/packages/storage-plugin/src/react-native/useStorageAgentTools.ts index e79b607b6..ca65da55d 100644 --- a/packages/storage-plugin/src/react-native/useStorageAgentTools.ts +++ b/packages/storage-plugin/src/react-native/useStorageAgentTools.ts @@ -5,7 +5,12 @@ import { storageToolDefinitions, type StorageListEntriesArgs, } from '../shared/agent-tools'; -import type { StorageEntry, StorageEntryType, StorageEntryValue } from '../shared/types'; +import { + isReadOnly, + type StorageEntry, + type StorageEntryType, + type StorageEntryValue, +} from '../shared/types'; import type { StorageView } from './storage-view'; import { compareKeys } from './entry-preview-pagination'; @@ -129,6 +134,7 @@ export const createStorageAgentHandlers = (views: readonly StorageView[]) => ({ adapterName: view.adapterName, storageName: view.storageName, supportedTypes: view.capabilities.supportedTypes, + readOnly: isReadOnly(view.capabilities), })), }), @@ -161,6 +167,11 @@ export const createStorageAgentHandlers = (views: readonly StorageView[]) => ({ }, }); +// Some entries exist but cannot be read (for example Keychain items behind biometrics), +// so fall back to the key list when reading returns nothing. +const hasEntry = async (view: StorageView, key: string) => + !!(await view.get(key)) || (await view.getAllKeys()).includes(key); + export const useStorageAgentTools = (views: StorageView[]) => { const handlers = createStorageAgentHandlers(views); @@ -187,9 +198,7 @@ export const useStorageAgentTools = (views: StorageView[]) => { tool: storageToolDefinitions.createEntry, handler: async ({ adapterId, storageId, key, type, value }) => { const view = resolveStorage(views, adapterId, storageId); - const existing = await view.get(key); - - if (existing) { + if (await hasEntry(view, key)) { throw new Error( `Key "${key}" already exists in storage "${formatViewRef(view)}". Use edit-entry instead.`, ); @@ -212,9 +221,7 @@ export const useStorageAgentTools = (views: StorageView[]) => { tool: storageToolDefinitions.editEntry, handler: async ({ adapterId, storageId, key, type, value }) => { const view = resolveStorage(views, adapterId, storageId); - const existing = await view.get(key); - - if (!existing) { + if (!(await hasEntry(view, key))) { throw new Error( `Key "${key}" not found in storage "${formatViewRef(view)}". Use create-entry instead.`, ); @@ -237,7 +244,7 @@ export const useStorageAgentTools = (views: StorageView[]) => { tool: storageToolDefinitions.removeEntry, handler: async ({ adapterId, storageId, key }) => { const view = resolveStorage(views, adapterId, storageId); - const existed = !!(await view.get(key)); + const existed = await hasEntry(view, key); await view.delete(key); return { diff --git a/packages/storage-plugin/src/shared/agent-tools.ts b/packages/storage-plugin/src/shared/agent-tools.ts index 881e15a4f..26d61d9da 100644 --- a/packages/storage-plugin/src/shared/agent-tools.ts +++ b/packages/storage-plugin/src/shared/agent-tools.ts @@ -39,6 +39,7 @@ export type StorageListStoragesItem = { adapterName: string; storageName: string; supportedTypes: StorageEntryType[]; + readOnly: boolean; }; export type StorageListStoragesResult = { @@ -109,11 +110,18 @@ export const storageToolDefinitions = { >({ name: 'list-storages', description: - 'List all storage adapters and their storage nodes currently available on the device, including supported entry types. Entry counts are omitted to avoid enumerating every storage.', + 'List all storage adapters and their storage nodes currently available on the device, including supported entry types and whether the storage is read-only. Entry counts are omitted to avoid enumerating every storage.', inputSchema: { type: 'object', properties: {} }, rows: { key: 'storages', - fields: ['adapterId', 'storageId', 'adapterName', 'storageName', 'supportedTypes'], + fields: [ + 'adapterId', + 'storageId', + 'adapterName', + 'storageName', + 'supportedTypes', + 'readOnly', + ], }, }), listEntries: definePaginatedAgentToolContract({ diff --git a/packages/storage-plugin/src/shared/types.ts b/packages/storage-plugin/src/shared/types.ts index f230e089e..720973deb 100644 --- a/packages/storage-plugin/src/shared/types.ts +++ b/packages/storage-plugin/src/shared/types.ts @@ -17,6 +17,8 @@ export type StorageEntryPreview = { export type StorageCapabilities = { supportedTypes: StorageEntryType[]; + /** The panel disables add, edit, delete, purge and import, and the device rejects them. */ + readOnly?: boolean; }; export type StorageDescriptor = { @@ -82,3 +84,5 @@ export const getStorageViewId = ({ adapterId, storageId }: StorageTarget) => export const supportsType = (capabilities: StorageCapabilities, type: StorageEntryType) => capabilities.supportedTypes.includes(type); + +export const isReadOnly = (capabilities: StorageCapabilities) => capabilities.readOnly === true; diff --git a/packages/storage-plugin/src/ui/__tests__/panel.test.tsx b/packages/storage-plugin/src/ui/__tests__/panel.test.tsx index 77c4dc2b4..e1146a7ea 100644 --- a/packages/storage-plugin/src/ui/__tests__/panel.test.tsx +++ b/packages/storage-plugin/src/ui/__tests__/panel.test.tsx @@ -202,13 +202,13 @@ const renderPanel = async () => { }; const discoveryRequests = () => mocks.client.send.mock.calls.filter(([type]) => type === 'discover-storages'); -const discover = async () => { +const discover = async (storages: StorageDescriptor[] = descriptors) => { const request = discoveryRequests().at(-1)?.[1]; await act(async () => mocks.emit('storage-descriptors', { type: 'storage-descriptors', requestId: request.requestId, - storages: descriptors, + storages, }), ); }; @@ -393,4 +393,19 @@ describe('StoragePanel preview query cutover', () => { expect(container.textContent).toContain('First'); await act(async () => root.unmount()); }); + + it('disables add, purge and import for read-only storages', async () => { + const { root, container } = await renderPanel(); + await discover([ + { ...descriptors[0], capabilities: { supportedTypes: ['string'], readOnly: true } }, + ]); + const button = (label: string) => + container.querySelector(`button[aria-label="${label}"]`); + + expect(button('Add entry')?.disabled).toBe(true); + expect(button('Purge storage')?.disabled).toBe(true); + expect(button('Import storage')?.disabled).toBe(true); + expect(button('Export storage')?.disabled).toBe(false); + await act(async () => root.unmount()); + }); }); diff --git a/packages/storage-plugin/src/ui/panel.tsx b/packages/storage-plugin/src/ui/panel.tsx index c465d7eaf..2be300a6c 100644 --- a/packages/storage-plugin/src/ui/panel.tsx +++ b/packages/storage-plugin/src/ui/panel.tsx @@ -30,7 +30,7 @@ import type { StorageEntryValue, StorageTarget, } from '../shared/types'; -import { getStorageViewId } from '../shared/types'; +import { getStorageViewId, isReadOnly } from '../shared/types'; import { AddEntryDialog } from './add-entry-dialog'; import { EditEntryDialog } from './edit-entry-dialog'; import { EntryDetailDialog } from './entry-detail-dialog'; @@ -116,6 +116,7 @@ function StoragePanelContent() { }); const rows = flattenStorageEntryPreviewPages(previews.data); const supportedTypes = selectedDescriptor?.capabilities.supportedTypes ?? []; + const readOnly = selectedDescriptor ? isReadOnly(selectedDescriptor.capabilities) : false; const sidebarGroups = useMemo(() => { const storages = new Map( descriptors.map((descriptor) => [getStorageViewId(descriptor.target), descriptor]), @@ -508,30 +509,31 @@ function StoragePanelContent() { id: 'actions', header: '', enableSorting: false, - cell: ({ row }) => ( -
event.stopPropagation()}> - setInteraction({ key: row.original.key, mode: 'edit' })} - label={`Edit value for ${row.original.key}`} - > - - - void handleDeleteClick(row.original.key)} - label={`Delete entry ${row.original.key}`} - > - - -
- ), + cell: ({ row }) => + readOnly ? null : ( +
event.stopPropagation()}> + setInteraction({ key: row.original.key, mode: 'edit' })} + label={`Edit value for ${row.original.key}`} + > + + + void handleDeleteClick(row.original.key)} + label={`Delete entry ${row.original.key}`} + > + + +
+ ), }, ], - [handleDeleteClick], + [handleDeleteClick, readOnly], ); const sorting: SortingState = [{ id: 'key', desc: keySortDirection === 'descending' }]; const handleSortingChange: OnChangeFn = (updater) => { @@ -608,7 +610,7 @@ function StoragePanelContent() { setShowAddDialog(true)} - disabled={!selectedDescriptor} + disabled={!selectedDescriptor || readOnly} aria-label="Add entry" title="Add entry" className="w-6 px-0" @@ -617,7 +619,7 @@ function StoragePanelContent() { void handlePurgeClick()} - disabled={!client || !selectedTarget || previews.isFetching} + disabled={!client || !selectedTarget || previews.isFetching || readOnly} aria-label="Purge storage" title="Purge storage" className="w-6 px-0 text-muted-foreground hover:text-danger" @@ -641,7 +643,7 @@ function StoragePanelContent() { { if (!open) closeInteraction(); }} - onEdit={() => - setInteraction((current) => (current ? { ...current, mode: 'edit' } : current)) + onEdit={ + readOnly + ? undefined + : () => setInteraction((current) => (current ? { ...current, mode: 'edit' } : current)) } entry={fullEntry.entry ?? null} /> =16'} + react-native-mmkv@3.3.0: resolution: {integrity: sha512-2iPjIJ+IAODXN35wm53EN6nv+hR/0NXLLiLWOdPA/0gXDB2dYVrr6MqvoiFpxhLhdj0B8fkf5ARNVavJaSvuuQ==} peerDependencies: @@ -29796,6 +29803,8 @@ snapshots: react: 19.2.3 react-native: 0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.3)(supports-color@8.1.1) + react-native-keychain@10.0.0: {} + react-native-mmkv@3.3.0(react-native@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3): dependencies: react: 19.2.3 diff --git a/website/src/content/docs/docs/official-plugins/storage.mdx b/website/src/content/docs/docs/official-plugins/storage.mdx index 6e151e90b..cd2aa8715 100644 --- a/website/src/content/docs/docs/official-plugins/storage.mdx +++ b/website/src/content/docs/docs/official-plugins/storage.mdx @@ -4,7 +4,7 @@ title: 'Storage Plugin' import PackageManagerTabs from '@/components/PackageManagerTabs.astro'; -The Storage plugin is a single DevTools panel for MMKV, AsyncStorage, and Expo SecureStore. Register whichever adapters your app uses and inspect them all from one place. +The Storage plugin is a single DevTools panel for MMKV, AsyncStorage, Expo SecureStore, and react-native-keychain. Register whichever adapters your app uses and inspect them all from one place. ## Installation @@ -14,7 +14,7 @@ Make sure to go through the [Getting Started guide](/docs/getting-started) befor Install the peer dependencies for the storages you use: - + ## Setup @@ -22,6 +22,7 @@ Install the peer dependencies for the storages you use: import { createAsyncStorageAdapter, createExpoSecureStorageAdapter, + createKeychainStorageAdapter, createMMKVStorageAdapter, useRozeniteStoragePlugin, } from '@rozenite/storage-plugin'; @@ -37,6 +38,9 @@ const storages = [ storage: SecureStore, keys: ['token', 'session'], }), + createKeychainStorageAdapter({ + keychain: Keychain, + }), ]; function App() { @@ -89,9 +93,41 @@ createExpoSecureStorageAdapter({ SecureStore doesn't support key enumeration, so you provide known keys via `keys`. +### react-native-keychain + +```ts title="App.tsx" +import * as Keychain from 'react-native-keychain'; + +createKeychainStorageAdapter({ + keychain: Keychain, + storageName: 'Credentials', + readOnly: true, +}); +``` + +Each entry is one Keychain (iOS) or Keystore (Android) service, and its value is the stored password. By default every service reported by `getAllGenericPasswordServices` is listed, which can include services written by other libraries in your app. Pass `services` (a list or an async function) to limit it. Purging the storage removes every listed service except the ones hidden by `blacklist`. + +On iOS, services that need Face ID, Touch ID or a passcode are skipped when listing, so the panel doesn't trigger authentication prompts. Set `skipUIAuth: false` to list them anyway. Entries that can't be read, for example after a biometric change invalidates their Keystore key, are left out of the table with a warning in the app's console instead of failing the whole list. They still count towards the storage's entry count, and purging removes them. + +Editing an entry keeps its existing username and, on Android, its storage type unless `setOptions` sets `storage`. New entries, and entries that can't be read, use `defaultUsername`. `setOptions` is forwarded to `setGenericPassword` (for example `accessible` or `accessControl`). The keychain doesn't report these options for stored entries, so if your entries use different ones, pass a function that returns the options for each service: + +```ts +createKeychainStorageAdapter({ + keychain: Keychain, + setOptions: (service) => + service === 'auth-token' + ? { accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_CURRENT_SET } + : undefined, +}); +``` + +Set `readOnly` to disable adding, editing, deleting, purging and importing in the panel. + +If `keychain` is `undefined`, for example because you load the library optionally, the adapter exposes no storages instead of throwing. + ## Binary values -MMKV storages that hold binary values render and edit them through a hex viewer with a Base64 mode for copy/paste. AsyncStorage and Expo SecureStore don't support binary values. +MMKV storages that hold binary values render and edit them through a hex viewer with a Base64 mode for copy/paste. AsyncStorage, Expo SecureStore, and react-native-keychain don't support binary values. ## Hiding sensitive keys