Update Audit, Helpers, Migrations (+2 more)

Summary:
- Sources: Audit, Helpers, Migrations, Models, Protocols
- Added symbols: struct AppVersionConditionalMigration
- Removed symbols: struct AppVersionConditionalMigration, protocol ConditionalMigration

Stats:
- 29 files changed, 471 insertions(+), 25 deletions(-)
This commit is contained in:
Matt Bruce 2026-01-17 12:00:30 -06:00
parent 60ee5b380a
commit 1d6ac7893f
29 changed files with 471 additions and 25 deletions

View File

@ -1,9 +1,16 @@
import Foundation
/// Renders audit reports for storage key catalogs and registries.
///
/// Use this type to build human-readable snapshots of your storage surface area.
/// Reports are useful for security reviews, compliance audits, and debugging
/// catalog registration issues.
public struct StorageAuditReport: Sendable {
/// Returns descriptors for all keys in a catalog.
///
/// This applies the catalog name to each descriptor, so the output can be
/// grouped and traced back to its module.
///
/// - Parameter catalog: Catalog containing keys to describe.
/// - Returns: An array of ``StorageKeyDescriptor`` values.
public static func items(for catalog: some StorageKeyCatalog) -> [StorageKeyDescriptor] {
@ -20,6 +27,9 @@ public struct StorageAuditReport: Sendable {
/// Renders a text report for a list of type-erased keys.
///
/// Use this when you already have `AnyStorageKey` entries, such as from
/// `StorageRouter.allRegisteredEntries()`.
///
/// - Parameter entries: The keys to render.
/// - Returns: A newline-delimited report string.
public static func renderText(_ entries: [AnyStorageKey]) -> String {
@ -36,6 +46,9 @@ public struct StorageAuditReport: Sendable {
/// Renders a text report for the global registry grouped by catalog.
///
/// Each catalog section is prefixed with a header and followed by its
/// contained key descriptors.
///
/// - Returns: A report string grouped by catalog name.
public static func renderGlobalRegistryGrouped() async -> String {
let catalogs = await StorageRouter.shared.allRegisteredCatalogs()
@ -78,6 +91,9 @@ public struct StorageAuditReport: Sendable {
}
/// Formats a storage domain for audit output.
///
/// - Parameter domain: Domain to format.
/// - Returns: A concise, human-readable domain string.
private static func string(for domain: StorageDomain) -> String {
switch domain {
case .userDefaults(let suite):
@ -96,6 +112,9 @@ public struct StorageAuditReport: Sendable {
}
/// Formats a file directory for audit output.
///
/// - Parameter directory: Directory to format.
/// - Returns: A concise directory string.
private static func string(for directory: FileDirectory) -> String {
switch directory {
case .documents:
@ -108,6 +127,9 @@ public struct StorageAuditReport: Sendable {
}
/// Formats platform availability for audit output.
///
/// - Parameter availability: Availability to format.
/// - Returns: A concise availability string.
private static func string(for availability: PlatformAvailability) -> String {
switch availability {
case .all:
@ -122,6 +144,9 @@ public struct StorageAuditReport: Sendable {
}
/// Formats sync policy for audit output.
///
/// - Parameter syncPolicy: Sync policy to format.
/// - Returns: A concise sync policy string.
private static func string(for syncPolicy: SyncPolicy) -> String {
switch syncPolicy {
case .never:
@ -134,6 +159,9 @@ public struct StorageAuditReport: Sendable {
}
/// Formats security policy for audit output.
///
/// - Parameter security: Security policy to format.
/// - Returns: A concise security policy string.
private static func string(for security: SecurityPolicy) -> String {
switch security {
case .none:
@ -147,6 +175,9 @@ public struct StorageAuditReport: Sendable {
}
/// Formats encryption policy for audit output.
///
/// - Parameter policy: Encryption policy to format.
/// - Returns: A concise encryption policy string.
private static func string(for policy: SecurityPolicy.EncryptionPolicy) -> String {
switch policy {
case .aes256(let derivation):
@ -159,6 +190,9 @@ public struct StorageAuditReport: Sendable {
}
/// Formats key derivation for audit output.
///
/// - Parameter derivation: Derivation to format.
/// - Returns: A concise derivation string.
private static func string(for derivation: SecurityPolicy.KeyDerivation) -> String {
switch derivation {
case .pbkdf2(let iterations, _):

View File

@ -3,20 +3,29 @@ import CryptoKit
/// Actor that handles all encryption and decryption operations.
///
/// Uses AES-GCM or ChaChaPoly for symmetric encryption with derived keys, and
/// stores a master key in Keychain for deterministic derivation.
/// `EncryptionHelper` provides symmetric encryption using AES-GCM or
/// ChaChaPoly. It derives per-key symmetric keys from a master key stored in
/// Keychain (or from external key material when configured) so encrypted data
/// is deterministic and recoverable across app launches.
actor EncryptionHelper {
/// Shared encryption helper instance.
///
/// Prefer this shared instance in production code. Tests can inject a custom instance.
/// Prefer this shared instance in production code. Tests can inject a custom instance
/// with isolated configuration and keychain dependencies.
public static let shared = EncryptionHelper()
/// Current encryption configuration.
///
/// Controls key derivation defaults and master key settings.
private var configuration: EncryptionConfiguration
/// Keychain provider used for master key storage.
///
/// Abstracted to allow test stubs and isolation.
private var keychain: KeychainStoring
/// External key material providers keyed by source identifier.
///
/// Used for ``SecurityPolicy/EncryptionPolicy/external(source:keyDerivation:)``.
private var keyMaterialProviders: [KeyMaterialSource: any KeyMaterialProviding] = [:]
/// Creates an encryption helper with a configuration and keychain provider.
@ -37,6 +46,8 @@ actor EncryptionHelper {
/// Updates the configuration for the actor.
///
/// - Parameter configuration: New encryption configuration.
/// - Warning: Changing these values after data is encrypted may make
/// existing data unreadable unless migrated.
public func updateConfiguration(_ configuration: EncryptionConfiguration) {
self.configuration = configuration
}
@ -44,7 +55,7 @@ actor EncryptionHelper {
/// Updates the keychain helper used for master key storage.
///
/// - Parameter keychain: Keychain provider to use.
/// - Note: Internal for testing isolation.
/// - Note: Intended for testing isolation and dependency injection.
public func updateKeychainHelper(_ keychain: KeychainStoring) {
self.keychain = keychain
}
@ -56,6 +67,7 @@ actor EncryptionHelper {
/// - Parameters:
/// - provider: The provider that supplies key material.
/// - source: Identifier used to look up the provider.
/// - Note: Required when using ``SecurityPolicy/EncryptionPolicy/external(source:keyDerivation:)``.
public func registerKeyMaterialProvider(
_ provider: any KeyMaterialProviding,
for source: KeyMaterialSource
@ -99,8 +111,13 @@ actor EncryptionHelper {
// MARK: - Key Derivation
/// Derives an encryption key using the specified policy.
/// Derives a symmetric key based on the encryption policy.
///
/// - Parameters:
/// - keyName: Logical key name used to seed derivation.
/// - policy: Encryption policy describing algorithm and derivation.
/// - Returns: A symmetric key for encryption/decryption.
/// - Throws: ``StorageError/securityApplicationFailed`` when derivation fails or no provider exists.
private func deriveKey(
keyName: String,
policy: SecurityPolicy.EncryptionPolicy
@ -127,8 +144,14 @@ actor EncryptionHelper {
}
}
/// Derives key material based on the provided key derivation strategy.
/// Derives key material using HKDF or PBKDF2.
///
/// - Parameters:
/// - keyName: Logical key name used to seed derivation.
/// - derivation: Key derivation strategy.
/// - baseKeyMaterial: Base key material (master key or external).
/// - Returns: A symmetric key derived from the inputs.
/// - Throws: ``StorageError/securityApplicationFailed`` if derivation fails.
private func deriveKeyMaterial(
keyName: String,
derivation: SecurityPolicy.KeyDerivation,
@ -158,8 +181,10 @@ actor EncryptionHelper {
}
}
/// Gets or creates the master key stored in keychain.
/// Retrieves or creates the master key stored in Keychain.
///
/// - Returns: The master key data.
/// - Throws: ``StorageError/securityApplicationFailed`` or keychain errors.
private func getMasterKey() async throws -> Data {
if let existing = try await keychain.get(
service: configuration.masterKeyService,
@ -192,6 +217,13 @@ actor EncryptionHelper {
// MARK: - AES-GCM Operations
/// Encrypts data using the selected algorithm and key.
///
/// - Parameters:
/// - data: Plaintext data to encrypt.
/// - key: Symmetric key to use.
/// - policy: Encryption policy selecting the algorithm.
/// - Returns: Combined ciphertext for storage.
/// - Throws: ``StorageError/securityApplicationFailed`` if encryption fails.
private func encryptWithKey(
_ data: Data,
using key: SymmetricKey,
@ -208,6 +240,12 @@ actor EncryptionHelper {
}
/// Encrypts data using AES-GCM.
///
/// - Parameters:
/// - data: Plaintext data to encrypt.
/// - key: Symmetric key to use.
/// - Returns: Combined nonce + ciphertext + tag.
/// - Throws: ``StorageError/securityApplicationFailed`` if encryption fails.
private func encryptWithAESGCM(_ data: Data, using key: SymmetricKey) throws -> Data {
do {
let sealedBox = try AES.GCM.seal(data, using: key)
@ -221,6 +259,13 @@ actor EncryptionHelper {
}
/// Decrypts data using the selected algorithm and key.
///
/// - Parameters:
/// - data: Combined nonce + ciphertext + tag.
/// - key: Symmetric key to use.
/// - policy: Encryption policy selecting the algorithm.
/// - Returns: Decrypted plaintext data.
/// - Throws: ``StorageError/securityApplicationFailed`` if decryption fails.
private func decryptWithKey(
_ data: Data,
using key: SymmetricKey,
@ -237,6 +282,12 @@ actor EncryptionHelper {
}
/// Decrypts data using AES-GCM.
///
/// - Parameters:
/// - data: Combined nonce + ciphertext + tag.
/// - key: Symmetric key to use.
/// - Returns: Decrypted plaintext data.
/// - Throws: ``StorageError/securityApplicationFailed`` if decryption fails.
private func decryptWithAESGCM(_ data: Data, using key: SymmetricKey) throws -> Data {
do {
let sealedBox = try AES.GCM.SealedBox(combined: data)
@ -247,6 +298,12 @@ actor EncryptionHelper {
}
/// Encrypts data using ChaChaPoly.
///
/// - Parameters:
/// - data: Plaintext data to encrypt.
/// - key: Symmetric key to use.
/// - Returns: Combined nonce + ciphertext + tag.
/// - Throws: ``StorageError/securityApplicationFailed`` if encryption fails.
private func encryptWithChaChaPoly(_ data: Data, using key: SymmetricKey) throws -> Data {
do {
let sealedBox = try ChaChaPoly.seal(data, using: key)
@ -257,6 +314,12 @@ actor EncryptionHelper {
}
/// Decrypts data using ChaChaPoly.
///
/// - Parameters:
/// - data: Combined nonce + ciphertext + tag.
/// - key: Symmetric key to use.
/// - Returns: Decrypted plaintext data.
/// - Throws: ``StorageError/securityApplicationFailed`` if decryption fails.
private func decryptWithChaChaPoly(_ data: Data, using key: SymmetricKey) throws -> Data {
do {
let sealedBox = try ChaChaPoly.SealedBox(combined: data)
@ -269,6 +332,14 @@ actor EncryptionHelper {
// MARK: - PBKDF2 Implementation
/// Derives key data using PBKDF2-SHA256.
///
/// - Parameters:
/// - password: Base key material used as the PBKDF2 password.
/// - salt: Salt value to strengthen derivation.
/// - iterations: Number of PBKDF2 rounds.
/// - keyLength: Desired output length in bytes.
/// - Returns: Derived key material.
/// - Throws: ``StorageError/securityApplicationFailed`` if iterations are invalid.
private func pbkdf2SHA256(
password: Data,
salt: Data,
@ -305,6 +376,11 @@ actor EncryptionHelper {
}
/// Computes HMAC-SHA256 for PBKDF2.
///
/// - Parameters:
/// - key: HMAC key.
/// - data: Data to authenticate.
/// - Returns: HMAC output.
private func hmacSHA256(key: Data, data: Data) -> Data {
let symmetricKey = SymmetricKey(data: key)
let mac = HMAC<SHA256>.authenticationCode(for: data, using: symmetricKey)
@ -312,18 +388,29 @@ actor EncryptionHelper {
}
/// XORs two data buffers for PBKDF2 chaining.
///
/// - Parameters:
/// - left: First buffer.
/// - right: Second buffer.
/// - Returns: XORed data.
private func xor(_ left: Data, _ right: Data) -> Data {
let xored = zip(left, right).map { $0 ^ $1 }
return Data(xored)
}
/// Encodes a UInt32 as big-endian data.
///
/// - Parameter value: Value to encode.
/// - Returns: Big-endian data representation.
private func uint32BigEndian(_ value: UInt32) -> Data {
var bigEndian = value.bigEndian
return Data(bytes: &bigEndian, count: MemoryLayout<UInt32>.size)
}
/// Generates a deterministic salt from a key name.
///
/// - Parameter keyName: Key name used to build the salt.
/// - Returns: Salt data used for key derivation.
private func defaultSalt(for keyName: String) -> Data {
Data(keyName.utf8)
}

View File

@ -3,16 +3,20 @@ import WatchConnectivity
/// Actor that handles WatchConnectivity sync operations.
///
/// Manages data synchronization between iPhone and Apple Watch and provides
/// size- and policy-based gating for outbound sync.
/// `SyncHelper` manages data synchronization between iPhone and Apple Watch.
/// It enforces LocalData's sync policies, validates payload size constraints,
/// and delegates incoming application contexts to ``StorageRouter``.
actor SyncHelper {
/// Shared sync helper instance.
///
/// Prefer this shared instance in production code. Tests can inject a custom instance.
/// Prefer this shared instance in production code. Tests can inject a custom instance
/// with isolated configuration and `WCSession` behavior.
public static let shared = SyncHelper()
/// Current sync configuration.
///
/// Controls size thresholds for automatic sync.
private var configuration: SyncConfiguration
/// Creates a helper with a specific configuration.
@ -39,12 +43,18 @@ actor SyncHelper {
// MARK: - Public Interface
/// Syncs data to the paired device if appropriate.
///
/// This method enforces LocalData's eligibility rules:
/// - availability must be `.all` or `.phoneWithWatchSync`
/// - sync policy must be `.manual` or `.automaticSmall`
/// - `.automaticSmall` must be under the configured size threshold
///
/// - 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.
/// - Throws: ``StorageError/dataTooLargeForSync`` if data exceeds size limit for automatic sync.
public func syncIfNeeded(
data: Data,
keyName: String,
@ -72,6 +82,10 @@ actor SyncHelper {
}
/// Manually triggers a sync for the given data.
///
/// This bypasses automatic size gating; callers should ensure payload size
/// is reasonable for application context updates.
///
/// - Parameters:
/// - data: The data to sync.
/// - keyName: The key name for the application context.
@ -138,6 +152,9 @@ actor SyncHelper {
}
/// Lazily configures and activates WCSession.
///
/// LocalData does not own session lifecycle beyond the minimal setup needed
/// to send updates; app-level services should activate WCSession explicitly.
private func setupSession() {
let session = WCSession.default
session.delegate = SessionDelegateProxy.shared
@ -165,11 +182,15 @@ actor SyncHelper {
}
}
/// Internal proxy class that routes WCSessionDelegate callbacks to ``SyncHelper``.
/// Internal proxy class that routes `WCSessionDelegate` callbacks to ``SyncHelper``.
///
/// The proxy bridges delegate callbacks into async code without exposing
/// `WCSessionDelegate` conformance in the public API.
internal final class SessionDelegateProxy: NSObject, WCSessionDelegate {
/// Shared delegate proxy instance.
static let shared = SessionDelegateProxy()
/// Handles WCSession activation completion.
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
if let error = error {
Logger.error("WCSession activation failed: \(error.localizedDescription)")
@ -178,6 +199,7 @@ internal final class SessionDelegateProxy: NSObject, WCSessionDelegate {
}
}
/// Receives an updated application context and forwards it to `SyncHelper`.
func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
Task {
await SyncHelper.shared.handleReceivedContext(applicationContext)
@ -185,7 +207,9 @@ internal final class SessionDelegateProxy: NSObject, WCSessionDelegate {
}
#if os(iOS)
/// iOS-only callback when a session becomes inactive.
func sessionDidBecomeInactive(_ session: WCSession) {}
/// iOS-only callback when a session deactivates.
func sessionDidDeactivate(_ session: WCSession) {
session.activate()
}

View File

@ -1,7 +1,10 @@
import Foundation
/// Conditional migration that runs only when the app version is below a threshold.
public struct AppVersionConditionalMigration<Value: Codable & Sendable>: ConditionalMigration {
///
/// Use this wrapper to keep a legacy migration in place for older app versions
/// while allowing newer versions to skip it.
public struct AppVersionConditionalMigration<Value: Codable & Sendable>: StorageMigration {
/// Destination key for the migration.
public let destinationKey: StorageKey<Value>
/// Minimum app version required to skip this migration.
@ -10,6 +13,11 @@ public struct AppVersionConditionalMigration<Value: Codable & Sendable>: Conditi
public let fallbackMigration: AnyStorageMigration
/// Creates a version-gated migration.
///
/// - Parameters:
/// - destinationKey: The key that receives migrated data.
/// - minAppVersion: The minimum app version that should *skip* the migration.
/// - fallbackMigration: The migration to run when the version condition is met.
public init(
destinationKey: StorageKey<Value>,
minAppVersion: String,

View File

@ -1,6 +1,10 @@
import Foundation
/// Default migration that aggregates multiple source values into one destination value.
///
/// Use this migration when you need to combine multiple legacy keys into a single
/// destination value (for example, building a new composite model from several
/// old preferences).
public struct DefaultAggregatingMigration<Value: Codable & Sendable>: AggregatingMigration {
/// Destination key for aggregated data.
public let destinationKey: StorageKey<Value>
@ -10,6 +14,11 @@ public struct DefaultAggregatingMigration<Value: Codable & Sendable>: Aggregatin
public let aggregateAction: @Sendable ([AnyCodable]) async throws -> Value
/// Creates an aggregating migration with a custom aggregation closure.
///
/// - Parameters:
/// - destinationKey: The key that receives the aggregated value.
/// - sourceKeys: The legacy keys to read and aggregate.
/// - aggregate: Closure that combines source values into a destination value.
public init(
destinationKey: StorageKey<Value>,
sourceKeys: [AnyStorageKey],
@ -30,6 +39,11 @@ public struct DefaultAggregatingMigration<Value: Codable & Sendable>: Aggregatin
/// Determines whether the migration should run.
///
/// The migration runs when:
/// - the destination is allowed on the current platform
/// - the destination does not already exist
/// - at least one source key contains data
///
/// - Parameters:
/// - router: The storage router used to query state.
/// - context: Migration context for conditional checks.
@ -53,6 +67,12 @@ public struct DefaultAggregatingMigration<Value: Codable & Sendable>: Aggregatin
/// Executes the migration and returns a result.
///
/// The migration:
/// 1. Reads each source descriptor.
/// 2. Applies security to decode the raw data.
/// 3. Aggregates values into a destination value.
/// 4. Writes the destination value and deletes sources.
///
/// - Parameters:
/// - router: The storage router used to read and write values.
/// - context: Migration context for conditional checks.

View File

@ -1,6 +1,9 @@
import Foundation
/// Default migration that transforms a single source value into a destination value.
///
/// Use this migration when the destination value type differs from the legacy
/// type or when you need to normalize/clean legacy data before storage.
public struct DefaultTransformingMigration<SourceValue: Codable & Sendable, DestinationValue: Codable & Sendable>: TransformingMigration {
/// Destination key for the transformed value.
public let destinationKey: StorageKey<DestinationValue>
@ -10,6 +13,11 @@ public struct DefaultTransformingMigration<SourceValue: Codable & Sendable, Dest
public let transformAction: @Sendable (SourceValue) async throws -> DestinationValue
/// Creates a transforming migration with a custom transform closure.
///
/// - Parameters:
/// - destinationKey: The key that receives the transformed value.
/// - sourceKey: The legacy key providing the source value.
/// - transform: Closure that converts the source value into the destination type.
public init(
destinationKey: StorageKey<DestinationValue>,
sourceKey: StorageKey<SourceValue>,
@ -30,6 +38,11 @@ public struct DefaultTransformingMigration<SourceValue: Codable & Sendable, Dest
/// Determines whether the migration should run.
///
/// The migration runs when:
/// - the destination is allowed on the current platform
/// - the destination does not already exist
/// - the source key contains data
///
/// - Parameters:
/// - router: The storage router used to query state.
/// - context: Migration context for conditional checks.
@ -47,6 +60,12 @@ public struct DefaultTransformingMigration<SourceValue: Codable & Sendable, Dest
/// Executes the migration and returns a result.
///
/// The migration:
/// 1. Reads the source value.
/// 2. Transforms it into the destination type.
/// 3. Writes the destination value.
/// 4. Deletes the source key.
///
/// - Parameters:
/// - router: The storage router used to read and write values.
/// - context: Migration context for conditional checks.

View File

@ -1,6 +1,9 @@
import Foundation
/// Simple 1:1 legacy migration from a single source key.
///
/// Use this migration when the source and destination value types are identical
/// and no transformation is required.
public struct SimpleLegacyMigration<Value: Codable & Sendable>: StorageMigration {
/// Destination key for migrated data.
public let destinationKey: StorageKey<Value>
@ -8,6 +11,10 @@ public struct SimpleLegacyMigration<Value: Codable & Sendable>: StorageMigration
public let sourceKey: AnyStorageKey
/// Creates a migration from a legacy key to a destination key.
///
/// - Parameters:
/// - destinationKey: The key that receives migrated data.
/// - sourceKey: The legacy key to read and remove.
public init(destinationKey: StorageKey<Value>, sourceKey: AnyStorageKey) {
self.destinationKey = destinationKey
self.sourceKey = sourceKey
@ -15,6 +22,11 @@ public struct SimpleLegacyMigration<Value: Codable & Sendable>: StorageMigration
/// Determines whether the migration should run.
///
/// The migration runs when:
/// - the destination is allowed on the current platform
/// - the destination does not already exist
/// - the source key contains data
///
/// - Parameters:
/// - router: The storage router used to query state.
/// - context: Migration context for conditional checks.
@ -32,6 +44,12 @@ public struct SimpleLegacyMigration<Value: Codable & Sendable>: StorageMigration
/// Executes the migration and returns a result.
///
/// The migration:
/// 1. Reads raw source data.
/// 2. Removes legacy security (if needed).
/// 3. Decodes into the destination value type.
/// 4. Writes to the destination and deletes the source.
///
/// - Parameters:
/// - router: The storage router used to read and write values.
/// - context: Migration context for conditional checks.

View File

@ -1,9 +1,15 @@
/// Type-erased wrapper around ``StorageKey`` for catalogs and audits.
///
/// `StorageKey` is generic over its `Value` type, so heterogeneous keys cannot
/// be stored in a single array without type erasure. `AnyStorageKey` captures
/// the descriptor and optional migration, enabling catalog registration and
/// audit reporting.
public struct AnyStorageKey: Sendable {
/// Snapshot of key metadata for auditing and storage operations.
public internal(set) var descriptor: StorageKeyDescriptor
/// Optional migration associated with the key.
public internal(set) var migration: AnyStorageMigration?
/// Migration executor captured from the original key.
private let migrateAction: @Sendable (StorageRouter) async throws -> Void
/// Creates a type-erased key from a typed ``StorageKey``.
@ -17,6 +23,12 @@ public struct AnyStorageKey: Sendable {
}
}
/// Internal initializer for constructing modified wrappers.
///
/// - Parameters:
/// - descriptor: Descriptor describing the key.
/// - migration: Optional migration.
/// - migrateAction: Closure to execute migration for the key.
private init(
descriptor: StorageKeyDescriptor,
migration: AnyStorageMigration?,
@ -30,6 +42,7 @@ public struct AnyStorageKey: Sendable {
/// Convenience factory for creating a type-erased key.
///
/// - Parameter key: The concrete key to erase.
/// - Returns: A type-erased key wrapper.
public static func key<Value>(_ key: StorageKey<Value>) -> AnyStorageKey {
AnyStorageKey(key)
}

View File

@ -1,11 +1,20 @@
import Foundation
/// Type-erased wrapper for ``StorageMigration`` for use in catalogs and registrations.
///
/// `StorageMigration` is a protocol with an associated type, which makes it
/// difficult to store heterogeneous migrations in a single collection. This
/// wrapper captures the migration's behavior and destination descriptor while
/// preserving sendability.
public struct AnyStorageMigration: Sendable {
/// Descriptor for the migration destination key.
///
/// Useful for auditing and for determining the target of the migration.
public let destinationDescriptor: StorageKeyDescriptor
/// Captured closure that decides whether migration should run.
private let shouldMigrateAction: @Sendable (StorageRouter, MigrationContext) async throws -> Bool
/// Captured closure that executes the migration.
private let migrateAction: @Sendable (StorageRouter, MigrationContext) async throws -> MigrationResult
/// Creates a type-erased migration from a concrete migration.

View File

@ -1,15 +1,26 @@
import Foundation
/// File system directory for file-based storage.
///
/// Use this enum to describe where a file should live within the app sandbox
/// or a custom file URL.
public enum FileDirectory: Sendable, Hashable {
/// App documents directory.
///
/// Best for user-facing or critical data that should be backed up.
case documents
/// App caches directory.
///
/// Best for temporary or re-creatable data that may be purged by the system.
case caches
/// Custom directory URL.
///
/// Use this to scope storage to a custom location (including App Group paths).
case custom(URL)
/// Resolves the directory to a concrete URL.
///
/// - Returns: The resolved directory URL.
public func url() -> URL {
switch self {
case .documents:

View File

@ -1,11 +1,18 @@
import Foundation
/// Identifier for external key material providers.
///
/// Use this type to register and reference external key material when using
/// ``SecurityPolicy/EncryptionPolicy/external(source:keyDerivation:)``.
public struct KeyMaterialSource: Hashable, Sendable {
/// Stable identifier for the provider or key source.
///
/// This should be deterministic and consistent across app launches.
public let id: String
/// Creates a new key material source identifier.
///
/// - Parameter id: Stable identifier for the external key provider.
public init(id: String) {
self.id = id
}

View File

@ -1,6 +1,9 @@
import Foundation
/// Context information available for conditional migrations.
///
/// Migrations use this context to decide whether they should run and to tailor
/// behavior based on app version, device, system state, and historical data.
public struct MigrationContext: Sendable {
/// Current app version string.
public let appVersion: String
@ -14,6 +17,13 @@ public struct MigrationContext: Sendable {
public let systemInfo: SystemInfo
/// Creates a migration context with optional overrides.
///
/// - Parameters:
/// - appVersion: Current app version string. Defaults to the bundle version.
/// - deviceInfo: Device metadata for platform checks.
/// - migrationHistory: Historical migration timestamps by key name.
/// - userPreferences: Optional preferences influencing migration behavior.
/// - systemInfo: System information snapshot.
public init(
appVersion: String = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown",
deviceInfo: DeviceInfo = .current,

View File

@ -1,6 +1,9 @@
import Foundation
/// Migration-specific error types.
///
/// These errors describe why a migration failed or could not run, and are used
/// in ``MigrationResult`` for reporting.
public enum MigrationError: Error, Sendable, Equatable {
/// Validation failed before migration could run.
case validationFailed(String)

View File

@ -1,19 +1,42 @@
import Foundation
/// Result of a migration operation with detailed information.
/// Records the outcome of a migration along with counts, errors, and metadata.
///
/// `MigrationResult` is the canonical payload returned by migrations so callers can
/// surface success/failure, auditing details, and performance data in a consistent way.
/// It is intentionally lightweight and `Sendable` so it can cross concurrency boundaries.
public struct MigrationResult: Sendable {
/// Whether the migration completed successfully.
/// Indicates whether the migration completed without fatal errors.
///
/// A value of `false` does not always mean that no data moved; consult
/// `migratedCount` and `errors` for partial outcomes.
public let success: Bool
/// Number of values migrated.
/// Number of items or records migrated during the operation.
///
/// This count is used for audit logging and metrics. Its meaning depends on the
/// migration type (for example, key-by-key versus batch).
public let migratedCount: Int
/// Errors captured during migration.
/// Errors captured while the migration executed.
///
/// Consumers should surface these to developers and use them to decide on retries.
public let errors: [MigrationError]
/// Additional metadata provided by the migration.
/// Additional metadata emitted by the migration for diagnostics or reporting.
///
/// The dictionary must only contain `AnyCodable` values so results remain portable.
public let metadata: [String: AnyCodable]
/// Duration of the migration in seconds.
///
/// Use this value for instrumentation and to flag unusually slow migrations.
public let duration: TimeInterval
/// Creates a migration result with optional details.
///
/// - Parameters:
/// - success: Whether the migration completed without fatal errors.
/// - migratedCount: Number of items migrated. Defaults to `0`.
/// - errors: Errors captured during the migration. Defaults to an empty array.
/// - metadata: Metadata emitted by the migration. Defaults to an empty dictionary.
/// - duration: Duration in seconds. Defaults to `0`.
public init(
success: Bool,
migratedCount: Int = 0,

View File

@ -1,20 +1,35 @@
import Foundation
/// Specifies which platforms a storage key is allowed to run on.
/// Declares which platforms a storage key can be used on.
///
/// `PlatformAvailability` guides the router when enforcing platform-specific usage.
/// For example, watch-only keys prevent accidental reads on iOS, while sync-enabled
/// keys signal that watch-to-phone data flows should be configured.
public enum PlatformAvailability: Sendable {
/// Available on iOS and watchOS (small data only on watch).
///
/// Use this for data that is safe to exist on both devices without explicit sync.
case all
/// Available only on iOS (large or sensitive data).
///
/// Prefer this for data that is too large or too sensitive for watch storage.
case phoneOnly
/// Available only on watchOS.
///
/// Use this for watch-local data that should not be mirrored to iPhone.
case watchOnly
/// Available on iOS and watchOS with explicit sync behavior.
///
/// Use this when your key participates in a defined sync strategy.
case phoneWithWatchSync
}
/// Convenience helpers for platform checks.
public extension PlatformAvailability {
/// Returns `true` if the key should be available on the given platform.
/// Returns `true` when the key is permitted on the supplied platform.
///
/// - Parameter platform: The runtime platform to evaluate.
/// - Returns: `true` if the key can be used on the platform, otherwise `false`.
func isAvailable(on platform: Platform) -> Bool {
switch self {
case .all:

View File

@ -2,40 +2,71 @@ import Foundation
import CryptoKit
import Security
/// Security policy for a ``StorageKey``.
/// Describes how a ``StorageKey`` secures its persisted data.
///
/// A `SecurityPolicy` is attached to each key so the storage layer can enforce
/// encryption or Keychain placement consistently across the app.
public enum SecurityPolicy: Equatable, Sendable {
/// Stores data without additional security.
///
/// Use this only for non-sensitive, non-personal data.
case none
/// Encrypts data before storage using the specified policy.
///
/// The encryption policy describes the algorithm and key derivation strategy.
case encrypted(EncryptionPolicy)
/// Stores data directly in the Keychain with accessibility and access control options.
///
/// Use this for credentials or secrets that must remain in Keychain storage.
case keychain(accessibility: KeychainAccessibility, accessControl: KeychainAccessControl?)
/// Recommended security policy for most sensitive data.
///
/// This defaults to the recommended encryption policy to keep data encrypted at rest.
public static let recommended: SecurityPolicy = .encrypted(.recommended)
/// Encryption algorithm and key derivation settings.
///
/// Use these options to align with organizational security requirements.
public enum EncryptionPolicy: Equatable, Sendable {
/// AES-256-GCM encryption.
///
/// Choose when AES is preferred for compliance or interoperability reasons.
case aes256(keyDerivation: KeyDerivation)
/// ChaCha20-Poly1305 encryption.
///
/// This is the recommended default for modern Apple platforms.
case chacha20Poly1305(keyDerivation: KeyDerivation)
/// External key material with key derivation.
///
/// Use this when key material is provided by an HSM, secure enclave, or other source.
case external(source: KeyMaterialSource, keyDerivation: KeyDerivation)
/// Recommended encryption policy for most cases.
///
/// Uses ChaCha20-Poly1305 with HKDF-derived keys.
public static let recommended: EncryptionPolicy = .chacha20Poly1305(keyDerivation: .hkdf())
/// Convenience for external key material with default HKDF.
///
/// - Parameter source: The external key material provider.
/// - Returns: A policy configured with the default HKDF parameters.
public static func external(source: KeyMaterialSource) -> EncryptionPolicy {
.external(source: source, keyDerivation: .hkdf())
}
}
/// Key derivation algorithms for encryption keys.
///
/// These settings allow you to tune how raw key material is transformed into
/// encryption keys used by the selected algorithm.
public enum KeyDerivation: Equatable, Sendable {
/// PBKDF2 with optional iterations and salt.
///
/// Provide `iterations` and `salt` when you need deterministic derivation.
case pbkdf2(iterations: Int? = nil, salt: Data? = nil)
/// HKDF with optional salt and info.
///
/// Supply `info` to domain-separate keys for distinct purposes.
case hkdf(salt: Data? = nil, info: Data? = nil)
}
}

View File

@ -1,12 +1,21 @@
import Foundation
/// Encodes and decodes values for storage.
///
/// `Serializer` packages paired encode/decode closures with a human-readable name
/// so storage services can serialize values consistently and report the format used.
public struct Serializer<Value: Codable & Sendable>: Sendable, CustomStringConvertible {
/// Encodes a value into `Data`.
///
/// The closure must be `Sendable` so it can execute safely across concurrency contexts.
public let encode: @Sendable (Value) throws -> Data
/// Decodes a value from `Data`.
///
/// The closure must be `Sendable` so it can execute safely across concurrency contexts.
public let decode: @Sendable (Data) throws -> Value
/// Human-readable serializer name used in audit reports.
///
/// Keep names stable so audit output is predictable and searchable.
public let name: String
/// Creates a custom serializer.
@ -26,6 +35,8 @@ public struct Serializer<Value: Codable & Sendable>: Sendable, CustomStringConve
}
/// Description used by `CustomStringConvertible`.
///
/// Mirrors the `name` so logs and debug output show the configured serializer.
public var description: String { name }
/// JSON serializer using `JSONEncoder` and `JSONDecoder`.
@ -70,6 +81,8 @@ public struct Serializer<Value: Codable & Sendable>: Sendable, CustomStringConve
public extension Serializer where Value == Data {
/// Serializer that passes through raw `Data`.
///
/// Use this when the caller already owns the encoding format.
///
/// - Returns: A serializer that returns `Data` unchanged.
static var data: Serializer<Value> {
Serializer<Value>(encode: { $0 }, decode: { $0 }, name: "data")

View File

@ -1,17 +1,32 @@
import Foundation
/// Storage location for a ``StorageKey``.
///
/// `StorageDomain` describes where values are stored and which storage backend is used.
/// The router interprets these cases to route reads and writes to the correct helper.
public enum StorageDomain: Sendable, Equatable {
/// Standard `UserDefaults` using the provided suite name.
///
/// Pass `nil` to target the default `UserDefaults` suite.
case userDefaults(suite: String?)
/// App group `UserDefaults` using the provided group identifier.
///
/// Use this for data shared with extensions on the same device.
case appGroupUserDefaults(identifier: String?)
/// Keychain storage using the provided service identifier.
///
/// Keychain storage is used for sensitive or credential-like data.
case keychain(service: String?)
/// File system storage in the specified directory.
///
/// Suitable for larger values that should not live in defaults or Keychain.
case fileSystem(directory: FileDirectory)
/// Encrypted file system storage in the specified directory.
///
/// Values are encrypted before being written to the file system.
case encryptedFileSystem(directory: FileDirectory)
/// App group file storage using the group identifier and directory.
///
/// Use this for file-backed data that needs to be shared with app extensions.
case appGroupFileSystem(identifier: String?, directory: FileDirectory)
}

View File

@ -1,37 +1,68 @@
import Foundation
/// Errors thrown by storage operations and migrations.
///
/// `StorageError` standardizes failures across storage helpers so callers can handle
/// issues consistently, whether the underlying storage is defaults, files, or Keychain.
public enum StorageError: Error, Equatable {
/// Failed to encode a value.
///
/// This indicates the serializer could not transform a value into `Data`.
case serializationFailed
/// Failed to decode stored data.
///
/// This typically means the stored payload does not match the expected type.
case deserializationFailed
/// Failed to apply or remove security for stored data.
///
/// This error is used when encryption or decryption fails.
case securityApplicationFailed
/// Underlying Keychain error.
///
/// The associated `OSStatus` comes from Security framework APIs.
case keychainError(OSStatus)
/// File system error description.
///
/// Uses a `String` to preserve a descriptive message while remaining `Equatable`.
case fileError(String) // Changed from Error to String for easier Equatable conformance
/// A phone-only key was accessed on watchOS.
///
/// The associated value is the key name.
case phoneOnlyKeyAccessedOnWatch(String)
/// A watch-only key was accessed on iOS.
///
/// The associated value is the key name.
case watchOnlyKeyAccessedOnPhone(String)
/// Invalid UserDefaults suite name.
///
/// The associated value is the invalid suite identifier.
case invalidUserDefaultsSuite(String)
/// Invalid App Group identifier.
///
/// The associated value is the invalid group identifier.
case invalidAppGroupIdentifier(String)
/// Sync payload exceeded the configured maximum size.
///
/// The key cannot be synced because the payload is too large.
case dataTooLargeForSync
/// No value exists for the requested key.
case notFound
/// The key is not registered in any catalog.
///
/// The associated value is the missing key name.
case unregisteredKey(String)
/// Duplicate key names detected during registration.
///
/// The associated array lists the duplicate key names.
case duplicateRegisteredKeys([String])
/// Missing or empty key description.
///
/// The associated value is the key name missing the description.
case missingDescription(String)
/// Compares two storage errors for equality.
///
/// This custom equality handles associated values, including `OSStatus`.
public static func == (lhs: StorageError, rhs: StorageError) -> Bool {
switch (lhs, rhs) {
case (.serializationFailed, .serializationFailed),
@ -59,4 +90,8 @@ public enum StorageError: Error, Equatable {
}
}
/// `StorageError` includes `OSStatus` values which are not `Sendable` by default.
///
/// The enum is effectively immutable, so we mark it `@unchecked Sendable` to allow
/// it to cross concurrency boundaries.
extension StorageError: @unchecked Sendable {}

View File

@ -1,29 +1,54 @@
import Foundation
/// Snapshot of a ``StorageKey`` used for audit and registration.
///
/// `StorageKeyDescriptor` captures the immutable metadata needed for audits, duplicate
/// detection, and catalog reporting without carrying the key's generic type.
public struct StorageKeyDescriptor: Sendable, CustomStringConvertible {
/// Key name within its domain.
///
/// This is the primary identifier used for duplicate detection.
public let name: String
/// Storage domain for the key.
///
/// The router uses this to determine which storage helper should be used.
public let domain: StorageDomain
/// Security policy applied to the key.
///
/// Indicates whether encryption or Keychain placement is required.
public let security: SecurityPolicy
/// Serializer name used for encoding/decoding.
///
/// This is recorded for audit output; the serializer itself remains on the key.
public let serializer: String
/// String representation of the value type.
///
/// Useful for diagnostics when reviewing audit reports.
public let valueType: String
/// Owning module or feature name.
///
/// Use this to identify the feature responsible for the stored data.
public let owner: String
/// Platform availability for the key.
///
/// Governs which runtime platforms are allowed to access the key.
public let availability: PlatformAvailability
/// Sync policy for WatchConnectivity.
///
/// Used when determining sync eligibility and constraints.
public let syncPolicy: SyncPolicy
/// Human-readable description for audit reports.
///
/// Descriptions are required to keep audit results interpretable.
public let description: String
/// Optional catalog name the key belongs to.
///
/// Catalog names aid in grouping keys by feature or module.
public let catalog: String?
/// Internal initializer used by factories and audits.
///
/// Callers should prefer `from(_:)` unless building descriptors manually.
init(
name: String,
domain: StorageDomain,

View File

@ -1,11 +1,20 @@
import Foundation
/// Defines how a key participates in WatchConnectivity sync.
///
/// `SyncPolicy` is interpreted by sync helpers to decide if and when data should
/// be pushed between iPhone and Apple Watch.
public enum SyncPolicy: Sendable {
/// No sync behavior.
///
/// Use this for keys that should remain device-local.
case never
/// Sync only when the app explicitly requests it.
///
/// Use this when you need full control over sync timing.
case manual
/// Automatically sync when data size is below the configured threshold.
///
/// Use this for small payloads that should stay in sync without manual triggers.
case automaticSmall
}

View File

@ -1,3 +1,6 @@
/// Defines migrations that aggregate multiple source keys into a single destination.
///
/// Use this protocol when a new storage key is derived from multiple legacy values.
import Foundation
/// Migration protocol that combines multiple sources into a single destination.

View File

@ -1,4 +0,0 @@
import Foundation
/// Marker protocol for migrations that primarily use conditional checks.
public protocol ConditionalMigration: StorageMigration {}

View File

@ -1,3 +1,6 @@
/// Supplies encryption key material to support external key sources.
///
/// Conformers provide raw bytes used by ``SecurityPolicy.EncryptionPolicy.external``.
import Foundation
/// Supplies external key material for encryption policies.

View File

@ -1,3 +1,6 @@
/// Defines the Keychain operations required by the storage layer.
///
/// This protocol enables swapping concrete Keychain implementations for testing.
import Foundation
/// Protocol defining the interface for Keychain operations.

View File

@ -1,3 +1,6 @@
// Defines catalog types that group storage keys for registration and auditing.
//
// Catalogs are the mechanism used by the router to validate keys and detect duplicates.
/// Collection of storage keys used for registration and auditing.
public protocol StorageKeyCatalog: Sendable {
/// Human-readable catalog name used in audit reports.

View File

@ -1,3 +1,6 @@
/// Defines the core migration contract used by the storage router.
///
/// All migration types build on this protocol to move or transform stored data.
import Foundation
/// Core migration protocol for moving data into a destination ``StorageKey``.

View File

@ -1,3 +1,6 @@
/// Defines the storage operations required by concrete backends.
///
/// Storage helpers conform to this protocol to provide a common API surface.
import Foundation
/// Abstraction for basic storage operations.

View File

@ -1,3 +1,6 @@
/// Defines migrations that transform a source value into a new destination value.
///
/// Use this protocol when a single legacy key can be mapped to a new schema.
import Foundation
/// Migration protocol that transforms a source value into a destination value.