Go to file
Matt Bruce df4dc42f98 Update Audit, Configuration, Migrations (+4 more)
Summary:
- Sources: update Audit, Configuration, Migrations (+4 more)

Stats:
- 37 files changed, 256 insertions(+), 22 deletions(-)
2026-01-18 13:43:11 -06:00
Documentation Docs: remove docs for Migration_Refactor_Plan_Clean 2026-01-18 13:43:11 -06:00
Sources/LocalData Update Audit, Configuration, Migrations (+4 more) 2026-01-18 13:43:11 -06:00
Tests Tests: update tests for AnyStorageKeyTests.swift, AuditTests.swift, LocalDataTests.swift (+13 more) 2026-01-18 13:43:11 -06:00
.gitignore Add LocalData.swift, Models, Protocols (+1 more) and tests, docs, config 2026-01-18 13:43:07 -06:00
FutureEnhancements.md Update Models, Protocols, Services and tests, docs 2026-01-18 13:43:07 -06:00
Package.swift Update Helpers, Models and tests, config 2026-01-18 13:43:09 -06:00
Proposal.md Update LocalData.swift and docs 2026-01-18 13:43:11 -06:00
README.md Update LocalData.swift and docs 2026-01-18 13:43:11 -06:00

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
%%{init: {'theme': 'base', 'themeVariables': { 'fontSize': '16px', 'primaryColor': '#ffffff', 'lineColor': '#000000', 'textColor': '#000000', 'mainBkg': '#ffffff', 'nodeBorder': '#000000' }}}%%
flowchart TD
    %% Global White Container
    subgraph Architecture ["LocalData Architecture"]
    direction TB
    
    %% Vertical Flow
    App(("📱 <b>APP / FEATURE</b>"))
    
    SR["🔀 <b>STORAGE ROUTER</b><br/>(The Central Engine)"]

    subgraph Config ["⚙️ GLOBAL CONFIGURATION"]
        direction TB
        C1["Encryption & Sync Config"]
        C2["App Group & File Config"]
    end

    subgraph Helpers ["🛠️ INTERNAL HELPER ACTORS"]
        direction TB
        H1["Keychain & File Storage Helpers"]
        H2["UserDefaults & Sync Helpers"]
        H3["🔐 Encryption Service"]
    end

    subgraph Storage ["💾 HARDWARE STORAGE LAYER"]
        direction LR
        S1[("🗝️ Keychain")]
        S2[("📁 File System")]
        S3[("⚙️ UserDefaults")]
    end

    %% Relationships
    App -->|StorageKey| SR
    SR -.->|Resolves| Config
    SR ==>|1. Orchestrate| Helpers
    Helpers ==>|2. Persist| Storage
    
    %% Migration Loop
    Storage -.->|<b>3. AUTOMATIC MIGRATION</b>| SR
    end

    %% Explicit Styling for High Contrast
    style Architecture fill:#ffffff,stroke:#000,stroke-width:2px
    style App fill:#ffffff,stroke:#000,stroke-width:3px
    style SR fill:#e1f5fe,stroke:#000,stroke-width:4px
    style Config fill:#fffde7,stroke:#000,stroke-dasharray: 5 5
    style Helpers fill:#f5f5f5,stroke:#000,stroke-width:2px
    style Storage fill:#e8f5e9,stroke:#000,stroke-width:3px
    
    %% Link Styling
    linkStyle default stroke:#000,stroke-width:2px,color:#000

What Ships in the Package

Protocols

  • StorageProviding - Abstraction for storage operations
  • KeyMaterialProviding - Supplies external key material for encryption
  • StorageMigration - Protocol-based migration workflows
  • ConditionalMigration - Marker protocol for conditional migrations
  • TransformingMigration - Migrations that transform source values
  • AggregatingMigration - Migrations that aggregate multiple sources

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.

Global Configuration Models

These are used at app lifecycle start to tune library engine behaviors:

  • StorageConfiguration - Default Keychain service and App Group IDs
  • EncryptionConfiguration - Global encryption settings (Keychain identifiers, key length)
  • SyncConfiguration - Global sync settings (Max automatic sync size)
  • FileStorageConfiguration - Global file settings (Sub-directory scoping)

Other 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
  • StorageKey - Typed storage configuration (generic over Value)
  • StorageKeyDescriptor - Audit snapshot of a keys storage metadata
  • AnyStorageKey - Type-erased storage key for catalogs
  • AnyCodable - Type-erased Codable for mixed-type payloads
  • AnyStorageMigration - Type-erased migration for catalogs and registrations
  • MigrationContext - Context for conditional migrations
  • MigrationResult - Migration outcome and error reporting
  • MigrationError - Migration error cases

