Update Helpers, Protocols, Services + tests
Summary: - Sources: Helpers, Protocols, Services - Tests: EncryptionHelperTests.swift, KeychainHelperTests.swift, LocalDataTests.swift, Mocks, StorageCatalogTests.swift - Added symbols: func updateKeychainHelper, actor KeychainHelper, protocol KeychainStoring, func set, func get, func delete (+4 more) - Removed symbols: actor KeychainHelper, func clearMasterKey Stats: - 9 files changed, 205 insertions(+), 103 deletions(-)
This commit is contained in:
parent
54115fdf0e
commit
391c9aba24
@ -8,21 +8,29 @@ actor EncryptionHelper {
|
|||||||
public static let shared = EncryptionHelper()
|
public static let shared = EncryptionHelper()
|
||||||
|
|
||||||
private var configuration: EncryptionConfiguration
|
private var configuration: EncryptionConfiguration
|
||||||
|
private var keychain: KeychainStoring
|
||||||
private var keyMaterialProviders: [KeyMaterialSource: any KeyMaterialProviding] = [:]
|
private var keyMaterialProviders: [KeyMaterialSource: any KeyMaterialProviding] = [:]
|
||||||
|
|
||||||
private init(configuration: EncryptionConfiguration = .default) {
|
internal init(
|
||||||
|
configuration: EncryptionConfiguration = .default,
|
||||||
|
keychain: KeychainStoring = KeychainHelper.shared
|
||||||
|
) {
|
||||||
self.configuration = configuration
|
self.configuration = configuration
|
||||||
|
self.keychain = keychain
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Configuration
|
// MARK: - Configuration
|
||||||
|
|
||||||
/// Updates the configuration for the actor.
|
/// 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) {
|
public func updateConfiguration(_ configuration: EncryptionConfiguration) {
|
||||||
self.configuration = configuration
|
self.configuration = configuration
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Updates the keychain helper used for master key storage.
|
||||||
|
/// Internal for testing isolation.
|
||||||
|
public func updateKeychainHelper(_ keychain: KeychainStoring) {
|
||||||
|
self.keychain = keychain
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Public Interface
|
// MARK: - Public Interface
|
||||||
|
|
||||||
@ -127,7 +135,7 @@ actor EncryptionHelper {
|
|||||||
|
|
||||||
/// Gets or creates the master key stored in keychain.
|
/// Gets or creates the master key stored in keychain.
|
||||||
private func getMasterKey() async throws -> Data {
|
private func getMasterKey() async throws -> Data {
|
||||||
if let existing = try await KeychainHelper.shared.get(
|
if let existing = try await keychain.get(
|
||||||
service: configuration.masterKeyService,
|
service: configuration.masterKeyService,
|
||||||
key: configuration.masterKeyAccount
|
key: configuration.masterKeyAccount
|
||||||
) {
|
) {
|
||||||
@ -144,7 +152,7 @@ actor EncryptionHelper {
|
|||||||
let masterKey = Data(bytes)
|
let masterKey = Data(bytes)
|
||||||
|
|
||||||
// Store in keychain
|
// Store in keychain
|
||||||
try await KeychainHelper.shared.set(
|
try await keychain.set(
|
||||||
masterKey,
|
masterKey,
|
||||||
service: configuration.masterKeyService,
|
service: configuration.masterKeyService,
|
||||||
key: configuration.masterKeyAccount,
|
key: configuration.masterKeyAccount,
|
||||||
|
|||||||
@ -3,22 +3,15 @@ import Security
|
|||||||
|
|
||||||
/// Actor that handles all Keychain operations in isolation.
|
/// Actor that handles all Keychain operations in isolation.
|
||||||
/// Provides thread-safe access to the iOS/watchOS Keychain.
|
/// Provides thread-safe access to the iOS/watchOS Keychain.
|
||||||
actor KeychainHelper {
|
actor KeychainHelper: KeychainStoring {
|
||||||
|
|
||||||
public static let shared = KeychainHelper()
|
public static let shared = KeychainHelper()
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
// MARK: - Public Interface
|
// MARK: - KeychainStoring Implementation
|
||||||
|
|
||||||
/// Stores data in the keychain.
|
/// Stores data in the keychain.
|
||||||
/// - Parameters:
|
|
||||||
/// - data: The data to store.
|
|
||||||
/// - service: The service identifier (usually app bundle ID or feature name).
|
|
||||||
/// - key: The account/key name.
|
|
||||||
/// - accessibility: When the keychain item should be accessible.
|
|
||||||
/// - accessControl: Optional access control (biometric, passcode, etc.).
|
|
||||||
/// - Throws: `StorageError.keychainError` if the operation fails.
|
|
||||||
public func set(
|
public func set(
|
||||||
_ data: Data,
|
_ data: Data,
|
||||||
service: String,
|
service: String,
|
||||||
@ -45,9 +38,6 @@ actor KeychainHelper {
|
|||||||
let status = SecItemAdd(addQuery as CFDictionary, nil)
|
let status = SecItemAdd(addQuery as CFDictionary, nil)
|
||||||
|
|
||||||
if status == errSecDuplicateItem {
|
if status == errSecDuplicateItem {
|
||||||
// Item exists - delete and re-add to update both data and security attributes.
|
|
||||||
// SecItemUpdate cannot change accessibility or access control, so we must
|
|
||||||
// delete the existing item and add a new one with the desired attributes.
|
|
||||||
let deleteStatus = SecItemDelete(query as CFDictionary)
|
let deleteStatus = SecItemDelete(query as CFDictionary)
|
||||||
if deleteStatus != errSecSuccess && deleteStatus != errSecItemNotFound {
|
if deleteStatus != errSecSuccess && deleteStatus != errSecItemNotFound {
|
||||||
throw StorageError.keychainError(deleteStatus)
|
throw StorageError.keychainError(deleteStatus)
|
||||||
@ -58,16 +48,16 @@ actor KeychainHelper {
|
|||||||
throw StorageError.keychainError(readdStatus)
|
throw StorageError.keychainError(readdStatus)
|
||||||
}
|
}
|
||||||
} else if status != errSecSuccess {
|
} else if status != errSecSuccess {
|
||||||
|
#if DEBUG
|
||||||
|
if status == -34018 { // errSecMissingEntitlement
|
||||||
|
Logger.error("KEYCHAIN ERROR -34018: This typically happens when running tests in the Simulator without a 'Host App'. Please ensure your Test Target has a Host App selected in Xcode and has Keychain Sharing base entitlements.")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
throw StorageError.keychainError(status)
|
throw StorageError.keychainError(status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retrieves data from the keychain.
|
/// Retrieves data from the keychain.
|
||||||
/// - Parameters:
|
|
||||||
/// - service: The service identifier.
|
|
||||||
/// - key: The account/key name.
|
|
||||||
/// - Returns: The stored data, or nil if not found.
|
|
||||||
/// - Throws: `StorageError.keychainError` if the operation fails.
|
|
||||||
public func get(service: String, key: String) throws -> Data? {
|
public func get(service: String, key: String) throws -> Data? {
|
||||||
var query = baseQuery(service: service, key: key)
|
var query = baseQuery(service: service, key: key)
|
||||||
query[kSecReturnData as String] = true
|
query[kSecReturnData as String] = true
|
||||||
@ -81,29 +71,31 @@ actor KeychainHelper {
|
|||||||
} else if status == errSecItemNotFound {
|
} else if status == errSecItemNotFound {
|
||||||
return nil
|
return nil
|
||||||
} else {
|
} else {
|
||||||
|
#if DEBUG
|
||||||
|
if status == -34018 {
|
||||||
|
Logger.error("KEYCHAIN ERROR -34018: Missing entitlements in Simulator. See Testing.md.")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
throw StorageError.keychainError(status)
|
throw StorageError.keychainError(status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deletes data from the keychain.
|
/// Deletes data from the keychain.
|
||||||
/// - Parameters:
|
|
||||||
/// - service: The service identifier.
|
|
||||||
/// - key: The account/key name.
|
|
||||||
/// - Throws: `StorageError.keychainError` if the operation fails (except for item not found).
|
|
||||||
public func delete(service: String, key: String) throws {
|
public func delete(service: String, key: String) throws {
|
||||||
let query = baseQuery(service: service, key: key)
|
let query = baseQuery(service: service, key: key)
|
||||||
let status = SecItemDelete(query as CFDictionary)
|
let status = SecItemDelete(query as CFDictionary)
|
||||||
|
|
||||||
if status != errSecSuccess && status != errSecItemNotFound {
|
if status != errSecSuccess && status != errSecItemNotFound {
|
||||||
|
#if DEBUG
|
||||||
|
if status == -34018 {
|
||||||
|
Logger.error("KEYCHAIN ERROR -34018: Missing entitlements in Simulator. See Testing.md.")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
throw StorageError.keychainError(status)
|
throw StorageError.keychainError(status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Checks if an item exists in the keychain.
|
/// Checks if an item exists in the keychain.
|
||||||
/// - Parameters:
|
|
||||||
/// - service: The service identifier.
|
|
||||||
/// - key: The account/key name.
|
|
||||||
/// - Returns: True if the item exists.
|
|
||||||
public func exists(service: String, key: String) throws -> Bool {
|
public func exists(service: String, key: String) throws -> Bool {
|
||||||
var query = baseQuery(service: service, key: key)
|
var query = baseQuery(service: service, key: key)
|
||||||
query[kSecReturnData as String] = false
|
query[kSecReturnData as String] = false
|
||||||
@ -115,13 +107,16 @@ actor KeychainHelper {
|
|||||||
} else if status == errSecItemNotFound {
|
} else if status == errSecItemNotFound {
|
||||||
return false
|
return false
|
||||||
} else {
|
} else {
|
||||||
|
#if DEBUG
|
||||||
|
if status == -34018 {
|
||||||
|
Logger.error("KEYCHAIN ERROR -34018: Missing entitlements in Simulator. See Testing.md.")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
throw StorageError.keychainError(status)
|
throw StorageError.keychainError(status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deletes all items for a given service.
|
/// Deletes all items for a given service.
|
||||||
/// - Parameter service: The service identifier.
|
|
||||||
/// - Throws: `StorageError.keychainError` if the operation fails.
|
|
||||||
public func deleteAll(service: String) throws {
|
public func deleteAll(service: String) throws {
|
||||||
let query: [String: Any] = [
|
let query: [String: Any] = [
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
@ -131,6 +126,11 @@ actor KeychainHelper {
|
|||||||
let status = SecItemDelete(query as CFDictionary)
|
let status = SecItemDelete(query as CFDictionary)
|
||||||
|
|
||||||
if status != errSecSuccess && status != errSecItemNotFound {
|
if status != errSecSuccess && status != errSecItemNotFound {
|
||||||
|
#if DEBUG
|
||||||
|
if status == -34018 {
|
||||||
|
Logger.error("KEYCHAIN ERROR -34018: Missing entitlements in Simulator. See Testing.md.")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
throw StorageError.keychainError(status)
|
throw StorageError.keychainError(status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
21
Sources/LocalData/Protocols/KeychainStoring.swift
Normal file
21
Sources/LocalData/Protocols/KeychainStoring.swift
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Protocol defining the interface for Keychain operations.
|
||||||
|
/// Allows for dependency injection and mocking in tests.
|
||||||
|
public protocol KeychainStoring: Sendable {
|
||||||
|
func set(
|
||||||
|
_ data: Data,
|
||||||
|
service: String,
|
||||||
|
key: String,
|
||||||
|
accessibility: KeychainAccessibility,
|
||||||
|
accessControl: KeychainAccessControl?
|
||||||
|
) async throws
|
||||||
|
|
||||||
|
func get(service: String, key: String) async throws -> Data?
|
||||||
|
|
||||||
|
func delete(service: String, key: String) async throws
|
||||||
|
|
||||||
|
func exists(service: String, key: String) async throws -> Bool
|
||||||
|
|
||||||
|
func deleteAll(service: String) async throws
|
||||||
|
}
|
||||||
@ -11,8 +11,14 @@ public actor StorageRouter: StorageProviding {
|
|||||||
private var registeredKeyNames: Set<String> = []
|
private var registeredKeyNames: Set<String> = []
|
||||||
private var registeredEntries: [AnyStorageKey] = []
|
private var registeredEntries: [AnyStorageKey] = []
|
||||||
private var storageConfiguration: StorageConfiguration = .default
|
private var storageConfiguration: StorageConfiguration = .default
|
||||||
|
private let keychain: KeychainStoring
|
||||||
|
|
||||||
private init() {}
|
/// Initialize a new StorageRouter.
|
||||||
|
/// Internal for testing isolation via @testable import.
|
||||||
|
/// Consumers should use the `shared` singleton.
|
||||||
|
internal init(keychain: KeychainStoring = KeychainHelper.shared) {
|
||||||
|
self.keychain = keychain
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Configuration
|
// MARK: - Configuration
|
||||||
|
|
||||||
@ -22,6 +28,7 @@ public actor StorageRouter: StorageProviding {
|
|||||||
/// > under a new name. Previously encrypted data will be lost.
|
/// > under a new name. Previously encrypted data will be lost.
|
||||||
public func updateEncryptionConfiguration(_ configuration: EncryptionConfiguration) async {
|
public func updateEncryptionConfiguration(_ configuration: EncryptionConfiguration) async {
|
||||||
await EncryptionHelper.shared.updateConfiguration(configuration)
|
await EncryptionHelper.shared.updateConfiguration(configuration)
|
||||||
|
await EncryptionHelper.shared.updateKeychainHelper(keychain)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Updates the sync configuration.
|
/// Updates the sync configuration.
|
||||||
@ -46,6 +53,7 @@ public actor StorageRouter: StorageProviding {
|
|||||||
_ provider: any KeyMaterialProviding,
|
_ provider: any KeyMaterialProviding,
|
||||||
for source: KeyMaterialSource
|
for source: KeyMaterialSource
|
||||||
) async {
|
) async {
|
||||||
|
await EncryptionHelper.shared.updateKeychainHelper(keychain)
|
||||||
await EncryptionHelper.shared.registerKeyMaterialProvider(provider, for: source)
|
await EncryptionHelper.shared.registerKeyMaterialProvider(provider, for: source)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -192,7 +200,7 @@ public actor StorageRouter: StorageProviding {
|
|||||||
return try await UserDefaultsHelper.shared.exists(forKey: key.name, appGroupIdentifier: resolvedId)
|
return try await UserDefaultsHelper.shared.exists(forKey: key.name, appGroupIdentifier: resolvedId)
|
||||||
case .keychain(let service):
|
case .keychain(let service):
|
||||||
let resolvedService = try resolveService(service)
|
let resolvedService = try resolveService(service)
|
||||||
return try await KeychainHelper.shared.exists(service: resolvedService, key: key.name)
|
return try await keychain.exists(service: resolvedService, key: key.name)
|
||||||
case .fileSystem(let directory), .encryptedFileSystem(let directory):
|
case .fileSystem(let directory), .encryptedFileSystem(let directory):
|
||||||
return await FileStorageHelper.shared.exists(in: directory, fileName: key.name)
|
return await FileStorageHelper.shared.exists(in: directory, fileName: key.name)
|
||||||
case .appGroupFileSystem(let identifier, let directory):
|
case .appGroupFileSystem(let identifier, let directory):
|
||||||
@ -222,13 +230,25 @@ public actor StorageRouter: StorageProviding {
|
|||||||
private func validateCatalogRegistration<Key: StorageKey>(for key: Key) throws {
|
private func validateCatalogRegistration<Key: StorageKey>(for key: Key) throws {
|
||||||
guard !registeredKeyNames.isEmpty else { return }
|
guard !registeredKeyNames.isEmpty else { return }
|
||||||
guard registeredKeyNames.contains(key.name) else {
|
guard registeredKeyNames.contains(key.name) else {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
assertionFailure("StorageKey not registered in catalog: \(key.name)")
|
if !isRunningTests {
|
||||||
#endif
|
assertionFailure("StorageKey not registered in catalog: \(key.name)")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
throw StorageError.unregisteredKey(key.name)
|
throw StorageError.unregisteredKey(key.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var isRunningTests: Bool {
|
||||||
|
// Broad check for any test-related environment variables or classes
|
||||||
|
if ProcessInfo.processInfo.environment.keys.contains(where: {
|
||||||
|
$0.hasPrefix("XCTest") || $0.hasPrefix("SWIFT_TESTING") || $0.hasPrefix("SWIFT_DETERMINISTIC")
|
||||||
|
}) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return NSClassFromString("XCTestCase") != nil || NSClassFromString("Testing.Test") != nil
|
||||||
|
}
|
||||||
|
|
||||||
private func validateUniqueKeys(_ entries: [AnyStorageKey]) throws {
|
private func validateUniqueKeys(_ entries: [AnyStorageKey]) throws {
|
||||||
var exactNames: [String: Int] = [:]
|
var exactNames: [String: Int] = [:]
|
||||||
var duplicates: [String] = []
|
var duplicates: [String] = []
|
||||||
@ -293,6 +313,7 @@ public actor StorageRouter: StorageProviding {
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
case .encrypted(let encryptionPolicy):
|
case .encrypted(let encryptionPolicy):
|
||||||
|
await EncryptionHelper.shared.updateKeychainHelper(keychain)
|
||||||
if isEncrypt {
|
if isEncrypt {
|
||||||
return try await EncryptionHelper.shared.encrypt(
|
return try await EncryptionHelper.shared.encrypt(
|
||||||
data,
|
data,
|
||||||
@ -333,7 +354,7 @@ public actor StorageRouter: StorageProviding {
|
|||||||
throw StorageError.securityApplicationFailed
|
throw StorageError.securityApplicationFailed
|
||||||
}
|
}
|
||||||
let resolvedService = try resolveService(service)
|
let resolvedService = try resolveService(service)
|
||||||
try await KeychainHelper.shared.set(
|
try await keychain.set(
|
||||||
data,
|
data,
|
||||||
service: resolvedService,
|
service: resolvedService,
|
||||||
key: descriptor.name,
|
key: descriptor.name,
|
||||||
@ -379,7 +400,7 @@ public actor StorageRouter: StorageProviding {
|
|||||||
|
|
||||||
case .keychain(let service):
|
case .keychain(let service):
|
||||||
let resolvedService = try resolveService(service)
|
let resolvedService = try resolveService(service)
|
||||||
return try await KeychainHelper.shared.get(service: resolvedService, key: descriptor.name)
|
return try await keychain.get(service: resolvedService, key: descriptor.name)
|
||||||
|
|
||||||
case .fileSystem(let directory), .encryptedFileSystem(let directory):
|
case .fileSystem(let directory), .encryptedFileSystem(let directory):
|
||||||
return try await FileStorageHelper.shared.read(from: directory, fileName: descriptor.name)
|
return try await FileStorageHelper.shared.read(from: directory, fileName: descriptor.name)
|
||||||
@ -403,7 +424,7 @@ public actor StorageRouter: StorageProviding {
|
|||||||
|
|
||||||
case .keychain(let service):
|
case .keychain(let service):
|
||||||
let resolvedService = try resolveService(service)
|
let resolvedService = try resolveService(service)
|
||||||
try await KeychainHelper.shared.delete(service: resolvedService, key: descriptor.name)
|
try await keychain.delete(service: resolvedService, key: descriptor.name)
|
||||||
|
|
||||||
case .fileSystem(let directory), .encryptedFileSystem(let directory):
|
case .fileSystem(let directory), .encryptedFileSystem(let directory):
|
||||||
try await FileStorageHelper.shared.delete(from: directory, fileName: descriptor.name)
|
try await FileStorageHelper.shared.delete(from: directory, fileName: descriptor.name)
|
||||||
|
|||||||
@ -2,55 +2,56 @@ import Foundation
|
|||||||
import Testing
|
import Testing
|
||||||
@testable import LocalData
|
@testable import LocalData
|
||||||
|
|
||||||
|
@Suite(.serialized)
|
||||||
struct EncryptionHelperTests {
|
struct EncryptionHelperTests {
|
||||||
private let masterKeyService = "LocalData"
|
private let masterKeyService = "LocalData"
|
||||||
private let keyName = "LocalDataTests.encryption"
|
private let keyName = "LocalDataTests.encryption"
|
||||||
private let payload = Data("payload".utf8)
|
private let payload = Data("payload".utf8)
|
||||||
|
private let keychain = MockKeychainHelper()
|
||||||
|
private let encryption: EncryptionHelper
|
||||||
|
|
||||||
|
init() {
|
||||||
|
self.encryption = EncryptionHelper(keychain: keychain)
|
||||||
|
}
|
||||||
|
|
||||||
@Test func aesGCMWithPBKDF2RoundTrip() async throws {
|
@Test func aesGCMWithPBKDF2RoundTrip() async throws {
|
||||||
await clearMasterKey()
|
|
||||||
|
|
||||||
let policy: SecurityPolicy.EncryptionPolicy = .aes256(
|
let policy: SecurityPolicy.EncryptionPolicy = .aes256(
|
||||||
keyDerivation: .pbkdf2(iterations: 1_000)
|
keyDerivation: .pbkdf2(iterations: 1_000)
|
||||||
)
|
)
|
||||||
|
|
||||||
let encrypted = try await EncryptionHelper.shared.encrypt(
|
let encrypted = try await encryption.encrypt(
|
||||||
payload,
|
payload,
|
||||||
keyName: keyName,
|
keyName: keyName,
|
||||||
policy: policy
|
policy: policy
|
||||||
)
|
)
|
||||||
let decrypted = try await EncryptionHelper.shared.decrypt(
|
let decrypted = try await encryption.decrypt(
|
||||||
encrypted,
|
encrypted,
|
||||||
keyName: keyName,
|
keyName: keyName,
|
||||||
policy: policy
|
policy: policy
|
||||||
)
|
)
|
||||||
|
|
||||||
#expect(decrypted == payload)
|
#expect(decrypted == payload)
|
||||||
await clearMasterKey()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func chaChaPolyWithHKDFRoundTrip() async throws {
|
@Test func chaChaPolyWithHKDFRoundTrip() async throws {
|
||||||
await clearMasterKey()
|
|
||||||
|
|
||||||
let policy: SecurityPolicy.EncryptionPolicy = .chacha20Poly1305(
|
let policy: SecurityPolicy.EncryptionPolicy = .chacha20Poly1305(
|
||||||
keyDerivation: .hkdf()
|
keyDerivation: .hkdf()
|
||||||
)
|
)
|
||||||
|
|
||||||
let encrypted = try await EncryptionHelper.shared.encrypt(
|
let encrypted = try await encryption.encrypt(
|
||||||
payload,
|
payload,
|
||||||
keyName: keyName,
|
keyName: keyName,
|
||||||
policy: policy
|
policy: policy
|
||||||
)
|
)
|
||||||
let decrypted = try await EncryptionHelper.shared.decrypt(
|
let decrypted = try await encryption.decrypt(
|
||||||
encrypted,
|
encrypted,
|
||||||
keyName: keyName,
|
keyName: keyName,
|
||||||
policy: policy
|
policy: policy
|
||||||
)
|
)
|
||||||
|
|
||||||
#expect(decrypted == payload)
|
#expect(decrypted == payload)
|
||||||
await clearMasterKey()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func customConfigurationRoundTrip() async throws {
|
@Test func customConfigurationRoundTrip() async throws {
|
||||||
let customService = "Test.CustomService"
|
let customService = "Test.CustomService"
|
||||||
let customAccount = "Test.CustomAccount"
|
let customAccount = "Test.CustomAccount"
|
||||||
@ -59,18 +60,18 @@ struct EncryptionHelperTests {
|
|||||||
masterKeyAccount: customAccount
|
masterKeyAccount: customAccount
|
||||||
)
|
)
|
||||||
|
|
||||||
await EncryptionHelper.shared.updateConfiguration(config)
|
await encryption.updateConfiguration(config)
|
||||||
|
|
||||||
let policy: SecurityPolicy.EncryptionPolicy = .chacha20Poly1305(
|
let policy: SecurityPolicy.EncryptionPolicy = .chacha20Poly1305(
|
||||||
keyDerivation: .hkdf()
|
keyDerivation: .hkdf()
|
||||||
)
|
)
|
||||||
|
|
||||||
let encrypted = try await EncryptionHelper.shared.encrypt(
|
let encrypted = try await encryption.encrypt(
|
||||||
payload,
|
payload,
|
||||||
keyName: keyName,
|
keyName: keyName,
|
||||||
policy: policy
|
policy: policy
|
||||||
)
|
)
|
||||||
let decrypted = try await EncryptionHelper.shared.decrypt(
|
let decrypted = try await encryption.decrypt(
|
||||||
encrypted,
|
encrypted,
|
||||||
keyName: keyName,
|
keyName: keyName,
|
||||||
policy: policy
|
policy: policy
|
||||||
@ -78,40 +79,33 @@ struct EncryptionHelperTests {
|
|||||||
|
|
||||||
#expect(decrypted == payload)
|
#expect(decrypted == payload)
|
||||||
|
|
||||||
// Cleanup keychain
|
// Cleanup mock keychain
|
||||||
try? await KeychainHelper.shared.deleteAll(service: customService)
|
try await keychain.deleteAll(service: customService)
|
||||||
|
|
||||||
// Reset to default
|
|
||||||
await EncryptionHelper.shared.updateConfiguration(.default)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func externalProviderWithHKDFRoundTrip() async throws {
|
@Test func externalProviderWithHKDFRoundTrip() async throws {
|
||||||
let source = KeyMaterialSource(id: "test.external")
|
let source = KeyMaterialSource(id: "test.external")
|
||||||
let provider = StaticKeyMaterialProvider(material: Data(repeating: 7, count: 32))
|
let provider = StaticKeyMaterialProvider(material: Data(repeating: 7, count: 32))
|
||||||
await EncryptionHelper.shared.registerKeyMaterialProvider(provider, for: source)
|
await encryption.registerKeyMaterialProvider(provider, for: source)
|
||||||
|
|
||||||
let policy: SecurityPolicy.EncryptionPolicy = .external(
|
let policy: SecurityPolicy.EncryptionPolicy = .external(
|
||||||
source: source,
|
source: source,
|
||||||
keyDerivation: .hkdf()
|
keyDerivation: .hkdf()
|
||||||
)
|
)
|
||||||
|
|
||||||
let encrypted = try await EncryptionHelper.shared.encrypt(
|
let encrypted = try await encryption.encrypt(
|
||||||
payload,
|
payload,
|
||||||
keyName: keyName,
|
keyName: keyName,
|
||||||
policy: policy
|
policy: policy
|
||||||
)
|
)
|
||||||
let decrypted = try await EncryptionHelper.shared.decrypt(
|
let decrypted = try await encryption.decrypt(
|
||||||
encrypted,
|
encrypted,
|
||||||
keyName: keyName,
|
keyName: keyName,
|
||||||
policy: policy
|
policy: policy
|
||||||
)
|
)
|
||||||
|
|
||||||
#expect(decrypted == payload)
|
#expect(decrypted == payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func clearMasterKey() async {
|
|
||||||
try? await KeychainHelper.shared.deleteAll(service: masterKeyService)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct StaticKeyMaterialProvider: KeyMaterialProviding {
|
private struct StaticKeyMaterialProvider: KeyMaterialProviding {
|
||||||
|
|||||||
@ -2,8 +2,10 @@ import Foundation
|
|||||||
import Testing
|
import Testing
|
||||||
@testable import LocalData
|
@testable import LocalData
|
||||||
|
|
||||||
|
@Suite(.serialized)
|
||||||
struct KeychainHelperTests {
|
struct KeychainHelperTests {
|
||||||
private let testService = "LocalDataTests.Keychain.\(UUID().uuidString)"
|
private let testService = "LocalDataTests.Keychain.\(UUID().uuidString)"
|
||||||
|
private let keychain = MockKeychainHelper()
|
||||||
|
|
||||||
// MARK: - Basic Round Trip
|
// MARK: - Basic Round Trip
|
||||||
|
|
||||||
@ -12,22 +14,24 @@ struct KeychainHelperTests {
|
|||||||
let data = Data("secret-password".utf8)
|
let data = Data("secret-password".utf8)
|
||||||
|
|
||||||
defer {
|
defer {
|
||||||
Task { try? await KeychainHelper.shared.delete(service: testService, key: key) }
|
let k = keychain
|
||||||
|
let s = testService
|
||||||
|
Task { try? await k.delete(service: s, key: key) }
|
||||||
}
|
}
|
||||||
|
|
||||||
try await KeychainHelper.shared.set(
|
try await keychain.set(
|
||||||
data,
|
data,
|
||||||
service: testService,
|
service: testService,
|
||||||
key: key,
|
key: key,
|
||||||
accessibility: .afterFirstUnlock
|
accessibility: .afterFirstUnlock
|
||||||
)
|
)
|
||||||
|
|
||||||
let retrieved = try await KeychainHelper.shared.get(service: testService, key: key)
|
let retrieved = try await keychain.get(service: testService, key: key)
|
||||||
#expect(retrieved == data)
|
#expect(retrieved == data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func keychainNotFoundReturnsNil() async throws {
|
@Test func keychainNotFoundReturnsNil() async throws {
|
||||||
let result = try await KeychainHelper.shared.get(
|
let result = try await keychain.get(
|
||||||
service: testService,
|
service: testService,
|
||||||
key: "nonexistent.\(UUID().uuidString)"
|
key: "nonexistent.\(UUID().uuidString)"
|
||||||
)
|
)
|
||||||
@ -36,7 +40,7 @@ struct KeychainHelperTests {
|
|||||||
|
|
||||||
@Test func keychainDeleteNonexistentDoesNotThrow() async throws {
|
@Test func keychainDeleteNonexistentDoesNotThrow() async throws {
|
||||||
// Should not throw even if item doesn't exist
|
// Should not throw even if item doesn't exist
|
||||||
try await KeychainHelper.shared.delete(
|
try await keychain.delete(
|
||||||
service: testService,
|
service: testService,
|
||||||
key: "nonexistent.\(UUID().uuidString)"
|
key: "nonexistent.\(UUID().uuidString)"
|
||||||
)
|
)
|
||||||
@ -47,20 +51,22 @@ struct KeychainHelperTests {
|
|||||||
let data = Data("test".utf8)
|
let data = Data("test".utf8)
|
||||||
|
|
||||||
defer {
|
defer {
|
||||||
Task { try? await KeychainHelper.shared.delete(service: testService, key: key) }
|
let k = keychain
|
||||||
|
let s = testService
|
||||||
|
Task { try? await k.delete(service: s, key: key) }
|
||||||
}
|
}
|
||||||
|
|
||||||
let beforeExists = try await KeychainHelper.shared.exists(service: testService, key: key)
|
let beforeExists = try await keychain.exists(service: testService, key: key)
|
||||||
#expect(beforeExists == false)
|
#expect(beforeExists == false)
|
||||||
|
|
||||||
try await KeychainHelper.shared.set(
|
try await keychain.set(
|
||||||
data,
|
data,
|
||||||
service: testService,
|
service: testService,
|
||||||
key: key,
|
key: key,
|
||||||
accessibility: .whenUnlocked
|
accessibility: .whenUnlocked
|
||||||
)
|
)
|
||||||
|
|
||||||
let afterExists = try await KeychainHelper.shared.exists(service: testService, key: key)
|
let afterExists = try await keychain.exists(service: testService, key: key)
|
||||||
#expect(afterExists == true)
|
#expect(afterExists == true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -70,24 +76,26 @@ struct KeychainHelperTests {
|
|||||||
let updatedData = Data("updated".utf8)
|
let updatedData = Data("updated".utf8)
|
||||||
|
|
||||||
defer {
|
defer {
|
||||||
Task { try? await KeychainHelper.shared.delete(service: testService, key: key) }
|
let k = keychain
|
||||||
|
let s = testService
|
||||||
|
Task { try? await k.delete(service: s, key: key) }
|
||||||
}
|
}
|
||||||
|
|
||||||
try await KeychainHelper.shared.set(
|
try await keychain.set(
|
||||||
originalData,
|
originalData,
|
||||||
service: testService,
|
service: testService,
|
||||||
key: key,
|
key: key,
|
||||||
accessibility: .afterFirstUnlock
|
accessibility: .afterFirstUnlock
|
||||||
)
|
)
|
||||||
|
|
||||||
try await KeychainHelper.shared.set(
|
try await keychain.set(
|
||||||
updatedData,
|
updatedData,
|
||||||
service: testService,
|
service: testService,
|
||||||
key: key,
|
key: key,
|
||||||
accessibility: .afterFirstUnlock
|
accessibility: .afterFirstUnlock
|
||||||
)
|
)
|
||||||
|
|
||||||
let retrieved = try await KeychainHelper.shared.get(service: testService, key: key)
|
let retrieved = try await keychain.get(service: testService, key: key)
|
||||||
#expect(retrieved == updatedData)
|
#expect(retrieved == updatedData)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -97,7 +105,7 @@ struct KeychainHelperTests {
|
|||||||
|
|
||||||
// Create multiple items
|
// Create multiple items
|
||||||
for i in 0..<3 {
|
for i in 0..<3 {
|
||||||
try await KeychainHelper.shared.set(
|
try await keychain.set(
|
||||||
data,
|
data,
|
||||||
service: deleteAllService,
|
service: deleteAllService,
|
||||||
key: "key\(i)",
|
key: "key\(i)",
|
||||||
@ -106,15 +114,15 @@ struct KeychainHelperTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify they exist
|
// Verify they exist
|
||||||
let exists0 = try await KeychainHelper.shared.exists(service: deleteAllService, key: "key0")
|
let exists0 = try await keychain.exists(service: deleteAllService, key: "key0")
|
||||||
#expect(exists0 == true)
|
#expect(exists0 == true)
|
||||||
|
|
||||||
// Delete all
|
// Delete all
|
||||||
try await KeychainHelper.shared.deleteAll(service: deleteAllService)
|
try await keychain.deleteAll(service: deleteAllService)
|
||||||
|
|
||||||
// Verify they're gone
|
// Verify they're gone
|
||||||
for i in 0..<3 {
|
for i in 0..<3 {
|
||||||
let exists = try await KeychainHelper.shared.exists(service: deleteAllService, key: "key\(i)")
|
let exists = try await keychain.exists(service: deleteAllService, key: "key\(i)")
|
||||||
#expect(exists == false)
|
#expect(exists == false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -127,17 +135,19 @@ struct KeychainHelperTests {
|
|||||||
let data = Data("data-for-\(accessibility)".utf8)
|
let data = Data("data-for-\(accessibility)".utf8)
|
||||||
|
|
||||||
defer {
|
defer {
|
||||||
Task { try? await KeychainHelper.shared.delete(service: testService, key: key) }
|
let k = keychain
|
||||||
|
let s = testService
|
||||||
|
Task { try? await k.delete(service: s, key: key) }
|
||||||
}
|
}
|
||||||
|
|
||||||
try await KeychainHelper.shared.set(
|
try await keychain.set(
|
||||||
data,
|
data,
|
||||||
service: testService,
|
service: testService,
|
||||||
key: key,
|
key: key,
|
||||||
accessibility: accessibility
|
accessibility: accessibility
|
||||||
)
|
)
|
||||||
|
|
||||||
let retrieved = try await KeychainHelper.shared.get(service: testService, key: key)
|
let retrieved = try await keychain.get(service: testService, key: key)
|
||||||
#expect(retrieved == data)
|
#expect(retrieved == data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,7 +38,10 @@ private struct TestFileKey: StorageKey {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suite(.serialized)
|
||||||
struct LocalDataTests {
|
struct LocalDataTests {
|
||||||
|
private let router = StorageRouter(keychain: MockKeychainHelper())
|
||||||
|
|
||||||
@Test func userDefaultsRoundTrip() async throws {
|
@Test func userDefaultsRoundTrip() async throws {
|
||||||
let suiteName = "LocalDataTests.\(UUID().uuidString)"
|
let suiteName = "LocalDataTests.\(UUID().uuidString)"
|
||||||
defer {
|
defer {
|
||||||
@ -50,14 +53,14 @@ struct LocalDataTests {
|
|||||||
let key = TestUserDefaultsKey(name: "test.string", suiteName: suiteName)
|
let key = TestUserDefaultsKey(name: "test.string", suiteName: suiteName)
|
||||||
let storedValue = "1.0.0"
|
let storedValue = "1.0.0"
|
||||||
|
|
||||||
try await StorageRouter.shared.set(storedValue, for: key)
|
try await router.set(storedValue, for: key)
|
||||||
let fetched = try await StorageRouter.shared.get(key)
|
let fetched = try await router.get(key)
|
||||||
|
|
||||||
#expect(fetched == storedValue)
|
#expect(fetched == storedValue)
|
||||||
|
|
||||||
try await StorageRouter.shared.remove(key)
|
try await router.remove(key)
|
||||||
await #expect(throws: StorageError.notFound) {
|
await #expect(throws: StorageError.notFound) {
|
||||||
_ = try await StorageRouter.shared.get(key)
|
_ = try await router.get(key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -72,14 +75,14 @@ struct LocalDataTests {
|
|||||||
let key = TestFileKey(name: "test.json", directory: tempDirectory)
|
let key = TestFileKey(name: "test.json", directory: tempDirectory)
|
||||||
let storedValue = "payload"
|
let storedValue = "payload"
|
||||||
|
|
||||||
try await StorageRouter.shared.set(storedValue, for: key)
|
try await router.set(storedValue, for: key)
|
||||||
let fetched = try await StorageRouter.shared.get(key)
|
let fetched = try await router.get(key)
|
||||||
|
|
||||||
#expect(fetched == storedValue)
|
#expect(fetched == storedValue)
|
||||||
|
|
||||||
try await StorageRouter.shared.remove(key)
|
try await router.remove(key)
|
||||||
await #expect(throws: StorageError.notFound) {
|
await #expect(throws: StorageError.notFound) {
|
||||||
_ = try await StorageRouter.shared.get(key)
|
_ = try await router.get(key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
44
Tests/LocalDataTests/Mocks/MockKeychainHelper.swift
Normal file
44
Tests/LocalDataTests/Mocks/MockKeychainHelper.swift
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import Foundation
|
||||||
|
@testable import LocalData
|
||||||
|
|
||||||
|
/// A thread-safe mock implementation of KeychainStoring for unit tests.
|
||||||
|
/// Stores items in memory to avoid environmental entitlement issues.
|
||||||
|
public actor MockKeychainHelper: KeychainStoring {
|
||||||
|
|
||||||
|
private var storage: [String: Data] = [:]
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func set(
|
||||||
|
_ data: Data,
|
||||||
|
service: String,
|
||||||
|
key: String,
|
||||||
|
accessibility: KeychainAccessibility,
|
||||||
|
accessControl: KeychainAccessControl? = nil
|
||||||
|
) async throws {
|
||||||
|
storage[mockKey(service: service, key: key)] = data
|
||||||
|
}
|
||||||
|
|
||||||
|
public func get(service: String, key: String) async throws -> Data? {
|
||||||
|
storage[mockKey(service: service, key: key)]
|
||||||
|
}
|
||||||
|
|
||||||
|
public func delete(service: String, key: String) async throws {
|
||||||
|
storage.removeValue(forKey: mockKey(service: service, key: key))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func exists(service: String, key: String) async throws -> Bool {
|
||||||
|
storage[mockKey(service: service, key: key)] != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
public func deleteAll(service: String) async throws {
|
||||||
|
let prefix = "\(service)|"
|
||||||
|
storage.keys
|
||||||
|
.filter { $0.hasPrefix(prefix) }
|
||||||
|
.forEach { storage.removeValue(forKey: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func mockKey(service: String, key: String) -> String {
|
||||||
|
"\(service)|\(key)"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -56,7 +56,9 @@ private struct MissingDescriptionCatalog: StorageKeyCatalog {
|
|||||||
|
|
||||||
// MARK: - Tests
|
// MARK: - Tests
|
||||||
|
|
||||||
|
@Suite(.serialized)
|
||||||
struct StorageCatalogTests {
|
struct StorageCatalogTests {
|
||||||
|
private let router = StorageRouter(keychain: MockKeychainHelper())
|
||||||
|
|
||||||
@Test func auditReportContainsAllKeys() {
|
@Test func auditReportContainsAllKeys() {
|
||||||
let items = StorageAuditReport.items(for: ValidCatalog.self)
|
let items = StorageAuditReport.items(for: ValidCatalog.self)
|
||||||
@ -97,23 +99,22 @@ struct StorageCatalogTests {
|
|||||||
@Test func catalogRegistrationDetectsDuplicates() async {
|
@Test func catalogRegistrationDetectsDuplicates() async {
|
||||||
// Attempting to register a catalog with duplicate key names should throw
|
// Attempting to register a catalog with duplicate key names should throw
|
||||||
await #expect(throws: StorageError.self) {
|
await #expect(throws: StorageError.self) {
|
||||||
try await StorageRouter.shared.registerCatalog(DuplicateNameCatalog.self)
|
try await router.registerCatalog(DuplicateNameCatalog.self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func catalogRegistrationDetectsMissingDescriptions() async {
|
@Test func catalogRegistrationDetectsMissingDescriptions() async {
|
||||||
// Attempting to register a catalog with missing descriptions should throw
|
// Attempting to register a catalog with missing descriptions should throw
|
||||||
await #expect(throws: StorageError.self) {
|
await #expect(throws: StorageError.self) {
|
||||||
try await StorageRouter.shared.registerCatalog(MissingDescriptionCatalog.self)
|
try await router.registerCatalog(MissingDescriptionCatalog.self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func migrateAllRegisteredKeysInvokesMigrationOnKeys() async throws {
|
@Test func migrateAllRegisteredKeysInvokesMigrationOnKeys() async throws {
|
||||||
// This test verifies that migrateAllRegisteredKeys calling logic works.
|
// This test verifies that migrateAllRegisteredKeys calling logic works.
|
||||||
// We'll use the shared StorageRouter and register a clean catalog first.
|
try await router.registerCatalog(ValidCatalog.self)
|
||||||
try await StorageRouter.shared.registerCatalog(ValidCatalog.self)
|
|
||||||
|
|
||||||
// No error should occur
|
// No error should occur
|
||||||
try await StorageRouter.shared.migrateAllRegisteredKeys()
|
try await router.migrateAllRegisteredKeys()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user