From 4228fbc849cf809e277129b25e6383f2876170cc Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Tue, 13 Jan 2026 21:59:49 -0600 Subject: [PATCH] Update Models, Services + docs Summary: - Sources: Models, Services - Docs: Proposal, README - Added symbols: extension StorageKeys, struct UserTokenKey, typealias Value, enum KeychainAccessControl, enum KeychainAccessibility, actor EncryptionHelper (+38 more) - Removed symbols: enum KeychainAccessControl, enum KeychainAccessibility, enum EncryptionConstants, func serialize, func deserialize, func applySecurity (+17 more) Stats: - 10 files changed, 1089 insertions(+), 348 deletions(-) --- Proposal.md | 39 +- README.md | 132 +++++- .../Models/KeychainAccessControl.swift | 83 +++- .../Models/KeychainAccessibility.swift | 68 ++- .../LocalData/Services/EncryptionHelper.swift | 191 ++++++++ .../Services/FileStorageHelper.swift | 161 +++++++ .../LocalData/Services/KeychainHelper.swift | 143 ++++++ .../LocalData/Services/StorageRouter.swift | 433 ++++++------------ Sources/LocalData/Services/SyncHelper.swift | 111 +++++ .../Services/UserDefaultsHelper.swift | 78 ++++ 10 files changed, 1090 insertions(+), 349 deletions(-) create mode 100644 Sources/LocalData/Services/EncryptionHelper.swift create mode 100644 Sources/LocalData/Services/FileStorageHelper.swift create mode 100644 Sources/LocalData/Services/KeychainHelper.swift create mode 100644 Sources/LocalData/Services/SyncHelper.swift create mode 100644 Sources/LocalData/Services/UserDefaultsHelper.swift diff --git a/Proposal.md b/Proposal.md index bfdef15..7a51e7c 100644 --- a/Proposal.md +++ b/Proposal.md @@ -14,14 +14,33 @@ Create a single, typed, discoverable namespace for persisted app data with consi - CryptoKit (encryption) - WatchConnectivity (sync helpers) -## Implemented Scope -- StorageKey protocol and StorageKeys namespace for app-defined keys -- StorageRouter actor with async set, get, and remove -- StorageDomain options for user defaults, keychain, file system, and encrypted file system -- SecurityPolicy options for none, keychain, and encrypted data -- Serializer for JSON, property lists, raw Data, or custom encode and decode -- PlatformAvailability and SyncPolicy metadata -- AnyCodable for simple mixed-type payloads +## Architecture + +### Core Components +- **StorageKey** protocol - Defines storage configuration for each data type +- **StorageRouter** actor - Main entry point coordinating all storage operations +- **StorageProviding** protocol - Abstraction for storage operations + +### Isolated Helper Classes (Actors) +Each helper is a dedicated actor providing thread-safe access to a specific storage domain: + +- **KeychainHelper** - All keychain operations (set, get, delete, exists, deleteAll) +- **EncryptionHelper** - AES-256-GCM encryption with PBKDF2 key derivation +- **FileStorageHelper** - File system operations (read, write, delete, list, size) +- **UserDefaultsHelper** - UserDefaults operations with suite support +- **SyncHelper** - WatchConnectivity sync operations + +### Models +- **StorageDomain** - userDefaults, keychain, fileSystem, encryptedFileSystem +- **SecurityPolicy** - none, keychain (with accessibility/accessControl), encrypted (AES-256) +- **Serializer** - JSON, property list, raw Data, or custom encode/decode +- **PlatformAvailability** - all, phoneOnly, watchOnly, phoneWithWatchSync +- **SyncPolicy** - never, manual, automaticSmall +- **KeychainAccessibility** - All 7 iOS options (whenUnlocked, afterFirstUnlock, etc.) +- **KeychainAccessControl** - All 6 options (userPresence, biometryAny, devicePasscode, etc.) +- **FileDirectory** - documents, caches, custom URL +- **StorageError** - Comprehensive error types +- **AnyCodable** - Type-erased Codable for mixed-type payloads ## Usage Pattern Apps extend StorageKeys with their own key types and use StorageRouter.shared. This follows the Notification.Name pattern for discoverable keys. @@ -29,6 +48,10 @@ Apps extend StorageKeys with their own key types and use StorageRouter.shared. T ## Sync Behavior StorageRouter can call WCSession.updateApplicationContext for manual or automaticSmall sync policies when availability allows it. Session activation and receiving data are owned by the app. +## Platforms +- iOS 17+ +- watchOS 10+ + ## Future Ideas (Not Implemented) - Migration helpers for legacy storage - Key rotation strategies for encrypted data diff --git a/README.md b/README.md index e8b7754..29229e9 100644 --- a/README.md +++ b/README.md @@ -2,25 +2,129 @@ LocalData provides a typed, discoverable namespace for persisted app data across UserDefaults, Keychain, and file storage with optional encryption. -## What ships in the package -- StorageKey protocol and StorageKeys namespace for app-defined keys -- StorageRouter actor (StorageProviding) with async set/get/remove -- StorageDomain options for user defaults, keychain, and file storage -- SecurityPolicy options for none, keychain, or encrypted data -- Serializer for JSON, property lists, raw Data, or custom encode and decode -- PlatformAvailability and SyncPolicy metadata for watch behavior -- AnyCodable utility for structured payloads +## Architecture + +The package uses a clean, modular architecture with isolated actors for thread safety: + +``` +StorageRouter (main entry point) + ├── UserDefaultsHelper + ├── KeychainHelper + ├── FileStorageHelper + ├── EncryptionHelper + └── SyncHelper +``` + +## What Ships in the Package + +### Protocols +- **StorageKey** - Define storage configuration for each data type +- **StorageProviding** - Abstraction for storage operations + +### Services (Actors) +- **StorageRouter** - Main entry point for all storage operations +- **KeychainHelper** - Secure keychain storage +- **EncryptionHelper** - AES-256-GCM encryption with PBKDF2 +- **FileStorageHelper** - File system operations +- **UserDefaultsHelper** - UserDefaults with suite support +- **SyncHelper** - WatchConnectivity sync + +### Models +- **StorageDomain** - userDefaults, keychain, fileSystem, encryptedFileSystem +- **SecurityPolicy** - none, keychain, encrypted (AES-256) +- **Serializer** - JSON, plist, Data, or custom +- **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 +- **AnyCodable** - Type-erased Codable for mixed-type payloads ## Usage -Define keys in your app by extending StorageKeys. Use StorageRouter.shared to set, get, and remove values. See SecureStorgageSample/SecureStorgageSample for working examples. -## Sync behavior -StorageRouter can call WCSession.updateApplicationContext when SyncPolicy is manual or automaticSmall and availability is all or phoneWithWatchSync. The app is responsible for activating WCSession and handling incoming updates. +### 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 availability: PlatformAvailability = .phoneOnly + let syncPolicy: SyncPolicy = .never + } +} +``` + +### 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) +``` + +## Storage Domains + +| Domain | Use Case | +|--------|----------| +| `userDefaults` | Preferences, small settings | +| `keychain` | Credentials, tokens, sensitive data | +| `fileSystem` | Documents, cached data, large files | +| `encryptedFileSystem` | Sensitive files with AES-256 encryption | + +## 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 with PBKDF2-SHA256 key derivation +- Configurable iteration count +- Master key stored securely in keychain + +## 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+ -## Notes -- LocalData does not include sample key definitions or models. -- Keys should be stable and unique per domain or service. +## Sample App +See `SecureStorgageSample` for working examples of all storage domains and security options. diff --git a/Sources/LocalData/Models/KeychainAccessControl.swift b/Sources/LocalData/Models/KeychainAccessControl.swift index 4c6e9c4..3ea76b1 100644 --- a/Sources/LocalData/Models/KeychainAccessControl.swift +++ b/Sources/LocalData/Models/KeychainAccessControl.swift @@ -1,27 +1,76 @@ import Foundation import Security -public enum KeychainAccessControl: Sendable { +/// Defines additional access control requirements for keychain items. +/// These flags can require user authentication before accessing the item. +public enum KeychainAccessControl: Sendable, CaseIterable { + /// Requires any form of user presence (biometric or passcode). case userPresence - // Add more as needed - + + /// Requires biometric authentication only (Face ID or Touch ID). + /// Falls back to nothing if biometry is not available. + case biometryAny + + /// Requires the currently enrolled biometric. + /// If biometric enrollment changes, item becomes inaccessible. + case biometryCurrentSet + + /// Requires device passcode entry. + case devicePasscode + + /// Requires biometric or device passcode. + /// Biometric is preferred, passcode is fallback. + case biometryAnyOrDevicePasscode + + /// Requires current biometric or device passcode. + /// If biometric changes, still accessible via passcode. + case biometryCurrentSetOrDevicePasscode + + /// Creates a SecAccessControl object with the specified accessibility. + /// - Parameter accessibility: The base accessibility level. + /// - Returns: A configured SecAccessControl, or nil if creation fails. func accessControl(accessibility: KeychainAccessibility) -> SecAccessControl? { - let accessibilityValue: CFString - switch accessibility { - case .whenUnlocked: - accessibilityValue = kSecAttrAccessibleWhenUnlocked - case .afterFirstUnlock: - accessibilityValue = kSecAttrAccessibleAfterFirstUnlock - } - + let accessibilityValue = accessibility.cfString + + let flags: SecAccessControlCreateFlags switch self { case .userPresence: - return SecAccessControlCreateWithFlags( - nil, - accessibilityValue, - .userPresence, - nil - ) + flags = .userPresence + case .biometryAny: + flags = .biometryAny + case .biometryCurrentSet: + flags = .biometryCurrentSet + case .devicePasscode: + flags = .devicePasscode + case .biometryAnyOrDevicePasscode: + flags = [.biometryAny, .or, .devicePasscode] + case .biometryCurrentSetOrDevicePasscode: + flags = [.biometryCurrentSet, .or, .devicePasscode] + } + + return SecAccessControlCreateWithFlags( + nil, + accessibilityValue, + flags, + nil + ) + } + + /// Human-readable description for UI display. + public var displayName: String { + switch self { + case .userPresence: + return "User Presence" + case .biometryAny: + return "Biometry (Any)" + case .biometryCurrentSet: + return "Biometry (Current Set)" + case .devicePasscode: + return "Device Passcode" + case .biometryAnyOrDevicePasscode: + return "Biometry or Passcode" + case .biometryCurrentSetOrDevicePasscode: + return "Current Biometry or Passcode" } } } diff --git a/Sources/LocalData/Models/KeychainAccessibility.swift b/Sources/LocalData/Models/KeychainAccessibility.swift index 525b866..edf2b6b 100644 --- a/Sources/LocalData/Models/KeychainAccessibility.swift +++ b/Sources/LocalData/Models/KeychainAccessibility.swift @@ -1,15 +1,73 @@ import Foundation import Security -public enum KeychainAccessibility: Sendable { +/// Defines when a keychain item can be accessed. +/// Maps directly to Security framework's kSecAttrAccessible constants. +public enum KeychainAccessibility: Sendable, CaseIterable { + /// Item is only accessible while the device is unlocked. + /// This is the most restrictive option for general use. case whenUnlocked + + /// Item is accessible after the first unlock until device restart. + /// Good balance of security and background access. case afterFirstUnlock - // Add more as needed - + + /// Item is only accessible when the device is unlocked. + /// Data is not migrated to a new device. + case whenUnlockedThisDeviceOnly + + /// Item is accessible after first unlock until device restart. + /// Data is not migrated to a new device. + case afterFirstUnlockThisDeviceOnly + + /// Item is always accessible, regardless of device lock state. + /// Least secure - use only when absolutely necessary. + case always + + /// Item is always accessible but not migrated to new devices. + case alwaysThisDeviceOnly + + /// Item is only accessible when the device has a passcode set. + /// If passcode is removed, item becomes inaccessible. + case whenPasscodeSetThisDeviceOnly + + /// The corresponding Security framework constant. var cfString: CFString { switch self { - case .whenUnlocked: return kSecAttrAccessibleWhenUnlocked - case .afterFirstUnlock: return kSecAttrAccessibleAfterFirstUnlock + case .whenUnlocked: + return kSecAttrAccessibleWhenUnlocked + case .afterFirstUnlock: + return kSecAttrAccessibleAfterFirstUnlock + case .whenUnlockedThisDeviceOnly: + return kSecAttrAccessibleWhenUnlockedThisDeviceOnly + case .afterFirstUnlockThisDeviceOnly: + return kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + case .always: + return kSecAttrAccessibleAlways + case .alwaysThisDeviceOnly: + return kSecAttrAccessibleAlwaysThisDeviceOnly + case .whenPasscodeSetThisDeviceOnly: + return kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly + } + } + + /// Human-readable description for UI display. + public var displayName: String { + switch self { + case .whenUnlocked: + return "When Unlocked" + case .afterFirstUnlock: + return "After First Unlock" + case .whenUnlockedThisDeviceOnly: + return "When Unlocked (This Device)" + case .afterFirstUnlockThisDeviceOnly: + return "After First Unlock (This Device)" + case .always: + return "Always" + case .alwaysThisDeviceOnly: + return "Always (This Device)" + case .whenPasscodeSetThisDeviceOnly: + return "When Passcode Set (This Device)" } } } diff --git a/Sources/LocalData/Services/EncryptionHelper.swift b/Sources/LocalData/Services/EncryptionHelper.swift new file mode 100644 index 0000000..d7d7a0f --- /dev/null +++ b/Sources/LocalData/Services/EncryptionHelper.swift @@ -0,0 +1,191 @@ +import Foundation +import CryptoKit + +/// Actor that handles all encryption and decryption operations. +/// Uses AES-GCM for symmetric encryption with PBKDF2 key derivation. +public actor EncryptionHelper { + + public static let shared = EncryptionHelper() + + private enum Constants { + static let masterKeyService = "LocalData.MasterKey" + static let masterKeyAccount = "LocalData.MasterKey" + static let masterKeyLength = 32 + } + + private init() {} + + // MARK: - Public Interface + + /// Encrypts data using AES-GCM. + /// - Parameters: + /// - data: The plaintext data to encrypt. + /// - keyName: A unique name used for key derivation salt. + /// - policy: The encryption policy specifying algorithm and key derivation. + /// - Returns: The encrypted data (nonce + ciphertext + tag combined). + /// - Throws: `StorageError.securityApplicationFailed` if encryption fails. + public func encrypt( + _ data: Data, + keyName: String, + policy: SecurityPolicy.EncryptionPolicy + ) async throws -> Data { + let key = try await deriveKey(keyName: keyName, policy: policy) + return try encryptWithKey(data, using: key) + } + + /// Decrypts data using AES-GCM. + /// - Parameters: + /// - data: The encrypted data (nonce + ciphertext + tag combined). + /// - keyName: The same unique name used during encryption. + /// - policy: The same encryption policy used during encryption. + /// - Returns: The decrypted plaintext data. + /// - Throws: `StorageError.securityApplicationFailed` if decryption fails. + public func decrypt( + _ data: Data, + keyName: String, + policy: SecurityPolicy.EncryptionPolicy + ) async throws -> Data { + let key = try await deriveKey(keyName: keyName, policy: policy) + return try decryptWithKey(data, using: key) + } + + // MARK: - Key Derivation + + /// Derives an encryption key using the specified policy. + private func deriveKey( + keyName: String, + policy: SecurityPolicy.EncryptionPolicy + ) async throws -> SymmetricKey { + switch policy { + case .aes256(let keyDerivation): + let masterKey = try await getMasterKey() + + // Extract salt and iterations from key derivation + let (salt, iterations) = extractDerivationParams(keyDerivation, keyName: keyName) + + let derivedKeyData = try pbkdf2SHA256( + password: masterKey, + salt: salt, + iterations: iterations, + keyLength: Constants.masterKeyLength + ) + + return SymmetricKey(data: derivedKeyData) + } + } + + /// Extracts parameters from KeyDerivation enum. + private func extractDerivationParams(_ derivation: SecurityPolicy.KeyDerivation, keyName: String) -> (salt: Data, iterations: Int) { + switch derivation { + case .pbkdf2(let iterations, let customSalt): + let salt = customSalt ?? Data(keyName.utf8) + return (salt, iterations) + } + } + + /// Gets or creates the master key stored in keychain. + private func getMasterKey() async throws -> Data { + if let existing = try await KeychainHelper.shared.get( + service: Constants.masterKeyService, + key: Constants.masterKeyAccount + ) { + return existing + } + + // Generate new master key + var bytes = [UInt8](repeating: 0, count: Constants.masterKeyLength) + let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + guard status == errSecSuccess else { + throw StorageError.securityApplicationFailed + } + + let masterKey = Data(bytes) + + // Store in keychain + try await KeychainHelper.shared.set( + masterKey, + service: Constants.masterKeyService, + key: Constants.masterKeyAccount, + accessibility: .afterFirstUnlock, + accessControl: nil + ) + + return masterKey + } + + // MARK: - AES-GCM Operations + + private func encryptWithKey(_ data: Data, using key: SymmetricKey) throws -> Data { + do { + let sealedBox = try AES.GCM.seal(data, using: key) + guard let combined = sealedBox.combined else { + throw StorageError.securityApplicationFailed + } + return combined + } catch { + throw StorageError.securityApplicationFailed + } + } + + private func decryptWithKey(_ data: Data, using key: SymmetricKey) throws -> Data { + do { + let sealedBox = try AES.GCM.SealedBox(combined: data) + return try AES.GCM.open(sealedBox, using: key) + } catch { + throw StorageError.securityApplicationFailed + } + } + + // MARK: - PBKDF2 Implementation + + private func pbkdf2SHA256( + password: Data, + salt: Data, + iterations: Int, + keyLength: Int + ) throws -> Data { + guard iterations > 0 else { + throw StorageError.securityApplicationFailed + } + + var derivedKey = Data() + var blockIndex: UInt32 = 1 + + while derivedKey.count < keyLength { + var block = Data() + block.append(salt) + block.append(uint32BigEndian(blockIndex)) + + var u = hmacSHA256(key: password, data: block) + var t = u + + if iterations > 1 { + for _ in 1.. Data { + let symmetricKey = SymmetricKey(data: key) + let mac = HMAC.authenticationCode(for: data, using: symmetricKey) + return Data(mac) + } + + private func xor(_ left: Data, _ right: Data) -> Data { + let xored = zip(left, right).map { $0 ^ $1 } + return Data(xored) + } + + private func uint32BigEndian(_ value: UInt32) -> Data { + var bigEndian = value.bigEndian + return Data(bytes: &bigEndian, count: MemoryLayout.size) + } +} diff --git a/Sources/LocalData/Services/FileStorageHelper.swift b/Sources/LocalData/Services/FileStorageHelper.swift new file mode 100644 index 0000000..1f47af8 --- /dev/null +++ b/Sources/LocalData/Services/FileStorageHelper.swift @@ -0,0 +1,161 @@ +import Foundation + +/// Actor that handles all file system operations. +/// Provides thread-safe file reading, writing, and deletion. +public actor FileStorageHelper { + + public static let shared = FileStorageHelper() + + private init() {} + + // MARK: - Public Interface + + /// Writes data to a file. + /// - Parameters: + /// - data: The data to write. + /// - directory: The base directory. + /// - fileName: The file name within the directory. + /// - useCompleteFileProtection: Whether to use iOS complete file protection. + /// - Throws: `StorageError.fileError` if the operation fails. + public func write( + _ data: Data, + to directory: FileDirectory, + fileName: String, + useCompleteFileProtection: Bool = false + ) throws { + let url = directory.url().appendingPathComponent(fileName) + + // Ensure directory exists + try ensureDirectoryExists(at: url.deletingLastPathComponent()) + + // Write with appropriate options + var options: Data.WritingOptions = [.atomic] + if useCompleteFileProtection { + options.insert(.completeFileProtection) + } + + do { + try data.write(to: url, options: options) + } catch { + throw StorageError.fileError(error) + } + } + + /// Reads data from a file. + /// - Parameters: + /// - directory: The base directory. + /// - fileName: The file name within the directory. + /// - Returns: The file contents, or nil if the file doesn't exist. + /// - Throws: `StorageError.fileError` if reading fails. + public func read( + from directory: FileDirectory, + fileName: String + ) throws -> Data? { + let url = directory.url().appendingPathComponent(fileName) + + guard FileManager.default.fileExists(atPath: url.path) else { + return nil + } + + do { + return try Data(contentsOf: url) + } catch { + throw StorageError.fileError(error) + } + } + + /// Deletes a file. + /// - Parameters: + /// - directory: The base directory. + /// - fileName: The file name within the directory. + /// - Throws: `StorageError.fileError` if deletion fails. + public func delete( + from directory: FileDirectory, + fileName: String + ) throws { + let url = directory.url().appendingPathComponent(fileName) + + guard FileManager.default.fileExists(atPath: url.path) else { + return // File doesn't exist, nothing to delete + } + + do { + try FileManager.default.removeItem(at: url) + } catch { + throw StorageError.fileError(error) + } + } + + /// Checks if a file exists. + /// - Parameters: + /// - directory: The base directory. + /// - fileName: The file name within the directory. + /// - Returns: True if the file exists. + public func exists( + in directory: FileDirectory, + fileName: String + ) -> Bool { + let url = directory.url().appendingPathComponent(fileName) + return FileManager.default.fileExists(atPath: url.path) + } + + /// Lists all files in a directory. + /// - Parameter directory: The directory to list. + /// - Returns: An array of file names. + /// - Throws: `StorageError.fileError` if listing fails. + public func list(in directory: FileDirectory) throws -> [String] { + let url = directory.url() + + guard FileManager.default.fileExists(atPath: url.path) else { + return [] + } + + do { + return try FileManager.default.contentsOfDirectory(atPath: url.path) + } catch { + throw StorageError.fileError(error) + } + } + + /// Gets the size of a file in bytes. + /// - Parameters: + /// - directory: The base directory. + /// - fileName: The file name within the directory. + /// - Returns: The file size in bytes, or nil if the file doesn't exist. + /// - Throws: `StorageError.fileError` if getting attributes fails. + public func size( + of directory: FileDirectory, + fileName: String + ) throws -> Int64? { + let url = directory.url().appendingPathComponent(fileName) + + guard FileManager.default.fileExists(atPath: url.path) else { + return nil + } + + do { + let attributes = try FileManager.default.attributesOfItem(atPath: url.path) + return attributes[.size] as? Int64 + } catch { + throw StorageError.fileError(error) + } + } + + // MARK: - Private Helpers + + private func ensureDirectoryExists(at url: URL) throws { + guard !FileManager.default.fileExists(atPath: url.path) else { + return + } + + do { + try FileManager.default.createDirectory( + at: url, + withIntermediateDirectories: true, + attributes: nil + ) + } catch { + throw StorageError.fileError(error) + } + } +} diff --git a/Sources/LocalData/Services/KeychainHelper.swift b/Sources/LocalData/Services/KeychainHelper.swift new file mode 100644 index 0000000..9214331 --- /dev/null +++ b/Sources/LocalData/Services/KeychainHelper.swift @@ -0,0 +1,143 @@ +import Foundation +import Security + +/// Actor that handles all Keychain operations in isolation. +/// Provides thread-safe access to the iOS/watchOS Keychain. +public actor KeychainHelper { + + public static let shared = KeychainHelper() + + private init() {} + + // MARK: - Public Interface + + /// Stores data in the keychain. + /// - Parameters: + /// - data: The data to store. + /// - service: The service identifier (usually app bundle ID or feature name). + /// - key: The account/key name. + /// - accessibility: When the keychain item should be accessible. + /// - accessControl: Optional access control (biometric, passcode, etc.). + /// - Throws: `StorageError.keychainError` if the operation fails. + public func set( + _ data: Data, + service: String, + key: String, + accessibility: KeychainAccessibility, + accessControl: KeychainAccessControl? = nil + ) throws { + var attributes: [String: Any] = [ + kSecValueData as String: data + ] + + if let accessControl { + guard let accessControlValue = accessControl.accessControl(accessibility: accessibility) else { + throw StorageError.securityApplicationFailed + } + attributes[kSecAttrAccessControl as String] = accessControlValue + } else { + attributes[kSecAttrAccessible as String] = accessibility.cfString + } + + let query = baseQuery(service: service, key: key) + let addQuery = query.merging(attributes) { _, new in new } + + let status = SecItemAdd(addQuery as CFDictionary, nil) + + if status == errSecDuplicateItem { + // Item exists, update it + let updateStatus = SecItemUpdate( + query as CFDictionary, + [kSecValueData as String: data] as CFDictionary + ) + if updateStatus != errSecSuccess { + throw StorageError.keychainError(updateStatus) + } + } else if status != errSecSuccess { + throw StorageError.keychainError(status) + } + } + + /// Retrieves data from the keychain. + /// - Parameters: + /// - service: The service identifier. + /// - key: The account/key name. + /// - Returns: The stored data, or nil if not found. + /// - Throws: `StorageError.keychainError` if the operation fails. + public func get(service: String, key: String) throws -> Data? { + var query = baseQuery(service: service, key: key) + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var item: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &item) + + if status == errSecSuccess { + return item as? Data + } else if status == errSecItemNotFound { + return nil + } else { + throw StorageError.keychainError(status) + } + } + + /// Deletes data from the keychain. + /// - Parameters: + /// - service: The service identifier. + /// - key: The account/key name. + /// - Throws: `StorageError.keychainError` if the operation fails (except for item not found). + public func delete(service: String, key: String) throws { + let query = baseQuery(service: service, key: key) + let status = SecItemDelete(query as CFDictionary) + + if status != errSecSuccess && status != errSecItemNotFound { + throw StorageError.keychainError(status) + } + } + + /// Checks if an item exists in the keychain. + /// - Parameters: + /// - service: The service identifier. + /// - key: The account/key name. + /// - Returns: True if the item exists. + public func exists(service: String, key: String) throws -> Bool { + var query = baseQuery(service: service, key: key) + query[kSecReturnData as String] = false + + let status = SecItemCopyMatching(query as CFDictionary, nil) + + if status == errSecSuccess { + return true + } else if status == errSecItemNotFound { + return false + } else { + throw StorageError.keychainError(status) + } + } + + /// Deletes all items for a given service. + /// - Parameter service: The service identifier. + /// - Throws: `StorageError.keychainError` if the operation fails. + public func deleteAll(service: String) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service + ] + + let status = SecItemDelete(query as CFDictionary) + + if status != errSecSuccess && status != errSecItemNotFound { + throw StorageError.keychainError(status) + } + } + + // MARK: - Private Helpers + + private func baseQuery(service: String, key: String) -> [String: Any] { + return [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key + ] + } +} diff --git a/Sources/LocalData/Services/StorageRouter.swift b/Sources/LocalData/Services/StorageRouter.swift index a6a3e2d..f7d9806 100644 --- a/Sources/LocalData/Services/StorageRouter.swift +++ b/Sources/LocalData/Services/StorageRouter.swift @@ -1,53 +1,74 @@ import Foundation -import CryptoKit -import Security #if os(iOS) || os(watchOS) import WatchConnectivity #endif +/// The main storage router that coordinates all storage operations. +/// Uses specialized helper actors for each storage domain. public actor StorageRouter: StorageProviding { + public static let shared = StorageRouter() - - private enum EncryptionConstants { - static let masterKeyService = "LocalData.MasterKey" - static let masterKeyAccount = "LocalData.MasterKey" - static let masterKeyLength = 32 - } - + private init() {} - + + // MARK: - StorageProviding Implementation + + /// Stores a value for the given key. + /// - Parameters: + /// - value: The value to store. + /// - key: The storage key defining where and how to store. + /// - Throws: Various errors depending on the storage domain and security policy. public func set(_ value: Key.Value, for key: Key) async throws { try validatePlatformAvailability(for: key) - + let data = try serialize(value, with: key.serializer) - - let securedData = try applySecurity(data, for: key, isEncrypt: true) - + let securedData = try await applySecurity(data, for: key, isEncrypt: true) + try await store(securedData, for: key) - try await handleSync(key, data: securedData) } - + + /// Retrieves a value for the given key. + /// - Parameter key: The storage key to retrieve. + /// - Returns: The stored value. + /// - Throws: `StorageError.notFound` if no value exists, plus domain-specific errors. public func get(_ key: Key) async throws -> Key.Value { try validatePlatformAvailability(for: key) - + guard let securedData = try await retrieve(for: key) else { throw StorageError.notFound } - - let data = try applySecurity(securedData, for: key, isEncrypt: false) - + + let data = try await applySecurity(securedData, for: key, isEncrypt: false) return try deserialize(data, with: key.serializer) } - + + /// Removes the value for the given key. + /// - Parameter key: The storage key to remove. + /// - Throws: Domain-specific errors if removal fails. public func remove(_ key: Key) async throws { try validatePlatformAvailability(for: key) - try await delete(for: key) } - + + /// Checks if a value exists for the given key. + /// - Parameter key: The storage key to check. + /// - Returns: True if a value exists. + public func exists(_ key: Key) async throws -> Bool { + try validatePlatformAvailability(for: key) + + switch key.domain { + case .userDefaults(let suite): + return try await UserDefaultsHelper.shared.exists(forKey: key.name, suite: suite) + case .keychain(let service): + return try await KeychainHelper.shared.exists(service: service, key: key.name) + case .fileSystem(let directory), .encryptedFileSystem(let directory): + return await FileStorageHelper.shared.exists(in: directory, fileName: key.name) + } + } + // MARK: - Platform Validation - + private func validatePlatformAvailability(for key: Key) throws { #if os(watchOS) if key.availability == .phoneOnly { @@ -59,332 +80,134 @@ public actor StorageRouter: StorageProviding { } #endif } - + // MARK: - Serialization - - private func serialize(_ value: Value, with serializer: Serializer) throws -> Data { + + private func serialize( + _ value: Value, + with serializer: Serializer + ) throws -> Data { do { return try serializer.encode(value) } catch { throw StorageError.serializationFailed } } - - private func deserialize(_ data: Data, with serializer: Serializer) throws -> Value { + + private func deserialize( + _ data: Data, + with serializer: Serializer + ) throws -> Value { do { return try serializer.decode(data) } catch { throw StorageError.deserializationFailed } } - + // MARK: - Security - - private func applySecurity(_ data: Data, for key: any StorageKey, isEncrypt: Bool) throws -> Data { + + private func applySecurity( + _ data: Data, + for key: any StorageKey, + isEncrypt: Bool + ) async throws -> Data { switch key.security { case .none: return data + case .encrypted(let encryptionPolicy): - let derivedKey = try encryptionKey(for: key, policy: encryptionPolicy) - return isEncrypt ? try encrypt(data, using: derivedKey) : try decrypt(data, using: derivedKey) + if isEncrypt { + return try await EncryptionHelper.shared.encrypt( + data, + keyName: key.name, + policy: encryptionPolicy + ) + } else { + return try await EncryptionHelper.shared.decrypt( + data, + keyName: key.name, + policy: encryptionPolicy + ) + } + case .keychain: // Keychain security is handled in store/retrieve return data } } - - private func encrypt(_ data: Data, using key: SymmetricKey) throws -> Data { - let sealedBox = try AES.GCM.seal(data, using: key) - guard let combined = sealedBox.combined else { - throw StorageError.securityApplicationFailed - } - return combined - } - - private func decrypt(_ data: Data, using key: SymmetricKey) throws -> Data { - let sealedBox = try AES.GCM.SealedBox(combined: data) - return try AES.GCM.open(sealedBox, using: key) - } - - private func encryptionKey(for key: any StorageKey, policy: SecurityPolicy.EncryptionPolicy) throws -> SymmetricKey { - switch policy { - case .aes256(let derivation): - let password = try masterKeyData() - let salt = derivation.salt ?? Data(key.name.utf8) - let derivedKeyData = try pbkdf2SHA256( - password: password, - salt: salt, - iterations: derivation.iterations, - keyLength: EncryptionConstants.masterKeyLength - ) - return SymmetricKey(data: derivedKeyData) - } - } - - private func masterKeyData() throws -> Data { - if let existing = try keychainGet( - service: EncryptionConstants.masterKeyService, - key: EncryptionConstants.masterKeyAccount - ) { - return existing - } - - var bytes = [UInt8](repeating: 0, count: EncryptionConstants.masterKeyLength) - let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) - guard status == errSecSuccess else { - throw StorageError.securityApplicationFailed - } - - let data = Data(bytes) - try keychainSet( - data, - service: EncryptionConstants.masterKeyService, - key: EncryptionConstants.masterKeyAccount, - policy: .keychain(accessibility: .afterFirstUnlock, accessControl: nil) - ) - return data - } - - private func pbkdf2SHA256(password: Data, salt: Data, iterations: Int, keyLength: Int) throws -> Data { - guard iterations > 0 else { - throw StorageError.securityApplicationFailed - } - - var derivedKey = Data() - var blockIndex: UInt32 = 1 - - while derivedKey.count < keyLength { - var block = Data() - block.append(salt) - block.append(uint32Data(blockIndex)) - - var u = hmacSHA256(key: password, data: block) - var t = u - - if iterations > 1 { - for _ in 1.. Data { - let symmetricKey = SymmetricKey(data: key) - let mac = HMAC.authenticationCode(for: data, using: symmetricKey) - return Data(mac) - } - - private func xor(_ left: Data, _ right: Data) -> Data { - let xored = zip(left, right).map { $0 ^ $1 } - return Data(xored) - } - - private func uint32Data(_ value: UInt32) -> Data { - var bigEndian = value.bigEndian - return Data(bytes: &bigEndian, count: MemoryLayout.size) - } - - // MARK: - Storage - + + // MARK: - Storage Operations + private func store(_ data: Data, for key: any StorageKey) async throws { switch key.domain { case .userDefaults(let suite): - let defaults = try userDefaults(for: suite) - defaults.set(data, forKey: key.name) + try await UserDefaultsHelper.shared.set(data, forKey: key.name, suite: suite) + case .keychain(let service): - try keychainSet(data, service: service, key: key.name, policy: key.security) + guard case let .keychain(accessibility, accessControl) = key.security else { + throw StorageError.securityApplicationFailed + } + try await KeychainHelper.shared.set( + data, + service: service, + key: key.name, + accessibility: accessibility, + accessControl: accessControl + ) + case .fileSystem(let directory): - let url = directory.url().appending(path: key.name) - try ensureDirectoryExists(at: url.deletingLastPathComponent()) - try writeData(data, to: url, options: [.atomic]) + try await FileStorageHelper.shared.write( + data, + to: directory, + fileName: key.name, + useCompleteFileProtection: false + ) + case .encryptedFileSystem(let directory): - let url = directory.url().appending(path: key.name) - try ensureDirectoryExists(at: url.deletingLastPathComponent()) - try writeData(data, to: url, options: [.atomic, .completeFileProtection]) + try await FileStorageHelper.shared.write( + data, + to: directory, + fileName: key.name, + useCompleteFileProtection: true + ) } } - + private func retrieve(for key: any StorageKey) async throws -> Data? { switch key.domain { case .userDefaults(let suite): - let defaults = try userDefaults(for: suite) - return defaults.data(forKey: key.name) + return try await UserDefaultsHelper.shared.get(forKey: key.name, suite: suite) + case .keychain(let service): - return try keychainGet(service: service, key: key.name) - case .fileSystem(let directory): - let url = directory.url().appending(path: key.name) - return try readData(from: url) - case .encryptedFileSystem(let directory): - let url = directory.url().appending(path: key.name) - return try readData(from: url) + return try await KeychainHelper.shared.get(service: service, key: key.name) + + case .fileSystem(let directory), .encryptedFileSystem(let directory): + return try await FileStorageHelper.shared.read(from: directory, fileName: key.name) } } - + private func delete(for key: any StorageKey) async throws { switch key.domain { case .userDefaults(let suite): - let defaults = try userDefaults(for: suite) - defaults.removeObject(forKey: key.name) + try await UserDefaultsHelper.shared.remove(forKey: key.name, suite: suite) + case .keychain(let service): - try keychainDelete(service: service, key: key.name) + try await KeychainHelper.shared.delete(service: service, key: key.name) + case .fileSystem(let directory), .encryptedFileSystem(let directory): - let url = directory.url().appending(path: key.name) - try deleteItemIfExists(at: url) + try await FileStorageHelper.shared.delete(from: directory, fileName: key.name) } } - - private func userDefaults(for suite: String?) throws -> UserDefaults { - guard let suite else { return .standard } - guard let defaults = UserDefaults(suiteName: suite) else { - throw StorageError.invalidUserDefaultsSuite(suite) - } - return defaults - } - - private func ensureDirectoryExists(at url: URL) throws { - do { - try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil) - } catch { - throw StorageError.fileError(error) - } - } - - private func writeData(_ data: Data, to url: URL, options: Data.WritingOptions) throws { - do { - try data.write(to: url, options: options) - } catch { - throw StorageError.fileError(error) - } - } - - private func readData(from url: URL) throws -> Data? { - guard FileManager.default.fileExists(atPath: url.path) else { - return nil - } - - do { - return try Data(contentsOf: url) - } catch { - throw StorageError.fileError(error) - } - } - - private func deleteItemIfExists(at url: URL) throws { - guard FileManager.default.fileExists(atPath: url.path) else { - return - } - - do { - try FileManager.default.removeItem(at: url) - } catch { - throw StorageError.fileError(error) - } - } - - // MARK: - Keychain Helpers - - private func keychainSet(_ data: Data, service: String, key: String, policy: SecurityPolicy) throws { - guard case let .keychain(accessibility, accessControl) = policy else { - throw StorageError.securityApplicationFailed - } - - var attributes: [String: Any] = [ - kSecValueData as String: data - ] - - if let accessControl { - guard let accessControlValue = accessControl.accessControl(accessibility: accessibility) else { - throw StorageError.securityApplicationFailed - } - attributes[kSecAttrAccessControl as String] = accessControlValue - } else { - attributes[kSecAttrAccessible as String] = accessibility.cfString - } - - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: key - ] - - let addQuery = query.merging(attributes) { _, new in new } - let status = SecItemAdd(addQuery as CFDictionary, nil) - if status == errSecDuplicateItem { - let updateStatus = SecItemUpdate(query as CFDictionary, [kSecValueData as String: data] as CFDictionary) - if updateStatus != errSecSuccess { - throw StorageError.keychainError(updateStatus) - } - } else if status != errSecSuccess { - throw StorageError.keychainError(status) - } - } - - private func keychainGet(service: String, key: String) throws -> Data? { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: key, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne - ] - - var item: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &item) - if status == errSecSuccess { - return item as? Data - } else if status == errSecItemNotFound { - return nil - } else { - throw StorageError.keychainError(status) - } - } - - private func keychainDelete(service: String, key: String) throws { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: key - ] - - let status = SecItemDelete(query as CFDictionary) - if status != errSecSuccess && status != errSecItemNotFound { - throw StorageError.keychainError(status) - } - } - + // MARK: - Sync -#if os(iOS) || os(watchOS) + private func handleSync(_ key: any StorageKey, data: Data) async throws { - guard key.availability == .all || key.availability == .phoneWithWatchSync else { - return - } - - switch key.syncPolicy { - case .never: - return - case .automaticSmall: - if data.count > 100_000 { throw StorageError.dataTooLargeForSync } - fallthrough - case .manual: - guard WCSession.isSupported() else { return } - let session = WCSession.default - guard session.activationState == .activated else { return } - #if os(iOS) - guard session.isPaired, session.isWatchAppInstalled else { return } - #endif - try session.updateApplicationContext([key.name: data]) - } + try await SyncHelper.shared.syncIfNeeded( + data: data, + keyName: key.name, + availability: key.availability, + syncPolicy: key.syncPolicy + ) } -#else - private func handleSync(_ key: any StorageKey, data: Data) async throws { - // No sync on other platforms - } -#endif } diff --git a/Sources/LocalData/Services/SyncHelper.swift b/Sources/LocalData/Services/SyncHelper.swift new file mode 100644 index 0000000..d99f4b8 --- /dev/null +++ b/Sources/LocalData/Services/SyncHelper.swift @@ -0,0 +1,111 @@ +import Foundation +#if os(iOS) || os(watchOS) +import WatchConnectivity +#endif + +/// Actor that handles WatchConnectivity sync operations. +/// Manages data synchronization between iPhone and Apple Watch. +public actor SyncHelper { + + public static let shared = SyncHelper() + + /// Maximum data size for automatic sync (100KB). + public static let maxAutoSyncSize = 100_000 + + private init() {} + + // MARK: - Public Interface + + /// Syncs data to the paired device if appropriate. + /// - Parameters: + /// - data: The data to sync. + /// - keyName: The key name for the application context. + /// - availability: The platform availability setting. + /// - syncPolicy: The sync policy setting. + /// - Throws: `StorageError.dataTooLargeForSync` if data exceeds size limit for automatic sync. + public func syncIfNeeded( + data: Data, + keyName: String, + availability: PlatformAvailability, + syncPolicy: SyncPolicy + ) throws { + #if os(iOS) || os(watchOS) + // Only sync for appropriate availability settings + guard availability == .all || availability == .phoneWithWatchSync else { + return + } + + switch syncPolicy { + case .never: + return + + case .automaticSmall: + guard data.count <= Self.maxAutoSyncSize else { + throw StorageError.dataTooLargeForSync + } + try performSync(data: data, keyName: keyName) + + case .manual: + try performSync(data: data, keyName: keyName) + } + #endif + } + + /// Manually triggers a sync for the given data. + /// - Parameters: + /// - data: The data to sync. + /// - keyName: The key name for the application context. + /// - Throws: Various errors if sync fails. + public func manualSync(data: Data, keyName: String) throws { + #if os(iOS) || os(watchOS) + try performSync(data: data, keyName: keyName) + #endif + } + + /// Checks if sync is available. + /// - Returns: True if WatchConnectivity is supported and active. + public func isSyncAvailable() -> Bool { + #if os(iOS) || os(watchOS) + guard WCSession.isSupported() else { return false } + + let session = WCSession.default + guard session.activationState == .activated else { return false } + + #if os(iOS) + return session.isPaired && session.isWatchAppInstalled + #else + return true + #endif + #else + return false + #endif + } + + /// Gets the current application context. + /// - Returns: The current application context dictionary. + public func currentContext() -> [String: Any] { + #if os(iOS) || os(watchOS) + guard WCSession.isSupported() else { return [:] } + return WCSession.default.applicationContext + #else + return [:] + #endif + } + + // MARK: - Private Helpers + + #if os(iOS) || os(watchOS) + private func performSync(data: Data, keyName: String) throws { + guard WCSession.isSupported() else { return } + + let session = WCSession.default + guard session.activationState == .activated else { return } + + #if os(iOS) + guard session.isPaired, session.isWatchAppInstalled else { return } + #endif + + try session.updateApplicationContext([keyName: data]) + } + #endif +} diff --git a/Sources/LocalData/Services/UserDefaultsHelper.swift b/Sources/LocalData/Services/UserDefaultsHelper.swift new file mode 100644 index 0000000..5e820a0 --- /dev/null +++ b/Sources/LocalData/Services/UserDefaultsHelper.swift @@ -0,0 +1,78 @@ +import Foundation + +/// Actor that handles all UserDefaults operations. +/// Provides thread-safe access to UserDefaults with suite support. +public actor UserDefaultsHelper { + + public static let shared = UserDefaultsHelper() + + private init() {} + + // MARK: - Public Interface + + /// Stores data in UserDefaults. + /// - Parameters: + /// - data: The data to store. + /// - key: The key to store the data under. + /// - suite: Optional suite name. Nil uses standard UserDefaults. + /// - Throws: `StorageError.invalidUserDefaultsSuite` if suite is invalid. + public func set(_ data: Data, forKey key: String, suite: String?) throws { + let defaults = try userDefaults(for: suite) + defaults.set(data, forKey: key) + } + + /// Retrieves data from UserDefaults. + /// - Parameters: + /// - key: The key to retrieve. + /// - suite: Optional suite name. + /// - Returns: The stored data, or nil if not found. + /// - Throws: `StorageError.invalidUserDefaultsSuite` if suite is invalid. + public func get(forKey key: String, suite: String?) throws -> Data? { + let defaults = try userDefaults(for: suite) + return defaults.data(forKey: key) + } + + /// Removes data from UserDefaults. + /// - Parameters: + /// - key: The key to remove. + /// - suite: Optional suite name. + /// - Throws: `StorageError.invalidUserDefaultsSuite` if suite is invalid. + public func remove(forKey key: String, suite: String?) throws { + let defaults = try userDefaults(for: suite) + defaults.removeObject(forKey: key) + } + + /// Checks if a key exists in UserDefaults. + /// - Parameters: + /// - key: The key to check. + /// - suite: Optional suite name. + /// - Returns: True if the key exists. + /// - Throws: `StorageError.invalidUserDefaultsSuite` if suite is invalid. + public func exists(forKey key: String, suite: String?) throws -> Bool { + let defaults = try userDefaults(for: suite) + return defaults.object(forKey: key) != nil + } + + /// Gets all keys in UserDefaults for a given suite. + /// - Parameter suite: Optional suite name. + /// - Returns: An array of all keys. + /// - Throws: `StorageError.invalidUserDefaultsSuite` if suite is invalid. + public func allKeys(suite: String?) throws -> [String] { + let defaults = try userDefaults(for: suite) + return Array(defaults.dictionaryRepresentation().keys) + } + + // MARK: - Private Helpers + + private func userDefaults(for suite: String?) throws -> UserDefaults { + guard let suite else { + return .standard + } + + guard let defaults = UserDefaults(suiteName: suite) else { + throw StorageError.invalidUserDefaultsSuite(suite) + } + + return defaults + } +}