Utilities

  • DeviceInfo - Device metadata used in migration context
  • SystemInfo - System metrics used in migration context
  • MigrationUtils - Common migration helpers

Usage

1. Define Keys

Extend StorageKey with typed static keys:

import LocalData

extension StorageKey where Value == String {
    static let userToken = StorageKey(
        name: "user_token",
        domain: .keychain(service: "com.myapp"),
        security: .keychain(
            accessibility: .afterFirstUnlock,
            accessControl: .biometryAny
        ),
        serializer: .json,
        owner: "AuthService",
        description: "Stores the current user auth token.",
        availability: .phoneOnly,
        syncPolicy: .never
    )
}

If you omit security, it defaults to SecurityPolicy.recommended.

2. Use StorageRouter

// Save
let key = StorageKey.userToken
try await StorageRouter.shared.set("token123", for: key)

// Retrieve
let token = try await StorageRouter.shared.get(key)

// Remove
try await StorageRouter.shared.remove(key)

Complex Codable Support

LocalData handles complex Codable types automatically. You are not limited to simple strings or integers.

struct UserProfile: Codable {
    let id: UUID
    let name: String
    let settings: [String: String]
}

extension StorageKey where Value == UserProfile {
    static let profile = StorageKey(
        name: "user_profile",
        domain: .fileSystem(directory: .documents),
        owner: "ProfileService",
        description: "Stores the current user profile."
    )
}

        // ... other properties
    }
}

## Data Migration

LocalData supports protocol-based migrations between storage keys and domains (e.g., from a legacy `UserDefaults` string key to a modern secure `Keychain` key).

### 1. Automatic Migration (Lazy)
When you define a `migration` on a key, `StorageRouter.get(key)` will automatically run it if the primary key is not found. If data exists:
- It is retrieved using the source's metadata.
- It is saved to the new key using the new security policy.
- It is deleted from the source.
- It is returned to the caller.

