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.
This commit is contained in:
J Logan authored and GitHub committed 2026-03-04 16:48:27 -08:00
1 parent 6f9ae02933
commit eff935e35f
3 files changed
+116 -34

No files matched your search

+4
View File
@@ -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",
]
),
@@ -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.
@@ -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 {