# LocalData LocalData provides a typed, discoverable namespace for persisted app data across UserDefaults, Keychain, and file storage with optional encryption. ## Architecture The package uses a clean, modular architecture with isolated actors for thread safety: ``` StorageRouter (main entry point) ├── UserDefaultsHelper ├── KeychainHelper ├── FileStorageHelper ├── EncryptionHelper └── SyncHelper ``` ```mermaid %%{init: {'theme': 'base', 'themeVariables': { 'fontSize': '16px', 'primaryColor': '#ffffff', 'lineColor': '#000000', 'textColor': '#000000', 'mainBkg': '#ffffff', 'nodeBorder': '#000000' }}}%% flowchart TD %% Vertical Flow App(("📱 APP / FEATURE")) SR["🔀 STORAGE ROUTER
(The Central Engine)"] subgraph Config ["⚙️ GLOBAL CONFIGURATION"] direction TB C1["Encryption & Sync Config"] C2["App Group & File Config"] end subgraph Helpers ["🛠️ INTERNAL HELPER ACTORS"] direction TB H1["Keychain & File Storage Helpers"] H2["UserDefaults & Sync Helpers"] H3["🔐 Encryption Service"] end subgraph Storage ["💾 HARDWARE STORAGE LAYER"] direction LR S1[("🗝️ Keychain")] S2[("📁 File System")] S3[("⚙️ UserDefaults")] end %% Relationships App -->|StorageKey| SR SR -.->|Resolves| Config SR ==>|1. Orchestrate| Helpers Helpers ==>|2. Persist| Storage %% Migration Loop Storage -.->|3. AUTOMATIC MIGRATION| SR %% Explicit Styling for High Contrast style App fill:#ffffff,stroke:#000,stroke-width:3px style SR fill:#e1f5fe,stroke:#000,stroke-width:4px style Config fill:#fffde7,stroke:#000,stroke-dasharray: 5 5 style Helpers fill:#f5f5f5,stroke:#000,stroke-width:2px style Storage fill:#e8f5e9,stroke:#000,stroke-width:3px %% Link Styling linkStyle default stroke:#000,stroke-width:2px,color:#000 ``` ## What Ships in the Package ### Protocols - **StorageKey** - Define storage configuration for each data type - **StorageProviding** - Abstraction for storage operations - **KeyMaterialProviding** - Supplies external key material for encryption ### Services (Actors) - **StorageRouter** - Main entry point for all storage operations ### Internal Helpers (Not Public API) These helpers are internal implementation details used by `StorageRouter`. They are not part of the public API and should not be used directly. - **KeychainHelper** - Reads/writes secure items with Keychain APIs. - **EncryptionHelper** - Handles encryption/decryption and key derivation. - **FileStorageHelper** - Reads/writes files with appropriate protection. - **UserDefaultsHelper** - Wraps UserDefaults and suites safely. - **SyncHelper** - Manages WatchConnectivity sync. ### Global Configuration Models These are used at app lifecycle start to tune library engine behaviors: - **StorageConfiguration** - Default Keychain service and App Group IDs - **EncryptionConfiguration** - Global encryption settings (Keychain identifiers, key length) - **SyncConfiguration** - Global sync settings (Max automatic sync size) - **FileStorageConfiguration** - Global file settings (Sub-directory scoping) ### Other Models - **StorageDomain** - userDefaults, appGroupUserDefaults, keychain, fileSystem, encryptedFileSystem, appGroupFileSystem - **SecurityPolicy** - none, keychain, encrypted (AES-256 or ChaCha20-Poly1305) - **Serializer** - JSON, plist, Data, or custom - **KeyMaterialSource** - Identifier for external key material providers - **PlatformAvailability** - all, phoneOnly, watchOnly, phoneWithWatchSync - **SyncPolicy** - never, manual, automaticSmall - **KeychainAccessibility** - All 7 iOS accessibility options - **KeychainAccessControl** - All 6 access control options (biometry, passcode, etc.) - **FileDirectory** - documents, caches, custom URL - **StorageError** - Comprehensive error types - **StorageKeyDescriptor** - Audit snapshot of a key’s storage metadata - **AnyStorageKey** - Type-erased storage key for catalogs - **AnyCodable** - Type-erased Codable for mixed-type payloads ## Usage ### 1. Define Keys Extend `StorageKeys` with your own key types: ```swift import LocalData extension StorageKeys { struct UserTokenKey: StorageKey { typealias Value = String let name = "user_token" let domain: StorageDomain = .keychain(service: "com.myapp") let security: SecurityPolicy = .keychain( accessibility: .afterFirstUnlock, accessControl: .biometryAny ) let serializer: Serializer = .json let owner = "AuthService" let description = "Stores the current user auth token." let availability: PlatformAvailability = .phoneOnly let syncPolicy: SyncPolicy = .never } } ``` If you omit `security`, it defaults to `SecurityPolicy.recommended`. ### 2. Use StorageRouter ```swift // Save let key = StorageKeys.UserTokenKey() try await StorageRouter.shared.set("token123", for: key) // Retrieve let token = try await StorageRouter.shared.get(key) // Remove try await StorageRouter.shared.remove(key) ``` ### Complex Codable Support LocalData handles complex `Codable` types automatically. You are not limited to simple strings or integers. ```swift struct UserProfile: Codable { let id: UUID let name: String let settings: [String: String] } extension StorageKeys { struct ProfileKey: StorageKey { typealias Value = UserProfile // Library handles serialization let name = "user_profile" let domain: StorageDomain = .fileSystem(directory: .documents) // ... other properties } } // ... other properties } } ## Data Migration LocalData supports migrating data between different storage keys and domains (e.g., from a legacy `UserDefaults` string key to a modern secure `Keychain` key). ### 1. Automatic Fallback (Lazy) When you define `migrationSources` on a key, `StorageRouter.get(key)` will automatically check those sources if the primary key is not found. If data exists in a source: - It is retrieved using the source's metadata. - It is saved to the new key using the new security policy. - It is deleted from the source. - It is returned to the caller. ### 2. Proactive Sweep (Drain) To ensure no "ghost data" remains in legacy keys (e.g., if a bug causes old code to write to them again), you can use either a manual call or an automated startup sweep. #### Manual Call ```swift try await StorageRouter.shared.migrate(for: StorageKeys.ModernKey()) ``` #### Automated Startup Sweep When registering a catalog, you can enable `migrateImmediately` to perform a global sweep of all legacy keys for every key in the catalog. This ensures your storage is clean at every app launch. ```swift try await StorageRouter.shared.registerCatalog(AppStorageCatalog.self, migrateImmediately: true) ``` ## Storage Design Philosophy This app intentionally uses a **Type-Safe Storage Design**. Unlike standard iOS development which uses string keys (e.g., `UserDefaults.standard.string(forKey: "user_name")`), this library requires you to define a `StorageKey` type. ### Why types instead of strings? 1. **Safety**: The compiler prevents typos. You can't accidentally load from `"user_name"` and save to `"username"`. 2. **Codable Support**: Keys define their own value types. You can store complex `Codable` structs or classes just as easily as strings, and the library handles the JSON/Plist serialization automatically. 3. **Visibility**: All data your app stores is discoverable in the `StorageKeys/` folder. It serves as a manifest of your app's persistence layer. 4. **Migration**: You can move a piece of data from `UserDefaults` to `EncryptedFileSystem` just by changing the `domain` in the Key definition. No UI code needs to change. ## Storage Key Examples | Domain | Use Case | |--------|----------| | `userDefaults` | Preferences, small settings | | `appGroupUserDefaults` | Shared settings across extensions via App Groups | | `keychain` | Credentials, tokens, sensitive data | | `fileSystem(directory:)` | Local storage in Documents or Caches | | `encryptedFileSystem(directory:)` | Sensitive files with encryption policies | | `appGroupFileSystem(id:directory:)` | Shared files across targets via App Groups | ### File Directories The library supports two standard iOS locations via `FileDirectory`: | Directory | Persistence | iCloud Backup | Recommended Use | | :--- | :--- | :--- | :--- | | `.documents` | Permanent | Yes | User data, critical settings | | `.caches` | Purgeable* | No | Temporary files, downloaded assets | *\*iOS may delete files in `.caches` if the device runs low on storage.* By configuring a `subDirectory` in `FileStorageConfiguration`, you ensure that the library's data is isolated within its own folder in both locations (e.g., `Documents/MyData/` and `Caches/MyData/`). ## App Group Support App Group storage is explicit via `StorageDomain.appGroupUserDefaults` and `StorageDomain.appGroupFileSystem`. These require a valid App Group identifier and the corresponding entitlement on every target that needs access. If the identifier is invalid or missing, LocalData throws `StorageError.invalidAppGroupIdentifier`. Use standard `userDefaults` or `fileSystem` for data that should remain scoped to a single target, even when App Groups are configured. For app-level configuration (App Group identifiers, keychain service identifiers, etc.), centralize constants in a shared module so keys do not hardcode string literals. ## Security Options ### Keychain Accessibility - `whenUnlocked` - Only when device unlocked - `afterFirstUnlock` - After first unlock until restart - `whenUnlockedThisDeviceOnly` - No migration to new device - `afterFirstUnlockThisDeviceOnly` - No migration - `always` - Always accessible (least secure) - `alwaysThisDeviceOnly` - Always, no migration - `whenPasscodeSetThisDeviceOnly` - Requires passcode ### Access Control - `userPresence` - Any authentication - `biometryAny` - Face ID or Touch ID - `biometryCurrentSet` - Current enrolled biometric only - `devicePasscode` - Passcode only - `biometryAnyOrDevicePasscode` - Biometric preferred, passcode fallback - `biometryCurrentSetOrDevicePasscode` - Current biometric or passcode ### Encryption - AES-256-GCM or ChaCha20-Poly1305 - PBKDF2-SHA256 or HKDF-SHA256 key derivation - Configurable PBKDF2 iteration count - Master key stored securely in keychain - Default security policy: `SecurityPolicy.recommended` (ChaCha20-Poly1305 + HKDF) - External key material providers can be registered via `StorageRouter` #### Global Encryption Configuration You can customize the identifiers used for the master key in the Keychain: ```swift let config = EncryptionConfiguration( masterKeyService: "com.myapp.LocalData", masterKeyAccount: "MasterKey", pbkdf2Iterations: 50_000 ) await StorageRouter.shared.updateEncryptionConfiguration(config) ``` > [!WARNING] > Changing the `masterKeyService`, `masterKeyAccount`, or `pbkdf2Iterations` in an existing app will cause the app to look for or derive keys differently. Previously encrypted data will be inaccessible. #### Global Sync Configuration You can customize the maximum size for automatic synchronization: ```swift let syncConfig = SyncConfiguration(maxAutoSyncSize: 50_000) // 50KB limit await StorageRouter.shared.updateSyncConfiguration(syncConfig) ``` #### Global File Storage Configuration You can scope all library files into a specific sub-directory (e.g., to avoid cluttering the root Documents folder): ```swift let fileConfig = FileStorageConfiguration(subDirectory: "MyAppStorage") await StorageRouter.shared.updateFileStorageConfiguration(fileConfig) ``` This will result in paths like: - `.../Documents/MyAppStorage/` (Main Sandbox) - `.../SharedContainer/Documents/MyAppStorage/` (App Group) > [!WARNING] > Changing the `subDirectory` in an existing app will cause the library to look in the new location. Existing files in the old location will not be automatically moved. #### Global Storage Defaults To avoid repeating the same Keychain service or App Group identifier in every key, you can set library-wide defaults: ```swift let storageConfig = StorageConfiguration( defaultKeychainService: "com.myapp.keychain", defaultAppGroupIdentifier: "group.com.myapp" ) await StorageRouter.shared.updateStorageConfiguration(storageConfig) ``` When defaults are set, you can define keys using `nil` for these identifiers: - `.keychain(service: nil)` -> Uses "com.myapp.keychain" - `.appGroupUserDefaults(identifier: nil)` -> Uses "group.com.myapp" ```swift struct RemoteKeyProvider: KeyMaterialProviding { func keyMaterial(for keyName: String) async throws -> Data { // Example only: fetch from service or keychain Data(repeating: 1, count: 32) } } let source = KeyMaterialSource(id: "remote.key") await StorageRouter.shared.registerKeyMaterialProvider(RemoteKeyProvider(), for: source) let policy: SecurityPolicy.EncryptionPolicy = .external( source: source, keyDerivation: .hkdf() ) ``` ## Sync Behavior StorageRouter can sync data to Apple Watch via WCSession when: - `availability` is `.all` or `.phoneWithWatchSync` - `syncPolicy` is `.manual` or `.automaticSmall` (≤100KB) - WCSession is activated and watch is paired The app owns WCSession activation and handling incoming updates. ## Platforms - iOS 17+ - watchOS 10+ ## Testing - Unit tests use Swift Testing (`Testing` package) ## Storage Audit LocalData can generate a catalog of all configured storage keys, even if no data has been written yet. This is useful for security reviews and compliance. ### Why `AnyStorageKey`? `StorageKey` has an associated type (`Value`), which means you cannot store different keys in a single array using `[StorageKey]` or `some StorageKey`. Swift requires type erasure for heterogeneous protocol values, so the catalog uses `[AnyStorageKey]` and builds descriptors behind the scenes. 1) Define a catalog in your app that lists all keys: ```swift struct AppStorageCatalog: StorageKeyCatalog { static var allKeys: [AnyStorageKey] { [ .key(StorageKeys.AppVersionKey()), .key(StorageKeys.UserPreferencesKey()) ] } } ``` 2) Generate a report: ```swift let report = StorageAuditReport.renderText(for: AppStorageCatalog.self) print(report) ``` 3) Register the catalog to enforce usage and catch duplicates: ```swift do { try StorageRouter.shared.registerCatalog(AppStorageCatalog.self) } catch { assertionFailure("Storage catalog registration failed: \(error)") } ``` Each `StorageKey` must provide a human-readable `description` used in audit reports. Dynamic key names are intentionally not supported in the core API to keep storage auditing strict and predictable. If you need this later, see `FutureEnhancements.md` for a proposed design. ## Sample App See `SecureStorageSample` for working examples of all storage domains and security options.