Summary: - Sources: Helpers, Protocols, Services - Tests: EncryptionHelperTests.swift, KeychainHelperTests.swift, LocalDataTests.swift, Mocks, StorageCatalogTests.swift - Added symbols: func updateKeychainHelper, actor KeychainHelper, protocol KeychainStoring, func set, func get, func delete (+4 more) - Removed symbols: actor KeychainHelper, func clearMasterKey Stats: - 9 files changed, 205 insertions(+), 103 deletions(-)
489 lines
20 KiB
Swift
489 lines
20 KiB
Swift
import Foundation
|
|
|
|
import WatchConnectivity
|
|
|
|
/// 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 var registeredKeyNames: Set<String> = []
|
|
private var registeredEntries: [AnyStorageKey] = []
|
|
private var storageConfiguration: StorageConfiguration = .default
|
|
private let keychain: KeychainStoring
|
|
|
|
/// Initialize a new StorageRouter.
|
|
/// Internal for testing isolation via @testable import.
|
|
/// Consumers should use the `shared` singleton.
|
|
internal init(keychain: KeychainStoring = KeychainHelper.shared) {
|
|
self.keychain = keychain
|
|
}
|
|
|
|
// MARK: - Configuration
|
|
|
|
/// Updates the encryption configuration.
|
|
/// > [!WARNING]
|
|
/// > Changing these constants in an existing app will cause the app to look for the master key
|
|
/// > under a new name. Previously encrypted data will be lost.
|
|
public func updateEncryptionConfiguration(_ configuration: EncryptionConfiguration) async {
|
|
await EncryptionHelper.shared.updateConfiguration(configuration)
|
|
await EncryptionHelper.shared.updateKeychainHelper(keychain)
|
|
}
|
|
|
|
/// Updates the sync configuration.
|
|
public func updateSyncConfiguration(_ configuration: SyncConfiguration) async {
|
|
await SyncHelper.shared.updateConfiguration(configuration)
|
|
}
|
|
|
|
/// Updates the file storage configuration.
|
|
public func updateFileStorageConfiguration(_ configuration: FileStorageConfiguration) async {
|
|
await FileStorageHelper.shared.updateConfiguration(configuration)
|
|
}
|
|
|
|
/// Updates the global storage configuration (defaults).
|
|
public func updateStorageConfiguration(_ configuration: StorageConfiguration) {
|
|
self.storageConfiguration = configuration
|
|
}
|
|
|
|
// MARK: - Key Material Providers
|
|
|
|
/// Registers a key material provider for external encryption policies.
|
|
public func registerKeyMaterialProvider(
|
|
_ provider: any KeyMaterialProviding,
|
|
for source: KeyMaterialSource
|
|
) async {
|
|
await EncryptionHelper.shared.updateKeychainHelper(keychain)
|
|
await EncryptionHelper.shared.registerKeyMaterialProvider(provider, for: source)
|
|
}
|
|
|
|
/// Registers a catalog of known storage keys for audit and validation.
|
|
/// When registered, all storage operations will verify keys are listed.
|
|
/// - Parameters:
|
|
/// - catalog: The catalog type to register.
|
|
/// - migrateImmediately: If true, triggers a proactive migration (sweep) for all keys in the catalog.
|
|
public func registerCatalog<C: StorageKeyCatalog>(_ catalog: C.Type, migrateImmediately: Bool = false) async throws {
|
|
let entries = catalog.allKeys
|
|
try validateDescription(entries)
|
|
try validateUniqueKeys(entries)
|
|
registeredKeyNames = Set(entries.map { $0.descriptor.name })
|
|
registeredEntries = entries
|
|
|
|
if migrateImmediately {
|
|
try await migrateAllRegisteredKeys()
|
|
}
|
|
}
|
|
|
|
/// Triggers a proactive migration (sweep) for all registered storage keys.
|
|
/// This "drains" any legacy data into the modern storage locations.
|
|
public func migrateAllRegisteredKeys() async throws {
|
|
Logger.debug(">>> [STORAGE] STARTING GLOBAL MIGRATION SWEEP")
|
|
for entry in registeredEntries {
|
|
try await entry.migrate(on: self)
|
|
}
|
|
Logger.debug("<<< [STORAGE] GLOBAL MIGRATION SWEEP COMPLETE")
|
|
}
|
|
|
|
// 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 {
|
|
Logger.debug(">>> [STORAGE] SET: \(key.name) [Domain: \(key.domain)]")
|
|
try validateCatalogRegistration(for: key)
|
|
try validatePlatformAvailability(for: key)
|
|
|
|
let data = try serialize(value, with: key.serializer)
|
|
let securedData = try await applySecurity(data, for: .from(key), isEncrypt: true)
|
|
|
|
try await store(securedData, for: key)
|
|
try await handleSync(key, data: securedData)
|
|
Logger.debug("<<< [STORAGE] SET SUCCESS: \(key.name)")
|
|
}
|
|
|
|
/// 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 {
|
|
Logger.debug(">>> [STORAGE] GET: \(key.name)")
|
|
try validateCatalogRegistration(for: key)
|
|
try validatePlatformAvailability(for: key)
|
|
|
|
// 1. Try primary location
|
|
if let securedData = try await retrieve(for: .from(key)) {
|
|
let data = try await applySecurity(securedData, for: .from(key), isEncrypt: false)
|
|
let result = try deserialize(data, with: key.serializer)
|
|
Logger.debug("<<< [STORAGE] GET SUCCESS: \(key.name)")
|
|
return result
|
|
}
|
|
|
|
// 2. Try migration sources
|
|
for source in key.migrationSources {
|
|
let descriptor = source.descriptor
|
|
Logger.debug("!!! [STORAGE] MIGRATION: Checking source '\(descriptor.name)' for key '\(key.name)'")
|
|
|
|
if let securedOldData = try await retrieve(for: descriptor) {
|
|
Logger.info("!!! [STORAGE] MIGRATION: Found data for '\(key.name)' at legacy source '\(descriptor.name)'. Migrating...")
|
|
|
|
// Unsecure using OLD key's policy
|
|
let oldData = try await applySecurity(securedOldData, for: descriptor, isEncrypt: false)
|
|
|
|
// Decode using NEW key's serializer (assuming types are compatible)
|
|
let value = try deserialize(oldData, with: key.serializer)
|
|
|
|
// Store in NEW location with NEW security
|
|
// This will also handle sync and catalog validation via recursive call to public set
|
|
try await self.set(value, for: key)
|
|
|
|
// Delete OLD data
|
|
try await delete(for: descriptor)
|
|
|
|
Logger.info("!!! [STORAGE] MIGRATION: Successfully migrated '\(key.name)' from '\(descriptor.name)'")
|
|
return value
|
|
}
|
|
}
|
|
|
|
Logger.debug("<<< [STORAGE] GET NOT FOUND: \(key.name)")
|
|
throw StorageError.notFound
|
|
}
|
|
|
|
/// Manually triggers migration from legacy sources for a specific key.
|
|
/// This is useful for "draining" old keys at app startup even if the destination already has data.
|
|
/// - Parameter key: The storage key to migrate.
|
|
/// - Throws: Various storage and serialization errors.
|
|
public func migrate<Key: StorageKey>(for key: Key) async throws {
|
|
Logger.debug(">>> [STORAGE] MANUAL MIGRATION: \(key.name)")
|
|
|
|
for source in key.migrationSources {
|
|
let descriptor = source.descriptor
|
|
if let securedOldData = try await retrieve(for: descriptor) {
|
|
Logger.info("!!! [STORAGE] MANUAL MIGRATION: Migrating found data from '\(descriptor.name)' to '\(key.name)'")
|
|
|
|
let oldData = try await applySecurity(securedOldData, for: descriptor, isEncrypt: false)
|
|
let value = try deserialize(oldData, with: key.serializer)
|
|
|
|
// Store in NEW location
|
|
try await self.set(value, for: key)
|
|
|
|
// Delete OLD data
|
|
try await delete(for: descriptor)
|
|
}
|
|
}
|
|
Logger.debug("<<< [STORAGE] MANUAL MIGRATION COMPLETE: \(key.name)")
|
|
}
|
|
|
|
/// 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 validateCatalogRegistration(for: key)
|
|
try validatePlatformAvailability(for: key)
|
|
try await delete(for: .from(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 validateCatalogRegistration(for: key)
|
|
try validatePlatformAvailability(for: key)
|
|
|
|
switch key.domain {
|
|
case .userDefaults(let suite):
|
|
return try await UserDefaultsHelper.shared.exists(forKey: key.name, suite: suite)
|
|
case .appGroupUserDefaults(let identifier):
|
|
let resolvedId = try resolveIdentifier(identifier)
|
|
return try await UserDefaultsHelper.shared.exists(forKey: key.name, appGroupIdentifier: resolvedId)
|
|
case .keychain(let service):
|
|
let resolvedService = try resolveService(service)
|
|
return try await keychain.exists(service: resolvedService, key: key.name)
|
|
case .fileSystem(let directory), .encryptedFileSystem(let directory):
|
|
return await FileStorageHelper.shared.exists(in: directory, fileName: key.name)
|
|
case .appGroupFileSystem(let identifier, let directory):
|
|
let resolvedId = try resolveIdentifier(identifier)
|
|
return await FileStorageHelper.shared.exists(
|
|
in: directory,
|
|
fileName: key.name,
|
|
appGroupIdentifier: resolvedId
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Platform Validation
|
|
|
|
private func validatePlatformAvailability<Key: StorageKey>(for key: Key) throws {
|
|
#if os(watchOS)
|
|
if key.availability == .phoneOnly {
|
|
throw StorageError.phoneOnlyKeyAccessedOnWatch(key.name)
|
|
}
|
|
#elseif os(iOS)
|
|
if key.availability == .watchOnly {
|
|
throw StorageError.watchOnlyKeyAccessedOnPhone(key.name)
|
|
}
|
|
#endif
|
|
}
|
|
|
|
private func validateCatalogRegistration<Key: StorageKey>(for key: Key) throws {
|
|
guard !registeredKeyNames.isEmpty else { return }
|
|
guard registeredKeyNames.contains(key.name) else {
|
|
#if DEBUG
|
|
if !isRunningTests {
|
|
assertionFailure("StorageKey not registered in catalog: \(key.name)")
|
|
}
|
|
#endif
|
|
throw StorageError.unregisteredKey(key.name)
|
|
}
|
|
}
|
|
|
|
private var isRunningTests: Bool {
|
|
// Broad check for any test-related environment variables or classes
|
|
if ProcessInfo.processInfo.environment.keys.contains(where: {
|
|
$0.hasPrefix("XCTest") || $0.hasPrefix("SWIFT_TESTING") || $0.hasPrefix("SWIFT_DETERMINISTIC")
|
|
}) {
|
|
return true
|
|
}
|
|
return NSClassFromString("XCTestCase") != nil || NSClassFromString("Testing.Test") != nil
|
|
}
|
|
|
|
private func validateUniqueKeys(_ entries: [AnyStorageKey]) throws {
|
|
var exactNames: [String: Int] = [:]
|
|
var duplicates: [String] = []
|
|
|
|
for entry in entries {
|
|
exactNames[entry.descriptor.name, default: 0] += 1
|
|
}
|
|
|
|
for (name, count) in exactNames where count > 1 {
|
|
duplicates.append(name)
|
|
}
|
|
|
|
guard duplicates.isEmpty else {
|
|
throw StorageError.duplicateRegisteredKeys(duplicates.sorted())
|
|
}
|
|
}
|
|
|
|
private func validateDescription(_ entries: [AnyStorageKey]) throws {
|
|
let missing = entries
|
|
.map(\.descriptor)
|
|
.filter { $0.description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
|
.map(\.name)
|
|
|
|
guard missing.isEmpty else {
|
|
throw StorageError.missingDescription(missing.sorted().joined(separator: ", "))
|
|
}
|
|
}
|
|
|
|
// MARK: - Serialization
|
|
|
|
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 {
|
|
do {
|
|
return try serializer.decode(data)
|
|
} catch {
|
|
throw StorageError.deserializationFailed
|
|
}
|
|
}
|
|
|
|
// MARK: - Security
|
|
|
|
private func applySecurity(
|
|
_ data: Data,
|
|
for descriptor: StorageKeyDescriptor,
|
|
isEncrypt: Bool
|
|
) async throws -> Data {
|
|
switch descriptor.security {
|
|
case .none:
|
|
return data
|
|
|
|
case .encrypted(let encryptionPolicy):
|
|
await EncryptionHelper.shared.updateKeychainHelper(keychain)
|
|
if isEncrypt {
|
|
return try await EncryptionHelper.shared.encrypt(
|
|
data,
|
|
keyName: descriptor.name,
|
|
policy: encryptionPolicy
|
|
)
|
|
} else {
|
|
return try await EncryptionHelper.shared.decrypt(
|
|
data,
|
|
keyName: descriptor.name,
|
|
policy: encryptionPolicy
|
|
)
|
|
}
|
|
|
|
case .keychain:
|
|
// Keychain security is handled in store/retrieve
|
|
return data
|
|
}
|
|
}
|
|
|
|
// MARK: - Storage Operations
|
|
|
|
private func store(_ data: Data, for key: any StorageKey) async throws {
|
|
try await store(data, for: .from(key))
|
|
}
|
|
|
|
private func store(_ data: Data, for descriptor: StorageKeyDescriptor) async throws {
|
|
switch descriptor.domain {
|
|
case .userDefaults(let suite):
|
|
try await UserDefaultsHelper.shared.set(data, forKey: descriptor.name, suite: suite)
|
|
|
|
case .appGroupUserDefaults(let identifier):
|
|
let resolvedId = try resolveIdentifier(identifier)
|
|
try await UserDefaultsHelper.shared.set(data, forKey: descriptor.name, appGroupIdentifier: resolvedId)
|
|
|
|
case .keychain(let service):
|
|
guard case let .keychain(accessibility, accessControl) = descriptor.security else {
|
|
throw StorageError.securityApplicationFailed
|
|
}
|
|
let resolvedService = try resolveService(service)
|
|
try await keychain.set(
|
|
data,
|
|
service: resolvedService,
|
|
key: descriptor.name,
|
|
accessibility: accessibility,
|
|
accessControl: accessControl
|
|
)
|
|
|
|
case .fileSystem(let directory):
|
|
try await FileStorageHelper.shared.write(
|
|
data,
|
|
to: directory,
|
|
fileName: descriptor.name,
|
|
useCompleteFileProtection: false
|
|
)
|
|
|
|
case .encryptedFileSystem(let directory):
|
|
try await FileStorageHelper.shared.write(
|
|
data,
|
|
to: directory,
|
|
fileName: descriptor.name,
|
|
useCompleteFileProtection: true
|
|
)
|
|
|
|
case .appGroupFileSystem(let identifier, let directory):
|
|
let resolvedId = try resolveIdentifier(identifier)
|
|
try await FileStorageHelper.shared.write(
|
|
data,
|
|
to: directory,
|
|
fileName: descriptor.name,
|
|
appGroupIdentifier: resolvedId,
|
|
useCompleteFileProtection: false
|
|
)
|
|
}
|
|
}
|
|
|
|
private func retrieve(for descriptor: StorageKeyDescriptor) async throws -> Data? {
|
|
switch descriptor.domain {
|
|
case .userDefaults(let suite):
|
|
return try await UserDefaultsHelper.shared.get(forKey: descriptor.name, suite: suite)
|
|
case .appGroupUserDefaults(let identifier):
|
|
let resolvedId = try resolveIdentifier(identifier)
|
|
return try await UserDefaultsHelper.shared.get(forKey: descriptor.name, appGroupIdentifier: resolvedId)
|
|
|
|
case .keychain(let service):
|
|
let resolvedService = try resolveService(service)
|
|
return try await keychain.get(service: resolvedService, key: descriptor.name)
|
|
|
|
case .fileSystem(let directory), .encryptedFileSystem(let directory):
|
|
return try await FileStorageHelper.shared.read(from: directory, fileName: descriptor.name)
|
|
case .appGroupFileSystem(let identifier, let directory):
|
|
let resolvedId = try resolveIdentifier(identifier)
|
|
return try await FileStorageHelper.shared.read(
|
|
from: directory,
|
|
fileName: descriptor.name,
|
|
appGroupIdentifier: resolvedId
|
|
)
|
|
}
|
|
}
|
|
|
|
private func delete(for descriptor: StorageKeyDescriptor) async throws {
|
|
switch descriptor.domain {
|
|
case .userDefaults(let suite):
|
|
try await UserDefaultsHelper.shared.remove(forKey: descriptor.name, suite: suite)
|
|
case .appGroupUserDefaults(let identifier):
|
|
let resolvedId = try resolveIdentifier(identifier)
|
|
try await UserDefaultsHelper.shared.remove(forKey: descriptor.name, appGroupIdentifier: resolvedId)
|
|
|
|
case .keychain(let service):
|
|
let resolvedService = try resolveService(service)
|
|
try await keychain.delete(service: resolvedService, key: descriptor.name)
|
|
|
|
case .fileSystem(let directory), .encryptedFileSystem(let directory):
|
|
try await FileStorageHelper.shared.delete(from: directory, fileName: descriptor.name)
|
|
case .appGroupFileSystem(let identifier, let directory):
|
|
let resolvedId = try resolveIdentifier(identifier)
|
|
try await FileStorageHelper.shared.delete(
|
|
from: directory,
|
|
fileName: descriptor.name,
|
|
appGroupIdentifier: resolvedId
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Sync
|
|
|
|
private func handleSync(_ key: any StorageKey, data: Data) async throws {
|
|
try await SyncHelper.shared.syncIfNeeded(
|
|
data: data,
|
|
keyName: key.name,
|
|
availability: key.availability,
|
|
syncPolicy: key.syncPolicy
|
|
)
|
|
}
|
|
|
|
// MARK: - Internal Sync Handling
|
|
|
|
/// Internal method to update storage from received sync data.
|
|
/// This is called by SyncHelper when the paired device sends new context.
|
|
func updateFromSync(keyName: String, data: Data) async throws {
|
|
// Find the registered entry for this key
|
|
guard let entry = registeredEntries.first(where: { $0.descriptor.name == keyName }) else {
|
|
Logger.debug("Received sync data for unregistered or uncatalogued key: \(keyName)")
|
|
return
|
|
}
|
|
|
|
// The data received is already 'secured' (encrypted if necessary) by the sender.
|
|
// We can store it directly in our local domain.
|
|
try await store(data, for: entry.descriptor)
|
|
Logger.info("Successfully updated local storage from sync for key: \(keyName)")
|
|
}
|
|
|
|
// MARK: - Resolution Helpers
|
|
|
|
private func resolveService(_ service: String?) throws -> String {
|
|
guard let resolved = service ?? storageConfiguration.defaultKeychainService else {
|
|
Logger.error("No keychain service provided and no default configured")
|
|
throw StorageError.keychainError(errSecBadReq) // Or a more specific error
|
|
}
|
|
Logger.debug("Resolved Keychain Service: \(resolved)")
|
|
return resolved
|
|
}
|
|
|
|
private func resolveIdentifier(_ identifier: String?) throws -> String {
|
|
guard let resolved = identifier ?? storageConfiguration.defaultAppGroupIdentifier else {
|
|
Logger.error("No App Group identifier provided and no default configured")
|
|
throw StorageError.invalidAppGroupIdentifier("none")
|
|
}
|
|
Logger.debug("Resolved App Group ID: \(resolved)")
|
|
return resolved
|
|
}
|
|
}
|