diff --git a/.npmignore b/.npmignore new file mode 100644 index 00000000..cd5fe296 --- /dev/null +++ b/.npmignore @@ -0,0 +1,45 @@ +# Development files +src/ +.nitro/ +.github/ +docs/ +coverage/ +example/ + +# Configuration files +eslint.config.mts +babel.config.js +jest.config.js +tsconfig.json +tsconfig.test.json +tsconfig.tsbuildinfo +.prettierrc.js +.watchmanconfig +nitro.json +release.config.cjs +post-script.js + +# Git and environment +.git/ +.gitignore +.DS_Store +.yarn/ +.yarnrc.yml + +# Node modules and lock files +node_modules/ +yarn.lock + +# Build artifacts (except lib/) +.nitro/ +nitrogen/ + +# Documentation +CODE_OF_CONDUCT.md +SECURITY.md + +# Test files +**/*.test.ts +**/*.test.tsx +**/*.spec.ts +**/*.spec.tsx diff --git a/.prettierrc.js b/.prettierrc.js index dcfe0418..853d3be7 100644 --- a/.prettierrc.js +++ b/.prettierrc.js @@ -1,5 +1,8 @@ module.exports = { singleQuote: true, - trailingComma: 'all', - printWidth: 100, + semi: true, + tabWidth: 2, + trailingComma: 'es5', + useTabs: false, + quoteProps: 'consistent', }; diff --git a/README.md b/README.md index 8ab03dde..d168f9c7 100644 --- a/README.md +++ b/README.md @@ -5,422 +5,302 @@ [![Coverage](https://img.shields.io/badge/coverage-92%25-brightgreen)](https://github.com/mcodex/react-native-sensitive-info) [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE) -Modern secure storage for React Native, powered by Nitro Modules. Version 6 ships a new headless API surface, stronger security defaults, and a fully revamped example app. +Modern secure storage for React Native. Type-safe, hardware-backed encryption with biometric protection on iOS and Android. -> [!TIP] -> Need the TL;DR? Jump to [🚀 Highlights](#-highlights) and [⚙️ Installation](#-installation) to get productive in under five minutes. +- ⚡ **Nitro Modules** — 3.3× faster than v5's React Native bridge +- 🔒 **Hardware Security** — Secure Enclave (iOS) + StrongBox (Android) with automatic fallbacks +- 📱 **Hooks & Imperative** — Reactive hooks for UI, imperative API for custom control +- 🔑 **Key Rotation** — Automatic zero-downtime re-encryption with versioning +- ✅ **Fully Typed** — Type-safe error codes, branded types, complete TypeScript support +- 🧪 **Well Tested** — 132 tests, 90%+ core coverage -> [!WARNING] -> Version 6 drops Windows support. The module now targets Android plus the Apple platforms (iOS, macOS, visionOS, watchOS). - -> [!IMPORTANT] -> This README tracks the in-progress v6 work on `master`. For the stable legacy release, switch to the `v5.x` branch. - -> [!NOTE] -> **Choosing between 5.6.x and 6.x** -> -> - **Need bridge stability?** `5.6.x` is the last pre-Nitro release with the latest biometric fixes, docs, and Android namespace cleanups. It’s drop-in for any `5.5.x` app already running on React Native’s Fabric architecture, but you keep the legacy JS bridge overhead—Paper is no longer supported. -> - **Ready for Nitro speed?** `6.x` swaps in the Nitro hybrid core, auto-enforces Class 3/StrongBox biometrics, and ships the refreshed sample app plus richer metadata. Upgrade when you can adopt the Nitro toolchain (RN 0.76+, Node 18+, `react-native-nitro-modules`). -> - **Staying back on 5.5.x?** You remain on the legacy (Paper) architecture and miss the Android 13 prompt fixes, the manual credential fallback restoration, and the new docs—migrate to `5.6.x` at minimum before planning the Nitro jump. - -## Table of contents - -- [🚀 Highlights](#-highlights) -- [🧭 Platform support](#-platform-support) -- [⚙️ Installation](#-installation) -- [⚡️ Quick start](#-quick-start) -- [📚 API reference](#-api-reference) -- [🔐 Access control & metadata](#-access-control--metadata) -- [❗ Error handling](#-error-handling) -- [🧪 Simulators and emulators](#-simulators-and-emulators) -- [📈 Performance benchmarks](#-performance-benchmarks) -- [🎮 Example application](#-example-application) -- [🛠️ Development](#-development) -- [🩺 Troubleshooting](#-troubleshooting) -- [🤝 Contributing](#-contributing) -- [📄 License](#-license) - -## 🚀 Highlights - -- Headless Nitro hybrid object with a simple Promise-based API (`setItem`, `getItem`, `hasItem`, `getAllItems`, `clearService`). -- Automatic security negotiation: locks onto Secure Enclave (iOS) or Class 3 / StrongBox biometrics (Android) with graceful fallbacks when hardware is limited. -- Unified metadata reporting (security level, backend, access control, timestamp) for every stored secret. -- Friendly example app showcasing prompts, metadata inspection, and per-platform capability detection. -- First-class TypeScript definitions and tree-shakeable distribution via `react-native-builder-bob`. - -> [!NOTE] -> All APIs are fully typed. Hover over any option in your editor to explore the metadata surface without leaving VS Code. - -## 🧭 Platform support - -| Platform | Minimum OS | Notes | -| --- | --- | --- | -| React Native | 0.76.0 | Requires `react-native-nitro-modules` for Nitro hybrid core. | -| iOS | 13.0 | Requires Face ID usage string when biometrics are enabled. | -| macOS | 11.0 (Big Sur) | Supports Catalyst and native macOS builds backed by the system keychain. | -| visionOS | 1.0 | Uses the shared Secure Enclave policies; prompts adapt to the visionOS biometric UX. | -| watchOS | 7.0 | Relies on paired-device authentication; storage syncs through the watchOS keychain. | -| Android | API 23 (Marshmallow) | StrongBox detection requires API 28+; biometrics fall back to device credential when unavailable. | -| Windows | ❌ | Removed in v6. Earlier versions may still work but are no longer maintained. | - -## ⚙️ Installation +## Installation ```bash -# with npm -npm install react-native-sensitive-info@next react-native-nitro-modules - -# or with yarn yarn add react-native-sensitive-info@next react-native-nitro-modules - -# or with pnpm -pnpm add react-native-sensitive-info@next react-native-nitro-modules +cd ios && pod install ``` -No manual linking is required. Nitro handles platform registration via autolinking. - -### 🍏 iOS setup - -- Install pods from the root of your project: - - ```bash - cd ios && pod install - ``` - -- Add a Face ID usage description to your app’s `Info.plist` if you intend to use biometric prompts (already present in the example app): - - ```xml - NSFaceIDUsageDescription - Face ID is used to unlock secrets stored in the secure enclave. - ``` - -### 🤖 Android setup - -- Ensure the following permissions are present in your `AndroidManifest.xml`: - - ```xml - - - ``` - -- If you rely on hardware-backed keystores, verify the device/emulator supports the biometrics you request. - -### 🧪 Expo setup - -> [!WARNING] -> The Expo Go client does not ship native Nitro modules. Use a custom dev client (`expo run:*`) or an EAS build instead. - -1. Add the plugin to your `app.json`/`app.config.js` so prebuild toggles the new architecture for both platforms: - - ```json - { - "expo": { - "plugins": [ - "react-native-sensitive-info" - ] - } - } - ``` - -2. Regenerate the native projects after updating the config: - - ```bash - npx expo prebuild --clean - ``` - -3. Create a development client or production build that bundles the native module: +## Quick Start - ```bash - npx expo run:android - npx expo run:ios - # or via EAS - eas build --profile development --platform android - ``` - -The plugin enables React Native's new architecture on both platforms, ensuring the `HybridSensitiveInfo` Nitro class is included during compilation. - -> [!TIP] -> Use `includeValue: false` during reads when you only care about metadata—this keeps plaintext out of memory and speeds up list views. - -## ⚛️ React Hooks API (Recommended) - -For a modern, reactive approach with automatic memory management and loading states, use the dedicated hooks: +### React Hooks ```tsx -import { Text, View, ActivityIndicator } from 'react-native' -import { - useSecureStorage, - useSecurityAvailability, -} from 'react-native-sensitive-info' - -// Use hooks directly in any component - no provider needed! -function YourComponent() { - // Fetch and manage all secrets in a service (with CRUD) - const { - items, - isLoading, - error, - saveSecret, - removeSecret, - } = useSecureStorage({ service: 'myapp', includeValues: true }) - - // Query device security capabilities (cached automatically) - const { data: capabilities } = useSecurityAvailability() - - if (isLoading) return - if (error) return Error: {error.message} +import { useSecureStorage } from 'react-native-sensitive-info' + +function SecureComponent() { + const { items, saveSecret, removeSecret } = useSecureStorage({ + service: 'auth' + }) return ( - + <> {items.map(item => ( - - {item.key}: {item.value} ({item.metadata.securityLevel}) - + {item.key} ))} - - Biometry available: {capabilities?.biometry ? 'Yes' : 'No'} - - + } + * {state.loading && } + * {state.data && {state.data}} + * + * ) + * } + * ``` + * + * @see {@link useCallback} for memoizing operation functions + * @see {@link HookError} for error handling + */ +export function useAsyncOperation( + operation: () => Promise, + operationName: HookOperation, + mapper?: (raw: TRaw) => TData +): AsyncOperationResult { + const [state, setState] = useState>({ + data: null, + loading: false, + error: null, + }); + + const isMountedRef = useRef(true); + const lastOperationRef = useRef(operation); + + // Track mounted state to prevent memory leaks + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + + // Keep operation reference up to date for retries + useEffect(() => { + lastOperationRef.current = operation; + }, [operation]); + + const execute = useCallback(async () => { + if (!isMountedRef.current) return; + + setState((prev) => ({ + ...prev, + loading: true, + error: null, + })); + + try { + const result = await lastOperationRef.current(); + + if (!isMountedRef.current) return; + + const mappedData = mapper ? mapper(result) : (result as unknown as TData); + + setState({ + data: mappedData, + loading: false, + error: null, + }); + } catch (error) { + if (!isMountedRef.current) return; + + const hookError = createOperationError(operationName, error); + + setState({ + data: null, + loading: false, + error: hookError, + }); + } + }, [operationName, mapper]); + + const reset = useCallback(() => { + if (!isMountedRef.current) return; + + setState({ + data: null, + loading: false, + error: null, + }); + }, []); + + const retry = useCallback(async () => { + await execute(); + }, [execute]); + + return { + state, + execute, + reset, + retry, + }; +} + +/** + * Hook for managing async mutation operations (create, update, delete). + * + * Similar to useAsyncOperation but optimized for mutations with: + * - Initial data preservation (doesn't clear on retry) + * - Optimistic updates support + * - Mutation-specific error handling + * + * @template TData - Type of the result data + * + * @param operation - Async mutation function + * @param operationName - Name for error reporting + * @param mapper - Optional result transformer + * @returns Result with state and execution methods + * + * @example + * ```ts + * const { state, execute: saveSecret } = useAsyncMutation( + * useCallback(() => setItem(key, value), [key, value]), + * 'save' + * ) + * ``` + */ +export function useAsyncMutation( + operation: () => Promise, + operationName: HookOperation, + mapper?: (raw: TRaw) => TData +): AsyncOperationResult { + const result = useAsyncOperation(operation, operationName, mapper); + + // For mutations, preserve data on error (don't clear) + const executePreservingData = useCallback(async () => { + await result.execute(); + }, [result.execute]); + + return { + state: result.state, + execute: executePreservingData, + reset: result.reset, + retry: result.retry, + }; +} diff --git a/src/hooks/useSecret.ts b/src/hooks/useSecret.ts index d43d2208..025e7222 100644 --- a/src/hooks/useSecret.ts +++ b/src/hooks/useSecret.ts @@ -1,7 +1,6 @@ import { useCallback } from 'react'; import type { SensitiveInfoItem, - SensitiveInfoOptions, } from '../sensitive-info.nitro'; import { deleteItem, setItem } from '../core/storage'; import { @@ -11,7 +10,10 @@ import { type AsyncState, } from './types'; import { useSecretItem, type UseSecretItemOptions } from './useSecretItem'; -import createHookError from './error-utils'; +import { + createMutationError, + extractCoreStorageOptions, +} from './error-factory'; /** * Configuration object for {@link useSecret}. @@ -31,24 +33,44 @@ export interface UseSecretResult extends AsyncState { readonly refetch: () => Promise; } -/** - * Removes hook-specific flags before delegating to the storage module. - */ -const normalizeMutationOptions = ( - options?: UseSecretOptions -): SensitiveInfoOptions | undefined => { - if (!options) return undefined; - const { skip: _skip, includeValue: _includeValue, ...core } = options; - return core as SensitiveInfoOptions; -}; - /** * Maintains a secure item while exposing imperative helpers to mutate or refresh it. * + * Combines the read state of {@link useSecretItem} with mutation operations + * (save/delete) in a single hook, eliminating the need to pair hooks. + * + * @param key - The storage key to track + * @param options - Configuration for reading the item (service, access control, etc.) + * + * @returns Complete state and mutation helpers for the secret + * * @example * ```tsx + * // Simple secret management * const secret = useSecret('refreshToken', { service: 'com.example.session' }) + * + * if (secret.error) { + * return + * } + * + * if (secret.isLoading) { + * return + * } + * + * return ( + * + * ) * ``` + * + * @see {@link useSecretItem} for read-only access + * @see {@link useSecureStorage} for managing multiple items + * + * @since 6.0.0 */ export function useSecret( key: string, @@ -62,15 +84,15 @@ export function useSecret( const saveSecret = useCallback( async (value: string) => { try { - await setItem(key, value, normalizeMutationOptions(options)); + const coreOptions = extractCoreStorageOptions(options ?? {}, [ + 'skip', + 'includeValue', + ]); + await setItem(key, value, coreOptions); await refetch(); return createHookSuccessResult(); } catch (errorLike) { - const hookError = createHookError( - 'useSecret.saveSecret', - errorLike, - 'Check the access control requirements for this key.' - ); + const hookError = createMutationError('useSecret.save', errorLike); return createHookFailureResult(hookError); } }, @@ -79,15 +101,15 @@ export function useSecret( const deleteSecret = useCallback(async () => { try { - await deleteItem(key, normalizeMutationOptions(options)); + const coreOptions = extractCoreStorageOptions(options ?? {}, [ + 'skip', + 'includeValue', + ]); + await deleteItem(key, coreOptions); await refetch(); return createHookSuccessResult(); } catch (errorLike) { - const hookError = createHookError( - 'useSecret.deleteSecret', - errorLike, - 'Ensure the user completed biometric prompts or that the key is spelled correctly.' - ); + const hookError = createMutationError('useSecret.delete', errorLike); return createHookFailureResult(hookError); } }, [key, options, refetch]); diff --git a/src/hooks/useSecretItem.ts b/src/hooks/useSecretItem.ts index e5b59d82..db1c62ad 100644 --- a/src/hooks/useSecretItem.ts +++ b/src/hooks/useSecretItem.ts @@ -8,7 +8,7 @@ import { createInitialAsyncState } from './types'; import type { AsyncState } from './types'; import useAsyncLifecycle from './useAsyncLifecycle'; import useStableOptions from './useStableOptions'; -import createHookError, { isAuthenticationCanceledError } from './error-utils'; +import { createFetchError, isAuthenticationCanceled } from './error-factory'; /** * Configuration accepted by {@link useSecretItem}. @@ -39,13 +39,68 @@ export interface UseSecretItemResult extends AsyncState { /** * Fetches a single entry from the secure store and keeps the result in sync with the component lifecycle. * + * This hook automatically runs on mount and when options change, but can be skipped with `skip: true`. + * It handles lifecycle cleanup (abort in-flight requests when unmounting) and authentication + * cancellations (which are not errors, just user actions). + * + * @param key - The storage key to fetch + * @param options - Configuration including service, access control, and fetch behavior + * + * @returns Async state with the item and a refetch function + * + * @example + * ```tsx + * // Basic usage + * const { data, isLoading, error, refetch } = useSecretItem('authToken', { + * service: 'com.example.session' + * }) + * + * if (error) { + * return + * } + * + * if (isLoading) { + * return + * } + * + * return ( + * + * Token: {data?.value} + * Security: {data?.metadata.securityLevel} + * + * ) + * ``` + * + * @example + * ```tsx + * // Lazy loading: skip initial fetch + * const { data, refetch } = useSecretItem('onDemandSecret', { + * service: 'com.example', + * skip: true + * }) + * + * return ( + * + * ) + * ``` + * * @example * ```tsx - * const { data, isLoading, error, refetch } = useSecretItem('refreshToken', { - * service: 'com.example.session', - * includeValue: true, + * // Metadata only (no authentication required) + * const { data } = useSecretItem('secretKey', { + * service: 'com.example', + * includeValue: false * }) + * + * return Last modified: {data?.metadata.timestamp} * ``` + * + * @see {@link useSecret} to combine read and write operations + * @see {@link useSecureStorage} to manage multiple items + * + * @since 6.0.0 */ export function useSecretItem( key: string, @@ -90,7 +145,7 @@ export function useSecretItem( } } catch (errorLike) { if (mountedRef.current && !controller.signal.aborted) { - if (isAuthenticationCanceledError(errorLike)) { + if (isAuthenticationCanceled(errorLike)) { setState((prev) => ({ data: prev.data, error: null, @@ -98,10 +153,9 @@ export function useSecretItem( isPending: false, })); } else { - const hookError = createHookError( + const hookError = createFetchError( 'useSecretItem.fetch', - errorLike, - 'Verify that the key/service pair exists and that includeValue is allowed for the caller.' + errorLike ); setState({ data: null, diff --git a/src/hooks/useSecureStorage.ts b/src/hooks/useSecureStorage.ts index ef951ab9..e93f1c43 100644 --- a/src/hooks/useSecureStorage.ts +++ b/src/hooks/useSecureStorage.ts @@ -17,7 +17,12 @@ import { } from './types'; import useAsyncLifecycle from './useAsyncLifecycle'; import useStableOptions from './useStableOptions'; -import createHookError, { isAuthenticationCanceledError } from './error-utils'; +import { + createFetchError, + createMutationError, + isAuthenticationCanceled, + extractCoreStorageOptions, +} from './error-factory'; /** * Options accepted by {@link useSecureStorage}. @@ -36,16 +41,6 @@ const DEFAULTS: Required< skip: false, }; -/** - * Removes hook-only flags so that mutation helpers receive pristine {@link SensitiveInfoOptions}. - */ -const extractCoreOptions = ( - options: UseSecureStorageOptions -): SensitiveInfoOptions => { - const { skip: _skip, includeValues: _includeValues, ...core } = options; - return core as SensitiveInfoOptions; -}; - /** * Structure returned by {@link useSecureStorage}. */ @@ -72,15 +67,65 @@ export interface UseSecureStorageResult { /** * Manages a collection of secure items, exposing read/write helpers and render-ready state. * + * This hook maintains a collection of all secrets in a service and provides + * helpers for common operations (save, delete, clear). The collection is + * automatically updated after mutations. + * + * @param options - Configuration including service and value inclusion preference + * + * @returns Collection state and mutation helpers + * * @example * ```tsx + * // List all items in a service * const { * items, + * isLoading, + * error, * saveSecret, * removeSecret, - * clearAll, - * } = useSecureStorage({ service: 'com.example.session', includeValues: true }) + * clearAll + * } = useSecureStorage({ + * service: 'com.example.session', + * includeValues: true + * }) + * + * if (error) return + * if (isLoading) return + * + * return ( + * <> + * {items.map(item => ( + * removeSecret(item.key)} + * /> + * ))} + * + * + * ) + * ``` + * + * @example + * ```tsx + * // Lazy loading: populate on demand + * const { items, refreshItems } = useSecureStorage({ + * service: 'com.example', + * skip: true + * }) + * + * return ( + * + * ) * ``` + * + * @see {@link useSecretItem} for a single item + * @see {@link useSecret} for single item with mutations + * + * @since 6.0.0 */ export function useSecureStorage( options?: UseSecureStorageOptions @@ -96,10 +141,10 @@ export function useSecureStorage( ); const applyError = useCallback( - (operation: string, errorLike: unknown, hint: string): HookError => { - const hookError = createHookError(operation, errorLike, hint); + (operation: string, errorLike: unknown): HookError => { + const hookError = createFetchError(operation as any, errorLike); - if (isAuthenticationCanceledError(errorLike)) { + if (isAuthenticationCanceled(errorLike)) { if (mountedRef.current) { setError(null); } @@ -136,13 +181,9 @@ export function useSecureStorage( } } catch (errorLike) { if (mountedRef.current && !controller.signal.aborted) { - const canceled = isAuthenticationCanceledError(errorLike); + const canceled = isAuthenticationCanceled(errorLike); - applyError( - 'useSecureStorage.fetchItems', - errorLike, - 'Ensure the service name matches the one used when storing the items.' - ); + applyError('useSecureStorage.fetch', errorLike); if (!canceled) { setItems([]); @@ -153,7 +194,7 @@ export function useSecureStorage( setIsLoading(false); } } - }, [begin, mountedRef, stableOptions]); + }, [begin, mountedRef, stableOptions, applyError]); useEffect(() => { fetchItems().catch(() => {}); @@ -166,60 +207,80 @@ export function useSecureStorage( const saveSecret = useCallback( async (key: string, value: string) => { try { - await setItem(key, value, extractCoreOptions(stableOptions)); + const coreOptions = extractCoreStorageOptions(stableOptions, [ + 'skip', + 'includeValues', + ]); + await setItem(key, value, coreOptions); if (mountedRef.current) { await fetchItems(); + setError(null); } return createHookSuccessResult(); } catch (errorLike) { - const hookError = applyError( - 'useSecureStorage.saveSecret', - errorLike, - 'Check for duplicate keys or permission prompts that might have been dismissed.' + const hookError = createMutationError( + 'useSecureStorage.save', + errorLike ); + if (mountedRef.current && !isAuthenticationCanceled(errorLike)) { + setError(hookError); + } return createHookFailureResult(hookError); } }, - [applyError, fetchItems, mountedRef, stableOptions] + [fetchItems, mountedRef, stableOptions] ); const removeSecret = useCallback( async (key: string) => { try { - await deleteItem(key, extractCoreOptions(stableOptions)); + const coreOptions = extractCoreStorageOptions(stableOptions, [ + 'skip', + 'includeValues', + ]); + await deleteItem(key, coreOptions); if (mountedRef.current) { setItems((prev) => prev.filter((item) => item.key !== key)); + setError(null); } return createHookSuccessResult(); } catch (errorLike) { - const hookError = applyError( - 'useSecureStorage.removeSecret', - errorLike, - 'Confirm the item still exists or that the user completed biometric prompts.' + const hookError = createMutationError( + 'useSecureStorage.remove', + errorLike ); + if (mountedRef.current && !isAuthenticationCanceled(errorLike)) { + setError(hookError); + } return createHookFailureResult(hookError); } }, - [applyError, mountedRef, stableOptions] + [mountedRef, stableOptions] ); const clearAll = useCallback(async () => { try { - await clearService(extractCoreOptions(stableOptions)); + const coreOptions = extractCoreStorageOptions(stableOptions, [ + 'skip', + 'includeValues', + ]); + await clearService(coreOptions); if (mountedRef.current) { setItems([]); setError(null); } return createHookSuccessResult(); } catch (errorLike) { - const hookError = applyError( + const hookError = createMutationError( 'useSecureStorage.clearAll', - errorLike, - 'Inspect whether another process holds a lock on the secure storage.' + errorLike ); + if (mountedRef.current && !isAuthenticationCanceled(errorLike)) { + setError(hookError); + } return createHookFailureResult(hookError); } - }, [applyError, mountedRef, stableOptions]); + }, [mountedRef, stableOptions]); return { items, diff --git a/src/index.ts b/src/index.ts index df3d28aa..ecb6c4c6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,36 @@ export type { StorageMetadata, } from './sensitive-info.nitro'; +/** + * Error classification and handling for type-safe error management. + */ +export { + SensitiveInfoError, + ErrorCode, + isSensitiveInfoError, + isNotFoundError, + isAuthenticationCanceledError, + isBiometryError, + isSecurityError, + getErrorMessage, + classifyError, +} from './internal/error-classifier'; + +/** + * Branded types for enhanced type safety. + */ +export { + createStorageKey, + createServiceName, + createStorageValue, + isStorageKey, + isServiceName, + isStorageValue, + type StorageKey, + type ServiceName, + type StorageValue, +} from './internal/branded-types'; + /** * Core storage helpers that mirror the native Nitro surface. */ @@ -43,6 +73,8 @@ export { HookError, createHookFailureResult, createHookSuccessResult, + useAsyncOperation, + useAsyncMutation, useHasSecret, useSecret, useSecretItem, @@ -64,4 +96,43 @@ export { type UseSecurityAvailabilityResult, type AsyncState, type VoidAsyncState, + type AsyncOperationState, + type AsyncOperationResult, } from './hooks'; + +/** + * Key rotation for automatic key management with zero-downtime support. + */ +export { + initializeKeyRotation, + rotateKeys, + getKeyVersion, + getRotationStatus, + setRotationPolicy, + getRotationPolicy, + migrateToNewKey, + validateMigration, + getMigrationPreview, + reEncryptAllItems, + on, + off, + handleBiometricChange, + handleCredentialChange, + type RotationPolicy, + type RotationStatus, + type RotationOptions, + type RotationEvent, + type RotationStartedEvent, + type RotationCompletedEvent, + type RotationFailedEvent, + type KeyVersion, + type EncryptedEnvelope, + type MigrationResult, + type MigrationOptions, +} from './rotation/rotation-api'; + +export type { + BiometricChangeEvent, + RotationEventCallback, + RotationAuditEntry, +} from './rotation/types'; diff --git a/src/internal/branded-types.ts b/src/internal/branded-types.ts new file mode 100644 index 00000000..00582be8 --- /dev/null +++ b/src/internal/branded-types.ts @@ -0,0 +1,189 @@ +/** + * Branded types for enhanced type safety in storage operations. + * + * Branded types (also called "flavored" or "tagged" types) provide nominal typing + * in TypeScript, allowing the type system to distinguish between semantically different + * string types. This prevents accidental mixing of keys and services. + * + * @module internal/branded-types + * @internal + * + * @example + * ```ts + * import type { StorageKey, ServiceName } from '../internal/branded-types' + * import { createStorageKey, createServiceName } from '../internal/branded-types' + * + * // Compile-time safety + * const key: StorageKey = 'authToken' as StorageKey // ✓ OK + * const service: ServiceName = 'com.app' as ServiceName // ✓ OK + * + * // This would be a type error: + * const key: StorageKey = service // ✗ Type error: ServiceName is not StorageKey + * const service: ServiceName = key // ✗ Type error: StorageKey is not ServiceName + * + * // Runtime validation with branded type creation + * const validKey = createStorageKey('authToken') // ✓ Returns branded type + * const invalidKey = createStorageKey('') // ✗ Throws error + * ``` + */ + +/** + * Branded type for storage keys. + * + * This is a string that has been validated to meet storage key requirements: + * - Non-empty + * - Max 255 characters + * - Alphanumeric with dots, hyphens, underscores, colons + * + * Using StorageKey prevents accidental mixing with other string types like ServiceName. + * + * @see {@link createStorageKey} to create a validated StorageKey + */ +export type StorageKey = string & { readonly __brand: 'StorageKey' }; + +/** + * Branded type for service names. + * + * This is a string that has been validated to meet service name requirements: + * - Non-empty + * - Alphanumeric with dots, hyphens, underscores + * - Typically follows reverse-domain notation (e.g., 'com.example.app') + * + * Using ServiceName prevents accidental mixing with other string types like StorageKey. + * + * @see {@link createServiceName} to create a validated ServiceName + */ +export type ServiceName = string & { readonly __brand: 'ServiceName' }; + +/** + * Creates a validated StorageKey with runtime checks. + * + * @param key - The key to validate and brand + * @returns A branded StorageKey if validation passes + * @throws {SensitiveInfoError} If the key fails validation + * + * @example + * ```ts + * const key = createStorageKey('authToken') + * // key has type StorageKey and cannot be mixed with ServiceName + * + * // Validation errors + * createStorageKey('') // ✗ Error: empty key + * createStorageKey('a'.repeat(300)) // ✗ Error: too long + * createStorageKey('invalid@key') // ✗ Error: invalid characters + * ``` + */ +export function createStorageKey(key: string): StorageKey { + // Validation is delegated to the validator module + // This is a marker function that indicates the key has been validated + if (typeof key !== 'string' || key.length === 0 || key.length > 255) { + throw new Error('Invalid storage key'); + } + return key as StorageKey; +} + +/** + * Creates a validated ServiceName with runtime checks. + * + * @param service - The service name to validate and brand + * @returns A branded ServiceName if validation passes + * @throws {SensitiveInfoError} If the service name fails validation + * + * @example + * ```ts + * const service = createServiceName('com.example.auth') + * // service has type ServiceName and cannot be mixed with StorageKey + * + * // Validation errors + * createServiceName('') // ✗ Error: empty service + * createServiceName('invalid@service') // ✗ Error: invalid characters + * ``` + */ +export function createServiceName(service: string): ServiceName { + // Validation is delegated to the validator module + // This is a marker function that indicates the service has been validated + if (typeof service !== 'string' || service.length === 0) { + throw new Error('Invalid service name'); + } + return service as ServiceName; +} + +/** + * Type guard to check if a string is a valid StorageKey. + * + * @param value - The value to check + * @returns true if value is a StorageKey + * + * @example + * ```ts + * function handleKey(value: string) { + * if (isStorageKey(value)) { + * // value is now typed as StorageKey + * await getItem(value) + * } + * } + * ``` + */ +export function isStorageKey(value: unknown): value is StorageKey { + return typeof value === 'string' && value.length > 0 && value.length <= 255; +} + +/** + * Type guard to check if a string is a valid ServiceName. + * + * @param value - The value to check + * @returns true if value is a ServiceName + * + * @example + * ```ts + * function handleService(value: string) { + * if (isServiceName(value)) { + * // value is now typed as ServiceName + * await getAllItems({ service: value }) + * } + * } + * ``` + */ +export function isServiceName(value: unknown): value is ServiceName { + return typeof value === 'string' && value.length > 0; +} + +/** + * Branded type for storage values. + * + * Currently not strictly enforced beyond being a string, + * but branded for potential future validation (e.g., max size). + * + * @see {@link createStorageValue} to create a validated StorageValue + */ +export type StorageValue = string & { readonly __brand: 'StorageValue' }; + +/** + * Creates a validated StorageValue with runtime checks. + * + * @param value - The value to validate and brand + * @returns A branded StorageValue if validation passes + * @throws {SensitiveInfoError} If the value fails validation + * + * @example + * ```ts + * const value = createStorageValue('secret-token-here') + * // value has type StorageValue and cannot be mixed with other strings + * ``` + */ +export function createStorageValue(value: unknown): StorageValue { + if (typeof value !== 'string') { + throw new Error('Storage value must be a string'); + } + return value as StorageValue; +} + +/** + * Type guard to check if a string is a valid StorageValue. + * + * @param value - The value to check + * @returns true if value is a StorageValue + */ +export function isStorageValue(value: unknown): value is StorageValue { + return typeof value === 'string'; +} diff --git a/src/internal/error-classifier.ts b/src/internal/error-classifier.ts new file mode 100644 index 00000000..ffc04b54 --- /dev/null +++ b/src/internal/error-classifier.ts @@ -0,0 +1,505 @@ +/** + * Type-safe error classification and handling for the SensitiveInfo API. + * This module provides structured error codes and utilities for proper error + * handling across all platforms (iOS, Android, Web). + * + * @module internal/error-classifier + * @internal + * + * @example + * ```ts + * import { SensitiveInfoError, ErrorCode } from './internal/error-classifier' + * + * try { + * await setItem('key', 'value') + * } catch (error) { + * if (error instanceof SensitiveInfoError) { + * if (error.code === ErrorCode.ItemNotFound) { + * console.log('Item was not found') + * } else if (error.code === ErrorCode.AuthenticationCanceled) { + * console.log('User cancelled authentication') + * } + * } + * } + * ``` + */ + +/** + * Enumeration of all error codes that can occur in SensitiveInfo operations. + * Each code represents a distinct error condition that applications should + * handle differently. + * + * @enum {string} + */ +export enum ErrorCode { + /** Item with the specified key was not found in storage */ + ItemNotFound = 'ITEM_NOT_FOUND', + + /** User cancelled authentication prompt (biometric, device credential, etc.) */ + AuthenticationCanceled = 'AUTHENTICATION_CANCELED', + + /** Biometric feature is not available on this device */ + BiometryNotAvailable = 'BIOMETRY_NOT_AVAILABLE', + + /** Biometric data has changed since item was encrypted (e.g., fingerprint added/removed) */ + BiometryInvalidated = 'BIOMETRY_INVALIDATED', + + /** User cancelled or failed biometric authentication */ + BiometryFailed = 'BIOMETRY_FAILED', + + /** Device passcode/PIN/pattern is not set up */ + DevicePasscodeNotSet = 'DEVICE_PASSCODE_NOT_SET', + + /** Storage key exceeds maximum length (255 characters) */ + KeyTooLong = 'KEY_TOO_LONG', + + /** Storage key is empty or invalid */ + InvalidKey = 'INVALID_KEY', + + /** Service name is empty or invalid */ + InvalidService = 'INVALID_SERVICE', + + /** Encryption/decryption operation failed */ + EncryptionFailed = 'ENCRYPTION_FAILED', + + /** Decryption operation failed */ + DecryptionFailed = 'DECRYPTION_FAILED', + + /** Insufficient device storage space */ + InsufficientStorage = 'INSUFFICIENT_STORAGE', + + /** File system permissions are insufficient */ + PermissionDenied = 'PERMISSION_DENIED', + + /** Keychain or Android Keystore is corrupted or inaccessible */ + StorageCorrupted = 'STORAGE_CORRUPTED', + + /** Key rotation operation failed */ + KeyRotationFailed = 'KEY_ROTATION_FAILED', + + /** Hardware security backend (Secure Enclave, StrongBox) is unavailable */ + HardwareSecurityUnavailable = 'HARDWARE_SECURITY_UNAVAILABLE', + + /** Access control setting is not supported on this platform/device */ + AccessControlNotSupported = 'ACCESS_CONTROL_NOT_SUPPORTED', + + /** Invalid or unsupported options were provided */ + InvalidOptions = 'INVALID_OPTIONS', + + /** Unknown error that doesn't fit other categories */ + Unknown = 'UNKNOWN', +} + +/** + * Structured error class for all SensitiveInfo API errors. + * Provides type-safe error handling with error codes, messages, and optional details. + * + * @class SensitiveInfoError + * @extends Error + * + * @example + * ```ts + * try { + * await getItem('token') + * } catch (error) { + * if (error instanceof SensitiveInfoError) { + * console.error(`Error [${error.code}]: ${error.message}`) + * if (error.originalError) { + * console.error('Original error:', error.originalError) + * } + * } + * } + * ``` + */ +export class SensitiveInfoError extends Error { + /** + * The error code categorizing this error. + * @readonly + */ + readonly code: ErrorCode; + + /** + * Original error that caused this SensitiveInfoError, if applicable. + * Useful for debugging and understanding root causes. + * @readonly + */ + readonly originalError?: Error; + + /** + * Additional context data about this error. + * Can contain platform-specific details, parameter values, etc. + * @readonly + */ + readonly context?: Record; + + /** + * Creates a new SensitiveInfoError. + * + * @param code - The error code from ErrorCode enum + * @param message - Human-readable error message + * @param originalError - Optional original error that caused this + * @param context - Optional additional context data + * + * @example + * ```ts + * throw new SensitiveInfoError( + * ErrorCode.ItemNotFound, + * 'The specified key does not exist in storage', + * undefined, + * { key: 'notExistingKey', service: 'auth' } + * ) + * ``` + */ + constructor( + code: ErrorCode, + message: string, + originalError?: Error, + context?: Record + ) { + super(message); + this.name = 'SensitiveInfoError'; + this.code = code; + this.originalError = originalError; + this.context = context; + + // Maintain proper prototype chain for instanceof checks + Object.setPrototypeOf(this, SensitiveInfoError.prototype); + } + + /** + * Returns a detailed string representation of this error. + * @internal + */ + toString(): string { + const parts = [`[${this.code}]`, this.message]; + if (this.originalError) { + parts.push(`caused by: ${this.originalError.message}`); + } + if (this.context && Object.keys(this.context).length > 0) { + parts.push(`context: ${JSON.stringify(this.context)}`); + } + return parts.join(' '); + } +} + +/** + * Type guard to check if an error is a SensitiveInfoError. + * + * @param error - The error to check + * @returns true if error is a SensitiveInfoError + * + * @example + * ```ts + * catch (error) { + * if (isSensitiveInfoError(error)) { + * // Type-safe access to error.code + * handleSensitiveInfoError(error.code) + * } else { + * handleGenericError(error) + * } + * } + * ``` + */ +export function isSensitiveInfoError( + error: unknown +): error is SensitiveInfoError { + return error instanceof SensitiveInfoError; +} + +/** + * Checks if an error represents an item not found condition. + * + * @param error - The error to check + * @returns true if this is a "not found" error + * + * @example + * ```ts + * if (isNotFoundError(error)) { + * return null // Normalize to null for not-found case + * } + * ``` + */ +export function isNotFoundError(error: unknown): boolean { + if (error instanceof SensitiveInfoError) { + return error.code === ErrorCode.ItemNotFound; + } + + // Fallback for legacy string-based errors + if (error instanceof Error) { + return error.message.includes('[E_NOT_FOUND]'); + } + if (typeof error === 'string') { + return error.includes('[E_NOT_FOUND]'); + } + + return false; +} + +/** + * Checks if an error represents a cancelled authentication. + * + * @param error - The error to check + * @returns true if this is an authentication cancelled error + * + * @example + * ```ts + * if (isAuthenticationCanceledError(error)) { + * console.log('User declined authentication') + * return + * } + * ``` + */ +export function isAuthenticationCanceledError(error: unknown): boolean { + if (error instanceof SensitiveInfoError) { + return error.code === ErrorCode.AuthenticationCanceled; + } + + // Fallback for legacy string-based errors + if (error instanceof Error) { + return error.message.includes('[E_AUTH_CANCELED]'); + } + if (typeof error === 'string') { + return error.includes('[E_AUTH_CANCELED]'); + } + + return false; +} + +/** + * Checks if an error is related to biometry (Face ID, Touch ID, fingerprint). + * + * @param error - The error to check + * @returns true if this is a biometry-related error + * + * @example + * ```ts + * if (isBiometryError(error)) { + * showBiometrySettings() + * } + * ``` + */ +export function isBiometryError(error: unknown): boolean { + if (error instanceof SensitiveInfoError) { + return [ + ErrorCode.BiometryNotAvailable, + ErrorCode.BiometryInvalidated, + ErrorCode.BiometryFailed, + ].includes(error.code); + } + return false; +} + +/** + * Checks if an error is a security-related error (permissions, hardware, etc.). + * + * @param error - The error to check + * @returns true if this is a security error + * + * @example + * ```ts + * if (isSecurityError(error)) { + * notifyUser('Security issue detected') + * } + * ``` + */ +export function isSecurityError(error: unknown): boolean { + if (error instanceof SensitiveInfoError) { + return [ + ErrorCode.PermissionDenied, + ErrorCode.StorageCorrupted, + ErrorCode.HardwareSecurityUnavailable, + ErrorCode.AccessControlNotSupported, + ].includes(error.code); + } + return false; +} + +/** + * Extracts a human-readable error message from any error type. + * Falls back to generic message for unknown error types. + * + * @param error - The error to extract message from + * @returns Human-readable error message + * + * @example + * ```ts + * try { + * await setItem('key', value) + * } catch (error) { + * console.error(getErrorMessage(error)) + * } + * ``` + */ +export function getErrorMessage(error: unknown): string { + if (error instanceof SensitiveInfoError) { + return error.message; + } + if (error instanceof Error) { + return error.message; + } + if (typeof error === 'string') { + return error; + } + return 'An unknown error occurred'; +} + +/** + * Creates a SensitiveInfoError from an unknown error source. + * Automatically classifies the error based on its message or type. + * + * @param error - The error to classify and wrap + * @param context - Optional context information about the operation + * @returns A SensitiveInfoError with appropriate code + * + * @example + * ```ts + * try { + * await nativeModule.setItem(payload) + * } catch (error) { + * throw classifyError(error, { operation: 'setItem', key }) + * } + * ``` + * + * @internal + */ +export function classifyError( + error: unknown, + context?: Record +): SensitiveInfoError { + if (error instanceof SensitiveInfoError) { + return error; + } + + const message = getErrorMessage(error); + const originalError = error instanceof Error ? error : undefined; + + // Classify based on error message patterns + if (message.includes('[E_NOT_FOUND]') || message.includes('not found')) { + return new SensitiveInfoError( + ErrorCode.ItemNotFound, + 'Item not found in storage', + originalError, + context + ); + } + + if ( + message.includes('[E_AUTH_CANCELED]') || + message.includes('canceled') || + message.includes('cancelled') + ) { + return new SensitiveInfoError( + ErrorCode.AuthenticationCanceled, + 'User cancelled authentication', + originalError, + context + ); + } + + if (message.includes('biometry') || message.includes('biometric')) { + if (message.includes('not available')) { + return new SensitiveInfoError( + ErrorCode.BiometryNotAvailable, + 'Biometry is not available on this device', + originalError, + context + ); + } + if (message.includes('invalidated')) { + return new SensitiveInfoError( + ErrorCode.BiometryInvalidated, + 'Biometric data has been invalidated', + originalError, + context + ); + } + return new SensitiveInfoError( + ErrorCode.BiometryFailed, + 'Biometric authentication failed', + originalError, + context + ); + } + + if (message.includes('passcode') || message.includes('device credential')) { + if (message.includes('not set')) { + return new SensitiveInfoError( + ErrorCode.DevicePasscodeNotSet, + 'Device passcode is not set up', + originalError, + context + ); + } + } + + if (message.includes('encryption')) { + return new SensitiveInfoError( + ErrorCode.EncryptionFailed, + 'Encryption operation failed', + originalError, + context + ); + } + + if (message.includes('decryption')) { + return new SensitiveInfoError( + ErrorCode.DecryptionFailed, + 'Decryption operation failed', + originalError, + context + ); + } + + if (message.includes('key') && message.includes('long')) { + return new SensitiveInfoError( + ErrorCode.KeyTooLong, + 'Storage key exceeds maximum length', + originalError, + context + ); + } + + if (message.includes('permission')) { + return new SensitiveInfoError( + ErrorCode.PermissionDenied, + 'Insufficient permissions to perform operation', + originalError, + context + ); + } + + if (message.includes('storage') && message.includes('corrupted')) { + return new SensitiveInfoError( + ErrorCode.StorageCorrupted, + 'Storage is corrupted or inaccessible', + originalError, + context + ); + } + + if (message.includes('rotation')) { + return new SensitiveInfoError( + ErrorCode.KeyRotationFailed, + 'Key rotation operation failed', + originalError, + context + ); + } + + if (message.includes('storage') && message.includes('space')) { + return new SensitiveInfoError( + ErrorCode.InsufficientStorage, + 'Insufficient storage space available', + originalError, + context + ); + } + + // Default to unknown error + return new SensitiveInfoError( + ErrorCode.Unknown, + message, + originalError, + context + ); +} diff --git a/src/internal/errors.ts b/src/internal/errors.ts deleted file mode 100644 index a0dbc483..00000000 --- a/src/internal/errors.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Shared error helpers used across infrastructure layers and hooks. - */ - -const NOT_FOUND_MARKER = '[E_NOT_FOUND]'; -const AUTH_CANCELED_MARKER = '[E_AUTH_CANCELED]'; - -const hasErrorMarker = (error: unknown, marker: string): boolean => { - if (error instanceof Error) { - return error.message.includes(marker); - } - if (typeof error === 'string') { - return error.includes(marker); - } - return false; -}; - -export function isNotFoundError(error: unknown): boolean { - return hasErrorMarker(error, NOT_FOUND_MARKER); -} - -/** - * Determines whether an error value represents a cancelled authentication prompt. - */ -export function isAuthenticationCanceledError(error: unknown): boolean { - return hasErrorMarker(error, AUTH_CANCELED_MARKER); -} - -/** - * Extracts a human-readable message from arbitrary error values. - * Falls back to a generic description when the payload is opaque. - */ -export function getErrorMessage(error: unknown): string { - if (error instanceof Error) { - return error.message; - } - if (typeof error === 'string') { - return error; - } - return 'An unknown error occurred'; -} diff --git a/src/internal/validator.ts b/src/internal/validator.ts new file mode 100644 index 00000000..a83208e9 --- /dev/null +++ b/src/internal/validator.ts @@ -0,0 +1,465 @@ +/** + * Validators for SensitiveInfo storage operations. + * Provides reusable, type-safe validation utilities for keys, values, + * services, and options across all API functions. + * + * @module internal/validator + * @internal + * + * @example + * ```ts + * import { validateStorageKey, validateService } from './internal/validator' + * + * function setItem(key: string, value: string, options?: SensitiveInfoOptions) { + * validateStorageKey(key) + * validateService(options?.service) + * // ... rest of operation + * } + * ``` + */ + +import { SensitiveInfoError, ErrorCode } from './error-classifier'; +import type { + SensitiveInfoOptions, + AccessControl, +} from '../sensitive-info.nitro'; + +/** + * Maximum allowed length for storage keys (in characters). + * This limit is set to accommodate both iOS Keychain and Android Keystore + * maximum identifier lengths. + * + * @internal + */ +export const MAX_KEY_LENGTH = 255; + +/** + * Maximum recommended length for values (in bytes). + * Larger values may impact performance and device storage. + * This is a soft limit and can be exceeded on most devices. + * + * @internal + */ +export const MAX_VALUE_SIZE_BYTES = 10 * 1024 * 1024; // 10MB + +/** + * Regular expression for validating service names. + * Allows alphanumeric characters, dots, hyphens, and underscores. + * + * @internal + */ +const VALID_SERVICE_PATTERN = /^[a-zA-Z0-9._-]+$/; + +/** + * Regular expression for validating storage keys. + * Allows alphanumeric characters, dots, hyphens, underscores, and colons. + * + * @internal + */ +const VALID_KEY_PATTERN = /^[a-zA-Z0-9._\-:]+$/; + +/** + * Valid access control modes supported by the API. + * + * @internal + */ +const VALID_ACCESS_CONTROLS: AccessControl[] = [ + 'secureEnclaveBiometry', + 'biometryCurrentSet', + 'biometryAny', + 'devicePasscode', + 'none', +]; + +/** + * Validates a storage key. + * Throws SensitiveInfoError if the key is invalid. + * + * @param key - The key to validate + * @throws {SensitiveInfoError} If key is empty, too long, or contains invalid characters + * + * @example + * ```ts + * validateStorageKey('authToken') // ✓ Valid + * validateStorageKey('') // ✗ Throws error + * validateStorageKey('a'.repeat(300)) // ✗ Throws error (too long) + * validateStorageKey('auth@token') // ✗ Throws error (invalid chars) + * ``` + * + * @internal + */ +export function validateStorageKey(key: unknown): asserts key is string { + if (typeof key !== 'string') { + throw new SensitiveInfoError( + ErrorCode.InvalidKey, + 'Storage key must be a string', + undefined, + { providedType: typeof key } + ); + } + + if (key.length === 0) { + throw new SensitiveInfoError( + ErrorCode.InvalidKey, + 'Storage key cannot be empty', + undefined, + { keyLength: 0 } + ); + } + + if (key.length > MAX_KEY_LENGTH) { + throw new SensitiveInfoError( + ErrorCode.KeyTooLong, + `Storage key exceeds maximum length of ${MAX_KEY_LENGTH} characters`, + undefined, + { keyLength: key.length, maxLength: MAX_KEY_LENGTH } + ); + } + + if (!VALID_KEY_PATTERN.test(key)) { + throw new SensitiveInfoError( + ErrorCode.InvalidKey, + 'Storage key contains invalid characters. Only alphanumeric, dots, hyphens, underscores, and colons are allowed', + undefined, + { key, pattern: VALID_KEY_PATTERN.source } + ); + } +} + +/** + * Validates a storage value. + * Throws SensitiveInfoError if the value is invalid. + * + * @param value - The value to validate + * @throws {SensitiveInfoError} If value is not a string or exceeds recommended size + * + * @example + * ```ts + * validateStorageValue('secret-token') // ✓ Valid + * validateStorageValue('') // ✓ Valid (empty strings allowed) + * validateStorageValue(null) // ✗ Throws error + * validateStorageValue(123) // ✗ Throws error + * ``` + * + * @internal + */ +export function validateStorageValue(value: unknown): asserts value is string { + if (typeof value !== 'string') { + throw new SensitiveInfoError( + ErrorCode.InvalidOptions, + 'Storage value must be a string', + undefined, + { providedType: typeof value } + ); + } + + // Note: We only warn about large values, not throw, as this is implementation-dependent + if (new Blob([value]).size > MAX_VALUE_SIZE_BYTES) { + console.warn( + `[SensitiveInfo] Value size (${new Blob([value]).size} bytes) exceeds recommended maximum (${MAX_VALUE_SIZE_BYTES} bytes). Performance may be affected.` + ); + } +} + +/** + * Validates a service name. + * Throws SensitiveInfoError if the service is invalid. + * + * @param service - The service name to validate + * @throws {SensitiveInfoError} If service is empty or contains invalid characters + * + * @example + * ```ts + * validateService('com.example.app') // ✓ Valid + * validateService('my_service') // ✓ Valid + * validateService('my-service') // ✓ Valid + * validateService('') // ✗ Throws error + * validateService('my@service') // ✗ Throws error + * ``` + * + * @internal + */ +export function validateService(service: unknown): asserts service is string { + if (typeof service !== 'string') { + throw new SensitiveInfoError( + ErrorCode.InvalidService, + 'Service name must be a string', + undefined, + { providedType: typeof service } + ); + } + + if (service.length === 0) { + throw new SensitiveInfoError( + ErrorCode.InvalidService, + 'Service name cannot be empty', + undefined, + { serviceLength: 0 } + ); + } + + if (!VALID_SERVICE_PATTERN.test(service)) { + throw new SensitiveInfoError( + ErrorCode.InvalidService, + 'Service name contains invalid characters. Only alphanumeric characters, dots, hyphens, and underscores are allowed', + undefined, + { service, pattern: VALID_SERVICE_PATTERN.source } + ); + } +} + +/** + * Validates an access control mode. + * Throws SensitiveInfoError if the access control is not supported. + * + * @param accessControl - The access control mode to validate + * @throws {SensitiveInfoError} If access control is not a valid mode + * + * @example + * ```ts + * validateAccessControl('biometry') // ✓ Valid + * validateAccessControl('secureEnclaveBiometry') // ✓ Valid + * validateAccessControl('invalid') // ✗ Throws error + * ``` + * + * @internal + */ +export function validateAccessControl( + accessControl: unknown +): asserts accessControl is AccessControl { + if (typeof accessControl !== 'string') { + throw new SensitiveInfoError( + ErrorCode.InvalidOptions, + 'Access control must be a string', + undefined, + { providedType: typeof accessControl } + ); + } + + if (!VALID_ACCESS_CONTROLS.includes(accessControl as AccessControl)) { + throw new SensitiveInfoError( + ErrorCode.AccessControlNotSupported, + `Invalid access control mode: "${accessControl}". Supported modes: ${VALID_ACCESS_CONTROLS.join(', ')}`, + undefined, + { providedValue: accessControl, validValues: VALID_ACCESS_CONTROLS } + ); + } +} + +/** + * Validates storage options. + * Throws SensitiveInfoError if any option is invalid. + * + * @param options - The options object to validate + * @throws {SensitiveInfoError} If any option is invalid + * + * @example + * ```ts + * validateOptions({ service: 'auth', accessControl: 'biometry' }) // ✓ Valid + * validateOptions({ service: '', accessControl: 'biometry' }) // ✗ Throws error + * validateOptions({ service: 'auth', accessControl: 'invalid' }) // ✗ Throws error + * ``` + * + * @internal + */ +export function validateOptions( + options: unknown +): asserts options is SensitiveInfoOptions { + if (options == null) { + // null/undefined options are valid (defaults will be applied) + return; + } + + if (typeof options !== 'object' || Array.isArray(options)) { + throw new SensitiveInfoError( + ErrorCode.InvalidOptions, + 'Options must be an object', + undefined, + { providedType: typeof options } + ); + } + + const opts = options as Record; + + // Validate service if provided + if ('service' in opts && opts.service != null) { + try { + validateService(opts.service); + } catch (error) { + if (error instanceof SensitiveInfoError) { + throw error; + } + throw new SensitiveInfoError( + ErrorCode.InvalidOptions, + 'Invalid service in options', + error instanceof Error ? error : undefined, + { field: 'service', value: opts.service } + ); + } + } + + // Validate accessControl if provided + if ('accessControl' in opts && opts.accessControl != null) { + try { + validateAccessControl(opts.accessControl); + } catch (error) { + if (error instanceof SensitiveInfoError) { + throw error; + } + throw new SensitiveInfoError( + ErrorCode.InvalidOptions, + 'Invalid accessControl in options', + error instanceof Error ? error : undefined, + { field: 'accessControl', value: opts.accessControl } + ); + } + } + + // Validate iosSynchronizable if provided + if ('iosSynchronizable' in opts && opts.iosSynchronizable != null) { + if (typeof opts.iosSynchronizable !== 'boolean') { + throw new SensitiveInfoError( + ErrorCode.InvalidOptions, + 'iosSynchronizable must be a boolean', + undefined, + { + field: 'iosSynchronizable', + providedType: typeof opts.iosSynchronizable, + } + ); + } + } + + // Validate keychainGroup if provided + if ('keychainGroup' in opts && opts.keychainGroup != null) { + if (typeof opts.keychainGroup !== 'string') { + throw new SensitiveInfoError( + ErrorCode.InvalidOptions, + 'keychainGroup must be a string', + undefined, + { field: 'keychainGroup', providedType: typeof opts.keychainGroup } + ); + } + + if (opts.keychainGroup.length === 0) { + throw new SensitiveInfoError( + ErrorCode.InvalidOptions, + 'keychainGroup cannot be empty', + undefined, + { field: 'keychainGroup' } + ); + } + } + + // Validate authenticationPrompt if provided + if ('authenticationPrompt' in opts && opts.authenticationPrompt != null) { + if ( + typeof opts.authenticationPrompt !== 'object' || + Array.isArray(opts.authenticationPrompt) + ) { + throw new SensitiveInfoError( + ErrorCode.InvalidOptions, + 'authenticationPrompt must be an object', + undefined, + { + field: 'authenticationPrompt', + providedType: typeof opts.authenticationPrompt, + } + ); + } + } +} + +/** + * Comprehensive storage validator class providing reusable validation methods. + * Recommended for use in storage-related functions. + * + * @class StorageValidator + * + * @example + * ```ts + * const validator = new StorageValidator() + * + * export async function setItem(key: string, value: string, options?: SensitiveInfoOptions) { + * validator.validateAll(key, value, options) + * // ... rest of operation + * } + * ``` + */ +export class StorageValidator { + /** + * Validates all parameters for a set operation. + * + * @param key - The storage key + * @param value - The storage value + * @param options - Optional storage options + * @throws {SensitiveInfoError} If any parameter is invalid + */ + validateSetOperation(key: unknown, value: unknown, options?: unknown): void { + validateStorageKey(key); + validateStorageValue(value); + validateOptions(options); + } + + /** + * Validates all parameters for a get operation. + * + * @param key - The storage key + * @param options - Optional storage options + * @throws {SensitiveInfoError} If any parameter is invalid + */ + validateGetOperation(key: unknown, options?: unknown): void { + validateStorageKey(key); + validateOptions(options); + } + + /** + * Validates all parameters for a delete operation. + * + * @param key - The storage key + * @param options - Optional storage options + * @throws {SensitiveInfoError} If any parameter is invalid + */ + validateDeleteOperation(key: unknown, options?: unknown): void { + validateStorageKey(key); + validateOptions(options); + } + + /** + * Validates all parameters for a has operation. + * + * @param key - The storage key + * @param options - Optional storage options + * @throws {SensitiveInfoError} If any parameter is invalid + */ + validateHasOperation(key: unknown, options?: unknown): void { + validateStorageKey(key); + validateOptions(options); + } + + /** + * Validates all parameters for an enumerate operation. + * + * @param options - Optional storage options + * @throws {SensitiveInfoError} If any parameter is invalid + */ + validateEnumerateOperation(options?: unknown): void { + validateOptions(options); + } + + /** + * Validates all parameters for a clear operation. + * + * @param options - Optional storage options + * @throws {SensitiveInfoError} If any parameter is invalid + */ + validateClearOperation(options?: unknown): void { + validateOptions(options); + } +} + +/** + * Singleton instance of StorageValidator for convenience. + * @internal + */ +export const storageValidator = new StorageValidator(); diff --git a/src/rotation/engine.ts b/src/rotation/engine.ts new file mode 100644 index 00000000..49acf649 --- /dev/null +++ b/src/rotation/engine.ts @@ -0,0 +1,537 @@ +/** + * Key Rotation Engine + * + * Platform-agnostic rotation logic that coordinates: + * - Key versioning and lifecycle management + * - Rotation trigger detection (time-based, biometric changes, manual) + * - DEK/KEK separation and envelope management + * - Backward compatibility with legacy (non-versioned) data + * - Zero-downtime rotation with dual-key support + */ + +import type { + KeyVersion, + RotationPolicy, + RotationStatus, + RotationEvent, + RotationEventCallback, + RotationOptions, + RotationAuditEntry, + BiometricChangeEvent, +} from './types'; + +/** Default rotation policy: 90 days, manual rotation allowed */ +const DEFAULT_ROTATION_POLICY: RotationPolicy = { + enabled: true, + rotationIntervalMs: 90 * 24 * 60 * 60 * 1000, // 90 days + rotateOnBiometricChange: true, + rotateOnCredentialChange: true, + manualRotationEnabled: true, + maxKeyVersions: 2, + backgroundReEncryption: true, +}; + +interface RotationManagerState { + policy: RotationPolicy; + currentKeyVersion: KeyVersion | null; + availableKeyVersions: KeyVersion[]; + lastRotationTimestamp: string | null; + isRotating: boolean; + auditLog: RotationAuditEntry[]; + eventCallbacks: Map; +} + +/** + * KeyRotationManager coordinates all rotation operations. + * This is platform-agnostic and relies on platform-specific implementations + * for actual key generation/storage via native modules. + */ +export class KeyRotationManager { + private state: RotationManagerState; + + private readonly maxAuditLogEntries = 1000; + + constructor(policy: RotationPolicy = DEFAULT_ROTATION_POLICY) { + this.state = { + policy: { ...policy }, + currentKeyVersion: null, + availableKeyVersions: [], + lastRotationTimestamp: null, + isRotating: false, + auditLog: [], + eventCallbacks: this.createDefaultCallbacks(), + }; + } + + /** + * Initializes the rotation manager with the current key state. + * Called once during app startup after native modules are loaded. + */ + public initialize( + currentKeyVersion: KeyVersion, + availableKeyVersions: KeyVersion[], + lastRotationTimestamp: string | null + ): void { + this.state.currentKeyVersion = currentKeyVersion; + this.state.availableKeyVersions = availableKeyVersions; + this.state.lastRotationTimestamp = lastRotationTimestamp; + + this.logAuditEntry({ + timestamp: new Date().toISOString(), + eventType: 'key_generated', + keyVersion: currentKeyVersion.id, + details: { availableVersions: availableKeyVersions.length }, + }); + } + + /** + * Registers a callback to be invoked for rotation events. + */ + public on( + eventType: RotationEvent['type'], + callback: RotationEventCallback + ): void { + if (!this.state.eventCallbacks.has(eventType)) { + this.state.eventCallbacks.set(eventType, []); + } + this.state.eventCallbacks.get(eventType)!.push(callback); + } + + /** + * Removes a callback. + */ + public off( + eventType: RotationEvent['type'], + callback: RotationEventCallback + ): void { + const callbacks = this.state.eventCallbacks.get(eventType); + if (!callbacks) return; + + const index = callbacks.indexOf(callback); + if (index > -1) { + callbacks.splice(index, 1); + } + } + + /** + * Creates default event callbacks map. + * @private + */ + private createDefaultCallbacks(): Map { + return new Map([ + ['rotation:started', []], + ['rotation:completed', []], + ['rotation:failed', []], + ['biometric-change', []], + ['credential-change', []], + ]); + } + + /** + * Emits a rotation event to all registered callbacks. + */ + private async emitEvent(event: RotationEvent): Promise { + const callbacks = this.state.eventCallbacks.get(event.type) || []; + await Promise.all(callbacks.map((cb) => Promise.resolve(cb(event)))); + } + + /** + * Updates the rotation policy. + */ + public setRotationPolicy(policy: Partial): void { + this.state.policy = { ...this.state.policy, ...policy }; + } + + /** + * Returns the current rotation policy. + */ + public getRotationPolicy(): RotationPolicy { + return { ...this.state.policy }; + } + + /** + * Gets the current rotation status snapshot. + */ + public getRotationStatus(): RotationStatus { + return { + isRotating: this.state.isRotating, + currentKeyVersion: this.state.currentKeyVersion, + availableKeyVersions: [...this.state.availableKeyVersions], + lastRotationTimestamp: this.state.lastRotationTimestamp, + itemsPendingReEncryption: 0, // Calculated by caller if needed + }; + } + + /** + * Determines if rotation is needed based on policy and current state. + */ + public shouldRotate( + reason: + | 'time-based' + | 'biometric-change' + | 'credential-change' + | 'manual' + | string = 'time-based' + ): boolean { + if (!this.state.policy.enabled) { + return false; + } + + switch (reason) { + case 'time-based': { + if (!this.state.currentKeyVersion || !this.state.lastRotationTimestamp) { + return false; // No previous rotation recorded + } + if (!this.state.policy.enabled) return false; + const lastRotation = new Date( + this.state.lastRotationTimestamp + ).getTime(); + const now = Date.now(); + return now - lastRotation > this.state.policy.rotationIntervalMs; + } + + case 'biometric-change': + return this.state.policy.rotateOnBiometricChange; + + case 'credential-change': + return this.state.policy.rotateOnCredentialChange; + + case 'manual': + return this.state.policy.manualRotationEnabled; + + default: + // For custom reasons, treat as manual rotation if enabled + return this.state.policy.manualRotationEnabled; + } + } + + /** + * Initiates a key rotation operation. + * Returns immediately; actual rotation happens asynchronously via native modules. + * + * @param newKeyVersion The newly generated key version + * @param reason Why rotation is happening + * @param options Additional rotation context + */ + public async startRotation( + newKeyVersion: KeyVersion, + reason: 'time-based' | 'biometric-change' | 'credential-change' | 'manual', + options?: RotationOptions + ): Promise { + if ( + !this.state.policy.manualRotationEnabled && + reason === 'manual' && + !options?.force + ) { + throw new Error('Manual rotation is disabled by policy'); + } + + if (this.state.isRotating) { + throw new Error('Rotation already in progress'); + } + + if (!this.state.currentKeyVersion) { + throw new Error('Current key version not initialized'); + } + + this.state.isRotating = true; + const oldKeyVersion = this.state.currentKeyVersion; + + const event: RotationEvent = { + type: 'rotation:started', + timestamp: new Date().toISOString(), + reason, + currentKeyVersion: oldKeyVersion, + newKeyVersion, + }; + + await this.emitEvent(event); + + this.logAuditEntry({ + timestamp: event.timestamp, + eventType: 'key_rotated', + keyVersion: newKeyVersion.id, + details: { reason, previous: oldKeyVersion.id, ...options?.metadata }, + }); + } + + /** + * Marks rotation as completed. + * Called by native modules after successfully switching keys. + */ + public async completeRotation( + newKeyVersion: KeyVersion, + itemsReEncrypted: number, + duration: number + ): Promise { + if (!this.state.isRotating) { + throw new Error('No rotation in progress'); + } + + if (!this.state.currentKeyVersion) { + throw new Error('Current key version not initialized'); + } + + const oldKeyVersion = this.state.currentKeyVersion; + + // Update current key + this.state.currentKeyVersion = newKeyVersion; + this.state.lastRotationTimestamp = new Date().toISOString(); + + // Maintain available versions for transition period + const updatedVersions = [ + newKeyVersion, + ...this.state.availableKeyVersions.filter( + (v) => v.id !== oldKeyVersion.id && v.id !== newKeyVersion.id + ), + ]; + + // Enforce max version retention + while ( + updatedVersions.length > this.state.policy.maxKeyVersions && + updatedVersions[updatedVersions.length - 1] + ) { + updatedVersions.pop(); + } + + this.state.availableKeyVersions = updatedVersions; + this.state.isRotating = false; + + const event: RotationEvent = { + type: 'rotation:completed', + timestamp: new Date().toISOString(), + oldKeyVersion, + newKeyVersion, + duration, + itemsReEncrypted, + }; + + await this.emitEvent(event); + + this.logAuditEntry({ + timestamp: event.timestamp, + eventType: 'migration_completed', + keyVersion: newKeyVersion.id, + itemsAffected: itemsReEncrypted, + details: { duration, previousVersion: oldKeyVersion.id }, + }); + } + + /** + * Marks rotation as failed. + * Called by native modules if rotation encounters unrecoverable errors. + */ + public async failRotation( + error: Error, + recoverable: boolean = true + ): Promise { + if (!this.state.isRotating) { + return; // Already failed or completed + } + + this.state.isRotating = false; + + const event: RotationEvent = { + type: 'rotation:failed', + timestamp: new Date().toISOString(), + reason: error.message, + error, + recoverable, + }; + + await this.emitEvent(event); + + this.logAuditEntry({ + timestamp: event.timestamp, + eventType: 'rotation_failed', + error: error.message, + details: { recoverable }, + }); + } + + /** + * Handles biometric enrollment changes (iOS Face ID/Touch ID or Android biometric sensor changes). + * Triggers key rotation if policy allows. + */ + public async handleBiometricChange( + platform: 'ios' | 'android' + ): Promise { + if (!this.state.policy.rotateOnBiometricChange) { + return; + } + + const event: BiometricChangeEvent = { + type: 'biometric-change', + timestamp: new Date().toISOString(), + platform, + actionRequired: true, + }; + + await this.emitEvent(event); + + this.logAuditEntry({ + timestamp: event.timestamp, + eventType: 'biometric_enrollment_changed', + details: { platform }, + }); + } + + /** + * Handles device credential changes (iOS passcode or Android password/PIN changes). + */ + public async handleCredentialChange( + platform: 'ios' | 'android' + ): Promise { + if (!this.state.policy.rotateOnCredentialChange) { + return; + } + + const event: BiometricChangeEvent = { + type: 'credential-change', + timestamp: new Date().toISOString(), + platform, + actionRequired: true, + }; + + await this.emitEvent(event); + + this.logAuditEntry({ + timestamp: event.timestamp, + eventType: 'device_credential_changed', + details: { platform }, + }); + } + + /** + * Determines which key version should be used to decrypt an envelope. + * Searches through available key versions in reverse chronological order. + */ + public findKeyVersionForDecryption( + envelopeKEKVersion: string + ): KeyVersion | null { + // First, check if current key matches + // First, check if current key matches + if (this.state.currentKeyVersion?.id === envelopeKEKVersion) { + return this.state.currentKeyVersion; + } + + // Search through available versions + return ( + this.state.availableKeyVersions.find( + (v) => v.id === envelopeKEKVersion + ) || null + ); + } + + /** + * Gets the current active key version for new encryptions. + */ + public getCurrentKeyVersion(): KeyVersion | null { + return this.state.currentKeyVersion; + } + + /** + * Gets all available key versions during transition period. + */ + public getAvailableKeyVersions(): KeyVersion[] { + return [...this.state.availableKeyVersions]; + } + + /** + * Adds a new key version to the available set. + * Typically called during initialization or after successful rotation. + */ + public addKeyVersion(keyVersion: KeyVersion): void { + if (!this.state.availableKeyVersions.find((v) => v.id === keyVersion.id)) { + this.state.availableKeyVersions.push(keyVersion); + // Sort by timestamp descending (newest first) + this.state.availableKeyVersions.sort((a, b) => b.timestamp - a.timestamp); + } + } + + /** + * Removes a key version from the available set. + * Called during cleanup after transition period expires. + */ + public removeKeyVersion(keyVersionId: string): void { + this.state.availableKeyVersions = this.state.availableKeyVersions.filter( + (v) => v.id !== keyVersionId + ); + + this.logAuditEntry({ + timestamp: new Date().toISOString(), + eventType: 'key_deleted', + keyVersion: keyVersionId, + }); + } + + /** + * Logs an audit entry for compliance and debugging. + */ + private logAuditEntry(entry: RotationAuditEntry): void { + this.state.auditLog.push(entry); + + // Maintain max log size + if (this.state.auditLog.length > this.maxAuditLogEntries) { + this.state.auditLog = this.state.auditLog.slice(-this.maxAuditLogEntries); + } + } + + /** + * Retrieves audit log entries (typically for compliance/debugging). + * Optionally filtered by event type or time range. + */ + public getAuditLog(options?: { + eventType?: string; + since?: string; + limit?: number; + }): RotationAuditEntry[] { + let entries = [...this.state.auditLog]; + + if (options?.eventType) { + entries = entries.filter((e) => e.eventType === options.eventType); + } + + if (options?.since) { + const sinceTime = new Date(options.since).getTime(); + entries = entries.filter( + (e) => new Date(e.timestamp).getTime() >= sinceTime + ); + } + + if (options?.limit) { + entries = entries.slice(-options.limit); + } + + return entries; + } + + /** + * Clears the audit log (typically for testing). + */ + public clearAuditLog(): void { + this.state.auditLog = []; + } +} + +// Singleton instance +let rotationManager: KeyRotationManager | null = null; + +/** + * Gets or creates the global rotation manager instance. + */ +export function getRotationManager( + policy?: RotationPolicy +): KeyRotationManager { + if (!rotationManager) { + rotationManager = new KeyRotationManager(policy); + } + return rotationManager; +} + +/** + * Resets the rotation manager (primarily for testing). + */ +export function resetRotationManager(): void { + rotationManager = null; +} diff --git a/src/rotation/envelope.ts b/src/rotation/envelope.ts new file mode 100644 index 00000000..9b62fb96 --- /dev/null +++ b/src/rotation/envelope.ts @@ -0,0 +1,266 @@ +/** + * Envelope Encryption Implementation + * + * Implements the Data Encryption Key (DEK) / Key Encryption Key (KEK) pattern: + * - DEKs directly encrypt user data (never stored directly, always wrapped) + * - KEKs wrap/unwrap DEKs and are the primary rotation target + * - Enables efficient rotation without re-encrypting all user data + */ + +import type { + EncryptedEnvelope, + KeyVersion, + LegacyEncryptedData, +} from './types'; + +const ENVELOPE_VERSION = 2; +const SUPPORTED_ALGORITHMS = ['AES-256-CBC', 'AES-256-GCM']; + +/** + * Determines if a value is a legacy (pre-versioned) encrypted data format. + * + * Legacy data is either: + * - A plain string (raw encrypted value from before versioning) + * - Unable to be parsed as an envelope + */ +export function isLegacyEncryptedData( + data: unknown +): data is LegacyEncryptedData { + if (typeof data === 'string') { + return true; + } + + if (typeof data !== 'object' || data === null) { + return false; + } + + const obj = data as Record; + + // If it has version field and it's not the current version, still check if it's old format + if (typeof obj.version === 'number' && obj.version < ENVELOPE_VERSION) { + // Could be an older envelope or legacy format + // For now, treat older envelopes as requiring migration + return !isValidEnvelope(data); + } + + // If it doesn't have the required envelope fields, it's likely legacy + return ( + !('encryptedDEK' in obj) || + !('KEKVersion' in obj) || + !('timestamp' in obj) || + !('algorithm' in obj) + ); +} + +/** + * Validates that data conforms to the current EncryptedEnvelope format. + */ +export function isValidEnvelope(data: unknown): data is EncryptedEnvelope { + if (typeof data !== 'object' || data === null) { + return false; + } + + const envelope = data as Record; + + return ( + typeof envelope.version === 'number' && + envelope.version === ENVELOPE_VERSION && + typeof envelope.encryptedDEK === 'string' && + typeof envelope.KEKVersion === 'string' && + typeof envelope.timestamp === 'string' && + typeof envelope.algorithm === 'string' && + SUPPORTED_ALGORITHMS.includes(envelope.algorithm as string) + ); +} + +/** + * Creates a new encrypted envelope wrapping a DEK with metadata. + * + * @param encryptedDEK Base64-encoded encrypted data encryption key + * @param currentKeyVersion The KEK version used to encrypt the DEK + * @param algorithm Encryption algorithm identifier + * @returns Complete envelope with versioning metadata + */ +export function createEncryptedEnvelope( + encryptedDEK: string, + currentKeyVersion: KeyVersion, + algorithm: string = 'AES-256-CBC' +): EncryptedEnvelope { + if (!SUPPORTED_ALGORITHMS.includes(algorithm)) { + throw new Error( + `Unsupported algorithm: ${algorithm}. Supported: ${SUPPORTED_ALGORITHMS.join(', ')}` + ); + } + + if (!encryptedDEK || typeof encryptedDEK !== 'string') { + throw new Error('encryptedDEK must be a non-empty base64-encoded string'); + } + + const now = new Date().toISOString(); + + return { + version: ENVELOPE_VERSION, + encryptedDEK, + KEKVersion: currentKeyVersion.id, + timestamp: now, + algorithm, + }; +} + +/** + * Parses an envelope from JSON, with fallback to legacy format detection. + * Handles graceful degradation if envelope is malformed. + * + * @param data Stringified or parsed envelope/legacy data + * @returns Parsed envelope or null if data cannot be interpreted + * @throws Error if data appears to be envelope format but is invalid + */ +export function parseEncryptedEnvelope( + data: string | object | null | undefined +): EncryptedEnvelope | LegacyEncryptedData | null { + if (!data) { + return null; + } + + let parsed: unknown; + + // If it's a string, try to parse as JSON first + if (typeof data === 'string') { + try { + parsed = JSON.parse(data); + } catch { + // If JSON parsing fails, treat entire string as legacy encrypted value + return { value: data }; + } + } else { + parsed = data; + } + + // Check if it's a valid current-version envelope + if (isValidEnvelope(parsed)) { + return parsed; + } + + // Check if it's legacy format + if (isLegacyEncryptedData(parsed)) { + return typeof parsed === 'string' + ? { value: parsed } + : (parsed as LegacyEncryptedData); + } + + // If it looks like it was meant to be an envelope but is invalid, throw + if ( + typeof parsed === 'object' && + parsed !== null && + ('version' in parsed || 'encryptedDEK' in parsed) + ) { + throw new Error( + `Invalid envelope format. Expected version ${ENVELOPE_VERSION} with complete metadata.` + ); + } + + // Treat as legacy + return typeof parsed === 'string' + ? { value: parsed } + : (parsed as LegacyEncryptedData); +} + +/** + * Serializes an envelope to JSON string. + */ +export function serializeEncryptedEnvelope( + envelope: EncryptedEnvelope +): string { + return JSON.stringify(envelope); +} + +/** + * Extracts the KEK version from an envelope, needed to select the right key for decryption. + */ +export function getEnvelopeKEKVersion(envelope: EncryptedEnvelope): string { + return envelope.KEKVersion; +} + +/** + * Checks if an envelope requires re-encryption with a new key version. + * Returns true if envelope's KEK version doesn't match the current active key version. + */ +export function needsReEncryption( + envelope: EncryptedEnvelope, + currentKeyVersion: KeyVersion +): boolean { + return envelope.KEKVersion !== currentKeyVersion.id; +} + +/** + * Migrates a legacy encrypted value to the current versioned envelope format. + * Does not perform re-encryption; wraps the existing encrypted value. + * + * @param legacyValue The raw encrypted value from legacy storage + * @param currentKeyVersion The current active key version + * @returns New envelope wrapping the legacy value + * + * @note This is used during migration to attach versioning metadata + * to legacy encrypted data without re-encrypting it. + */ +export function migrateToEnvelope( + legacyValue: string, + currentKeyVersion: KeyVersion +): EncryptedEnvelope { + // The legacy value becomes the encryptedDEK in our envelope + // (we're treating existing encrypted data as a wrapped key) + return createEncryptedEnvelope(legacyValue, currentKeyVersion, 'AES-256-CBC'); +} + +/** + * Extracts the raw encrypted value from legacy data for decryption. + * Used when decrypting data that wasn't properly versioned. + */ +export function extractLegacyValue(legacy: LegacyEncryptedData): string { + return legacy.value; +} + +/** + * Batch metadata extraction for multiple envelopes. + * Useful for planning re-encryption operations. + */ +export interface EnvelopeMetadata { + readonly envelopeVersion: number; + readonly algorithm: string; + readonly KEKVersion: string; + readonly timestamp: string; +} + +export function getEnvelopeMetadata( + envelope: EncryptedEnvelope +): EnvelopeMetadata { + return { + envelopeVersion: envelope.version, + algorithm: envelope.algorithm, + KEKVersion: envelope.KEKVersion, + timestamp: envelope.timestamp, + }; +} + +/** + * Calculates total size of an envelope (useful for batch operations). + */ +export function getEnvelopeSize(envelope: EncryptedEnvelope): number { + return serializeEncryptedEnvelope(envelope).length; +} + +/** + * Compares two envelopes to determine if they're equivalent. + * Used for validation after re-encryption. + */ +export function areEnvelopesEqual( + envelope1: EncryptedEnvelope, + envelope2: EncryptedEnvelope +): boolean { + return ( + envelope1.version === envelope2.version && + envelope1.algorithm === envelope2.algorithm && + envelope1.KEKVersion === envelope2.KEKVersion && + envelope1.encryptedDEK === envelope2.encryptedDEK + ); +} diff --git a/src/rotation/migration.ts b/src/rotation/migration.ts new file mode 100644 index 00000000..92553eef --- /dev/null +++ b/src/rotation/migration.ts @@ -0,0 +1,345 @@ +/** + * Key Rotation Migration Utilities + * + * Handles upgrade from legacy (non-versioned) encrypted data to the versioned envelope format. + * Designed for zero-downtime migration with minimal performance impact. + */ + +import { getAllItems, setItem } from '../core/storage'; + +import { + migrateToEnvelope, + parseEncryptedEnvelope, + isLegacyEncryptedData, + serializeEncryptedEnvelope, +} from './envelope'; + +import type { KeyVersion, MigrationResult } from './types'; + +import type { SensitiveInfoItem } from '../sensitive-info.nitro'; + +/** + * Configuration for migration operations. + */ +export interface MigrationOptions { + /** Service name for storing items (iOS Keychain service) */ + service?: string; + /** iOS Keychain synchronization flag */ + iosSynchronizable?: boolean; + /** iOS Keychain access group */ + keychainGroup?: string; + /** Batch size for migrating items (default: 50) */ + batchSize?: number; + /** Delay between batches in milliseconds (default: 100) */ + batchDelayMs?: number; + /** Abort migration if more than this percentage of items fail */ + failureTolerancePercent?: number; + /** Callback invoked after each batch */ + onProgress?: (completed: number, total: number) => void; +} + +/** + * Migrates all stored items from legacy format to versioned envelopes. + * This is a non-destructive operation that can be safely run multiple times. + * + * Strategy: + * 1. Enumerate all items for the given service + * 2. Identify items in legacy format + * 3. Wrap legacy values in versioned envelopes + * 4. Write back with same key/service + * 5. Track success/failure rates + * + * The operation processes items in batches to avoid blocking the main thread. + * + * @param currentKeyVersion The key version to attach to migrated items + * @param options Migration options including service and batch settings + * @returns Migration result with statistics + */ +export async function migrateToVersionedEnvelopes( + currentKeyVersion: KeyVersion, + options?: MigrationOptions +): Promise { + const batchSize = options?.batchSize ?? 50; + const batchDelayMs = options?.batchDelayMs ?? 100; + const failureTolerancePercent = options?.failureTolerancePercent ?? 10; + + try { + // Fetch all items for the service + const items = await getAllItems({ + service: options?.service, + iosSynchronizable: options?.iosSynchronizable, + keychainGroup: options?.keychainGroup, + includeValues: true, + }); + + if (items.length === 0) { + return { + success: true, + itemsMigrated: 0, + itemsFailed: 0, + timestamp: new Date().toISOString(), + }; + } + + let itemsMigrated = 0; + let itemsFailed = 0; + const errors: string[] = []; + + // Create batches without for loop + const batches = Array.from( + { length: Math.ceil(items.length / batchSize) }, + (_, i) => items.slice(i * batchSize, (i + 1) * batchSize) + ); + + for (let batchIndex = 0; batchIndex < batches.length; batchIndex++) { + const batch = batches[batchIndex]!; + + // Process batch items in parallel + await Promise.all( + batch.map(async (item) => { + try { + await migrateSingleItem(item, currentKeyVersion, options); + itemsMigrated++; + } catch (error) { + itemsFailed++; + const message = + error instanceof Error ? error.message : String(error); + errors.push(`Failed to migrate key "${item.key}": ${message}`); + } + }) + ); + + // Report progress + const completed = Math.min((batchIndex + 1) * batchSize, items.length); + options?.onProgress?.(completed, items.length); + + // Add delay between batches to avoid blocking + if (batchIndex < batches.length - 1) { + await new Promise((resolve) => setTimeout(resolve, batchDelayMs)); + } + } + + // Check if failure rate exceeds tolerance + const failureRate = + items.length > 0 ? (itemsFailed / items.length) * 100 : 0; + + const success = failureRate <= failureTolerancePercent; + + return { + success, + itemsMigrated, + itemsFailed, + timestamp: new Date().toISOString(), + errors: errors.length > 0 ? errors : undefined, + }; + } catch (error) { + const message = + error instanceof Error ? error.message : 'Unknown migration error'; + + return { + success: false, + itemsMigrated: 0, + itemsFailed: 0, + timestamp: new Date().toISOString(), + errors: [message], + }; + } +} + +/** + * Migrates a single item from legacy format to versioned envelope. + * No-op if item is already in versioned format. + * + * @internal + */ +async function migrateSingleItem( + item: SensitiveInfoItem, + currentKeyVersion: KeyVersion, + options?: MigrationOptions +): Promise { + // Parse existing value to check format + const parsed = parseEncryptedEnvelope(item.value); + + // If already in versioned format, nothing to do + if (parsed && !isLegacyEncryptedData(parsed)) { + return; + } + + // Wrap legacy value in versioned envelope + if (parsed && isLegacyEncryptedData(parsed)) { + const legacyValue = parsed.value; + const envelope = migrateToEnvelope(legacyValue, currentKeyVersion); + const envelopeStr = serializeEncryptedEnvelope(envelope); + + // Write back with same key and service + await setItem(item.key, envelopeStr, { + service: item.service, + iosSynchronizable: options?.iosSynchronizable, + keychainGroup: options?.keychainGroup, + accessControl: item.metadata.accessControl, + }); + } +} + +/** + * Validates migration readiness by checking item formats. + * Useful before initiating a full migration. + * + * @returns Object with statistics on legacy vs. versioned items + */ +export async function validateMigrationReadiness( + options?: MigrationOptions +): Promise<{ + totalItems: number; + legacyItems: number; + versionedItems: number; + unreadableItems: number; +}> { + try { + const items = await getAllItems({ + service: options?.service, + iosSynchronizable: options?.iosSynchronizable, + keychainGroup: options?.keychainGroup, + includeValues: true, + }); + + const stats = items.reduce( + (acc, item) => { + try { + const parsed = parseEncryptedEnvelope(item.value); + + if (parsed && isLegacyEncryptedData(parsed)) { + acc.legacyItems++; + } else if (parsed) { + acc.versionedItems++; + } else { + acc.unreadableItems++; + } + } catch { + acc.unreadableItems++; + } + return acc; + }, + { legacyItems: 0, versionedItems: 0, unreadableItems: 0 } + ); + + return { + totalItems: items.length, + ...stats, + }; + } catch (error) { + return { + totalItems: 0, + legacyItems: 0, + versionedItems: 0, + unreadableItems: 0, + }; + } +} + +/** + * Performs a dry-run of migration without modifying data. + * Useful for planning and validation before actual migration. + * + * @returns Detailed preview of what would be migrated + */ +export async function previewMigration(options?: MigrationOptions): Promise<{ + preview: Array<{ + key: string; + service: string; + needsMigration: boolean; + currentFormat: 'versioned' | 'legacy' | 'unreadable'; + }>; +}> { + const items = await getAllItems({ + service: options?.service, + iosSynchronizable: options?.iosSynchronizable, + keychainGroup: options?.keychainGroup, + includeValues: true, + }); + + const preview = items.map((item) => { + let currentFormat: 'versioned' | 'legacy' | 'unreadable' = 'unreadable'; + + try { + const parsed = parseEncryptedEnvelope(item.value); + currentFormat = + parsed && isLegacyEncryptedData(parsed) + ? 'legacy' + : parsed + ? 'versioned' + : 'unreadable'; + } catch { + currentFormat = 'unreadable'; + } + + return { + key: item.key, + service: item.service, + needsMigration: currentFormat === 'legacy', + currentFormat, + }; + }); + + return { preview }; +} + +/** + * Attempts to recover unreadable items during migration. + * This is a best-effort operation that may not succeed for all items. + * + * @returns List of recovered and unrecoverable item keys + */ +export async function recoverUnreadableItems( + options?: MigrationOptions +): Promise<{ + recovered: string[]; + unrecoverable: string[]; +}> { + const items = await getAllItems({ + service: options?.service, + iosSynchronizable: options?.iosSynchronizable, + keychainGroup: options?.keychainGroup, + includeValues: true, + }); + + const { recovered, unrecoverable } = items.reduce( + (acc, item) => { + try { + const parsed = parseEncryptedEnvelope(item.value); + + if (!parsed) { + // Try to treat raw value as legacy + // This is a heuristic and may fail + if (item.value && typeof item.value === 'string') { + acc.recovered.push(item.key); + } else { + acc.unrecoverable.push(item.key); + } + } + } catch { + acc.unrecoverable.push(item.key); + } + return acc; + }, + { recovered: [] as string[], unrecoverable: [] as string[] } + ); + + return { recovered, unrecoverable }; +} + +/** + * Performs rollback of a failed migration by restoring from backup. + * Requires that backup was created before migration started. + * + * @note This is a placeholder for future implementation. + * Actual implementation would depend on backup strategy. + */ +export async function rollbackMigration( + _backupId: string, + _options?: MigrationOptions +): Promise { + // TODO: Implement rollback from backup + // This would require a backup system to be in place + throw new Error('Migration rollback not yet implemented'); +} diff --git a/src/rotation/rotation-api.ts b/src/rotation/rotation-api.ts new file mode 100644 index 00000000..b9da35a7 --- /dev/null +++ b/src/rotation/rotation-api.ts @@ -0,0 +1,450 @@ +/** + * Key Rotation Public API + * + * Provides the main entry points for key rotation functionality. + * These functions coordinate between the TypeScript business logic + * and native platform implementations. + */ + +import { getRotationManager } from './engine'; + +import type { + RotationPolicy, + RotationStatus, + RotationOptions, + RotationEventCallback, + RotationEvent, + MigrationResult, + RotationStartedEvent, + RotationCompletedEvent, + RotationFailedEvent, + KeyVersion, + EncryptedEnvelope, +} from './types'; + +import { + migrateToVersionedEnvelopes, + validateMigrationReadiness, + previewMigration, +} from './migration'; + +import type { MigrationOptions } from './migration'; + +import getNativeInstance from '../internal/native'; + +// Re-export all types for public API +export type { + RotationPolicy, + RotationStatus, + RotationOptions, + RotationEvent, + RotationStartedEvent, + RotationCompletedEvent, + RotationFailedEvent, + KeyVersion, + EncryptedEnvelope, + MigrationResult, +}; +export type { MigrationOptions }; + +// Global rotation manager instance +let rotationManager: ReturnType | null = null; + +/** + * Initializes the key rotation system. + * Should be called once during app startup. + * + * @param policy Optional custom rotation policy + */ +export async function initializeKeyRotation( + policy?: RotationPolicy +): Promise { + rotationManager = getRotationManager(policy); + + try { + // Initialize the native rotation system first + const native = getNativeInstance() as any; + + if (!native.initializeKeyRotation) { + console.warn('Native rotation API not available. Key rotation disabled.'); + return; + } + + // Initialize native rotation system with policy settings + const initRequest = { + enabled: policy?.enabled ?? true, + rotationIntervalMs: policy?.rotationIntervalMs, + rotateOnBiometricChange: policy?.rotateOnBiometricChange, + rotateOnCredentialChange: policy?.rotateOnCredentialChange, + manualRotationEnabled: policy?.manualRotationEnabled, + maxKeyVersions: policy?.maxKeyVersions, + backgroundReEncryption: policy?.backgroundReEncryption, + }; + + await native.initializeKeyRotation(initRequest); + + // Now get the current rotation status + const rotationStatus = await native.getRotationStatus(); + + if (rotationStatus.currentKeyVersion) { + rotationManager.initialize( + rotationStatus.currentKeyVersion, + rotationStatus.availableKeyVersions || [], + rotationStatus.lastRotationTimestamp + ); + } + } catch (error) { + console.error('Failed to initialize key rotation:', error); + throw error; + } +} /** + * Initiates a key rotation operation. + * + * If rotation is enabled in the policy and the conditions are met, + * this will: + * 1. Generate a new key version + * 2. Re-encrypt all data with the new key + * 3. Swap old and new keys atomically + * 4. Clean up old key version after transition period + * + * @param options Rotation options (force, reason, metadata) + * @throws Error if rotation fails or is not available + * + * @example + * ```ts + * // Manual rotation with custom reason + * await rotateKeys({ reason: 'security-audit' }) + * + * // Force rotation bypassing policy checks + * await rotateKeys({ force: true }) + * ``` + */ +export async function rotateKeys(options?: RotationOptions): Promise { + if (!rotationManager) { + throw new Error( + 'Key rotation not initialized. Call initializeKeyRotation first.' + ); + } + + try { + const native = getNativeInstance() as any; + + // Determine rotation trigger + const shouldForce = options?.force ?? false; + const reason = (options?.reason as any) || 'manual'; + + if (!shouldForce && !rotationManager.shouldRotate(reason)) { + throw new Error(`Rotation not needed for reason "${reason}"`); + } + + // Notify of rotation start + const currentKeyVersion = rotationManager.getCurrentKeyVersion(); + if (currentKeyVersion) { + await rotationManager.startRotation(currentKeyVersion, reason, options); + } + + // Perform actual rotation via native + const result = await native.rotateKeys({ reason }); + + // Mark rotation complete + await rotationManager.completeRotation( + result.newKeyVersion, + result.itemsReEncrypted, + result.duration + ); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + + if (rotationManager) { + await rotationManager.failRotation(err); + } + + throw err; + } +} + +/** + * Gets the current active key version. + * + * @returns The current key version or null if not initialized + * + * @example + * ```ts + * const version = await getKeyVersion() + * if (version) { + * console.log('Active key version:', version.id) + * } + * ``` + */ +export async function getKeyVersion(): Promise { + if (!rotationManager) { + return null; + } + + const keyVersion = rotationManager.getCurrentKeyVersion(); + return keyVersion?.id ?? null; +} + +export async function getRotationStatus(): Promise { + const native = getNativeInstance() as any; + + try { + const status = await native.getRotationStatus(); + return { + isRotating: status.isRotating, + currentKeyVersion: status.currentKeyVersion, + availableKeyVersions: status.availableKeyVersions, + lastRotationTimestamp: status.lastRotationTimestamp, + itemsPendingReEncryption: 0, // TODO: calculate from native + }; + } catch (error) { + console.error('Failed to get rotation status:', error); + return { + isRotating: false, + currentKeyVersion: null, + availableKeyVersions: [], + lastRotationTimestamp: null, + itemsPendingReEncryption: 0, + }; + } +} + +/** + * Updates the rotation policy. + * + * @param policy Partial policy updates + * + * @example + * ```ts + * setRotationPolicy({ + * enabled: true, + * rotationIntervalMs: 30 * 24 * 60 * 60 * 1000, // 30 days + * rotateOnBiometricChange: true, + * }) + * ``` + */ +export function setRotationPolicy(policy: Partial): void { + if (!rotationManager) { + rotationManager = getRotationManager(policy as any); + return; + } + + rotationManager.setRotationPolicy(policy); +} + +/** + * Gets the current rotation policy. + * + * @returns Active rotation policy + */ +export function getRotationPolicy(): RotationPolicy { + if (!rotationManager) { + throw new Error( + 'Key rotation not initialized. Call initializeKeyRotation first.' + ); + } + + return rotationManager.getRotationPolicy(); +} + +/** + * Registers a callback for rotation lifecycle events. + * + * @param eventType Type of event to listen for + * @param callback Callback function invoked when event occurs + * + * @example + * ```ts + * onKeyRotationStarted((event) => { + * console.log('Rotation started at', event.timestamp) + * }) + * + * onKeyRotationCompleted((event) => { + * console.log('Rotation completed, re-encrypted', event.itemsReEncrypted, 'items') + * }) + * + * onKeyRotationFailed((event) => { + * console.error('Rotation failed:', event.reason) + * }) + * ``` + */ +export function on( + eventType: RotationEvent['type'], + callback: RotationEventCallback +): void { + if (!rotationManager) { + rotationManager = getRotationManager(); + } + + rotationManager.on(eventType, callback); +} + +/** + * Unregisters a rotation event callback. + */ +export function off( + eventType: RotationEvent['type'], + callback: RotationEventCallback +): void { + if (rotationManager) { + rotationManager.off(eventType, callback); + } +} + +/** + * Migrates all existing (non-versioned) data to the versioned envelope format. + * + * This operation: + * - Processes items in batches to avoid blocking + * - Is non-destructive (can be safely run multiple times) + * - Maintains all encryption and security properties + * - Supports progress callbacks for long operations + * + * @param options Migration options (batch size, progress callback, etc.) + * @returns Migration result with statistics + * + * @example + * ```ts + * const result = await migrateToNewKey({ + * batchSize: 100, + * onProgress: (done, total) => console.log(`${done}/${total} items migrated`), + * }) + * + * console.log(`Migrated ${result.itemsMigrated} items`) + * if (!result.success) { + * console.error('Some items failed:', result.errors) + * } + * ``` + */ +export async function migrateToNewKey( + options?: MigrationOptions +): Promise { + if (!rotationManager) { + throw new Error( + 'Key rotation not initialized. Call initializeKeyRotation first.' + ); + } + + const currentKeyVersion = rotationManager.getCurrentKeyVersion(); + if (!currentKeyVersion) { + throw new Error('No current key version available'); + } + + return migrateToVersionedEnvelopes(currentKeyVersion, options); +} + +/** + * Validates that all data is in versioned format. + * Useful before relying on migration having completed. + * + * @returns Migration readiness statistics + * + * @example + * ```ts + * const stats = await validateMigration() + * if (stats.legacyItems > 0) { + * console.log('Still have', stats.legacyItems, 'legacy items') + * await migrateToNewKey() + * } + * ``` + */ +export async function validateMigration(options?: MigrationOptions): Promise<{ + totalItems: number; + legacyItems: number; + versionedItems: number; + unreadableItems: number; +}> { + return validateMigrationReadiness(options); +} + +/** + * Previews what would be migrated without modifying data. + * + * @returns Detailed preview of items and their current format + * + * @example + * ```ts + * const preview = await previewMigration() + * console.log('Items needing migration:', preview.preview.filter(p => p.needsMigration)) + * ``` + */ +export async function getMigrationPreview(options?: MigrationOptions): Promise<{ + preview: Array<{ + key: string; + service: string; + needsMigration: boolean; + currentFormat: 'versioned' | 'legacy' | 'unreadable'; + }>; +}> { + return previewMigration(options); +} + +export async function reEncryptAllItems( + options?: MigrationOptions & { batchSize?: number } +): Promise<{ itemsReEncrypted: number; errors?: string[] }> { + const native = getNativeInstance() as any; + + try { + const result = await native.reEncryptAllItems({ + service: options?.service, + }); + + return { + itemsReEncrypted: result.itemsReEncrypted, + errors: result.errors?.map((error: any) => error.error) || [], + }; + } catch (error) { + console.error('Re-encryption failed:', error); + throw error; + } +} + +/** + * Handles biometric enrollment changes. + * Typically called from native code when the system detects enrollment changes. + * + * @internal + */ +export async function handleBiometricChange( + platform: 'ios' | 'android' +): Promise { + if (!rotationManager) { + return; + } + + await rotationManager.handleBiometricChange(platform); + + // Trigger rotation if policy allows + if (rotationManager.shouldRotate('biometric-change')) { + try { + await rotateKeys({ reason: 'security-audit' }); + } catch (error) { + console.error('Auto-rotation after biometric change failed:', error); + } + } +} + +/** + * Handles device credential changes. + * Typically called from native code when the system detects credential changes. + * + * @internal + */ +export async function handleCredentialChange( + platform: 'ios' | 'android' +): Promise { + if (!rotationManager) { + return; + } + + await rotationManager.handleCredentialChange(platform); + + // Trigger rotation if policy allows + if (rotationManager.shouldRotate('credential-change')) { + try { + await rotateKeys({ reason: 'security-audit' }); + } catch (error) { + console.error('Auto-rotation after credential change failed:', error); + } + } +} diff --git a/src/rotation/types.ts b/src/rotation/types.ts new file mode 100644 index 00000000..1e2eee7b --- /dev/null +++ b/src/rotation/types.ts @@ -0,0 +1,204 @@ +/** + * Key Rotation Types & Interfaces + * + * Defines the contract for automatic key rotation across iOS and Android platforms, + * including envelope encryption metadata, rotation policies, and status tracking. + */ + +/** + * Represents a specific version of a key used for encryption/decryption. + * Keys are versioned by timestamp to enable progressive rotation without data loss. + */ +export interface KeyVersion { + /** ISO 8601 timestamp when this key was generated/rotated */ + readonly id: string; + /** Timestamp in milliseconds since epoch for sorting */ + readonly timestamp: number; + /** Whether this key is currently active for new encryptions */ + readonly isActive: boolean; + /** Platform-specific key identifier (e.g., Android KeyStore alias) */ + readonly platformKeyId?: string; +} + +/** + * Versioned envelope format that wraps encrypted data and metadata. + * + * @example + * ```json + * { + * "version": 2, + * "encryptedDEK": "base64-encoded-aes-256-cbc-encrypted-dek", + * "KEKVersion": "2025-11-01T10:00:00Z", + * "timestamp": "2025-11-01T10:00:00Z", + * "algorithm": "AES-256-CBC" + * } + * ``` + */ +export interface EncryptedEnvelope { + /** Envelope format version - increment when breaking changes occur */ + readonly version: number; + /** Base64-encoded Data Encryption Key encrypted with KEK */ + readonly encryptedDEK: string; + /** ISO 8601 timestamp of the KEK that encrypted this DEK */ + readonly KEKVersion: string; + /** ISO 8601 timestamp when this envelope was created */ + readonly timestamp: string; + /** Encryption algorithm used (e.g., "AES-256-CBC") */ + readonly algorithm: string; +} + +/** + * Legacy format for backward compatibility with non-versioned encrypted data. + * Used during migration to versioned envelopes. + */ +export interface LegacyEncryptedData { + /** Raw encrypted value without version information */ + readonly value: string; + /** Optional timestamp if available */ + readonly timestamp?: string; +} + +/** + * Configurable rotation policy that controls when and how keys are rotated. + */ +export interface RotationPolicy { + /** Enable automatic time-based rotation */ + readonly enabled: boolean; + /** Rotation interval in milliseconds (default: 90 days) */ + readonly rotationIntervalMs: number; + /** Enable rotation on biometric enrollment changes */ + readonly rotateOnBiometricChange: boolean; + /** Enable rotation on device credential updates */ + readonly rotateOnCredentialChange: boolean; + /** Allow manual/user-initiated rotation */ + readonly manualRotationEnabled: boolean; + /** Maximum number of key versions to keep during transition (default: 2) */ + readonly maxKeyVersions: number; + /** Enable background re-encryption of old keys (if supported) */ + readonly backgroundReEncryption: boolean; +} + +/** + * Describes the result of a rotation operation. + */ +export interface RotationStatus { + /** Whether a rotation is currently in progress */ + readonly isRotating: boolean; + /** Currently active key version */ + readonly currentKeyVersion: KeyVersion | null; + /** All available key versions during transition period */ + readonly availableKeyVersions: KeyVersion[]; + /** Timestamp of the last completed rotation */ + readonly lastRotationTimestamp: string | null; + /** Reason for the last rotation (if any) */ + readonly lastRotationReason?: string; + /** Number of items pending re-encryption with new key */ + readonly itemsPendingReEncryption: number; + /** Platform-specific rotation status details */ + readonly platformStatus?: Record; +} + +/** + * Event emitted when key rotation begins. + */ +export interface RotationStartedEvent { + readonly type: 'rotation:started'; + readonly timestamp: string; + readonly reason: + | 'time-based' + | 'biometric-change' + | 'credential-change' + | 'manual'; + readonly currentKeyVersion: KeyVersion; + readonly newKeyVersion: KeyVersion; +} + +/** + * Event emitted when key rotation completes successfully. + */ +export interface RotationCompletedEvent { + readonly type: 'rotation:completed'; + readonly timestamp: string; + readonly oldKeyVersion: KeyVersion; + readonly newKeyVersion: KeyVersion; + readonly duration: number; // milliseconds + readonly itemsReEncrypted: number; +} + +/** + * Event emitted when key rotation fails. + */ +export interface RotationFailedEvent { + readonly type: 'rotation:failed'; + readonly timestamp: string; + readonly reason: string; + readonly error: Error; + readonly recoverable: boolean; +} + +/** + * Event emitted when biometric enrollment or device credential changes are detected. + */ +export interface BiometricChangeEvent { + readonly type: 'biometric-change' | 'credential-change'; + readonly timestamp: string; + readonly platform: 'ios' | 'android'; + readonly actionRequired?: boolean; // true if immediate rotation is needed +} + +/** Union of all rotation-related events */ +export type RotationEvent = + | RotationStartedEvent + | RotationCompletedEvent + | RotationFailedEvent + | BiometricChangeEvent; + +/** + * Configuration options for initiating a manual rotation. + */ +export interface RotationOptions { + /** Force immediate rotation, bypassing policy checks */ + readonly force?: boolean; + /** Reason for manual rotation (for audit logging) */ + readonly reason?: string; + /** Custom metadata to attach to rotation event */ + readonly metadata?: Record; +} + +/** + * Result of a migration operation. + */ +export interface MigrationResult { + readonly success: boolean; + readonly itemsMigrated: number; + readonly itemsFailed: number; + readonly timestamp: string; + readonly errors?: string[]; +} + +/** + * Callback signatures for rotation lifecycle events. + */ +export type RotationEventCallback = ( + event: RotationEvent +) => void | Promise; + +/** + * Audit log entry for key rotation events. + */ +export interface RotationAuditEntry { + readonly timestamp: string; + readonly eventType: + | 'key_generated' + | 'key_rotated' + | 'key_deleted' + | 'migration_started' + | 'migration_completed' + | 'rotation_failed' + | 'biometric_enrollment_changed' + | 'device_credential_changed'; + readonly keyVersion?: string; + readonly itemsAffected?: number; + readonly details?: Record; + readonly error?: string; +} diff --git a/src/sensitive-info.nitro.ts b/src/sensitive-info.nitro.ts index a2dece30..7ef98268 100644 --- a/src/sensitive-info.nitro.ts +++ b/src/sensitive-info.nitro.ts @@ -104,6 +104,7 @@ export interface StorageMetadata { readonly backend: StorageBackend; readonly accessControl: AccessControl; readonly timestamp: number; + readonly alias: string; } /** @@ -137,6 +138,62 @@ export interface SecurityAvailability { readonly deviceCredential: boolean; } +export interface ReEncryptAllItemsRequest { + readonly service?: string; +} + +export interface ReEncryptError { + readonly key: string; + readonly error: string; +} + +export interface ReEncryptAllItemsResponse { + readonly itemsReEncrypted: number; + readonly errors: ReEncryptError[]; +} + +export interface InitializeKeyRotationRequest { + readonly enabled: boolean; + readonly rotationIntervalMs: number; + readonly rotateOnBiometricChange: boolean; + readonly rotateOnCredentialChange: boolean; + readonly manualRotationEnabled: boolean; + readonly maxKeyVersions: number; + readonly backgroundReEncryption: boolean; +} + +export interface RotateKeysRequest { + readonly reason?: string; + readonly metadata?: Record; +} + +export interface KeyVersion { + readonly id: string; +} + +export interface RotationResult { + readonly success: boolean; + readonly newKeyVersion: KeyVersion; + readonly itemsReEncrypted: number; + readonly duration: number; + readonly reason: string; +} + +export interface RotationStatus { + readonly isRotating: boolean; + readonly currentKeyVersion: KeyVersion | null; + readonly availableKeyVersions: KeyVersion[]; + readonly lastRotationTimestamp: number | null; +} + +export interface RotationEvent { + readonly type: string; + readonly timestamp: number; + readonly reason?: string; + readonly itemsReEncrypted?: number; + readonly duration?: number; +} + export interface SensitiveInfo extends HybridObject<{ ios: 'swift'; android: 'kotlin' }> { setItem(request: SensitiveInfoSetRequest): Promise; @@ -148,6 +205,13 @@ export interface SensitiveInfo ): Promise; clearService(request?: SensitiveInfoOptions): Promise; getSupportedSecurityLevels(): Promise; + initializeKeyRotation(request: InitializeKeyRotationRequest): Promise; + rotateKeys(request: RotateKeysRequest): Promise; + getRotationStatus(): Promise; + onRotationEvent(callback: (event: RotationEvent) => void): () => void; + reEncryptAllItems( + request: ReEncryptAllItemsRequest + ): Promise; } export type SensitiveInfoSpec = SensitiveInfo; diff --git a/yarn.lock b/yarn.lock index 3e05dbca..f366f971 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1858,10 +1858,10 @@ __metadata: languageName: node linkType: hard -"@eslint/js@npm:9.39.0, @eslint/js@npm:^9.39.0": - version: 9.39.0 - resolution: "@eslint/js@npm:9.39.0" - checksum: 10/5858c2468f68e9204ec0a3a07cbb22352e8de89eb51bc83ac9754e2365b9c2d2aa0e0a3da46b98ea5d98a484c77111537f2a565b867bbdfe0448a0222404ef6b +"@eslint/js@npm:9.39.1, @eslint/js@npm:^9.39.1": + version: 9.39.1 + resolution: "@eslint/js@npm:9.39.1" + checksum: 10/b10b9b953212c0f3ffca475159bbe519e9e98847200c7432d1637d444fddcd7b712d2b7710a7dc20510f9cfbe8db330039b2aad09cb55d9545b116d940dbeed2 languageName: node linkType: hard @@ -3953,7 +3953,7 @@ __metadata: languageName: node linkType: hard -"@types/normalize-package-data@npm:^2.4.3": +"@types/normalize-package-data@npm:^2.4.3, @types/normalize-package-data@npm:^2.4.4": version: 2.4.4 resolution: "@types/normalize-package-data@npm:2.4.4" checksum: 10/65dff72b543997b7be8b0265eca7ace0e34b75c3e5fee31de11179d08fa7124a7a5587265d53d0409532ecb7f7fba662c2012807963e1f9b059653ec2c83ee05 @@ -3999,24 +3999,24 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/eslint-plugin@npm:8.46.2": - version: 8.46.2 - resolution: "@typescript-eslint/eslint-plugin@npm:8.46.2" +"@typescript-eslint/eslint-plugin@npm:8.46.4": + version: 8.46.4 + resolution: "@typescript-eslint/eslint-plugin@npm:8.46.4" dependencies: "@eslint-community/regexpp": "npm:^4.10.0" - "@typescript-eslint/scope-manager": "npm:8.46.2" - "@typescript-eslint/type-utils": "npm:8.46.2" - "@typescript-eslint/utils": "npm:8.46.2" - "@typescript-eslint/visitor-keys": "npm:8.46.2" + "@typescript-eslint/scope-manager": "npm:8.46.4" + "@typescript-eslint/type-utils": "npm:8.46.4" + "@typescript-eslint/utils": "npm:8.46.4" + "@typescript-eslint/visitor-keys": "npm:8.46.4" graphemer: "npm:^1.4.0" ignore: "npm:^7.0.0" natural-compare: "npm:^1.4.0" ts-api-utils: "npm:^2.1.0" peerDependencies: - "@typescript-eslint/parser": ^8.46.2 + "@typescript-eslint/parser": ^8.46.4 eslint: ^8.57.0 || ^9.0.0 typescript: ">=4.8.4 <6.0.0" - checksum: 10/00c659fcc04c185e6cdfb6c7e52beae1935f1475fef4079193a719f93858b6255e07b4764fc7104e9524a4d0b7652e63616b93e7f112f1cba4e983d10383e224 + checksum: 10/5ae705d9dbf8cdeaf8cc2198cbfa1c3b70d5bf2fd20b5870448b53e9fe2f5a0d106162850aabd97897d250ec6fe7cebbb3f7ea2b6aa7ca9582b9b1b9e3be459f languageName: node linkType: hard @@ -4041,19 +4041,19 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/parser@npm:8.46.2": - version: 8.46.2 - resolution: "@typescript-eslint/parser@npm:8.46.2" +"@typescript-eslint/parser@npm:8.46.4": + version: 8.46.4 + resolution: "@typescript-eslint/parser@npm:8.46.4" dependencies: - "@typescript-eslint/scope-manager": "npm:8.46.2" - "@typescript-eslint/types": "npm:8.46.2" - "@typescript-eslint/typescript-estree": "npm:8.46.2" - "@typescript-eslint/visitor-keys": "npm:8.46.2" + "@typescript-eslint/scope-manager": "npm:8.46.4" + "@typescript-eslint/types": "npm:8.46.4" + "@typescript-eslint/typescript-estree": "npm:8.46.4" + "@typescript-eslint/visitor-keys": "npm:8.46.4" debug: "npm:^4.3.4" peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: ">=4.8.4 <6.0.0" - checksum: 10/2ee394d880b5a9372ecf50ddbf70f66e9ecc16691a210dd40b5b152310a539005dfed13105e0adc81f1a9f49d86f7b78ddf3bf8d777fe84c179eb6a8be2fa56c + checksum: 10/560635f5567dba6342cea2146051e5647dbc48f5fb7b0a7a6d577cada06d43e07030bb3999f90f6cd01d5b0fdb25d829a25252c84cf7a685c5c9373e6e1e4a73 languageName: node linkType: hard @@ -4086,16 +4086,16 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/project-service@npm:8.46.2": - version: 8.46.2 - resolution: "@typescript-eslint/project-service@npm:8.46.2" +"@typescript-eslint/project-service@npm:8.46.4": + version: 8.46.4 + resolution: "@typescript-eslint/project-service@npm:8.46.4" dependencies: - "@typescript-eslint/tsconfig-utils": "npm:^8.46.2" - "@typescript-eslint/types": "npm:^8.46.2" + "@typescript-eslint/tsconfig-utils": "npm:^8.46.4" + "@typescript-eslint/types": "npm:^8.46.4" debug: "npm:^4.3.4" peerDependencies: typescript: ">=4.8.4 <6.0.0" - checksum: 10/76ba446f86e83b4afd6dacbebc9a0737b5a3e0500a0712b37fea4f0141dcf4c9238e8e5a9a649cf609a4624cc575431506a2a56432aaa18d4c3a8cf2df9d1480 + checksum: 10/f145da5f0c063833f48d36f2c3a19a37e2fb77156f0cc7046ee15f2e59418309b95628c8e7216e4429fac9f1257fab945c5d3f5abfd8f924223d36125c633d32 languageName: node linkType: hard @@ -4109,13 +4109,13 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/scope-manager@npm:8.46.2": - version: 8.46.2 - resolution: "@typescript-eslint/scope-manager@npm:8.46.2" +"@typescript-eslint/scope-manager@npm:8.46.4": + version: 8.46.4 + resolution: "@typescript-eslint/scope-manager@npm:8.46.4" dependencies: - "@typescript-eslint/types": "npm:8.46.2" - "@typescript-eslint/visitor-keys": "npm:8.46.2" - checksum: 10/6a8a9b644ff57ca9e992348553f19f6e010d76ff4872d972d333a16952e93cce4bf5096a1fefe1af8b452bce963fde6c78410d15817e673b75176ec3241949e9 + "@typescript-eslint/types": "npm:8.46.4" + "@typescript-eslint/visitor-keys": "npm:8.46.4" + checksum: 10/1439ffc1458281282c1ae3aabbe89140ce15c796d4f1c59f0de38e8536803e10143fe322a7e1cb56fe41da9e4617898d70923b71621b47cff4472aa5dae88d7e languageName: node linkType: hard @@ -4128,12 +4128,12 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/tsconfig-utils@npm:8.46.2, @typescript-eslint/tsconfig-utils@npm:^8.46.2": - version: 8.46.2 - resolution: "@typescript-eslint/tsconfig-utils@npm:8.46.2" +"@typescript-eslint/tsconfig-utils@npm:8.46.4, @typescript-eslint/tsconfig-utils@npm:^8.46.4": + version: 8.46.4 + resolution: "@typescript-eslint/tsconfig-utils@npm:8.46.4" peerDependencies: typescript: ">=4.8.4 <6.0.0" - checksum: 10/e459d131ca646cca6ad164593ca7e8c45ad3daa103a24e1e57fd47b5c1e5b5418948b749f02baa42e61103a496fc80d32ddd1841c11495bbcf37808b88bb0ef4 + checksum: 10/eda25b1daee6abf51ee2dd5fc1dc1a5160a14301c0e7bed301ec5eb0f7b45418d509c035361f88a37f4af9771d7334f1dcb9bc7f7a38f07b09e85d4d9d92767f languageName: node linkType: hard @@ -4153,19 +4153,19 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/type-utils@npm:8.46.2": - version: 8.46.2 - resolution: "@typescript-eslint/type-utils@npm:8.46.2" +"@typescript-eslint/type-utils@npm:8.46.4": + version: 8.46.4 + resolution: "@typescript-eslint/type-utils@npm:8.46.4" dependencies: - "@typescript-eslint/types": "npm:8.46.2" - "@typescript-eslint/typescript-estree": "npm:8.46.2" - "@typescript-eslint/utils": "npm:8.46.2" + "@typescript-eslint/types": "npm:8.46.4" + "@typescript-eslint/typescript-estree": "npm:8.46.4" + "@typescript-eslint/utils": "npm:8.46.4" debug: "npm:^4.3.4" ts-api-utils: "npm:^2.1.0" peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: ">=4.8.4 <6.0.0" - checksum: 10/db5d3d782b44d31f828ebdbec44550c6f94fdcfac1164f59e3922f6413feed749d93df3977625fd5949aaff5c691cf4603a7cd93eaf7b19b9cf6fd91537fb8c7 + checksum: 10/438188d4db8889b1299df60e03be76bbbcfad6500cbdbaad83250bc3671d6d798d3eef01417dd2b4236334ed11e466b90a75d17c0d5b94b667b362ce746dd3e6 languageName: node linkType: hard @@ -4176,10 +4176,10 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/types@npm:8.46.2, @typescript-eslint/types@npm:^8.46.2": - version: 8.46.2 - resolution: "@typescript-eslint/types@npm:8.46.2" - checksum: 10/c641453c868b730ef64bd731cc47b19e1a5e45c090dfe9542ecd15b24c5a7b6dc94a8ef4e548b976aabcd1ca9dec1b766e417454b98ea59079795eb008226b38 +"@typescript-eslint/types@npm:8.46.4, @typescript-eslint/types@npm:^8.46.4": + version: 8.46.4 + resolution: "@typescript-eslint/types@npm:8.46.4" + checksum: 10/dd71692722254308f7954ade97800c141ec4a2bbdeef334df4ef9a5ee00db4597db4c3d0783607fc61c22238c9c534803a5421fe0856033a635e13fbe99b3cf0 languageName: node linkType: hard @@ -4203,14 +4203,14 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/typescript-estree@npm:8.46.2": - version: 8.46.2 - resolution: "@typescript-eslint/typescript-estree@npm:8.46.2" +"@typescript-eslint/typescript-estree@npm:8.46.4": + version: 8.46.4 + resolution: "@typescript-eslint/typescript-estree@npm:8.46.4" dependencies: - "@typescript-eslint/project-service": "npm:8.46.2" - "@typescript-eslint/tsconfig-utils": "npm:8.46.2" - "@typescript-eslint/types": "npm:8.46.2" - "@typescript-eslint/visitor-keys": "npm:8.46.2" + "@typescript-eslint/project-service": "npm:8.46.4" + "@typescript-eslint/tsconfig-utils": "npm:8.46.4" + "@typescript-eslint/types": "npm:8.46.4" + "@typescript-eslint/visitor-keys": "npm:8.46.4" debug: "npm:^4.3.4" fast-glob: "npm:^3.3.2" is-glob: "npm:^4.0.3" @@ -4219,7 +4219,7 @@ __metadata: ts-api-utils: "npm:^2.1.0" peerDependencies: typescript: ">=4.8.4 <6.0.0" - checksum: 10/4d2149ad97e7f7e2e4cf466932f52f38e90414d47341c5938e497fd0826d403db9896bbd5cc08e7488ad0d0ffb3817e6f18e9f0c623d8a8cda09af204f81aab8 + checksum: 10/2a932bdd7ac260e2b7290c952241bf06b2ddbeb3cf636bc624a64a9cfb046619620172a1967f30dbde6ac5f4fbdcfec66e1349af46313da86e01b5575dfebe2e languageName: node linkType: hard @@ -4238,18 +4238,18 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/utils@npm:8.46.2": - version: 8.46.2 - resolution: "@typescript-eslint/utils@npm:8.46.2" +"@typescript-eslint/utils@npm:8.46.4": + version: 8.46.4 + resolution: "@typescript-eslint/utils@npm:8.46.4" dependencies: "@eslint-community/eslint-utils": "npm:^4.7.0" - "@typescript-eslint/scope-manager": "npm:8.46.2" - "@typescript-eslint/types": "npm:8.46.2" - "@typescript-eslint/typescript-estree": "npm:8.46.2" + "@typescript-eslint/scope-manager": "npm:8.46.4" + "@typescript-eslint/types": "npm:8.46.4" + "@typescript-eslint/typescript-estree": "npm:8.46.4" peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: ">=4.8.4 <6.0.0" - checksum: 10/91f6216f858161c3f59b2e035e0abce68fcdc9fbe45cb693a111c11ce5352c42fe0b1145a91e538c5459ff81b5e3741a4b38189b97e0e1a756567b6467c7b6c9 + checksum: 10/8e11abb2e44b6e62ccf8fd9b96808cb58e68788564fa999f15b61c0ec929209ced7f92a57ffbfcaec80f926aa14dafcee756755b724ae543b4cbd84b0ffb890d languageName: node linkType: hard @@ -4263,13 +4263,13 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/visitor-keys@npm:8.46.2": - version: 8.46.2 - resolution: "@typescript-eslint/visitor-keys@npm:8.46.2" +"@typescript-eslint/visitor-keys@npm:8.46.4": + version: 8.46.4 + resolution: "@typescript-eslint/visitor-keys@npm:8.46.4" dependencies: - "@typescript-eslint/types": "npm:8.46.2" + "@typescript-eslint/types": "npm:8.46.4" eslint-visitor-keys: "npm:^4.2.1" - checksum: 10/4352629a33bc1619dc78d55eaec382be4c7e1059af02660f62bfdb22933021deaf98504d4030b8db74ec122e6d554e9015341f87aed729fb70fae613f12f55a4 + checksum: 10/bcf479fa5c59857cf7aa7b90d9c00e23f7303473b94a401cc3b64776ebb66978b5342459a1672581dcf1861fa5961bb59c901fe766c28b6bc3f93e60bfc34dae languageName: node linkType: hard @@ -6912,9 +6912,9 @@ __metadata: languageName: node linkType: hard -"eslint@npm:^9.39.0": - version: 9.39.0 - resolution: "eslint@npm:9.39.0" +"eslint@npm:^9.39.1": + version: 9.39.1 + resolution: "eslint@npm:9.39.1" dependencies: "@eslint-community/eslint-utils": "npm:^4.8.0" "@eslint-community/regexpp": "npm:^4.12.1" @@ -6922,7 +6922,7 @@ __metadata: "@eslint/config-helpers": "npm:^0.4.2" "@eslint/core": "npm:^0.17.0" "@eslint/eslintrc": "npm:^3.3.1" - "@eslint/js": "npm:9.39.0" + "@eslint/js": "npm:9.39.1" "@eslint/plugin-kit": "npm:^0.4.1" "@humanfs/node": "npm:^0.16.6" "@humanwhocodes/module-importer": "npm:^1.0.1" @@ -6957,7 +6957,7 @@ __metadata: optional: true bin: eslint: bin/eslint.js - checksum: 10/628c8c7ddd9ed9e0384ccfb7f880e4a1ac76885aa2310a4057ebbb5c0877540fcebf88537a15b321ccc3097bec7b6f812d9a4887d1cc5a89166c379ed2574432 + checksum: 10/c85fefe4a81a1a476e62087366907af830b62a6565ac153f6d50a100a42a946aeb049c3af8f06c0e091105ba0fe97ac109f379f32755a67f66ecb7d4d1e4dca3 languageName: node linkType: hard @@ -7299,7 +7299,7 @@ __metadata: languageName: node linkType: hard -"find-up-simple@npm:^1.0.0": +"find-up-simple@npm:^1.0.0, find-up-simple@npm:^1.0.1": version: 1.0.1 resolution: "find-up-simple@npm:1.0.1" checksum: 10/6e374bffda9f8425314eab47ef79752b6e77dcc95c0ad17d257aef48c32fe07bbc41bcafbd22941c25bb94fffaaaa8e178d928867d844c58100c7fe19ec82f72 @@ -10734,18 +10734,18 @@ __metadata: languageName: node linkType: hard -"nitrogen@npm:0.31.4": - version: 0.31.4 - resolution: "nitrogen@npm:0.31.4" +"nitrogen@npm:0.31.5": + version: 0.31.5 + resolution: "nitrogen@npm:0.31.5" dependencies: chalk: "npm:^5.3.0" - react-native-nitro-modules: "npm:^0.31.4" + react-native-nitro-modules: "npm:^0.31.5" ts-morph: "npm:^27.0.0" yargs: "npm:^18.0.0" zod: "npm:^4.0.5" bin: nitrogen: lib/index.js - checksum: 10/9efd15a939ad64fe10f1a70c6d5b1e34a293ef134a755bb59fda2105591bd2720245e0fa2b00ca055bf8e47f363e60da8401ee47da15407d0cec60fb439dd487 + checksum: 10/15a18aad48f9c4f44136d3d3dc86ddda0ffc44a0f873b7d30d863b80e8f1e110cf4ba3e41f4c1f07eb7f3be5b61c11b303299787606e0936bf7f8dbfab359d31 languageName: node linkType: hard @@ -10851,6 +10851,17 @@ __metadata: languageName: node linkType: hard +"normalize-package-data@npm:^8.0.0": + version: 8.0.0 + resolution: "normalize-package-data@npm:8.0.0" + dependencies: + hosted-git-info: "npm:^9.0.0" + semver: "npm:^7.3.5" + validate-npm-package-license: "npm:^3.0.4" + checksum: 10/43b52580e9c78dd43eaff3251c0f1ba65f36d524700a4870254fc310bf66446ce0804f5fb97606a10d37180112bcbba46d48f04e80d6b2baecaaeeebbe92a985 + languageName: node + linkType: hard + "normalize-path@npm:^3.0.0": version: 3.0.0 resolution: "normalize-path@npm:3.0.0" @@ -11492,7 +11503,7 @@ __metadata: languageName: node linkType: hard -"parse-json@npm:^8.0.0": +"parse-json@npm:^8.0.0, parse-json@npm:^8.3.0": version: 8.3.0 resolution: "parse-json@npm:8.3.0" dependencies: @@ -12038,13 +12049,13 @@ __metadata: languageName: node linkType: hard -"react-native-nitro-modules@npm:0.31.4, react-native-nitro-modules@npm:^0.31.4": - version: 0.31.4 - resolution: "react-native-nitro-modules@npm:0.31.4" +"react-native-nitro-modules@npm:0.31.5, react-native-nitro-modules@npm:^0.31.5": + version: 0.31.5 + resolution: "react-native-nitro-modules@npm:0.31.5" peerDependencies: react: "*" react-native: "*" - checksum: 10/be908aa8aec76261c12b3fe8788ad9e69d3bf9c568f1b77ff5f6bcae4c064bf0f7d73f3ac9dc24e1e113b49eaa92d1833dcc2a899f1364caaa475c8fbe8b036b + checksum: 10/9aaabd1a2b183e313b43522879b7ac4ddbf9f709810c07b11f6c4ad222125586920a1cf04e13b2afbf8882466af4b93864a21a98076e22c1d62416da808f7a62 languageName: node linkType: hard @@ -12077,7 +12088,7 @@ __metadata: babel-plugin-module-resolver: "npm:^5.0.2" react: "npm:19.1.1" react-native: "npm:0.82.1" - react-native-nitro-modules: "npm:0.31.4" + react-native-nitro-modules: "npm:0.31.5" react-native-safe-area-context: "npm:^5.6.2" languageName: unknown linkType: soft @@ -12087,7 +12098,7 @@ __metadata: resolution: "react-native-sensitive-info@workspace:." dependencies: "@eslint/compat": "npm:^1.4.1" - "@eslint/js": "npm:^9.39.0" + "@eslint/js": "npm:^9.39.1" "@jamesacarr/eslint-formatter-github-actions": "npm:^0.2.0" "@semantic-release/changelog": "npm:^6.0.3" "@semantic-release/git": "npm:^10.0.1" @@ -12097,7 +12108,7 @@ __metadata: "@types/react": "npm:19.2.x" babel-plugin-react-compiler: "npm:^1.0.0" conventional-changelog-conventionalcommits: "npm:^9.1.0" - eslint: "npm:^9.39.0" + eslint: "npm:^9.39.1" eslint-config-airbnb: "npm:^19.0.4" eslint-config-prettier: "npm:^10.1.8" eslint-import-resolver-typescript: "npm:^4.4.4" @@ -12111,18 +12122,18 @@ __metadata: jest: "npm:^30.2.0" jest-environment-jsdom: "npm:^30.2.0" jiti: "npm:^2.6.1" - nitrogen: "npm:0.31.4" + nitrogen: "npm:0.31.5" prettier: "npm:^3.6.2" react: "npm:19.1.1" react-dom: "npm:19.1.1" react-native: "npm:0.82" react-native-builder-bob: "npm:^0.40.14" - react-native-nitro-modules: "npm:0.31.4" - semantic-release: "npm:^25.0.1" + react-native-nitro-modules: "npm:0.31.5" + semantic-release: "npm:^25.0.2" ts-jest: "npm:^29.4.5" ts-node: "npm:^10.9.2" typescript: "npm:^5.9.3" - typescript-eslint: "npm:^8.46.2" + typescript-eslint: "npm:^8.46.4" peerDependencies: react: "*" react-native: "*" @@ -12264,6 +12275,30 @@ __metadata: languageName: node linkType: hard +"read-package-up@npm:^12.0.0": + version: 12.0.0 + resolution: "read-package-up@npm:12.0.0" + dependencies: + find-up-simple: "npm:^1.0.1" + read-pkg: "npm:^10.0.0" + type-fest: "npm:^5.2.0" + checksum: 10/b8fc1645228e2b136e75afd4e8d87eec9bff18382668a59f1fc409ee0596e65ba452ff65c5717a311ed9a8796debada9a4a547820593208a0ac659f6cfef86e7 + languageName: node + linkType: hard + +"read-pkg@npm:^10.0.0": + version: 10.0.0 + resolution: "read-pkg@npm:10.0.0" + dependencies: + "@types/normalize-package-data": "npm:^2.4.4" + normalize-package-data: "npm:^8.0.0" + parse-json: "npm:^8.3.0" + type-fest: "npm:^5.2.0" + unicorn-magic: "npm:^0.3.0" + checksum: 10/8ad56dc93d36cae8827e4917cfbf299b862e23389d7befd889d05a9fee771514631751991782d016ea96a3f10f87b4aee9972cb284e088b0011edff47e08aef5 + languageName: node + linkType: hard + "read-pkg@npm:^9.0.0": version: 9.0.1 resolution: "read-pkg@npm:9.0.1" @@ -12631,9 +12666,9 @@ __metadata: languageName: node linkType: hard -"semantic-release@npm:^25.0.1": - version: 25.0.1 - resolution: "semantic-release@npm:25.0.1" +"semantic-release@npm:^25.0.2": + version: 25.0.2 + resolution: "semantic-release@npm:25.0.2" dependencies: "@semantic-release/commit-analyzer": "npm:^13.0.1" "@semantic-release/error": "npm:^4.0.0" @@ -12658,7 +12693,7 @@ __metadata: micromatch: "npm:^4.0.2" p-each-series: "npm:^3.0.0" p-reduce: "npm:^3.0.0" - read-package-up: "npm:^11.0.0" + read-package-up: "npm:^12.0.0" resolve-from: "npm:^5.0.0" semver: "npm:^7.3.2" semver-diff: "npm:^5.0.0" @@ -12666,7 +12701,7 @@ __metadata: yargs: "npm:^18.0.0" bin: semantic-release: bin/semantic-release.js - checksum: 10/04432398ef0f2cdf81802c97aed35b63a43b8cf2aab117435f0b7aef174327d9cdc3afa03bade575a608cc40e001218539a9f332a4a7e306752285bfb2c3b866 + checksum: 10/5c45c3e8640c7325afd38f12b8d7bfdf44c227b7e49ee750c2509d030bdecc39038fad2a7b1936d758fb3cc813b7f366bc436e2a4ef929b37db72a2093bffa65 languageName: node linkType: hard @@ -13451,6 +13486,13 @@ __metadata: languageName: node linkType: hard +"tagged-tag@npm:^1.0.0": + version: 1.0.0 + resolution: "tagged-tag@npm:1.0.0" + checksum: 10/e37653df3e495daa7ea7790cb161b810b00075bba2e4d6c93fb06a709e747e3ae9da11a120d0489833203926511b39e038a2affbd9d279cfb7a2f3fcccd30b5d + languageName: node + linkType: hard + "tar@npm:^7.4.3, tar@npm:^7.5.1": version: 7.5.1 resolution: "tar@npm:7.5.1" @@ -13834,6 +13876,15 @@ __metadata: languageName: node linkType: hard +"type-fest@npm:^5.2.0": + version: 5.2.0 + resolution: "type-fest@npm:5.2.0" + dependencies: + tagged-tag: "npm:^1.0.0" + checksum: 10/4ce526139c05e92a1d92fa905840ff9ae725b8058df5a5571380bca1827db1f2e4e204c9d561fd63e972d45591dd4d38eab5f5529c0af93d6cfb5f99109aa74a + languageName: node + linkType: hard + "type-is@npm:~1.6.18": version: 1.6.18 resolution: "type-is@npm:1.6.18" @@ -13897,18 +13948,18 @@ __metadata: languageName: node linkType: hard -"typescript-eslint@npm:^8.46.2": - version: 8.46.2 - resolution: "typescript-eslint@npm:8.46.2" +"typescript-eslint@npm:^8.46.4": + version: 8.46.4 + resolution: "typescript-eslint@npm:8.46.4" dependencies: - "@typescript-eslint/eslint-plugin": "npm:8.46.2" - "@typescript-eslint/parser": "npm:8.46.2" - "@typescript-eslint/typescript-estree": "npm:8.46.2" - "@typescript-eslint/utils": "npm:8.46.2" + "@typescript-eslint/eslint-plugin": "npm:8.46.4" + "@typescript-eslint/parser": "npm:8.46.4" + "@typescript-eslint/typescript-estree": "npm:8.46.4" + "@typescript-eslint/utils": "npm:8.46.4" peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: ">=4.8.4 <6.0.0" - checksum: 10/cd1bbc5d33c0369f70032165224badf1a8a9f95f39c891e4f71c78ceea9e7b2d71e0516d8b38177a11217867f387788f3fa126381418581409e7a76cdfdfe909 + checksum: 10/6d28371033653395f1108d880f32ed5b03c15d94a4ca7564b81cdb5c563fa618b48cbcb6c00f3341e3399b27711feb1073305b425a22de23786a87c6a3a19ccd languageName: node linkType: hard