From eff935e35f85f9fc406576571e732b8a45aca65f Mon Sep 17 00:00:00 2001 From: J Logan Date: Wed, 4 Mar 2026 16:48:27 -0800 Subject: [PATCH] Adds access group to keychain helper APIs. (#553) - Will be used by the fix for apple/container#1253. - We use a common keychain ID between the `container CLI and `container-core-images`, but right now keychain entries created by the CLI must be opted-in for `container-core-images`. - Using an access group with a notarized application should give both apps automatic access to the keychain. --- Package.swift | 4 + .../Client/KeychainHelper.swift | 42 ++++++- .../Keychain/KeychainQuery.swift | 104 +++++++++++++----- 3 files changed, 116 insertions(+), 34 deletions(-) diff --git a/Package.swift b/Package.swift index d3e1c7db..fe86d844 100644 --- a/Package.swift +++ b/Package.swift @@ -76,6 +76,10 @@ let package = Package( .product(name: "Logging", package: "swift-log"), .product(name: "ArgumentParser", package: "swift-argument-parser"), "Containerization", + "ContainerizationArchive", + "ContainerizationEXT4", + "ContainerizationExtras", + "ContainerizationOCI", "ContainerizationOS", ] ), diff --git a/Sources/ContainerizationOCI/Client/KeychainHelper.swift b/Sources/ContainerizationOCI/Client/KeychainHelper.swift index 1d966b9f..7d64b8f9 100644 --- a/Sources/ContainerizationOCI/Client/KeychainHelper.swift +++ b/Sources/ContainerizationOCI/Client/KeychainHelper.swift @@ -21,16 +21,32 @@ import ContainerizationOS /// Helper type to lookup registry related values in the macOS keychain. public struct KeychainHelper: Sendable { private let securityDomain: String - public init(securityDomain: String) { + private let accessGroup: String? + + /// Create a new keychain helper. + /// - Parameters: + /// - securityDomain: The security domain used to fetch registry entries in the keychain. + /// - accessGroup: If present, the access group used to fetch registry entries in the keychain. + public init(securityDomain: String, accessGroup: String? = nil) { self.securityDomain = securityDomain + self.accessGroup = accessGroup } - /// Lookup authorization data for a given registry hostname. + /// Lookup authentication data for a given registry hostname. + /// - Parameters: + /// - hostname: The hostname for the registry. + /// - Returns: The authentication object for the registry. + /// - Throws: An error if the keychain query fails. public func lookup(hostname: String) throws -> Authentication { let kq = KeychainQuery() do { - guard let fetched = try kq.get(securityDomain: self.securityDomain, hostname: hostname) else { + guard + let fetched = try kq.get( + securityDomain: self.securityDomain, + accessGroup: self.accessGroup, + hostname: hostname) + else { throw Self.Error.keyNotFound } return BasicAuthentication( @@ -52,19 +68,33 @@ public struct KeychainHelper: Sendable { /// - Throws: An error if the keychain query fails. public func list() throws -> [RegistryInfo] { let kq = KeychainQuery() - return try kq.list(securityDomain: self.securityDomain) + return try kq.list(securityDomain: self.securityDomain, accessGroup: self.accessGroup) } /// Delete authorization data for a given hostname from the keychain. + /// - Parameters: + /// - hostname: The hostname for the registry. + /// - Throws: An error if the keychain query fails. public func delete(hostname: String) throws { let kq = KeychainQuery() - try kq.delete(securityDomain: self.securityDomain, hostname: hostname) + try kq.delete(securityDomain: self.securityDomain, accessGroup: self.accessGroup, hostname: hostname) } /// Save authorization data for a given hostname to the keychain. + /// - Parameters: + /// - hostname: The hostname for the registry. + /// - username: The username to present to the registry. + /// - password: The password to present to the registry. + /// - Throws: An error if the keychain query fails or returns unexpected data. public func save(hostname: String, username: String, password: String) throws { let kq = KeychainQuery() - try kq.save(securityDomain: self.securityDomain, hostname: hostname, username: username, password: password) + try kq.save( + securityDomain: self.securityDomain, + accessGroup: self.accessGroup, + hostname: hostname, + username: username, + password: password + ) } /// Prompt for authorization data for a given hostname to be saved to the keychain. diff --git a/Sources/ContainerizationOS/Keychain/KeychainQuery.swift b/Sources/ContainerizationOS/Keychain/KeychainQuery.swift index f873b419..fab385c1 100644 --- a/Sources/ContainerizationOS/Keychain/KeychainQuery.swift +++ b/Sources/ContainerizationOS/Keychain/KeychainQuery.swift @@ -30,15 +30,28 @@ public struct KeychainQuery { public init() {} /// Save a value to the keychain. - public func save(securityDomain: String, hostname: String, username: String, password: String) throws { - if try exists(securityDomain: securityDomain, hostname: hostname) { - try delete(securityDomain: securityDomain, hostname: hostname) + /// - 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 } - let query: [String: Any] = [ + var query: [String: Any] = [ kSecClass as String: kSecClassInternetPassword, kSecAttrSecurityDomain as String: securityDomain, kSecAttrServer as String: hostname, @@ -47,18 +60,30 @@ public struct KeychainQuery { 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. - public func delete(securityDomain: String, hostname: String) throws { - let query: [String: Any] = [ + /// - 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) @@ -66,8 +91,14 @@ public struct KeychainQuery { } /// Retrieve a value from the keychain. - public func get(securityDomain: String, hostname: String) throws -> KeychainQueryResult? { - let query: [String: Any] = [ + /// - 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, @@ -75,6 +106,9 @@ public struct KeychainQuery { 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) @@ -108,18 +142,23 @@ public struct KeychainQuery { ) } - /// List all registry entries in the keychain for a domain. - /// - Parameter securityDomain: The security domain used to fetch registry entries in the keychain. - /// - Returns: An array of registry metadata for each matching entry, or an empty array if none are found. + /// 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) throws -> [RegistryInfo] { - let query: [String: Any] = [ + 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) @@ -154,6 +193,30 @@ public struct KeychainQuery { } } + /// 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 @@ -163,21 +226,6 @@ public struct KeychainQuery { } return true } - - /// Check if a value exists in the keychain. - public func exists(securityDomain: String, hostname: String) throws -> Bool { - let 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, - ] - - let status = SecItemCopyMatching(query as CFDictionary, nil) - return try isQuerySuccessful(status) - } } extension KeychainQuery {