Summary: - Sources: Models, Services - Docs: README - Added symbols: struct SyncConfiguration, func updateSyncConfiguration, func updateConfiguration Stats: - 4 files changed, 38 insertions(+), 4 deletions(-)
242 lines
8.5 KiB
Markdown
242 lines
8.5 KiB
Markdown
# LocalData
|
||
|
||
LocalData provides a typed, discoverable namespace for persisted app data across UserDefaults, Keychain, and file storage with optional encryption.
|
||
|
||
## Architecture
|
||
|
||
The package uses a clean, modular architecture with isolated actors for thread safety:
|
||
|
||
```
|
||
StorageRouter (main entry point)
|
||
├── UserDefaultsHelper
|
||
├── KeychainHelper
|
||
├── FileStorageHelper
|
||
├── EncryptionHelper
|
||
└── SyncHelper
|
||
```
|
||
|
||
## What Ships in the Package
|
||
|
||
### Protocols
|
||
- **StorageKey** - Define storage configuration for each data type
|
||
- **StorageProviding** - Abstraction for storage operations
|
||
- **KeyMaterialProviding** - Supplies external key material for encryption
|
||
|
||
### Services (Actors)
|
||
- **StorageRouter** - Main entry point for all storage operations
|
||
|
||
### Internal Helpers (Not Public API)
|
||
These helpers are internal implementation details used by `StorageRouter`. They are not part of the public API and should not be used directly.
|
||
|
||
- **KeychainHelper** - Reads/writes secure items with Keychain APIs.
|
||
- **EncryptionHelper** - Handles encryption/decryption and key derivation.
|
||
- **FileStorageHelper** - Reads/writes files with appropriate protection.
|
||
- **UserDefaultsHelper** - Wraps UserDefaults and suites safely.
|
||
- **SyncHelper** - Manages WatchConnectivity sync.
|
||
|
||
### Models
|
||
- **StorageDomain** - userDefaults, appGroupUserDefaults, keychain, fileSystem, encryptedFileSystem, appGroupFileSystem
|
||
- **SecurityPolicy** - none, keychain, encrypted (AES-256 or ChaCha20-Poly1305)
|
||
- **Serializer** - JSON, plist, Data, or custom
|
||
- **KeyMaterialSource** - Identifier for external key material providers
|
||
- **PlatformAvailability** - all, phoneOnly, watchOnly, phoneWithWatchSync
|
||
- **SyncPolicy** - never, manual, automaticSmall
|
||
- **KeychainAccessibility** - All 7 iOS accessibility options
|
||
- **KeychainAccessControl** - All 6 access control options (biometry, passcode, etc.)
|
||
- **FileDirectory** - documents, caches, custom URL
|
||
- **StorageError** - Comprehensive error types
|
||
- **StorageKeyDescriptor** - Audit snapshot of a key’s storage metadata
|
||
- **EncryptionConfiguration** - Global encryption settings (Keychain identifiers, key length)
|
||
- **SyncConfiguration** - Global sync settings (Max automatic sync size)
|
||
- **AnyStorageKey** - Type-erased storage key for catalogs
|
||
- **AnyCodable** - Type-erased Codable for mixed-type payloads
|
||
|
||
## Usage
|
||
|
||
### 1. Define Keys
|
||
Extend `StorageKeys` with your own key types:
|
||
|
||
```swift
|
||
import LocalData
|
||
|
||
extension StorageKeys {
|
||
struct UserTokenKey: StorageKey {
|
||
typealias Value = String
|
||
|
||
let name = "user_token"
|
||
let domain: StorageDomain = .keychain(service: "com.myapp")
|
||
let security: SecurityPolicy = .keychain(
|
||
accessibility: .afterFirstUnlock,
|
||
accessControl: .biometryAny
|
||
)
|
||
let serializer: Serializer<String> = .json
|
||
let owner = "AuthService"
|
||
let description = "Stores the current user auth token."
|
||
let availability: PlatformAvailability = .phoneOnly
|
||
let syncPolicy: SyncPolicy = .never
|
||
}
|
||
}
|
||
```
|
||
If you omit `security`, it defaults to `SecurityPolicy.recommended`.
|
||
|
||
### 2. Use StorageRouter
|
||
```swift
|
||
// Save
|
||
let key = StorageKeys.UserTokenKey()
|
||
try await StorageRouter.shared.set("token123", for: key)
|
||
|
||
// Retrieve
|
||
let token = try await StorageRouter.shared.get(key)
|
||
|
||
// Remove
|
||
try await StorageRouter.shared.remove(key)
|
||
```
|
||
|
||
## Storage Domains
|
||
|
||
| Domain | Use Case |
|
||
|--------|----------|
|
||
| `userDefaults` | Preferences, small settings |
|
||
| `appGroupUserDefaults` | Shared settings across extensions via App Groups |
|
||
| `keychain` | Credentials, tokens, sensitive data |
|
||
| `fileSystem` | Documents, cached data, large files |
|
||
| `encryptedFileSystem` | Sensitive files with encryption policies |
|
||
| `appGroupFileSystem` | Shared files across extensions via App Groups |
|
||
|
||
## App Group Support
|
||
|
||
App Group storage is explicit via `StorageDomain.appGroupUserDefaults` and `StorageDomain.appGroupFileSystem`. These require a valid App Group identifier and the corresponding entitlement on every target that needs access. If the identifier is invalid or missing, LocalData throws `StorageError.invalidAppGroupIdentifier`.
|
||
|
||
Use standard `userDefaults` or `fileSystem` for data that should remain scoped to a single target, even when App Groups are configured.
|
||
|
||
For app-level configuration (App Group identifiers, keychain service identifiers, etc.), centralize constants in a shared module so keys do not hardcode string literals.
|
||
|
||
## Security Options
|
||
|
||
### Keychain Accessibility
|
||
- `whenUnlocked` - Only when device unlocked
|
||
- `afterFirstUnlock` - After first unlock until restart
|
||
- `whenUnlockedThisDeviceOnly` - No migration to new device
|
||
- `afterFirstUnlockThisDeviceOnly` - No migration
|
||
- `always` - Always accessible (least secure)
|
||
- `alwaysThisDeviceOnly` - Always, no migration
|
||
- `whenPasscodeSetThisDeviceOnly` - Requires passcode
|
||
|
||
### Access Control
|
||
- `userPresence` - Any authentication
|
||
- `biometryAny` - Face ID or Touch ID
|
||
- `biometryCurrentSet` - Current enrolled biometric only
|
||
- `devicePasscode` - Passcode only
|
||
- `biometryAnyOrDevicePasscode` - Biometric preferred, passcode fallback
|
||
- `biometryCurrentSetOrDevicePasscode` - Current biometric or passcode
|
||
|
||
### Encryption
|
||
- AES-256-GCM or ChaCha20-Poly1305
|
||
- PBKDF2-SHA256 or HKDF-SHA256 key derivation
|
||
- Configurable PBKDF2 iteration count
|
||
- Master key stored securely in keychain
|
||
- Default security policy: `SecurityPolicy.recommended` (ChaCha20-Poly1305 + HKDF)
|
||
- External key material providers can be registered via `StorageRouter`
|
||
|
||
#### Global Encryption Configuration
|
||
|
||
You can customize the identifiers used for the master key in the Keychain:
|
||
|
||
```swift
|
||
let config = EncryptionConfiguration(
|
||
masterKeyService: "com.myapp.LocalData",
|
||
masterKeyAccount: "MasterKey"
|
||
)
|
||
await StorageRouter.shared.updateEncryptionConfiguration(config)
|
||
```
|
||
|
||
> [!WARNING]
|
||
> Changing the `masterKeyService` or `masterKeyAccount` in an existing app will cause the app to look for the master key in a new location. Previously encrypted data will be lost.
|
||
|
||
#### Global Sync Configuration
|
||
|
||
You can customize the maximum size for automatic synchronization:
|
||
|
||
```swift
|
||
let syncConfig = SyncConfiguration(maxAutoSyncSize: 50_000) // 50KB limit
|
||
await StorageRouter.shared.updateSyncConfiguration(syncConfig)
|
||
```
|
||
|
||
```swift
|
||
struct RemoteKeyProvider: KeyMaterialProviding {
|
||
func keyMaterial(for keyName: String) async throws -> Data {
|
||
// Example only: fetch from service or keychain
|
||
Data(repeating: 1, count: 32)
|
||
}
|
||
}
|
||
|
||
let source = KeyMaterialSource(id: "remote.key")
|
||
await StorageRouter.shared.registerKeyMaterialProvider(RemoteKeyProvider(), for: source)
|
||
|
||
let policy: SecurityPolicy.EncryptionPolicy = .external(
|
||
source: source,
|
||
keyDerivation: .hkdf()
|
||
)
|
||
```
|
||
|
||
## Sync Behavior
|
||
|
||
StorageRouter can sync data to Apple Watch via WCSession when:
|
||
- `availability` is `.all` or `.phoneWithWatchSync`
|
||
- `syncPolicy` is `.manual` or `.automaticSmall` (≤100KB)
|
||
- WCSession is activated and watch is paired
|
||
|
||
The app owns WCSession activation and handling incoming updates.
|
||
|
||
## Platforms
|
||
- iOS 17+
|
||
- watchOS 10+
|
||
|
||
## Testing
|
||
- Unit tests use Swift Testing (`Testing` package)
|
||
|
||
## Storage Audit
|
||
|
||
LocalData can generate a catalog of all configured storage keys, even if no data has been written yet. This is useful for security reviews and compliance.
|
||
|
||
### Why `AnyStorageKey`?
|
||
|
||
`StorageKey` has an associated type (`Value`), which means you cannot store different keys in a single array using `[StorageKey]` or `some StorageKey`. Swift requires type erasure for heterogeneous protocol values, so the catalog uses `[AnyStorageKey]` and builds descriptors behind the scenes.
|
||
|
||
1) Define a catalog in your app that lists all keys:
|
||
|
||
```swift
|
||
struct AppStorageCatalog: StorageKeyCatalog {
|
||
static var allKeys: [AnyStorageKey] {
|
||
[
|
||
.key(StorageKeys.AppVersionKey()),
|
||
.key(StorageKeys.UserPreferencesKey())
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
2) Generate a report:
|
||
|
||
```swift
|
||
let report = StorageAuditReport.renderText(for: AppStorageCatalog.self)
|
||
print(report)
|
||
```
|
||
|
||
3) Register the catalog to enforce usage and catch duplicates:
|
||
|
||
```swift
|
||
do {
|
||
try StorageRouter.shared.registerCatalog(AppStorageCatalog.self)
|
||
} catch {
|
||
assertionFailure("Storage catalog registration failed: \(error)")
|
||
}
|
||
```
|
||
|
||
Each `StorageKey` must provide a human-readable `description` used in audit reports.
|
||
Dynamic key names are intentionally not supported in the core API to keep storage auditing strict and predictable.
|
||
If you need this later, see `FutureEnhancements.md` for a proposed design.
|
||
|
||
## Sample App
|
||
See `SecureStorageSample` for working examples of all storage domains and security options.
|