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(-)
This commit is contained in:
Matt Bruce 2026-01-13 21:59:49 -06:00
parent b9dca68c5d
commit 4228fbc849
10 changed files with 1090 additions and 349 deletions

View File

@ -14,14 +14,33 @@ Create a single, typed, discoverable namespace for persisted app data with consi
- CryptoKit (encryption) - CryptoKit (encryption)
- WatchConnectivity (sync helpers) - WatchConnectivity (sync helpers)
## Implemented Scope ## Architecture
- StorageKey protocol and StorageKeys namespace for app-defined keys
- StorageRouter actor with async set, get, and remove ### Core Components
- StorageDomain options for user defaults, keychain, file system, and encrypted file system - **StorageKey** protocol - Defines storage configuration for each data type
- SecurityPolicy options for none, keychain, and encrypted data - **StorageRouter** actor - Main entry point coordinating all storage operations
- Serializer for JSON, property lists, raw Data, or custom encode and decode - **StorageProviding** protocol - Abstraction for storage operations
- PlatformAvailability and SyncPolicy metadata
- AnyCodable for simple mixed-type payloads ### 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 ## Usage Pattern
Apps extend StorageKeys with their own key types and use StorageRouter.shared. This follows the Notification.Name pattern for discoverable keys. 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 ## 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. 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) ## Future Ideas (Not Implemented)
- Migration helpers for legacy storage - Migration helpers for legacy storage
- Key rotation strategies for encrypted data - Key rotation strategies for encrypted data

132
README.md
View File

@ -2,25 +2,129 @@
LocalData provides a typed, discoverable namespace for persisted app data across UserDefaults, Keychain, and file storage with optional encryption. LocalData provides a typed, discoverable namespace for persisted app data across UserDefaults, Keychain, and file storage with optional encryption.
## What ships in the package ## Architecture
- StorageKey protocol and StorageKeys namespace for app-defined keys
- StorageRouter actor (StorageProviding) with async set/get/remove The package uses a clean, modular architecture with isolated actors for thread safety:
- 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 StorageRouter (main entry point)
- PlatformAvailability and SyncPolicy metadata for watch behavior ├── UserDefaultsHelper
- AnyCodable utility for structured payloads ├── 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 ## 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 ### 1. Define Keys
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. 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 ## Platforms
- iOS 17+ - iOS 17+
- watchOS 10+ - watchOS 10+
## Notes ## Sample App
- LocalData does not include sample key definitions or models. See `SecureStorgageSample` for working examples of all storage domains and security options.
- Keys should be stable and unique per domain or service.

View File

@ -1,27 +1,76 @@
import Foundation import Foundation
import Security 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 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? { func accessControl(accessibility: KeychainAccessibility) -> SecAccessControl? {
let accessibilityValue: CFString let accessibilityValue = accessibility.cfString
switch accessibility {
case .whenUnlocked:
accessibilityValue = kSecAttrAccessibleWhenUnlocked
case .afterFirstUnlock:
accessibilityValue = kSecAttrAccessibleAfterFirstUnlock
}
let flags: SecAccessControlCreateFlags
switch self { switch self {
case .userPresence: case .userPresence:
return SecAccessControlCreateWithFlags( flags = .userPresence
nil, case .biometryAny:
accessibilityValue, flags = .biometryAny
.userPresence, case .biometryCurrentSet:
nil 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"
} }
} }
} }

View File

@ -1,15 +1,73 @@
import Foundation import Foundation
import Security 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 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 { var cfString: CFString {
switch self { switch self {
case .whenUnlocked: return kSecAttrAccessibleWhenUnlocked case .whenUnlocked:
case .afterFirstUnlock: return kSecAttrAccessibleAfterFirstUnlock 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)"
} }
} }
} }

View 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)
}
}

View 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)
}
}
}

View 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
]
}
}

View File

