//===----------------------------------------------------------------------===// // 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 import Security // [Nucleic vendored patch] SecKeychain*UserInteractionAllowed for prompt-free reads /// 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 } var status = SecItemAdd(query as CFDictionary, nil) // [Nucleic vendored patch] `exists` above no longer prompts, so it can under-report an // item this process isn't trusted to read; an explicit `login` re-save then hits // `errSecDuplicateItem`. Deleting (no read authorization required) and retrying once keeps // re-login working without ever raising a Keychain panel. if status == errSecDuplicateItem { try delete(securityDomain: securityDomain, accessGroup: accessGroup, hostname: hostname) 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 = Self.withoutInteractiveUI { 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 = Self.withoutInteractiveUI { 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 = Self.withoutInteractiveUI { SecItemCopyMatching(query as CFDictionary, nil) } return try isQuerySuccessful(status) } private func isQuerySuccessful(_ status: Int32) throws -> Bool { // [Nucleic vendored patch] With interactive UI suppressed (see `withoutInteractiveUI`), a // matching item the caller isn't trusted to read returns `errSecInteractionNotAllowed` // instead of prompting. Treat it like "not found" so registry lookups degrade to anonymous // / env-var auth rather than surfacing an error — and never raise a Keychain panel. guard status != errSecItemNotFound, status != errSecInteractionNotAllowed else { return false } guard status == errSecSuccess else { throw Self.Error.unhandledError(status: status) } return true } /// [Nucleic vendored patch] Run `body` with the legacy login Keychain's interactive /// authorization panel disabled, restoring the prior setting afterward. /// /// Nucleic requires that no automatic registry-credential lookup (image pull / push / list, via /// `KeychainHelper`) can ever raise the macOS " wants to use your confidential information /// stored in 'ghcr.io' in your keychain" panel. That panel appears whenever a process that isn't /// on an internet-password item's ACL reads it — e.g. a `cctl` binary re-signed ad-hoc by a fresh /// `make vminit-image` reading a token an earlier build stored. The data-protection /// `kSecUseAuthenticationUI*` flags do NOT govern that legacy ACL/partition-list dialog; the only /// switch that does is `SecKeychainSetUserInteractionAllowed(false)`, which makes a read that /// would otherwise prompt fail with `errSecInteractionNotAllowed`. An already-trusted item still /// reads silently; anything else fails silently and the caller falls back to anonymous / env-var /// auth. `SecKeychain*` is deprecated but remains the only API covering this panel. Mirrors /// `KeychainOwnedAccess.withoutLegacyKeychainUI` in NucleicCore. private static func withoutInteractiveUI(_ body: () -> T) -> T { var previous = DarwinBoolean(true) SecKeychainGetUserInteractionAllowed(&previous) SecKeychainSetUserInteractionAllowed(false) defer { SecKeychainSetUserInteractionAllowed(previous.boolValue) } return body() } } extension KeychainQuery { public enum Error: Swift.Error { case unhandledError(status: Int32) case unexpectedDataFetched case keyNotPresent(key: String) case invalidPasswordConversion } } #endif