```swift
extension StorageKey where Value == String {
    static let legacyToken = StorageKey(
        name: "legacy_token",
        domain: .userDefaults(suite: nil),
        security: .none,
        serializer: .json,
        owner: "AuthService",
        description: "Legacy token stored in UserDefaults."
    )

    static let modernToken = StorageKey(
        name: "modern_token",
        domain: .keychain(service: "com.myapp"),
        owner: "AuthService",
        description: "Stores the current user auth token.",
        migration: { key in
            AnyStorageMigration(
                SimpleLegacyMigration(
                    destinationKey: key,
                    sourceKey: .key(StorageKey.legacyToken)
                )
            )
        }
    )
}

For complex migrations, implement StorageMigration and attach it to the key.

struct TokenMigration: StorageMigration {
    typealias Value = String

    let destinationKey = StorageKey.userToken
    let legacyKey = StorageKey.legacyToken

    func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool {
        try await router.exists(legacyKey)
    }

    func migrate(using router: StorageRouter, context: MigrationContext) async throws -> MigrationResult {
        let legacyToken = try await router.get(legacyKey)
        try await router.set(legacyToken, for: destinationKey)
        try await router.remove(legacyKey)
        return MigrationResult(success: true, migratedCount: 1)
    }
}

extension StorageKey where Value == String {
    static let userToken = StorageKey(
        name: "user_token",
        domain: .keychain(service: "com.myapp"),
        owner: "AuthService",
        description: "Stores the current user auth token.",
        migration: { _ in AnyStorageMigration(TokenMigration()) }
    )
}

3. Proactive Sweep (Drain)

To ensure no "ghost data" remains in legacy keys (e.g., if a bug causes old code to write to them again), you can use either a manual call or an automated startup sweep.

Manual Call

try await StorageRouter.shared.forceMigration(for: StorageKey.modernToken)

Automated Startup Sweep

When registering a catalog, you can enable migrateImmediately to perform a global sweep of all legacy keys for every key in the catalog.

Note

Modular Registration: registerCatalog is additive. You can call it multiple times from different modules to build an aggregate registry. The library will throw an error if multiple catalogs attempt to register the same key name.

// Module A
try await StorageRouter.shared.registerCatalog(AuthCatalog())

// Module B
try await StorageRouter.shared.registerCatalog(ProfileCatalog())

Storage Design Philosophy

This app intentionally uses a Type-Safe Storage Design. Unlike standard iOS development which uses string keys (e.g., UserDefaults.standard.string(forKey: "user_name")), this library requires you to define StorageKey values.

Why types instead of strings?

  1. Safety: The compiler prevents typos. You can't accidentally load from "user_name" and save to "username".
  2. Codable Support: Keys define their own value types. You can store complex Codable structs or classes just as easily as strings, and the library handles the JSON/Plist serialization automatically.
  3. Visibility: All data your app stores is discoverable in the StorageKeys/ folder. It serves as a manifest of your app's persistence layer.
  4. Migration: You can move a piece of data from UserDefaults to EncryptedFileSystem just by changing the domain in the Key definition. No UI code needs to change.

Storage Key Examples

Domain Use Case
userDefaults Preferences, small settings
appGroupUserDefaults Shared settings across extensions via App Groups
keychain Credentials, tokens, sensitive data
fileSystem(directory:) Local storage in Documents or Caches
encryptedFileSystem(directory:) Sensitive files with encryption policies
appGroupFileSystem(id:directory:) Shared files across targets via App Groups

File Directories

The library supports two standard iOS locations via FileDirectory:

Directory Persistence iCloud Backup Recommended Use
.documents Permanent Yes User data, critical settings
.caches Purgeable* No Temporary files, downloaded assets

*iOS may delete files in .caches if the device runs low on storage.

By configuring a subDirectory in FileStorageConfiguration, you ensure that the library's data is isolated within its own folder in both locations (e.g., Documents/MyData/ and Caches/MyData/).

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:

let config = EncryptionConfiguration(
    masterKeyService: "com.myapp.LocalData",
    masterKeyAccount: "MasterKey",
    pbkdf2Iterations: 50_000
)
await StorageRouter.shared.updateEncryptionConfiguration(config)

Warning

Changing the masterKeyService, masterKeyAccount, or pbkdf2Iterations in an existing app will cause the app to look for or derive keys differently. Previously encrypted data will be inaccessible.

Global Sync Configuration

You can customize the maximum size for automatic synchronization:

let syncConfig = SyncConfiguration(maxAutoSyncSize: 50_000) // 50KB limit
await StorageRouter.shared.updateSyncConfiguration(syncConfig)

Global File Storage Configuration

You can scope all library files into a specific sub-directory (e.g., to avoid cluttering the root Documents folder):

let fileConfig = FileStorageConfiguration(subDirectory: "MyAppStorage")
await StorageRouter.shared.updateFileStorageConfiguration(fileConfig)

This will result in paths like:

  • .../Documents/MyAppStorage/ (Main Sandbox)
  • .../SharedContainer/Documents/MyAppStorage/ (App Group)

Warning

Changing the subDirectory in an existing app will cause the library to look in the new location. Existing files in the old location will not be automatically moved.

Global Storage Defaults

To avoid repeating the same Keychain service or App Group identifier in every key, you can set library-wide defaults:

let storageConfig = StorageConfiguration(
    defaultKeychainService: "com.myapp.keychain",
    defaultAppGroupIdentifier: "group.com.myapp"
)
await StorageRouter.shared.updateStorageConfiguration(storageConfig)

When defaults are set, you can define keys using nil for these identifiers:

  • .keychain(service: nil) -> Uses "com.myapp.keychain"
  • .appGroupUserDefaults(identifier: nil) -> Uses "group.com.myapp"
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.

Bootstrapping on Launch

To ensure the watch receives the latest values after app relaunch or reconnection, call:

await StorageRouter.shared.syncRegisteredKeysIfNeeded()

This re-sends stored values for any registered keys that are eligible for sync. Apps typically call this on launch and when WatchConnectivity becomes reachable.

Responding to Watch-Initiated Sync Requests

If your watch app asks for an explicit refresh, you can build a snapshot of syncable data and reply via WCSession messaging:

let snapshot = await StorageRouter.shared.syncSnapshot()
// Reply with snapshot (key: Data) via WCSession.sendMessage

Platform Sync Guide

For end-to-end iOS + watchOS setup (including a launch-order-safe handshake), see:

Documentation/PlatformSync.md

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 is generic over Value, which means you cannot store different key value types in a single array using [StorageKey]. Swift requires type erasure for heterogeneous key values, so the catalog uses [AnyStorageKey] and builds descriptors behind the scenes.

  1. Define a catalog in your app that lists all keys:
struct AppStorageCatalog: StorageKeyCatalog {
    let allKeys: [AnyStorageKey] = [
        .key(StorageKey.appVersion),
        .key(StorageKey.userPreferences)
    ]
}
  1. Generate a report:
let report = StorageAuditReport.renderText(AppStorageCatalog())
print(report)
  1. Create and register the catalog to enforce usage and catch duplicates:
let appCatalog = AppStorageCatalog()
do {
    try await StorageRouter.shared.registerCatalog(appCatalog)
} catch {
    assertionFailure("Storage catalog registration failed: \(error)")
}
  1. Render a global report of all registered keys across all catalogs:
// Flat list
let globalReport = await StorageAuditReport.renderGlobalRegistry()
print(globalReport)

// Grouped by catalog module
let groupedReport = await StorageAuditReport.renderGlobalRegistryGrouped()
print(groupedReport)

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.