;
+}
+
+/**
+ * Hook for managing async operation state with proper lifecycle handling.
+ *
+ * Automatically handles:
+ * - Component mount/unmount detection (prevents state updates after unmount)
+ * - Loading state during operation
+ * - Error classification using HookError
+ * - Data transformation via optional mapper function
+ * - Retry functionality
+ *
+ * @template TData - Type of the result data
+ *
+ * @param operation - Async function to execute (callback is recommended)
+ * @param operationName - Name of the operation for error reporting
+ * @param mapper - Optional function to transform raw result to desired type
+ * @returns Object with state, execute, reset, and retry methods
+ *
+ * @example
+ * ```ts
+ * // Simple fetch operation
+ * const { state, execute } = useAsyncOperation(
+ * useCallback(() => getItem(key), [key]),
+ * 'fetch',
+ * (item) => item?.value ?? null
+ * )
+ *
+ * if (state.loading) return
+ * if (state.error) return
+ * return {state.data}
+ * ```
+ *
+ * @example
+ * ```ts
+ * // With retry
+ * function useFetchWithRetry(key: string) {
+ * const { state, retry } = useAsyncOperation(
+ * useCallback(() => getItem(key), [key]),
+ * 'fetch'
+ * )
+ *
+ * return (
+ *
+ * {state.error && Retry }
+ * {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 (
+ * secret.saveSecret('new-token-value')}
+ * disabled={secret.isPending}
+ * >
+ * Update Secret
+ *
+ * )
* ```
+ *
+ * @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 (
+ *
+ * Load Secret
+ *
+ * )
+ * ```
+ *
* @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)}
+ * />
+ * ))}
+ *
Clear All
+ * >
+ * )
+ * ```
+ *
+ * @example
+ * ```tsx
+ * // Lazy loading: populate on demand
+ * const { items, refreshItems } = useSecureStorage({
+ * service: 'com.example',
+ * skip: true
+ * })
+ *
+ * return (
+ *
+ * Load Items
+ *
+ * )
* ```
+ *
+ * @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