2026-06-21 20:22:21 -07:00
|
|
|
//===----------------------------------------------------------------------===//
|
|
|
|
|
// 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
|
2026-07-17 16:03:36 -07:00
|
|
|
import Security // [Nucleic vendored patch] SecKeychain*UserInteractionAllowed for prompt-free reads
|
2026-06-21 20:22:21 -07:00
|
|
|
|
2026-07-17 22:21:12 -07:00
|
|
|
// [Nucleic vendored patch] `SecKeychainGet/SetUserInteractionAllowed` are formally deprecated but
|
|
|
|
|
// remain the ONLY API that suppresses the legacy Keychain ACL panel. Bind the C symbols directly —
|
|
|
|
|
// the deprecation rides on their Swift imports, not the raw symbols — so `withoutInteractiveUI`
|
|
|
|
|
// compiles without deprecation warnings. Mirrors `KeychainOwnedAccess` in NucleicCore.
|
|
|
|
|
@_silgen_name("SecKeychainGetUserInteractionAllowed")
|
|
|
|
|
private func nucleic_SecKeychainGetUserInteractionAllowed(_ state: UnsafeMutablePointer<DarwinBoolean>) -> OSStatus
|
|
|
|
|
@_silgen_name("SecKeychainSetUserInteractionAllowed")
|
|
|
|
|
private func nucleic_SecKeychainSetUserInteractionAllowed(_ state: DarwinBoolean) -> OSStatus
|
|
|
|
|
|
2026-06-21 20:22:21 -07:00
|
|
|
/// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 16:03:36 -07:00
|
|
|
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)
|
|
|
|
|
}
|
2026-06-21 20:22:21 -07:00
|
|
|
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?
|
2026-07-17 16:03:36 -07:00
|
|
|
let status = Self.withoutInteractiveUI { SecItemCopyMatching(query as CFDictionary, &item) }
|
2026-06-21 20:22:21 -07:00
|
|
|
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?
|
2026-07-17 16:03:36 -07:00
|
|
|
let status = Self.withoutInteractiveUI { SecItemCopyMatching(query as CFDictionary, &item) }
|
2026-06-21 20:22:21 -07:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 16:03:36 -07:00
|
|
|
let status = Self.withoutInteractiveUI { SecItemCopyMatching(query as CFDictionary, nil) }
|
2026-06-21 20:22:21 -07:00
|
|
|
return try isQuerySuccessful(status)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func isQuerySuccessful(_ status: Int32) throws -> Bool {
|
2026-07-17 16:03:36 -07:00
|
|
|
// [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 {
|
2026-06-21 20:22:21 -07:00
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
guard status == errSecSuccess else {
|
|
|
|
|
throw Self.Error.unhandledError(status: status)
|
|
|
|
|
}
|
|
|
|
|
return true
|
|
|
|
|
}
|
2026-07-17 16:03:36 -07:00
|
|
|
|
|
|
|
|
/// [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 "<app> 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<T>(_ body: () -> T) -> T {
|
|
|
|
|
var previous = DarwinBoolean(true)
|
2026-07-17 22:21:12 -07:00
|
|
|
_ = nucleic_SecKeychainGetUserInteractionAllowed(&previous)
|
|
|
|
|
_ = nucleic_SecKeychainSetUserInteractionAllowed(false)
|
|
|
|
|
defer { _ = nucleic_SecKeychainSetUserInteractionAllowed(previous) }
|
2026-07-17 16:03:36 -07:00
|
|
|
return body()
|
|
|
|
|
}
|
2026-06-21 20:22:21 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
extension KeychainQuery {
|
|
|
|
|
public enum Error: Swift.Error {
|
|
|
|
|
case unhandledError(status: Int32)
|
|
|
|
|
case unexpectedDataFetched
|
|
|
|
|
case keyNotPresent(key: String)
|
|
|
|
|
case invalidPasswordConversion
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
#endif
|