diff --git a/Sources/SpeziSecureStorage/SecureStorage.swift b/Sources/SpeziSecureStorage/SecureStorage.swift index 8efa083..410a9ae 100644 --- a/Sources/SpeziSecureStorage/SecureStorage.swift +++ b/Sources/SpeziSecureStorage/SecureStorage.swift @@ -178,6 +178,27 @@ public final class SecureStorage: Module, DefaultInitializable { try execute(SecItemDelete(query as CFDictionary)) } + /// Delete all existing credentials stored in the Keychain. + /// - Parameters: + /// - accessGroup: The access group associated with the credentials. + public func deleteAllCredentials(itemTypes: SecureStorageItemTypes = .all, accessGroup: String? = nil) throws { + for kSecClassType in itemTypes.kSecClass { + do { + var query: [String: Any] = [kSecClass as String: kSecClassType] + // Only append the accessGroup attribute if the `CredentialsStore` is configured to use KeyChain access groups + if let accessGroup { + query[kSecAttrAccessGroup as String] = accessGroup + } + try execute(SecItemDelete(query as CFDictionary)) + } catch SecureStorageError.notFound { + // We are fine it no keychain items have been found and therefore non had been deleted. + continue + } catch { + print(error) + } + } + } + /// Update existing credentials found in the Keychain. /// - Parameters: /// - username: The username associated with the old credentials. @@ -207,11 +228,23 @@ public final class SecureStorage: Module, DefaultInitializable { /// - accessGroup: The access group associated with the credentials. /// - Returns: Returns the credentials stored in the Keychain identified by the `username`, `server`, and `accessGroup`. public func retrieveCredentials(_ username: String, server: String? = nil, accessGroup: String? = nil) throws -> Credentials? { + try retrieveAllCredentials(forServer: server, accessGroup: accessGroup) + .first { credentials in + credentials.username == username + } + } + + /// Retrieve all existing credentials stored in the Keychain for a specific server. + /// - Parameters: + /// - server: The server associated with the credentials. + /// - accessGroup: The access group associated with the credentials. + /// - Returns: Returns all existing credentials stored in the Keychain identified by the `server` and `accessGroup`. + public func retrieveAllCredentials(forServer server: String? = nil, accessGroup: String? = nil) throws -> [Credentials] { // This method uses code provided by the Apple Developer documentation at // https://developer.apple.com/documentation/security/keychain_services/keychain_items/searching_for_keychain_items - var query: [String: Any] = queryFor(username, server: server, accessGroup: accessGroup) - query[kSecMatchLimit as String] = kSecMatchLimitOne + var query: [String: Any] = queryFor(nil, server: server, accessGroup: accessGroup) + query[kSecMatchLimit as String] = kSecMatchLimitAll query[kSecReturnAttributes as String] = true query[kSecReturnData as String] = true @@ -219,19 +252,28 @@ public final class SecureStorage: Module, DefaultInitializable { do { try execute(SecItemCopyMatching(query as CFDictionary, &item)) } catch SecureStorageError.notFound { - return nil + return [] } catch { throw error } - guard let existingItem = item as? [String: Any], - let passwordData = existingItem[kSecValueData as String] as? Data, - let password = String(data: passwordData, encoding: String.Encoding.utf8), - let account = existingItem[kSecAttrAccount as String] as? String else { + guard let existingItems = item as? [[String: Any]] else { throw SecureStorageError.unexpectedCredentialsData } - return Credentials(username: account, password: password) + var credentials: [Credentials] = [] + + for existingItem in existingItems { + guard let passwordData = existingItem[kSecValueData as String] as? Data, + let password = String(data: passwordData, encoding: String.Encoding.utf8), + let account = existingItem[kSecAttrAccount as String] as? String else { + continue + } + + credentials.append(Credentials(username: account, password: password)) + } + + return credentials } @@ -249,12 +291,14 @@ public final class SecureStorage: Module, DefaultInitializable { } } - private func queryFor(_ account: String, server: String?, accessGroup: String?) -> [String: Any] { + private func queryFor(_ account: String?, server: String?, accessGroup: String?) -> [String: Any] { // This method uses code provided by the Apple Developer documentation at // https://developer.apple.com/documentation/security/keychain_services/keychain_items/using_the_keychain_to_manage_user_secrets var query: [String: Any] = [:] - query[kSecAttrAccount as String] = account + if let account { + query[kSecAttrAccount as String] = account + } // Only append the accessGroup attribute if the `CredentialsStore` is configured to use KeyChain access groups if let accessGroup { diff --git a/Sources/SpeziSecureStorage/SecureStorageItemTypes.swift b/Sources/SpeziSecureStorage/SecureStorageItemTypes.swift new file mode 100644 index 0000000..dbf5020 --- /dev/null +++ b/Sources/SpeziSecureStorage/SecureStorageItemTypes.swift @@ -0,0 +1,48 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Security + + +/// Types of items that can be stored in the secure storage. +public struct SecureStorageItemTypes: OptionSet { + /// Keys as created with (``SecureStorage/createKey(_:size:storageScope:)``). + public static let keys = SecureStorageItemTypes(rawValue: 1 << 0) + /// Credentials as created with (``SecureStorage/store(credentials:server:removeDuplicate:storageScope:)``) by passing in a server name. + public static let serverCredentials = SecureStorageItemTypes(rawValue: 1 << 1) + /// Credentials as created with (``SecureStorage/store(credentials:server:removeDuplicate:storageScope:)``) by omitting a server name. + public static let nonServerCredentials = SecureStorageItemTypes(rawValue: 1 << 2) + + /// Credentials as created with (``SecureStorage/store(credentials:server:removeDuplicate:storageScope:)``). + public static let credentials: SecureStorageItemTypes = [.serverCredentials, .serverCredentials] + /// All types of items that can be handled by the secure storage component. + public static let all: SecureStorageItemTypes = [.keys, .serverCredentials, .nonServerCredentials] + + + public let rawValue: Int + + + var kSecClass: [CFString] { + var kSecClass: [CFString] = [] + if self.contains(.keys) { + kSecClass.append(kSecClassKey) + } + if self.contains(.serverCredentials) { + kSecClass.append(kSecClassGenericPassword) + } + if self.contains(.nonServerCredentials) { + kSecClass.append(kSecClassInternetPassword) + } + return kSecClass + } + + + public init(rawValue: Int) { + self.rawValue = rawValue + } +} diff --git a/Tests/UITests/TestApp/SecureStorageTests/SecureStorageTests.swift b/Tests/UITests/TestApp/SecureStorageTests/SecureStorageTests.swift index e8e3ef2..82ab3ea 100644 --- a/Tests/UITests/TestApp/SecureStorageTests/SecureStorageTests.swift +++ b/Tests/UITests/TestApp/SecureStorageTests/SecureStorageTests.swift @@ -24,12 +24,34 @@ final class SecureStorageTests: TestAppTestCase { func runTests() async throws { + try testDeleteAllCredentials() try testCredentials() try testInternetCredentials() + try testMultipleInternetCredentials() + try testMultipleCredentials() try testKeys() } + func testDeleteAllCredentials() throws { + let serverCredentials1 = Credentials(username: "@Schmiedmayer", password: "SpeziInventor") + try secureStorage.store(credentials: serverCredentials1, server: "apple.com") + + let serverCredentials2 = Credentials(username: "Stanford Spezi", password: "Paul") + try secureStorage.store(credentials: serverCredentials2) + + try secureStorage.createKey("DeleteKeyTest", storageScope: .keychain) + + try secureStorage.deleteAllCredentials() + + try XCTAssertEqual(try XCTUnwrap(secureStorage.retrieveAllCredentials(forServer: "apple.com")).count, 0) + try XCTAssertEqual(try XCTUnwrap(secureStorage.retrieveAllCredentials()).count, 0) + try XCTAssertNil(secureStorage.retrievePrivateKey(forTag: "DeleteKeyTest")) + try XCTAssertNil(secureStorage.retrievePublicKey(forTag: "DeleteKeyTest")) + } + func testCredentials() throws { + try secureStorage.deleteAllCredentials(itemTypes: .credentials) + var serverCredentials = Credentials(username: "@PSchmiedmayer", password: "SpeziInventor") try secureStorage.store(credentials: serverCredentials) try secureStorage.store(credentials: serverCredentials, storageScope: .keychainSynchronizable) @@ -52,6 +74,8 @@ final class SecureStorageTests: TestAppTestCase { } func testInternetCredentials() throws { + try secureStorage.deleteAllCredentials(itemTypes: .credentials) + var serverCredentials = Credentials(username: "@PSchmiedmayer", password: "SpeziInventor") try secureStorage.store(credentials: serverCredentials, server: "twitter.com") try secureStorage.store(credentials: serverCredentials, server: "twitter.com") // Overwrite existing credentials. @@ -76,8 +100,48 @@ final class SecureStorageTests: TestAppTestCase { try XCTAssertNil(try secureStorage.retrieveCredentials("@Spezi", server: "stanford.edu")) } + func testMultipleInternetCredentials() throws { + try secureStorage.deleteAllCredentials(itemTypes: .credentials) + + let serverCredentials1 = Credentials(username: "Paul Schmiedmayer", password: "SpeziInventor") + try secureStorage.store(credentials: serverCredentials1, server: "linkedin.com") + + let serverCredentials2 = Credentials(username: "Stanford Spezi", password: "Paul") + try secureStorage.store(credentials: serverCredentials2, server: "linkedin.com") + + let retrievedCredentials = try XCTUnwrap(secureStorage.retrieveAllCredentials(forServer: "linkedin.com")) + try XCTAssertEqual(retrievedCredentials.count, 2) + try XCTAssert(retrievedCredentials.contains(where: { $0 == serverCredentials1 })) + try XCTAssert(retrievedCredentials.contains(where: { $0 == serverCredentials2 })) + + try secureStorage.deleteCredentials("Paul Schmiedmayer", server: "linkedin.com") + try secureStorage.deleteCredentials("Stanford Spezi", server: "linkedin.com") + + try XCTAssertEqual(try XCTUnwrap(secureStorage.retrieveAllCredentials(forServer: "linkedin.com")).count, 0) + } + + func testMultipleCredentials() throws { + try secureStorage.deleteAllCredentials(itemTypes: .credentials) + + let serverCredentials1 = Credentials(username: "Paul Schmiedmayer", password: "SpeziInventor") + try secureStorage.store(credentials: serverCredentials1) + + let serverCredentials2 = Credentials(username: "Stanford Spezi", password: "Paul") + try secureStorage.store(credentials: serverCredentials2) + + let retrievedCredentials = try XCTUnwrap(secureStorage.retrieveAllCredentials()) + try XCTAssertEqual(retrievedCredentials.count, 2) + try XCTAssert(retrievedCredentials.contains(where: { $0 == serverCredentials1 })) + try XCTAssert(retrievedCredentials.contains(where: { $0 == serverCredentials2 })) + + try secureStorage.deleteCredentials("Paul Schmiedmayer") + try secureStorage.deleteCredentials("Stanford Spezi") + + try XCTAssertEqual(try XCTUnwrap(secureStorage.retrieveAllCredentials()).count, 0) + } + func testKeys() throws { - try secureStorage.deleteKeys(forTag: "MyKey") + try secureStorage.deleteAllCredentials(itemTypes: .keys) try XCTAssertNil(try secureStorage.retrievePublicKey(forTag: "MyKey")) try secureStorage.createKey("MyKey", storageScope: .keychain) diff --git a/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme b/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme index a5d3573..4e9d11c 100644 --- a/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme +++ b/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme @@ -57,7 +57,8 @@ shouldUseLaunchSchemeArgsEnv = "YES"> + reference = "container:TestApp.xctestplan" + default = "YES">