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)
- 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
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.
## 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.

View File

@ -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"
}
}
}

View File

@ -1,15 +1,73 @@
import Foundation
import Security
public enum KeychainAccessibility: Sendable {
/// Defines when a keychain item can be accessed.
/// Maps directly to Security framework's kSecAttrAccessible constants.
public enum KeychainAccessibility: Sendable, CaseIterable {
/// Item is only accessible while the device is unlocked.
/// This is the most restrictive option for general use.
case whenUnlocked
/// Item is accessible after the first unlock until device restart.
/// Good balance of security and background access.
case afterFirstUnlock
// Add more as needed
/// Item is only accessible when the device is unlocked.
/// Data is not migrated to a new device.
case whenUnlockedThisDeviceOnly
/// Item is accessible after first unlock until device restart.
/// Data is not migrated to a new device.
case afterFirstUnlockThisDeviceOnly
/// Item is always accessible, regardless of device lock state.
/// Least secure - use only when absolutely necessary.
case always
/// Item is always accessible but not migrated to new devices.
case alwaysThisDeviceOnly
/// Item is only accessible when the device has a passcode set.
/// If passcode is removed, item becomes inaccessible.
case whenPasscodeSetThisDeviceOnly
/// The corresponding Security framework constant.
var cfString: CFString {
switch self {
case .whenUnlocked: return kSecAttrAccessibleWhenUnlocked
case .afterFirstUnlock: return kSecAttrAccessibleAfterFirstUnlock
case .whenUnlocked:
return kSecAttrAccessibleWhenUnlocked
case .afterFirstUnlock:
return kSecAttrAccessibleAfterFirstUnlock
case .whenUnlockedThisDeviceOnly:
return kSecAttrAccessibleWhenUnlockedThisDeviceOnly
case .afterFirstUnlockThisDeviceOnly:
return kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
case .always:
return kSecAttrAccessibleAlways
case .alwaysThisDeviceOnly:
return kSecAttrAccessibleAlwaysThisDeviceOnly
case .whenPasscodeSetThisDeviceOnly:
return kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly
}
}
/// Human-readable description for UI display.
public var displayName: String {
switch self {
case .whenUnlocked:
return "When Unlocked"
case .afterFirstUnlock:
return "After First Unlock"
case .whenUnlockedThisDeviceOnly:
return "When Unlocked (This Device)"
case .afterFirstUnlockThisDeviceOnly:
return "After First Unlock (This Device)"
case .always:
return "Always"
case .alwaysThisDeviceOnly:
return "Always (This Device)"
case .whenPasscodeSetThisDeviceOnly:
return "When Passcode Set (This Device)"
}
}
}

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,53 +1,74 @@
import Foundation
import CryptoKit
import Security
#if os(iOS) || os(watchOS)
import WatchConnectivity
#endif
/// The main storage router that coordinates all storage operations.
/// Uses specialized helper actors for each storage domain.
public actor StorageRouter: StorageProviding {
public static let shared = StorageRouter()
private enum EncryptionConstants {
static let masterKeyService = "LocalData.MasterKey"
static let masterKeyAccount = "LocalData.MasterKey"
static let masterKeyLength = 32
}
private init() {}
// MARK: - StorageProviding Implementation
/// Stores a value for the given key.
/// - Parameters:
/// - value: The value to store.
/// - key: The storage key defining where and how to store.
/// - Throws: Various errors depending on the storage domain and security policy.
public func set<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)
guard let securedData = try await retrieve(for: key) else {
throw StorageError.notFound
}
let data = try applySecurity(securedData, for: key, isEncrypt: false)
let data = try await applySecurity(securedData, for: key, isEncrypt: false)
return try deserialize(data, with: key.serializer)
}
/// Removes the value for the given key.
/// - Parameter key: The storage key to remove.
/// - Throws: Domain-specific errors if removal fails.
public func remove<Key: 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 {
#if os(watchOS)
if key.availability == .phoneOnly {
@ -59,332 +80,134 @@ public actor StorageRouter: StorageProviding {
}
#endif
}
// 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 {
throw StorageError.serializationFailed
}
}
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 {
throw StorageError.deserializationFailed
}
}
// MARK: - Security
private func applySecurity(_ data: Data, for key: any StorageKey, isEncrypt: Bool) throws -> Data {
private func applySecurity(
_ data: Data,
for key: any StorageKey,
isEncrypt: Bool
) async throws -> Data {
switch key.security {
case .none:
return data
case .encrypted(let encryptionPolicy):
let derivedKey = try encryptionKey(for: key, policy: encryptionPolicy)
return isEncrypt ? try encrypt(data, using: derivedKey) : try decrypt(data, using: derivedKey)
if isEncrypt {
return try await EncryptionHelper.shared.encrypt(
data,
keyName: key.name,
policy: encryptionPolicy
)
} else {
return try await EncryptionHelper.shared.decrypt(
data,
keyName: key.name,
policy: encryptionPolicy
)
}
case .keychain:
// Keychain security is handled in store/retrieve
return data
}
}
private func encrypt(_ data: Data, using key: SymmetricKey) throws -> Data {
let sealedBox = try AES.GCM.seal(data, using: key)
guard let combined = sealedBox.combined else {
throw StorageError.securityApplicationFailed
}
return combined
}
private func decrypt(_ data: Data, using key: SymmetricKey) throws -> Data {
let sealedBox = try AES.GCM.SealedBox(combined: data)
return try AES.GCM.open(sealedBox, using: key)
}
private func encryptionKey(for key: any StorageKey, policy: SecurityPolicy.EncryptionPolicy) throws -> SymmetricKey {
switch policy {
case .aes256(let derivation):
let password = try masterKeyData()
let salt = derivation.salt ?? Data(key.name.utf8)
let derivedKeyData = try pbkdf2SHA256(
password: password,
salt: salt,
iterations: derivation.iterations,
keyLength: EncryptionConstants.masterKeyLength
)
return SymmetricKey(data: derivedKeyData)
}
}
private func masterKeyData() throws -> Data {
if let existing = try keychainGet(
service: EncryptionConstants.masterKeyService,
key: EncryptionConstants.masterKeyAccount
) {
return existing
}
var bytes = [UInt8](repeating: 0, count: EncryptionConstants.masterKeyLength)
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
guard status == errSecSuccess else {
throw StorageError.securityApplicationFailed
}
let data = Data(bytes)
try keychainSet(
data,
service: EncryptionConstants.masterKeyService,
key: EncryptionConstants.masterKeyAccount,
policy: .keychain(accessibility: .afterFirstUnlock, accessControl: nil)
)
return data
}
private func pbkdf2SHA256(password: Data, salt: Data, iterations: Int, keyLength: Int) throws -> Data {
guard iterations > 0 else {
throw StorageError.securityApplicationFailed
}
var derivedKey = Data()
var blockIndex: UInt32 = 1
while derivedKey.count < keyLength {
var block = Data()
block.append(salt)
block.append(uint32Data(blockIndex))
var u = hmacSHA256(key: password, data: block)
var t = u
if iterations > 1 {
for _ in 1..<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)
try await FileStorageHelper.shared.delete(from: directory, fileName: key.name)
}
}
private func userDefaults(for suite: String?) throws -> UserDefaults {
guard let suite else { return .standard }
guard let defaults = UserDefaults(suiteName: suite) else {
throw StorageError.invalidUserDefaultsSuite(suite)
}
return defaults
}
private func ensureDirectoryExists(at url: URL) throws {
do {
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil)
} catch {
throw StorageError.fileError(error)
}
}
private func writeData(_ data: Data, to url: URL, options: Data.WritingOptions) throws {
do {
try data.write(to: url, options: options)
} catch {
throw StorageError.fileError(error)
}
}
private func readData(from url: URL) throws -> Data? {
guard FileManager.default.fileExists(atPath: url.path) else {
return nil
}
do {
return try Data(contentsOf: url)
} catch {
throw StorageError.fileError(error)
}
}
private func deleteItemIfExists(at url: URL) throws {
guard FileManager.default.fileExists(atPath: url.path) else {
return
}
do {
try FileManager.default.removeItem(at: url)
} catch {
throw StorageError.fileError(error)
}
}
// MARK: - Keychain Helpers
private func keychainSet(_ data: Data, service: String, key: String, policy: SecurityPolicy) throws {
guard case let .keychain(accessibility, accessControl) = policy else {
throw StorageError.securityApplicationFailed
}
var attributes: [String: Any] = [
kSecValueData as String: data
]
if let accessControl {
guard let accessControlValue = accessControl.accessControl(accessibility: accessibility) else {
throw StorageError.securityApplicationFailed
}
attributes[kSecAttrAccessControl as String] = accessControlValue
} else {
attributes[kSecAttrAccessible as String] = accessibility.cfString
}
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key
]
let addQuery = query.merging(attributes) { _, new in new }
let status = SecItemAdd(addQuery as CFDictionary, nil)
if status == errSecDuplicateItem {
let updateStatus = SecItemUpdate(query as CFDictionary, [kSecValueData as String: data] as CFDictionary)
if updateStatus != errSecSuccess {
throw StorageError.keychainError(updateStatus)
}
} else if status != errSecSuccess {
throw StorageError.keychainError(status)
}
}
private func keychainGet(service: String, key: String) throws -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var item: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &item)
if status == errSecSuccess {
return item as? Data
} else if status == errSecItemNotFound {
return nil
} else {
throw StorageError.keychainError(status)
}
}
private func keychainDelete(service: String, key: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key
]
let status = SecItemDelete(query as CFDictionary)
if status != errSecSuccess && status != errSecItemNotFound {
throw StorageError.keychainError(status)
}
}
// MARK: - Sync
#if os(iOS) || os(watchOS)
private func handleSync(_ key: any StorageKey, data: Data) async throws {
guard key.availability == .all || key.availability == .phoneWithWatchSync else {
return
}
switch key.syncPolicy {
case .never:
return
case .automaticSmall:
if data.count > 100_000 { throw StorageError.dataTooLargeForSync }
fallthrough
case .manual:
guard WCSession.isSupported() else { return }
let session = WCSession.default
guard session.activationState == .activated else { return }
#if os(iOS)
guard session.isPaired, session.isWatchAppInstalled else { return }
#endif
try session.updateApplicationContext([key.name: data])
}
try await SyncHelper.shared.syncIfNeeded(
data: data,
keyName: key.name,
availability: key.availability,
syncPolicy: key.syncPolicy
)
}
#else
private func handleSync(_ key: any StorageKey, data: Data) async throws {
// No sync on other platforms
}
#endif
}

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