From 7e62c954c45789906caca53f9180146a90a9fce4 Mon Sep 17 00:00:00 2001 From: Christophe Vila Date: Fri, 22 May 2026 14:49:38 +0200 Subject: [PATCH] feat: add KeychainStore --- .../MyPassCore/Keychain/KeychainError.swift | 7 ++ .../MyPassCore/Keychain/KeychainStore.swift | 68 +++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 MyPassCore/Sources/MyPassCore/Keychain/KeychainError.swift create mode 100644 MyPassCore/Sources/MyPassCore/Keychain/KeychainStore.swift diff --git a/MyPassCore/Sources/MyPassCore/Keychain/KeychainError.swift b/MyPassCore/Sources/MyPassCore/Keychain/KeychainError.swift new file mode 100644 index 0000000..f6c1745 --- /dev/null +++ b/MyPassCore/Sources/MyPassCore/Keychain/KeychainError.swift @@ -0,0 +1,7 @@ +import Foundation + +public enum KeychainError: Error, Equatable { + case saveFailed(OSStatus) + case loadFailed(OSStatus) + case notFound +} diff --git a/MyPassCore/Sources/MyPassCore/Keychain/KeychainStore.swift b/MyPassCore/Sources/MyPassCore/Keychain/KeychainStore.swift new file mode 100644 index 0000000..8b507e9 --- /dev/null +++ b/MyPassCore/Sources/MyPassCore/Keychain/KeychainStore.swift @@ -0,0 +1,68 @@ +import Foundation +import Security + +public struct KeychainStore { + private let accessGroup: String + private let service: String + + public init(accessGroup: String, service: String = "com.christophevila.mypass") { + self.accessGroup = accessGroup + self.service = service + } + + public func save(password: String, for account: String) throws { + let data = Data(password.utf8) + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: account, + kSecAttrAccessGroup: accessGroup, + kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly, + kSecValueData: data, + ] + SecItemDelete(query as CFDictionary) + let status = SecItemAdd(query as CFDictionary, nil) + guard status == errSecSuccess else { throw KeychainError.saveFailed(status) } + } + + public func load(for account: String) throws -> String { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: account, + kSecAttrAccessGroup: accessGroup, + kSecReturnData: true, + kSecMatchLimit: kSecMatchLimitOne, + ] + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess else { + throw status == errSecItemNotFound ? KeychainError.notFound : KeychainError.loadFailed(status) + } + guard let data = result as? Data, let password = String(data: data, encoding: .utf8) else { + throw KeychainError.loadFailed(errSecInvalidData) + } + return password + } + + public func delete(for account: String) { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: account, + kSecAttrAccessGroup: accessGroup, + ] + SecItemDelete(query as CFDictionary) + } + + public func exists(for account: String) -> Bool { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: account, + kSecAttrAccessGroup: accessGroup, + kSecMatchLimit: kSecMatchLimitOne, + ] + return SecItemCopyMatching(query as CFDictionary, nil) == errSecSuccess + } +}