mirror of
https://github.com/apple/container.git
synced 2026-10-14 01:59:18 +00:00
[keychain]: add list function + update variable/parameter names (#502)
- Add a `list` function that is needed for the `container registry list` command - Updates to `KeychainHelper` and `KeychainQuery` variable/parameter names - Updates to test cases
This commit is contained in:
1 parent
df5f0f336a
commit
3f4eee7d2a
6 files changed
+171
-60
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user