From 0fed77a68e611998823a14a76512678775a7e5ab Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Wed, 14 Jan 2026 13:20:26 -0600 Subject: [PATCH] Update Models, Services and tests Summary: - Sources: update Models, Services - Tests: update tests for EncryptionHelperTests.swift Stats: - 4 files changed, 93 insertions(+), 20 deletions(-) --- .../Models/EncryptionConfiguration.swift | 23 ++++++++++ .../LocalData/Services/EncryptionHelper.swift | 44 +++++++++++-------- .../LocalData/Services/StorageRouter.swift | 10 +++++ .../EncryptionHelperTests.swift | 36 ++++++++++++++- 4 files changed, 93 insertions(+), 20 deletions(-) create mode 100644 Sources/LocalData/Models/EncryptionConfiguration.swift diff --git a/Sources/LocalData/Models/EncryptionConfiguration.swift b/Sources/LocalData/Models/EncryptionConfiguration.swift new file mode 100644 index 0000000..c272806 --- /dev/null +++ b/Sources/LocalData/Models/EncryptionConfiguration.swift @@ -0,0 +1,23 @@ +import Foundation + +/// Configuration for the EncryptionHelper. +public struct EncryptionConfiguration: Sendable { + public let masterKeyService: String + public let masterKeyAccount: String + public let masterKeyLength: Int + public let defaultHKDFInfo: String + + public init( + masterKeyService: String = "LocalData", + masterKeyAccount: String = "MasterKey", + masterKeyLength: Int = 32, + defaultHKDFInfo: String = "LocalData.Encryption" + ) { + self.masterKeyService = masterKeyService + self.masterKeyAccount = masterKeyAccount + self.masterKeyLength = masterKeyLength + self.defaultHKDFInfo = defaultHKDFInfo + } + + public static let `default` = EncryptionConfiguration() +} diff --git a/Sources/LocalData/Services/EncryptionHelper.swift b/Sources/LocalData/Services/EncryptionHelper.swift index 0488479..1f61e8c 100644 --- a/Sources/LocalData/Services/EncryptionHelper.swift +++ b/Sources/LocalData/Services/EncryptionHelper.swift @@ -2,21 +2,27 @@ import Foundation import CryptoKit /// Actor that handles all encryption and decryption operations. -/// Uses AES-GCM for symmetric encryption with PBKDF2 key derivation. +/// Uses AES-GCM or ChaChaPoly for symmetric encryption with derived keys. actor EncryptionHelper { public static let shared = EncryptionHelper() - private enum Constants { - static let masterKeyService = "LocalData.MasterKey" - static let masterKeyAccount = "LocalData.MasterKey" - static let masterKeyLength = 32 - static let defaultHKDFInfo = "LocalData.Encryption" - } - + private var configuration: EncryptionConfiguration private var keyMaterialProviders: [KeyMaterialSource: any KeyMaterialProviding] = [:] - private init() {} + private init(configuration: EncryptionConfiguration = .default) { + self.configuration = configuration + } + + // MARK: - Configuration + + /// Updates the configuration for the actor. + /// > [!WARNING] + /// > Changing the configuration (specifically service or account) on an existing instance + /// > will cause it to look for the master key in a new location. + public func updateConfiguration(_ configuration: EncryptionConfiguration) { + self.configuration = configuration + } // MARK: - Public Interface @@ -28,7 +34,7 @@ actor EncryptionHelper { keyMaterialProviders[source] = provider } - /// Encrypts data using AES-GCM. + /// Encrypts data using AES-GCM or ChaChaPoly. /// - Parameters: /// - data: The plaintext data to encrypt. /// - keyName: A unique name used for key derivation salt. @@ -44,7 +50,7 @@ actor EncryptionHelper { return try encryptWithKey(data, using: key, policy: policy) } - /// Decrypts data using AES-GCM. + /// Decrypts data using AES-GCM or ChaChaPoly. /// - Parameters: /// - data: The encrypted data (nonce + ciphertext + tag combined). /// - keyName: The same unique name used during encryption. @@ -102,18 +108,18 @@ actor EncryptionHelper { password: baseKeyMaterial, salt: salt, iterations: iterations, - keyLength: Constants.masterKeyLength + keyLength: configuration.masterKeyLength ) return SymmetricKey(data: derivedKeyData) case .hkdf(let customSalt, let customInfo): let salt = customSalt ?? defaultSalt(for: keyName) - let info = customInfo ?? Data(Constants.defaultHKDFInfo.utf8) + let info = customInfo ?? Data(configuration.defaultHKDFInfo.utf8) let inputKey = SymmetricKey(data: baseKeyMaterial) return HKDF.deriveKey( inputKeyMaterial: inputKey, salt: salt, info: info, - outputByteCount: Constants.masterKeyLength + outputByteCount: configuration.masterKeyLength ) } } @@ -121,14 +127,14 @@ actor EncryptionHelper { /// Gets or creates the master key stored in keychain. private func getMasterKey() async throws -> Data { if let existing = try await KeychainHelper.shared.get( - service: Constants.masterKeyService, - key: Constants.masterKeyAccount + service: configuration.masterKeyService, + key: configuration.masterKeyAccount ) { return existing } // Generate new master key - var bytes = [UInt8](repeating: 0, count: Constants.masterKeyLength) + var bytes = [UInt8](repeating: 0, count: configuration.masterKeyLength) let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) guard status == errSecSuccess else { throw StorageError.securityApplicationFailed @@ -139,8 +145,8 @@ actor EncryptionHelper { // Store in keychain try await KeychainHelper.shared.set( masterKey, - service: Constants.masterKeyService, - key: Constants.masterKeyAccount, + service: configuration.masterKeyService, + key: configuration.masterKeyAccount, accessibility: .afterFirstUnlock, accessControl: nil ) diff --git a/Sources/LocalData/Services/StorageRouter.swift b/Sources/LocalData/Services/StorageRouter.swift index 43876ab..b6e559e 100644 --- a/Sources/LocalData/Services/StorageRouter.swift +++ b/Sources/LocalData/Services/StorageRouter.swift @@ -13,6 +13,16 @@ public actor StorageRouter: StorageProviding { private init() {} + // 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) + } + // MARK: - Key Material Providers /// Registers a key material provider for external encryption policies. diff --git a/Tests/LocalDataTests/EncryptionHelperTests.swift b/Tests/LocalDataTests/EncryptionHelperTests.swift index 223d254..49380eb 100644 --- a/Tests/LocalDataTests/EncryptionHelperTests.swift +++ b/Tests/LocalDataTests/EncryptionHelperTests.swift @@ -3,7 +3,7 @@ import Testing @testable import LocalData struct EncryptionHelperTests { - private let masterKeyService = "LocalData.MasterKey" + private let masterKeyService = "LocalData" private let keyName = "LocalDataTests.encryption" private let payload = Data("payload".utf8) @@ -51,6 +51,40 @@ struct EncryptionHelperTests { await clearMasterKey() } + @Test func customConfigurationRoundTrip() async throws { + let customService = "Test.CustomService" + let customAccount = "Test.CustomAccount" + let config = EncryptionConfiguration( + masterKeyService: customService, + masterKeyAccount: customAccount + ) + + await EncryptionHelper.shared.updateConfiguration(config) + + 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) + + // Cleanup keychain + try? await KeychainHelper.shared.deleteAll(service: customService) + + // Reset to default + await EncryptionHelper.shared.updateConfiguration(.default) + } + @Test func externalProviderWithHKDFRoundTrip() async throws { let source = KeyMaterialSource(id: "test.external") let provider = StaticKeyMaterialProvider(material: Data(repeating: 7, count: 32))