diff --git a/Sources/ContainerizationOCI/Client/KeychainHelper.swift b/Sources/ContainerizationOCI/Client/KeychainHelper.swift index 63970e92..1d966b9f 100644 --- a/Sources/ContainerizationOCI/Client/KeychainHelper.swift +++ b/Sources/ContainerizationOCI/Client/KeychainHelper.swift @@ -20,22 +20,22 @@ import ContainerizationOS /// Helper type to lookup registry related values in the macOS keychain. public struct KeychainHelper: Sendable { - private let id: String - public init(id: String) { - self.id = id + private let securityDomain: String + public init(securityDomain: String) { + self.securityDomain = securityDomain } - /// Lookup authorization data for a given registry domain. - public func lookup(domain: String) throws -> Authentication { + /// Lookup authorization data for a given registry hostname. + public func lookup(hostname: String) throws -> Authentication { let kq = KeychainQuery() do { - guard let fetched = try kq.get(id: self.id, host: domain) else { + guard let fetched = try kq.get(securityDomain: self.securityDomain, hostname: hostname) else { throw Self.Error.keyNotFound } return BasicAuthentication( - username: fetched.account, - password: fetched.data + username: fetched.username, + password: fetched.password ) } catch let err as KeychainQuery.Error { switch err { @@ -47,30 +47,38 @@ public struct KeychainHelper: Sendable { } } - /// Delete authorization data for a given domain from the keychain. - public func delete(domain: String) throws { + /// Lists all registry entries for this security domain. + /// - Returns: An array of registry metadata for each matching entry, or an empty array if none are found. + /// - Throws: An error if the keychain query fails. + public func list() throws -> [RegistryInfo] { let kq = KeychainQuery() - try kq.delete(id: self.id, host: domain) + return try kq.list(securityDomain: self.securityDomain) } - /// Save authorization data for a given domain to the keychain. - public func save(domain: String, username: String, password: String) throws { + /// Delete authorization data for a given hostname from the keychain. + public func delete(hostname: String) throws { let kq = KeychainQuery() - try kq.save(id: self.id, host: domain, user: username, token: password) + try kq.delete(securityDomain: self.securityDomain, hostname: hostname) } - /// Prompt for authorization data for a given domain to be saved to the keychain. + /// Save authorization data for a given hostname to the keychain. + 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) + } + + /// Prompt for authorization data for a given hostname to be saved to the keychain. /// This will cause the current terminal to enter a password prompt state where /// key strokes are hidden. - public func credentialPrompt(domain: String) throws -> Authentication { - let username = try userPrompt(domain: domain) + public func credentialPrompt(hostname: String) throws -> Authentication { + let username = try userPrompt(hostname: hostname) let password = try passwordPrompt() return BasicAuthentication(username: username, password: password) } /// Prompts the current stdin for a username entry and then returns the value. - public func userPrompt(domain: String) throws -> String { - print("Provide registry username \(domain): ", terminator: "") + public func userPrompt(hostname: String) throws -> String { + print("Provide registry username \(hostname): ", terminator: "") guard let username = readLine() else { throw Self.Error.invalidInput } diff --git a/Sources/ContainerizationOS/Keychain/KeychainQuery.swift b/Sources/ContainerizationOS/Keychain/KeychainQuery.swift index 0cdf6b15..f873b419 100644 --- a/Sources/ContainerizationOS/Keychain/KeychainQuery.swift +++ b/Sources/ContainerizationOS/Keychain/KeychainQuery.swift @@ -19,8 +19,8 @@ import Foundation /// Holds the result of a query to the keychain. public struct KeychainQueryResult { - public var account: String - public var data: String + public var username: String + public var password: String public var modifiedDate: Date public var createdDate: Date } @@ -30,20 +30,20 @@ public struct KeychainQuery { public init() {} /// Save a value to the keychain. - public func save(id: String, host: String, user: String, token: String) throws { - if try exists(id: id, host: host) { - try delete(id: id, host: host) + 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) } - guard let tokenEncoded = token.data(using: String.Encoding.utf8) else { - throw Self.Error.invalidTokenConversion + guard let passwordEncoded = password.data(using: String.Encoding.utf8) else { + throw Self.Error.invalidPasswordConversion } let query: [String: Any] = [ kSecClass as String: kSecClassInternetPassword, - kSecAttrSecurityDomain as String: id, - kSecAttrServer as String: host, - kSecAttrAccount as String: user, - kSecValueData as String: tokenEncoded, + kSecAttrSecurityDomain as String: securityDomain, + kSecAttrServer as String: hostname, + kSecAttrAccount as String: username, + kSecValueData as String: passwordEncoded, kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, kSecAttrSynchronizable as String: false, ] @@ -52,11 +52,11 @@ public struct KeychainQuery { } /// Delete a value from the keychain. - public func delete(id: String, host: String) throws { + public func delete(securityDomain: String, hostname: String) throws { let query: [String: Any] = [ kSecClass as String: kSecClassInternetPassword, - kSecAttrSecurityDomain as String: id, - kSecAttrServer as String: host, + kSecAttrSecurityDomain as String: securityDomain, + kSecAttrServer as String: hostname, kSecMatchLimit as String: kSecMatchLimitOne, ] let status = SecItemDelete(query as CFDictionary) @@ -66,11 +66,11 @@ public struct KeychainQuery { } /// Retrieve a value from the keychain. - public func get(id: String, host: String) throws -> KeychainQueryResult? { + public func get(securityDomain: String, hostname: String) throws -> KeychainQueryResult? { let query: [String: Any] = [ kSecClass as String: kSecClassInternetPassword, - kSecAttrSecurityDomain as String: id, - kSecAttrServer as String: host, + kSecAttrSecurityDomain as String: securityDomain, + kSecAttrServer as String: hostname, kSecReturnAttributes as String: true, kSecMatchLimit as String: kSecMatchLimitOne, kSecReturnData as String: true, @@ -88,10 +88,10 @@ public struct KeychainQuery { guard let data = fetched[kSecValueData as String] as? Data else { throw Self.Error.keyNotPresent(key: kSecValueData as String) } - guard let decodedData = String(data: data, encoding: String.Encoding.utf8) else { + guard let password = String(data: data, encoding: String.Encoding.utf8) else { throw Self.Error.unexpectedDataFetched } - guard let account = fetched[kSecAttrAccount as String] as? String else { + 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 { @@ -101,13 +101,59 @@ public struct KeychainQuery { throw Self.Error.keyNotPresent(key: kSecAttrCreationDate as String) } return KeychainQueryResult( - account: account, - data: decodedData, + username: username, + password: password, modifiedDate: modifiedDate, createdDate: createdDate ) } + /// 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. + /// - Throws: An error if the keychain query fails or returns unexpected data. + public func list(securityDomain: String) throws -> [RegistryInfo] { + let query: [String: Any] = [ + kSecClass as String: kSecClassInternetPassword, + kSecAttrSecurityDomain as String: securityDomain, + kSecReturnAttributes as String: true, + kSecReturnData as String: false, + kSecMatchLimit as String: kSecMatchLimitAll, + ] + 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 + ) + } + } + private func isQuerySuccessful(_ status: Int32) throws -> Bool { guard status != errSecItemNotFound else { return false @@ -119,11 +165,11 @@ public struct KeychainQuery { } /// Check if a value exists in the keychain. - public func exists(id: String, host: String) throws -> Bool { + public func exists(securityDomain: String, hostname: String) throws -> Bool { let query: [String: Any] = [ kSecClass as String: kSecClassInternetPassword, - kSecAttrSecurityDomain as String: id, - kSecAttrServer as String: host, + kSecAttrSecurityDomain as String: securityDomain, + kSecAttrServer as String: hostname, kSecReturnAttributes as String: true, kSecMatchLimit as String: kSecMatchLimitOne, kSecReturnData as String: false, @@ -139,7 +185,7 @@ extension KeychainQuery { case unhandledError(status: Int32) case unexpectedDataFetched case keyNotPresent(key: String) - case invalidTokenConversion + case invalidPasswordConversion } } #endif diff --git a/Sources/ContainerizationOS/Keychain/RegistryInfo.swift b/Sources/ContainerizationOS/Keychain/RegistryInfo.swift new file mode 100644 index 00000000..12dd67d9 --- /dev/null +++ b/Sources/ContainerizationOS/Keychain/RegistryInfo.swift @@ -0,0 +1,29 @@ +//===----------------------------------------------------------------------===// +// Copyright © 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. +//===----------------------------------------------------------------------===// + +import Foundation + +/// Holds the stored attributes for a registry. +public struct RegistryInfo: Sendable { + /// The registry host as a domain name with an optional port. + public var hostname: String + /// The username used to authenticate with the registry. + public var username: String + /// The date the registry was last modified. + public let modifiedDate: Date + /// The date the registry was created. + public let createdDate: Date +} diff --git a/Sources/cctl/ImageCommand.swift b/Sources/cctl/ImageCommand.swift index 4bbb2dfb..42712205 100644 --- a/Sources/cctl/ImageCommand.swift +++ b/Sources/cctl/ImageCommand.swift @@ -275,8 +275,8 @@ extension Application { if let authentication { return try await body(authentication) } - let keychain = KeychainHelper(id: Application.keychainID) - authentication = try? keychain.lookup(domain: host) + let keychain = KeychainHelper(securityDomain: Application.keychainID) + authentication = try? keychain.lookup(hostname: host) return try await body(authentication) } diff --git a/Sources/cctl/LoginCommand.swift b/Sources/cctl/LoginCommand.swift index afe3c7bb..5737d5a1 100644 --- a/Sources/cctl/LoginCommand.swift +++ b/Sources/cctl/LoginCommand.swift @@ -54,9 +54,9 @@ extension Application { } password = String(decoding: passwordData, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) } - let keychain = KeychainHelper(id: Application.keychainID) + let keychain = KeychainHelper(securityDomain: Application.keychainID) if username == "" { - username = try keychain.userPrompt(domain: server) + username = try keychain.userPrompt(hostname: server) } if password == "" { password = try keychain.passwordPrompt() @@ -79,7 +79,7 @@ extension Application { tlsConfiguration: TLSUtils.makeEnvironmentAwareTLSConfiguration(), ) try await client.ping() - try keychain.save(domain: server, username: username, password: password) + try keychain.save(hostname: server, username: username, password: password) print("Login succeeded") } } diff --git a/Tests/ContainerizationOSTests/KeychainQueryTests.swift b/Tests/ContainerizationOSTests/KeychainQueryTests.swift index 3b209f73..54da7a52 100644 --- a/Tests/ContainerizationOSTests/KeychainQueryTests.swift +++ b/Tests/ContainerizationOSTests/KeychainQueryTests.swift @@ -14,32 +14,60 @@ // limitations under the License. //===----------------------------------------------------------------------===// -// - import Foundation import Testing @testable import ContainerizationOS struct KeychainQueryTests { - let id = "com.example.container-testing-keychain" - let domain = "testing-keychain.example.com" - let user = "containerization-test" + let securityDomain = "com.example.container-testing-keychain" + let hostname = "testing-keychain.example.com" + let username = "containerization-test" let kq = KeychainQuery() @Test(.enabled(if: !isCI)) func keychainQuery() throws { - defer { try? kq.delete(id: id, host: domain) } + defer { try? kq.delete(securityDomain: securityDomain, hostname: hostname) } do { - try kq.save(id: id, host: domain, user: user, token: "foobar") - #expect(try kq.exists(id: id, host: domain)) + try kq.save(securityDomain: securityDomain, hostname: hostname, username: username, password: "foobar") + #expect(try kq.exists(securityDomain: securityDomain, hostname: hostname)) - let fetched = try kq.get(id: id, host: domain) + let fetched = try kq.get(securityDomain: securityDomain, hostname: hostname) let result = try #require(fetched) - #expect(result.account == user) - #expect(result.data == "foobar") + #expect(result.username == username) + #expect(result.password == "foobar") + } catch KeychainQuery.Error.unhandledError(status: -25308) { + // ignore errSecInteractionNotAllowed + } + } + + @Test(.enabled(if: !isCI)) + func list() throws { + let hostname1 = "testing-1-keychain.example.com" + let hostname2 = "testing-2-keychain.example.com" + + defer { + try? kq.delete(securityDomain: securityDomain, hostname: hostname1) + try? kq.delete(securityDomain: securityDomain, hostname: hostname2) + } + + do { + try kq.save(securityDomain: securityDomain, hostname: hostname1, username: username, password: "foobar") + try kq.save(securityDomain: securityDomain, hostname: hostname2, username: username, password: "foobar") + + let entries = try kq.list(securityDomain: securityDomain) + + // Verify that both hostnames exist + let hostnames = entries.map { $0.hostname } + #expect(hostnames.contains(hostname1)) + #expect(hostnames.contains(hostname2)) + + // Verify that the accounts exist + for entry in entries { + #expect(entry.username == username) + } } catch KeychainQuery.Error.unhandledError(status: -25308) { // ignore errSecInteractionNotAllowed }