//===----------------------------------------------------------------------===// // Copyright © 2025-2026 Apple Inc. and the Containerization project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// #if os(macOS) #if canImport(FoundationEssentials) import FoundationEssentials #else import Foundation #endif /// Holds the result of a query to the keychain. public struct KeychainQueryResult { public var username: String public var password: String public var modifiedDate: Date public var createdDate: Date } /// Type that facilitates interacting with the macOS keychain. public struct KeychainQuery { public init() {} /// Save a value to the keychain. /// - Parameters: /// - securityDomain: The security domain used to fetch keychain entries. /// - accessGroup: If present, the access group used to fetch keychain entries. /// - hostname: The hostname for the authenticating server. /// - username: The username to present to the server. /// - password: The password to present to the server. /// - Throws: An error if the keychain query fails or returns unexpected data. public func save( securityDomain: String, accessGroup: String? = nil, hostname: String, username: String, password: String ) throws { if try exists(securityDomain: securityDomain, accessGroup: accessGroup, hostname: hostname) { try delete(securityDomain: securityDomain, accessGroup: accessGroup, hostname: hostname) } guard let passwordEncoded = password.data(using: String.Encoding.utf8) else { throw Self.Error.invalidPasswordConversion } var query: [String: Any] = [ kSecClass as String: kSecClassInternetPassword, kSecAttrSecurityDomain as String: securityDomain, kSecAttrServer as String: hostname, kSecAttrAccount as String: username, kSecValueData as String: passwordEncoded, kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, kSecAttrSynchronizable as String: false, ] if let accessGroup { query[kSecAttrAccessGroup as String] = accessGroup } let status = SecItemAdd(query as CFDictionary, nil) guard status == errSecSuccess else { throw Self.Error.unhandledError(status: status) } } /// Delete a value from the keychain. /// - Parameters: /// - securityDomain: The security domain used to fetch keychain entries. /// - accessGroup: If present, the access group used to fetch keychain entries. /// - hostname: The hostname for the authenticating server. /// - Throws: An error if the keychain query fails or returns unexpected data. public func delete(securityDomain: String, accessGroup: String? = nil, hostname: String) throws { var query: [String: Any] = [ kSecClass as String: kSecClassInternetPassword, kSecAttrSecurityDomain as String: securityDomain, kSecAttrServer as String: hostname, kSecMatchLimit as String: kSecMatchLimitOne, ] if let accessGroup { query[kSecAttrAccessGroup as String] = accessGroup } let status = SecItemDelete(query as CFDictionary) guard status == errSecSuccess || status == errSecItemNotFound else { throw Self.Error.unhandledError(status: status) } } /// Retrieve a value from the keychain. /// - Parameters: /// - securityDomain: The security domain used to fetch keychain entries. /// - accessGroup: If present, the access group used to fetch keychain entries. /// - hostname: The hostname for the authenticating server. /// - Returns: The keychain entry. /// - Throws: An error if the keychain query fails or returns unexpected data. public func get(securityDomain: String, accessGroup: String? = nil, hostname: String) throws -> KeychainQueryResult? { var query: [String: Any] = [ kSecClass as String: kSecClassInternetPassword, kSecAttrSecurityDomain as String: securityDomain, kSecAttrServer as String: hostname, kSecReturnAttributes as String: true, kSecMatchLimit as String: kSecMatchLimitOne, kSecReturnData as String: true, ] if let accessGroup { query[kSecAttrAccessGroup as String] = accessGroup } var item: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &item) let exists = try isQuerySuccessful(status) if !exists { return nil } guard let fetched = item as? [String: Any] else { throw Self.Error.unexpectedDataFetched } guard let data = fetched[kSecValueData as String] as? Data else { throw Self.Error.keyNotPresent(key: kSecValueData as String) } guard let password = String(data: data, encoding: String.Encoding.utf8) else { throw Self.Error.unexpectedDataFetched } guard let username = fetched[kSecAttrAccount as String] as? String else { throw Self.Error.keyNotPresent(key: kSecAttrAccount as String) } guard let modifiedDate = fetched[kSecAttrModificationDate as String] as? Date else { throw Self.Error.keyNotPresent(key: kSecAttrModificationDate as String) } guard let createdDate = fetched[kSecAttrCreationDate as String] as? Date else { throw Self.Error.keyNotPresent(key: kSecAttrCreationDate as String) } return KeychainQueryResult( username: username, password: password, modifiedDate: modifiedDate, createdDate: createdDate ) } /// List all keychain entries for a domain. /// - Parameters: /// - securityDomain: The security domain used to fetch keychain entries. /// - accessGroup: If present, the access group used to fetch keychain entries. /// - Returns: An array of keychain metadata for each matching entry, or an empty array if none are found. /// - Throws: An error if the keychain query fails or returns unexpected data. public func list(securityDomain: String, accessGroup: String? = nil) throws -> [RegistryInfo] { var query: [String: Any] = [ kSecClass as String: kSecClassInternetPassword, kSecAttrSecurityDomain as String: securityDomain, kSecReturnAttributes as String: true, kSecReturnData as String: false, kSecMatchLimit as String: kSecMatchLimitAll, ] if let accessGroup { query[kSecAttrAccessGroup as String] = accessGroup } var item: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &item) let exists = try isQuerySuccessful(status) if !exists { return [] } guard let fetched = item as? [[String: Any]] else { throw Self.Error.unexpectedDataFetched } return try fetched.map { registry in guard let hostname = registry[kSecAttrServer as String] as? String else { throw Self.Error.keyNotPresent(key: kSecAttrServer as String) } guard let username = registry[kSecAttrAccount as String] as? String else { throw Self.Error.keyNotPresent(key: kSecAttrAccount as String) } guard let modifiedDate = registry[kSecAttrModificationDate as String] as? Date else { throw Self.Error.keyNotPresent(key: kSecAttrModificationDate as String) } guard let createdDate = registry[kSecAttrCreationDate as String] as? Date else { throw Self.Error.keyNotPresent(key: kSecAttrCreationDate as String) } return RegistryInfo( hostname: hostname, username: username, modifiedDate: modifiedDate, createdDate: createdDate ) } } /// Check if a value exists in the keychain. /// - Parameters: /// - securityDomain: The security domain used to fetch keychain entries. /// - accessGroup: If present, the access group used to fetch keychain entries. /// - hostname: The hostname for the authenticating server. /// - Returns: `true` if the entry exists, `false` otherwise. /// - Throws: An error if the keychain query fails. public func exists(securityDomain: String, accessGroup: String? = nil, hostname: String) throws -> Bool { var query: [String: Any] = [ kSecClass as String: kSecClassInternetPassword, kSecAttrSecurityDomain as String: securityDomain, kSecAttrServer as String: hostname, kSecReturnAttributes as String: true, kSecMatchLimit as String: kSecMatchLimitOne, kSecReturnData as String: false, ] if let accessGroup { query[kSecAttrAccessGroup as String] = accessGroup } let status = SecItemCopyMatching(query as CFDictionary, nil) return try isQuerySuccessful(status) } private func isQuerySuccessful(_ status: Int32) throws -> Bool { guard status != errSecItemNotFound else { return false } guard status == errSecSuccess else { throw Self.Error.unhandledError(status: status) } return true } } extension KeychainQuery { public enum Error: Swift.Error { case unhandledError(status: Int32) case unexpectedDataFetched case keyNotPresent(key: String) case invalidPasswordConversion } } #endif