Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
5101cd7546
commit
e1faaa4e62
39
Proposal.md
39
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
|
||||
|
||||
132
README.md
132
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<String> = .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.
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
case afterFirstUnlock
|
||||
// Add more as needed
|
||||
|
||||
/// Item is accessible after the first unlock until device restart.
|
||||
/// Good balance of security and background access.
|
||||
case afterFirstUnlock
|
||||
|
||||
/// 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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
191
Sources/LocalData/Services/EncryptionHelper.swift
Normal file
191
Sources/LocalData/Services/EncryptionHelper.swift
Normal file
@ -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..<iterations {
|
||||
u = hmacSHA256(key: password, data: u)
|
||||
t = xor(t, u)
|
||||
}
|
||||
}
|
||||
|
||||
derivedKey.append(t)
|
||||
blockIndex += 1
|
||||
}
|
||||
|
||||
return derivedKey.prefix(keyLength)
|
||||
}
|
||||
|
||||
private func hmacSHA256(key: Data, data: Data) -> Data {
|
||||
let symmetricKey = SymmetricKey(data: key)
|
||||
let mac = HMAC<SHA256>.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<UInt32>.size)
|
||||
}
|
||||
}
|
||||
161
Sources/LocalData/Services/FileStorageHelper.swift
Normal file
161
Sources/LocalData/Services/FileStorageHelper.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
143
Sources/LocalData/Services/KeychainHelper.swift
Normal file
143
Sources/LocalData/Services/KeychainHelper.swift
Normal file
@ -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
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -1,33 +1,37 @@
|
||||
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
|
||||
}
|
||||
public static let shared = StorageRouter()
|
||||
|
||||
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<Key: StorageKey>(_ 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: StorageKey>(_ key: Key) async throws -> Key.Value {
|
||||
try validatePlatformAvailability(for: key)
|
||||
|
||||
@ -35,17 +39,34 @@ public actor StorageRouter: StorageProviding {
|
||||
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: StorageKey>(_ 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: StorageKey>(_ 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<Key: StorageKey>(for key: Key) throws {
|
||||
@ -62,7 +83,10 @@ public actor StorageRouter: StorageProviding {
|
||||
|
||||
// MARK: - Serialization
|
||||
|
||||
private func serialize<Value: Codable & Sendable>(_ value: Value, with serializer: Serializer<Value>) throws -> Data {
|
||||
private func serialize<Value: Codable & Sendable>(
|
||||
_ value: Value,
|
||||
with serializer: Serializer<Value>
|
||||
) throws -> Data {
|
||||
do {
|
||||
return try serializer.encode(value)
|
||||
} catch {
|
||||
@ -70,7 +94,10 @@ public actor StorageRouter: StorageProviding {
|
||||
}
|
||||
}
|
||||
|
||||
private func deserialize<Value: Codable & Sendable>(_ data: Data, with serializer: Serializer<Value>) throws -> Value {
|
||||
private func deserialize<Value: Codable & Sendable>(
|
||||
_ data: Data,
|
||||
with serializer: Serializer<Value>
|
||||
) throws -> Value {
|
||||
do {
|
||||
return try serializer.decode(data)
|
||||
} catch {
|
||||
@ -80,311 +107,107 @@ public actor StorageRouter: StorageProviding {
|
||||
|
||||
// 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..<iterations {
|
||||
u = hmacSHA256(key: password, data: u)
|
||||
t = xor(t, u)
|
||||
}
|
||||
}
|
||||
|
||||
derivedKey.append(t)
|
||||
blockIndex += 1
|
||||
}
|
||||
|
||||
return derivedKey.prefix(keyLength)
|
||||
}
|
||||
|
||||
private func hmacSHA256(key: Data, data: Data) -> Data {
|
||||
let symmetricKey = SymmetricKey(data: key)
|
||||
let mac = HMAC<SHA256>.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<UInt32>.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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
try await FileStorageHelper.shared.delete(from: directory, fileName: key.name)
|
||||
}
|
||||
}
|
||||
|
||||
// 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])
|
||||
}
|
||||
}
|
||||
#else
|
||||
private func handleSync(_ key: any StorageKey, data: Data) async throws {
|
||||
// No sync on other platforms
|
||||
try await SyncHelper.shared.syncIfNeeded(
|
||||
data: data,
|
||||
keyName: key.name,
|
||||
availability: key.availability,
|
||||
syncPolicy: key.syncPolicy
|
||||
)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
111
Sources/LocalData/Services/SyncHelper.swift
Normal file
111
Sources/LocalData/Services/SyncHelper.swift
Normal file
@ -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
|
||||
}
|
||||
78
Sources/LocalData/Services/UserDefaultsHelper.swift
Normal file
78
Sources/LocalData/Services/UserDefaultsHelper.swift
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user