From 312b2592dc50cc00d77ef1f7f37508ceee2e5a10 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Tue, 13 Jan 2026 23:20:28 -0600 Subject: [PATCH] Update Models, Protocols, Services + tests + docs + config Summary: - Sources: Models, Protocols, Services - Tests: EncryptionHelperTests.swift, LocalDataTests.swift - Docs: README - Config: Package - Added symbols: extension StorageKey, func deriveKeyMaterial, func encryptWithKey, func encryptWithAESGCM, func decryptWithKey, func decryptWithAESGCM (+6 more) - Removed symbols: func extractDerivationParams, func encryptWithKey, func decryptWithKey, class LocalDataTests, func testUserDefaultsRoundTrip, func testFileSystemRoundTrip Stats: - 8 files changed, 210 insertions(+), 64 deletions(-) --- Package.resolved | 23 ++++ Package.swift | 8 +- README.md | 13 ++- Sources/LocalData/Models/SecurityPolicy.swift | 6 + .../Protocols/StorageKey+Defaults.swift | 3 + .../LocalData/Services/EncryptionHelper.swift | 106 ++++++++++++++---- .../EncryptionHelperTests.swift | 57 ++++++++++ Tests/LocalDataTests/LocalDataTests.swift | 58 ++++------ 8 files changed, 210 insertions(+), 64 deletions(-) create mode 100644 Package.resolved create mode 100644 Sources/LocalData/Protocols/StorageKey+Defaults.swift create mode 100644 Tests/LocalDataTests/EncryptionHelperTests.swift diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..24a2a81 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,23 @@ +{ + "pins" : [ + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax.git", + "state" : { + "revision" : "0687f71944021d616d34d922343dcef086855920", + "version" : "600.0.1" + } + }, + { + "identity" : "swift-testing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-testing.git", + "state" : { + "revision" : "399f76dcd91e4c688ca2301fa24a8cc6d9927211", + "version" : "0.99.0" + } + } + ], + "version" : 2 +} diff --git a/Package.swift b/Package.swift index dbabbff..99150ad 100644 --- a/Package.swift +++ b/Package.swift @@ -15,13 +15,19 @@ let package = Package( targets: ["LocalData"] ), ], + dependencies: [ + .package(url: "https://github.com/apple/swift-testing.git", from: "0.7.0") + ], targets: [ .target( name: "LocalData" ), .testTarget( name: "LocalDataTests", - dependencies: ["LocalData"] + dependencies: [ + "LocalData", + .product(name: "Testing", package: "swift-testing") + ] ), ] ) diff --git a/README.md b/README.md index 29229e9..982dd7c 100644 --- a/README.md +++ b/README.md @@ -24,14 +24,14 @@ StorageRouter (main entry point) ### Services (Actors) - **StorageRouter** - Main entry point for all storage operations - **KeychainHelper** - Secure keychain storage -- **EncryptionHelper** - AES-256-GCM encryption with PBKDF2 +- **EncryptionHelper** - AES-256-GCM or ChaCha20-Poly1305 with PBKDF2/HKDF - **FileStorageHelper** - File system operations - **UserDefaultsHelper** - UserDefaults with suite support - **SyncHelper** - WatchConnectivity sync ### Models - **StorageDomain** - userDefaults, keychain, fileSystem, encryptedFileSystem -- **SecurityPolicy** - none, keychain, encrypted (AES-256) +- **SecurityPolicy** - none, keychain, encrypted (AES-256 or ChaCha20-Poly1305) - **Serializer** - JSON, plist, Data, or custom - **PlatformAvailability** - all, phoneOnly, watchOnly, phoneWithWatchSync - **SyncPolicy** - never, manual, automaticSmall @@ -66,6 +66,7 @@ extension StorageKeys { } } ``` +If you omit `security`, it defaults to `SecurityPolicy.recommended`. ### 2. Use StorageRouter ```swift @@ -87,7 +88,7 @@ try await StorageRouter.shared.remove(key) | `userDefaults` | Preferences, small settings | | `keychain` | Credentials, tokens, sensitive data | | `fileSystem` | Documents, cached data, large files | -| `encryptedFileSystem` | Sensitive files with AES-256 encryption | +| `encryptedFileSystem` | Sensitive files with encryption policies | ## Security Options @@ -109,9 +110,11 @@ try await StorageRouter.shared.remove(key) - `biometryCurrentSetOrDevicePasscode` - Current biometric or passcode ### Encryption -- AES-256-GCM with PBKDF2-SHA256 key derivation -- Configurable iteration count +- 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) ## Sync Behavior diff --git a/Sources/LocalData/Models/SecurityPolicy.swift b/Sources/LocalData/Models/SecurityPolicy.swift index aeef9a1..6c605e1 100644 --- a/Sources/LocalData/Models/SecurityPolicy.swift +++ b/Sources/LocalData/Models/SecurityPolicy.swift @@ -7,11 +7,17 @@ public enum SecurityPolicy: Sendable { case encrypted(EncryptionPolicy) case keychain(accessibility: KeychainAccessibility, accessControl: KeychainAccessControl?) + public static let recommended: SecurityPolicy = .encrypted(.recommended) + public enum EncryptionPolicy: Sendable { case aes256(keyDerivation: KeyDerivation) + case chacha20Poly1305(keyDerivation: KeyDerivation) + + public static let recommended: EncryptionPolicy = .chacha20Poly1305(keyDerivation: .hkdf()) } public enum KeyDerivation: Sendable { case pbkdf2(iterations: Int, salt: Data? = nil) + case hkdf(salt: Data? = nil, info: Data? = nil) } } diff --git a/Sources/LocalData/Protocols/StorageKey+Defaults.swift b/Sources/LocalData/Protocols/StorageKey+Defaults.swift new file mode 100644 index 0000000..e4073c2 --- /dev/null +++ b/Sources/LocalData/Protocols/StorageKey+Defaults.swift @@ -0,0 +1,3 @@ +public extension StorageKey { + var security: SecurityPolicy { .recommended } +} diff --git a/Sources/LocalData/Services/EncryptionHelper.swift b/Sources/LocalData/Services/EncryptionHelper.swift index d7d7a0f..4633a11 100644 --- a/Sources/LocalData/Services/EncryptionHelper.swift +++ b/Sources/LocalData/Services/EncryptionHelper.swift @@ -11,6 +11,7 @@ public actor EncryptionHelper { static let masterKeyService = "LocalData.MasterKey" static let masterKeyAccount = "LocalData.MasterKey" static let masterKeyLength = 32 + static let defaultHKDFInfo = "LocalData.Encryption" } private init() {} @@ -30,7 +31,7 @@ public actor EncryptionHelper { policy: SecurityPolicy.EncryptionPolicy ) async throws -> Data { let key = try await deriveKey(keyName: keyName, policy: policy) - return try encryptWithKey(data, using: key) + return try encryptWithKey(data, using: key, policy: policy) } /// Decrypts data using AES-GCM. @@ -46,7 +47,7 @@ public actor EncryptionHelper { policy: SecurityPolicy.EncryptionPolicy ) async throws -> Data { let key = try await deriveKey(keyName: keyName, policy: policy) - return try decryptWithKey(data, using: key) + return try decryptWithKey(data, using: key, policy: policy) } // MARK: - Key Derivation @@ -56,30 +57,47 @@ public actor EncryptionHelper { keyName: String, policy: SecurityPolicy.EncryptionPolicy ) async throws -> SymmetricKey { + let keyDerivation: SecurityPolicy.KeyDerivation switch policy { - case .aes256(let keyDerivation): - let masterKey = try await getMasterKey() - - // Extract salt and iterations from key derivation - let (salt, iterations) = extractDerivationParams(keyDerivation, keyName: keyName) - + case .aes256(let derivation), + .chacha20Poly1305(let derivation): + keyDerivation = derivation + } + + let masterKey = try await getMasterKey() + return try deriveKeyMaterial( + keyName: keyName, + derivation: keyDerivation, + masterKey: masterKey + ) + } + + /// Derives key material based on the provided key derivation strategy. + private func deriveKeyMaterial( + keyName: String, + derivation: SecurityPolicy.KeyDerivation, + masterKey: Data + ) throws -> SymmetricKey { + switch derivation { + case .pbkdf2(let iterations, let customSalt): + let salt = customSalt ?? defaultSalt(for: keyName) let derivedKeyData = try pbkdf2SHA256( password: masterKey, salt: salt, iterations: iterations, keyLength: Constants.masterKeyLength ) - return SymmetricKey(data: derivedKeyData) - } - } - - /// Extracts parameters from KeyDerivation enum. - private func extractDerivationParams(_ derivation: SecurityPolicy.KeyDerivation, keyName: String) -> (salt: Data, iterations: Int) { - switch derivation { - case .pbkdf2(let iterations, let customSalt): - let salt = customSalt ?? Data(keyName.utf8) - return (salt, iterations) + case .hkdf(let customSalt, let customInfo): + let salt = customSalt ?? defaultSalt(for: keyName) + let info = customInfo ?? Data(Constants.defaultHKDFInfo.utf8) + let inputKey = SymmetricKey(data: masterKey) + return HKDF.deriveKey( + inputKeyMaterial: inputKey, + salt: salt, + info: info, + outputByteCount: Constants.masterKeyLength + ) } } @@ -115,7 +133,20 @@ public actor EncryptionHelper { // MARK: - AES-GCM Operations - private func encryptWithKey(_ data: Data, using key: SymmetricKey) throws -> Data { + private func encryptWithKey( + _ data: Data, + using key: SymmetricKey, + policy: SecurityPolicy.EncryptionPolicy + ) throws -> Data { + switch policy { + case .aes256: + return try encryptWithAESGCM(data, using: key) + case .chacha20Poly1305: + return try encryptWithChaChaPoly(data, using: key) + } + } + + private func encryptWithAESGCM(_ data: Data, using key: SymmetricKey) throws -> Data { do { let sealedBox = try AES.GCM.seal(data, using: key) guard let combined = sealedBox.combined else { @@ -127,7 +158,20 @@ public actor EncryptionHelper { } } - private func decryptWithKey(_ data: Data, using key: SymmetricKey) throws -> Data { + private func decryptWithKey( + _ data: Data, + using key: SymmetricKey, + policy: SecurityPolicy.EncryptionPolicy + ) throws -> Data { + switch policy { + case .aes256: + return try decryptWithAESGCM(data, using: key) + case .chacha20Poly1305: + return try decryptWithChaChaPoly(data, using: key) + } + } + + private func decryptWithAESGCM(_ data: Data, using key: SymmetricKey) throws -> Data { do { let sealedBox = try AES.GCM.SealedBox(combined: data) return try AES.GCM.open(sealedBox, using: key) @@ -136,6 +180,24 @@ public actor EncryptionHelper { } } + private func encryptWithChaChaPoly(_ data: Data, using key: SymmetricKey) throws -> Data { + do { + let sealedBox = try ChaChaPoly.seal(data, using: key) + return sealedBox.combined + } catch { + throw StorageError.securityApplicationFailed + } + } + + private func decryptWithChaChaPoly(_ data: Data, using key: SymmetricKey) throws -> Data { + do { + let sealedBox = try ChaChaPoly.SealedBox(combined: data) + return try ChaChaPoly.open(sealedBox, using: key) + } catch { + throw StorageError.securityApplicationFailed + } + } + // MARK: - PBKDF2 Implementation private func pbkdf2SHA256( @@ -188,4 +250,8 @@ public actor EncryptionHelper { var bigEndian = value.bigEndian return Data(bytes: &bigEndian, count: MemoryLayout.size) } + + private func defaultSalt(for keyName: String) -> Data { + Data(keyName.utf8) + } } diff --git a/Tests/LocalDataTests/EncryptionHelperTests.swift b/Tests/LocalDataTests/EncryptionHelperTests.swift new file mode 100644 index 0000000..ec28324 --- /dev/null +++ b/Tests/LocalDataTests/EncryptionHelperTests.swift @@ -0,0 +1,57 @@ +import Foundation +import Testing +@testable import LocalData + +struct EncryptionHelperTests { + private let masterKeyService = "LocalData.MasterKey" + private let keyName = "LocalDataTests.encryption" + private let payload = Data("payload".utf8) + + @Test func aesGCMWithPBKDF2RoundTrip() async throws { + await clearMasterKey() + + let policy: SecurityPolicy.EncryptionPolicy = .aes256( + keyDerivation: .pbkdf2(iterations: 1_000) + ) + + let encrypted = try await EncryptionHelper.shared.encrypt( + payload, + keyName: keyName, + policy: policy + ) + let decrypted = try await EncryptionHelper.shared.decrypt( + encrypted, + keyName: keyName, + policy: policy + ) + + #expect(decrypted == payload) + await clearMasterKey() + } + + @Test func chaChaPolyWithHKDFRoundTrip() async throws { + await clearMasterKey() + + let policy: SecurityPolicy.EncryptionPolicy = .chacha20Poly1305( + keyDerivation: .hkdf() + ) + + let encrypted = try await EncryptionHelper.shared.encrypt( + payload, + keyName: keyName, + policy: policy + ) + let decrypted = try await EncryptionHelper.shared.decrypt( + encrypted, + keyName: keyName, + policy: policy + ) + + #expect(decrypted == payload) + await clearMasterKey() + } + + private func clearMasterKey() async { + try? await KeychainHelper.shared.deleteAll(service: masterKeyService) + } +} diff --git a/Tests/LocalDataTests/LocalDataTests.swift b/Tests/LocalDataTests/LocalDataTests.swift index 70a04f6..3ae429b 100644 --- a/Tests/LocalDataTests/LocalDataTests.swift +++ b/Tests/LocalDataTests/LocalDataTests.swift @@ -1,5 +1,5 @@ import Foundation -import XCTest +import Testing @testable import LocalData private struct TestUserDefaultsKey: StorageKey { @@ -36,66 +36,48 @@ private struct TestFileKey: StorageKey { } } -final class LocalDataTests: XCTestCase { - private var suiteName: String = "" - private var tempDirectory: URL = .temporaryDirectory - - override func setUp() { - super.setUp() - suiteName = "LocalDataTests.\(UUID().uuidString)" - tempDirectory = FileManager.default.temporaryDirectory - .appending(path: "LocalDataTests") - .appending(path: UUID().uuidString) - } - - override func tearDown() { - if let defaults = UserDefaults(suiteName: suiteName) { - defaults.removePersistentDomain(forName: suiteName) +struct LocalDataTests { + @Test func userDefaultsRoundTrip() async throws { + let suiteName = "LocalDataTests.\(UUID().uuidString)" + defer { + if let defaults = UserDefaults(suiteName: suiteName) { + defaults.removePersistentDomain(forName: suiteName) + } } - try? FileManager.default.removeItem(at: tempDirectory) - super.tearDown() - } - - func testUserDefaultsRoundTrip() async throws { let key = TestUserDefaultsKey(name: "test.string", suiteName: suiteName) let storedValue = "1.0.0" try await StorageRouter.shared.set(storedValue, for: key) let fetched = try await StorageRouter.shared.get(key) - XCTAssertEqual(fetched, storedValue) + #expect(fetched == storedValue) try await StorageRouter.shared.remove(key) - - do { + await #expect(throws: StorageError.notFound) { _ = try await StorageRouter.shared.get(key) - XCTFail("Expected notFound error after removal") - } catch StorageError.notFound { - return - } catch { - XCTFail("Unexpected error: \(error)") } } - func testFileSystemRoundTrip() async throws { + @Test func fileSystemRoundTrip() async throws { + let tempDirectory = FileManager.default.temporaryDirectory + .appending(path: "LocalDataTests") + .appending(path: UUID().uuidString) + defer { + try? FileManager.default.removeItem(at: tempDirectory) + } + let key = TestFileKey(name: "test.json", directory: tempDirectory) let storedValue = "payload" try await StorageRouter.shared.set(storedValue, for: key) let fetched = try await StorageRouter.shared.get(key) - XCTAssertEqual(fetched, storedValue) + #expect(fetched == storedValue) try await StorageRouter.shared.remove(key) - - do { + await #expect(throws: StorageError.notFound) { _ = try await StorageRouter.shared.get(key) - XCTFail("Expected notFound error after removal") - } catch StorageError.notFound { - return - } catch { - XCTFail("Unexpected error: \(error)") } } }