From 0f208488a55e013ccf7094df2092314366457f35 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Wed, 14 Jan 2026 10:24:05 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- README.md | 26 +++++ .../Models/StorageKeyDescriptor.swift | 53 +++++++++ .../Protocols/StorageKeyCatalog.swift | 3 + .../Services/StorageAuditReport.swift | 110 ++++++++++++++++++ 4 files changed, 192 insertions(+) create mode 100644 Sources/LocalData/Models/StorageKeyDescriptor.swift create mode 100644 Sources/LocalData/Protocols/StorageKeyCatalog.swift create mode 100644 Sources/LocalData/Services/StorageAuditReport.swift diff --git a/README.md b/README.md index e98bfb4..dc17456 100644 --- a/README.md +++ b/README.md @@ -151,5 +151,31 @@ The app owns WCSession activation and handling incoming updates. ## 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. + +1) Define a catalog in your app that lists all keys: + +```swift +struct AppStorageCatalog: StorageKeyCatalog { + static var allKeys: [StorageKeyDescriptor] { + [ + .from(StorageKeys.AppVersionKey(), serializer: "json"), + .from(StorageKeys.UserPreferencesKey(), serializer: "json") + ] + } +} +``` + +2) Generate a report: + +```swift +let report = StorageAuditReport.renderText(for: AppStorageCatalog.self) +print(report) +``` + +For dynamic key names, use a placeholder name and a note to describe how it is generated. + ## Sample App See `SecureStorgageSample` for working examples of all storage domains and security options. diff --git a/Sources/LocalData/Models/StorageKeyDescriptor.swift b/Sources/LocalData/Models/StorageKeyDescriptor.swift new file mode 100644 index 0000000..7aadce3 --- /dev/null +++ b/Sources/LocalData/Models/StorageKeyDescriptor.swift @@ -0,0 +1,53 @@ +import Foundation + +public struct StorageKeyDescriptor: Sendable { + public let name: String + public let domain: StorageDomain + public let security: SecurityPolicy + public let serializer: String + public let valueType: String + public let owner: String + public let availability: PlatformAvailability + public let syncPolicy: SyncPolicy + public let notes: String? + + public init( + name: String, + domain: StorageDomain, + security: SecurityPolicy, + serializer: String, + valueType: String, + owner: String, + availability: PlatformAvailability, + syncPolicy: SyncPolicy, + notes: String? = nil + ) { + self.name = name + self.domain = domain + self.security = security + self.serializer = serializer + self.valueType = valueType + self.owner = owner + self.availability = availability + self.syncPolicy = syncPolicy + self.notes = notes + } + + public static func from( + _ key: Key, + serializer: String, + notes: String? = nil + ) -> StorageKeyDescriptor { + StorageKeyDescriptor( + name: key.name, + domain: key.domain, + security: key.security, + serializer: serializer, + valueType: String(describing: Key.Value.self), + owner: key.owner, + availability: key.availability, + syncPolicy: key.syncPolicy, + notes: notes + ) + } +} diff --git a/Sources/LocalData/Protocols/StorageKeyCatalog.swift b/Sources/LocalData/Protocols/StorageKeyCatalog.swift new file mode 100644 index 0000000..a2c7f00 --- /dev/null +++ b/Sources/LocalData/Protocols/StorageKeyCatalog.swift @@ -0,0 +1,3 @@ +public protocol StorageKeyCatalog { + static var allKeys: [StorageKeyDescriptor] { get } +} diff --git a/Sources/LocalData/Services/StorageAuditReport.swift b/Sources/LocalData/Services/StorageAuditReport.swift new file mode 100644 index 0000000..50c6c71 --- /dev/null +++ b/Sources/LocalData/Services/StorageAuditReport.swift @@ -0,0 +1,110 @@ +import Foundation + +public struct StorageAuditReport: Sendable { + public static func items(for catalog: C.Type) -> [StorageKeyDescriptor] { + catalog.allKeys + } + + public static func renderText(for catalog: C.Type) -> String { + renderText(items(for: catalog)) + } + + public static func renderText(_ items: [StorageKeyDescriptor]) -> String { + let lines = items.map { item in + var parts: [String] = [] + parts.append("name=\(item.name)") + parts.append("domain=\(string(for: item.domain))") + parts.append("security=\(string(for: item.security))") + parts.append("serializer=\(item.serializer)") + parts.append("value=\(item.valueType)") + parts.append("owner=\(item.owner)") + parts.append("availability=\(string(for: item.availability))") + parts.append("sync=\(string(for: item.syncPolicy))") + if let notes = item.notes, !notes.isEmpty { + parts.append("notes=\(notes)") + } + return parts.joined(separator: " | ") + } + return lines.joined(separator: "\n") + } + + private static func string(for domain: StorageDomain) -> String { + switch domain { + case .userDefaults(let suite): + return "userDefaults(\(suite ?? "standard"))" + case .keychain(let service): + return "keychain(\(service))" + case .fileSystem(let directory): + return "fileSystem(\(string(for: directory)))" + case .encryptedFileSystem(let directory): + return "encryptedFileSystem(\(string(for: directory)))" + } + } + + private static func string(for directory: FileDirectory) -> String { + switch directory { + case .documents: + return "documents" + case .caches: + return "caches" + case .custom(let url): + return "custom(\(url.path))" + } + } + + private static func string(for availability: PlatformAvailability) -> String { + switch availability { + case .all: + return "all" + case .phoneOnly: + return "phoneOnly" + case .watchOnly: + return "watchOnly" + case .phoneWithWatchSync: + return "phoneWithWatchSync" + } + } + + private static func string(for syncPolicy: SyncPolicy) -> String { + switch syncPolicy { + case .never: + return "never" + case .manual: + return "manual" + case .automaticSmall: + return "automaticSmall" + } + } + + private static func string(for security: SecurityPolicy) -> String { + switch security { + case .none: + return "none" + case .encrypted(let policy): + return "encrypted(\(string(for: policy)))" + case .keychain(let accessibility, let accessControl): + let accessControlValue = accessControl?.displayName ?? "none" + return "keychain(\(accessibility.displayName), \(accessControlValue))" + } + } + + private static func string(for policy: SecurityPolicy.EncryptionPolicy) -> String { + switch policy { + case .aes256(let derivation): + return "aes256(\(string(for: derivation)))" + case .chacha20Poly1305(let derivation): + return "chacha20Poly1305(\(string(for: derivation)))" + case .external(let source, let derivation): + return "external(\(source.id), \(string(for: derivation)))" + } + } + + private static func string(for derivation: SecurityPolicy.KeyDerivation) -> String { + switch derivation { + case .pbkdf2(let iterations, _): + return "pbkdf2(\(iterations))" + case .hkdf: + return "hkdf" + } + } +}