@ -1,33 +1,37 @@
import Foundation import Foundation
import CryptoKit
import Security
#if os(iOS) || os(watchOS) #if os(iOS) || os(watchOS)
import WatchConnectivity import WatchConnectivity
#endif #endif
/// The main storage router that coordinates all storage operations.
/// Uses specialized helper actors for each storage domain.
public actor StorageRouter: StorageProviding { public actor StorageRouter: StorageProviding {
public static let shared = StorageRouter()
private enum EncryptionConstants { public static let shared = StorageRouter()
static let masterKeyService = "LocalData.MasterKey"
static let masterKeyAccount = "LocalData.MasterKey"
static let masterKeyLength = 32
}
private init() {} 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 { public func set<Key: StorageKey>(_ value: Key.Value, for key: Key) async throws {
try validatePlatformAvailability(for: key) try validatePlatformAvailability(for: key)
let data = try serialize(value, with: key.serializer) let data = try serialize(value, with: key.serializer)
let securedData = try await applySecurity(data, for: key, isEncrypt: true)
let securedData = try applySecurity(data, for: key, isEncrypt: true)
try await store(securedData, for: key) try await store(securedData, for: key)
try await handleSync(key, data: securedData) 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 { public func get<Key: StorageKey>(_ key: Key) async throws -> Key.Value {
try validatePlatformAvailability(for: key) try validatePlatformAvailability(for: key)
@ -35,17 +39,34 @@ public actor StorageRouter: StorageProviding {
throw StorageError.notFound 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) 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 { public func remove<Key: StorageKey>(_ key: Key) async throws {
try validatePlatformAvailability(for: key) try validatePlatformAvailability(for: key)
try await delete(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 // MARK: - Platform Validation
private func validatePlatformAvailability<Key: StorageKey>(for key: Key) throws { private func validatePlatformAvailability<Key: StorageKey>(for key: Key) throws {
@ -62,7 +83,10 @@ public actor StorageRouter: StorageProviding {
// MARK: - Serialization // 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 { do {
return try serializer.encode(value) return try serializer.encode(value)
} catch { } 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 { do {
return try serializer.decode(data) return try serializer.decode(data)
} catch { } catch {
@ -80,311 +107,107 @@ public actor StorageRouter: StorageProviding {
// MARK: - Security // 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 { switch key.security {
case .none: case .none:
return data return data
case .encrypted(let encryptionPolicy): case .encrypted(let encryptionPolicy):
let derivedKey = try encryptionKey(for: key, policy: encryptionPolicy) if isEncrypt {
return isEncrypt ? try encrypt(data, using: derivedKey) : try decrypt(data, using: derivedKey) 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: case .keychain:
// Keychain security is handled in store/retrieve // Keychain security is handled in store/retrieve
return data return data
} }
} }
private func encrypt(_ data: Data, using key: SymmetricKey) throws -> Data { // MARK: - Storage Operations
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
private func store(_ data: Data, for key: any StorageKey) async throws { private func store(_ data: Data, for key: any StorageKey) async throws {
switch key.domain { switch key.domain {
case .userDefaults(let suite): case .userDefaults(let suite):
let defaults = try userDefaults(for: suite) try await UserDefaultsHelper.shared.set(data, forKey: key.name, suite: suite)
defaults.set(data, forKey: key.name)
case .keychain(let service): 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): case .fileSystem(let directory):
let url = directory.url().appending(path: key.name) try await FileStorageHelper.shared.write(
try ensureDirectoryExists(at: url.deletingLastPathComponent()) data,
try writeData(data, to: url, options: [.atomic]) to: directory,
fileName: key.name,
useCompleteFileProtection: false
)
case .encryptedFileSystem(let directory): case .encryptedFileSystem(let directory):
let url = directory.url().appending(path: key.name) try await FileStorageHelper.shared.write(
try ensureDirectoryExists(at: url.deletingLastPathComponent()) data,
try writeData(data, to: url, options: [.atomic, .completeFileProtection]) to: directory,
fileName: key.name,
useCompleteFileProtection: true
)
} }
} }
private func retrieve(for key: any StorageKey) async throws -> Data? { private func retrieve(for key: any StorageKey) async throws -> Data? {
switch key.domain { switch key.domain {
case .userDefaults(let suite): case .userDefaults(let suite):
let defaults = try userDefaults(for: suite) return try await UserDefaultsHelper.shared.get(forKey: key.name, suite: suite)
return defaults.data(forKey: key.name)
case .keychain(let service): case .keychain(let service):
return try keychainGet(service: service, key: key.name) return try await KeychainHelper.shared.get(service: service, key: key.name)
case .fileSystem(let directory):
let url = directory.url().appending(path: key.name) case .fileSystem(let directory), .encryptedFileSystem(let directory):
return try readData(from: url) return try await FileStorageHelper.shared.read(from: directory, fileName: key.name)
case .encryptedFileSystem(let directory):
let url = directory.url().appending(path: key.name)
return try readData(from: url)
} }
} }
private func delete(for key: any StorageKey) async throws { private func delete(for key: any StorageKey) async throws {
switch key.domain { switch key.domain {
case .userDefaults(let suite): case .userDefaults(let suite):
let defaults = try userDefaults(for: suite) try await UserDefaultsHelper.shared.remove(forKey: key.name, suite: suite)
defaults.removeObject(forKey: key.name)
case .keychain(let service): 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): case .fileSystem(let directory), .encryptedFileSystem(let directory):
let url = directory.url().appending(path: key.name) try await FileStorageHelper.shared.delete(from: directory, fileName: 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)
} }
} }
// MARK: - Sync // 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 { 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
} }

View 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
}

View 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
}
}