Add keychain and cert functions

This commit is contained in:
Greg Neagle
2025-03-16 15:19:32 -07:00
parent f0c843c23a
commit 82a97f36a7
2 changed files with 480 additions and 0 deletions
@@ -283,6 +283,8 @@
C0BF62E82CEC11B10030885D /* reports.swift in Sources */ = {isa = PBXBuildFile; fileRef = C07074DE2C33B9A000B86310 /* reports.swift */; };
C0BF62E92CEC11CB0030885D /* prefs.swift in Sources */ = {isa = PBXBuildFile; fileRef = C07A6FAF2C2B22A400090743 /* prefs.swift */; };
C0BF62EA2CEC11EB0030885D /* plistutils.swift in Sources */ = {isa = PBXBuildFile; fileRef = C01364422C2DD1BA008DB215 /* plistutils.swift */; };
C0C4091D2D86549600704005 /* keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0C4091C2D86548800704005 /* keychain.swift */; };
C0C4091E2D86549600704005 /* keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0C4091C2D86548800704005 /* keychain.swift */; };
C0D00FA12C457E2B0021DA9C /* munkihash.swift in Sources */ = {isa = PBXBuildFile; fileRef = C030A98E2C39C135007F0B34 /* munkihash.swift */; };
C0D00FA22C457E4E0021DA9C /* display.swift in Sources */ = {isa = PBXBuildFile; fileRef = C01364572C3265D6008DB215 /* display.swift */; };
C0D00FA32C457E5F0021DA9C /* fileutils.swift in Sources */ = {isa = PBXBuildFile; fileRef = C030A9B52C3DF6D0007F0B34 /* fileutils.swift */; };
@@ -581,6 +583,7 @@
C0B7FA002D288C2700CC14F0 /* authrestart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = authrestart.swift; sourceTree = "<group>"; };
C0BF62CD2CEC00E90030885D /* repoclean */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = repoclean; sourceTree = BUILT_PRODUCTS_DIR; };
C0BF62CF2CEC00E90030885D /* repoclean.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = repoclean.swift; sourceTree = "<group>"; };
C0C4091C2D86548800704005 /* keychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = keychain.swift; sourceTree = "<group>"; };
C0D00FA72C45814F0021DA9C /* repoutils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = repoutils.swift; sourceTree = "<group>"; };
C0D00FAF2C458EAA0021DA9C /* version.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = version.swift; sourceTree = "<group>"; };
C0D00FB52C45BCB90021DA9C /* errors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = errors.swift; sourceTree = "<group>"; };
@@ -869,6 +872,7 @@
C07A6FD02C2B631800090743 /* shared */ = {
isa = PBXGroup;
children = (
C0C4091C2D86548800704005 /* keychain.swift */,
C008A0502D18BB780073ADBA /* launchd.swift */,
C0011CB02C7A99D00004ED70 /* headers */,
C07AED6D2C67DF4F00DE6119 /* network */,
@@ -1522,6 +1526,7 @@
C030A9B22C3DB88E007F0B34 /* osinstaller.swift in Sources */,
C01364542C321FE7008DB215 /* dmgutils.swift in Sources */,
C030A9B62C3DF6D0007F0B34 /* fileutils.swift in Sources */,
C0C4091D2D86549600704005 /* keychain.swift in Sources */,
C0D66AB92D2C923E009EF807 /* common.swift in Sources */,
C01364582C3265D6008DB215 /* display.swift in Sources */,
C07A6FD22C2B654300090743 /* utils.swift in Sources */,
@@ -1594,6 +1599,7 @@
C06C213E2C88CACE0023E9D9 /* SignalHandler.swift in Sources */,
C07074DD2C33AE5F00B86310 /* munkilog.swift in Sources */,
C07A6FBF2C2B5AF400090743 /* constants.swift in Sources */,
C0C4091E2D86549600704005 /* keychain.swift in Sources */,
C030A9902C39C135007F0B34 /* munkihash.swift in Sources */,
C07074E02C33B9A000B86310 /* reports.swift in Sources */,
C07AED6C2C66F56C00DE6119 /* manifests.swift in Sources */,
+474
View File
@@ -0,0 +1,474 @@
//
// keychain.swift
// munki
//
// Created by Greg Neagle on 3/15/25.
//
// Copyright 2025 Greg Neagle.
//
// 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 CryptoKit
import Security
private let DEFAULT_KEYCHAIN_NAME = "munki.keychain"
private let DEFAULT_KEYCHAIN_PASSWORD = "munki"
private let KEYCHAIN_DIRECTORY = managedInstallsDir(subpath: "Keychains") as NSString
/// Read in a base64 pem file, return data of embedded certificate
func pemCertData(_ certPath: String) throws -> Data {
guard let certString = try? String(contentsOfFile: certPath, encoding: .utf8) else {
throw MunkiError("File not decodeable as UTF-8")
}
guard let startIndex = certString.range(of: "-----BEGIN CERTIFICATE-----")?.upperBound,
let endIndex = certString.range(of: "-----END CERTIFICATE-----")?.lowerBound
else {
throw MunkiError("File does not appear to be .pem file")
}
let certDataString = String(certString[startIndex ..< endIndex]).split(separator: "\n").joined()
guard let certData = Data(base64Encoded: String(certDataString)) else {
throw MunkiError("Could not decode cert string as base64")
}
return certData
}
/// Return SHA1 digest for pem certificate at path
func pemCertSha1Digest(_ certPath: String) throws -> String {
let certData = try pemCertData(certPath)
let hashed = Insecure.SHA1.hash(data: certData)
return hashed.compactMap { String(format: "%02x", $0) }.joined().uppercased()
}
/// Attempt to get information we need from Munki's preferences or defaults.
/// Returns a dictionary.
func getMunkiServerCertInfo() -> [String: String] {
var certInfo = [
"ca_cert_path": "",
"ca_dir_path": "",
]
// get server CA cert if it exists so we can verify the Munki server
let default_ca_cert_path = managedInstallsDir(subpath: "certs/ca.pem")
if pathExists(default_ca_cert_path) {
certInfo["ca_cert_path"] = default_ca_cert_path
}
if let ca_path = pref("SoftwareRepoCAPath") as? String {
if pathIsRegularFile(ca_path) {
certInfo["ca_cert_path"] = ca_path
} else if pathIsDirectory(ca_path) {
certInfo["ca_cert_path"] = ""
certInfo["ca_dir_path"] = ca_path
}
}
if let ca_cert_path = pref("SoftwareRepoCACertificate") as? String {
certInfo["ca_cert_path"] = ca_cert_path
}
return certInfo
}
extension String {
// remove a suffix if it exists
func deletingSuffix(_ suffix: String) -> String {
guard hasSuffix(suffix) else { return self }
return String(dropLast(suffix.count))
}
}
/// Attempt to get client cert and key information from Munki's preferences or defaults.
/// Returns a dictionary.
func getMunkiClientCertInfo() -> [String: Any] {
var certInfo = [
"client_cert_path": "",
"client_key_path": "",
"site_urls": [String](),
] as [String: Any]
// should we use a client cert at all?
if pref("UseClientCertificate") as? Bool ?? false {
return certInfo
}
// get client cert if it exists
certInfo["client_cert_path"] = pref("ClientCertificatePath") as? String ?? ""
certInfo["client_key_path"] = pref("ClientKeyPath") as? String ?? ""
if (certInfo["client_cert_path"] as? String ?? "").isEmpty {
for name in ["cert.pem", "client.pem", "munki.pem"] {
let client_cert_path = managedInstallsDir(subpath: "certs/\(name)")
if pathExists(client_cert_path) {
certInfo["client_cert_path"] = client_cert_path
break
}
}
}
// get site urls
var siteUrls = [String]()
for key in ["SoftwareRepoURL", "PackageURL", "CatalogURL",
"ManifestURL", "IconURL", "ClientResourceURL"]
{
if let url = pref(key) as? String {
siteUrls.append(url.deletingSuffix("/") + "/")
}
}
certInfo["site_urls"] = siteUrls
return certInfo
}
/// Returns the common name for the client cert, if any
func getClientCertCommonName() -> String? {
let certInfo = getMunkiClientCertInfo()
if let certPath = certInfo["client_cert_path"] as? String {
if let certData = try? pemCertData(certPath) {
if let cert = SecCertificateCreateWithData(
kCFAllocatorDefault, certData as CFData
) {
var commonName: CFString?
if SecCertificateCopyCommonName(cert, &commonName) == errSecSuccess {
return commonName as String?
}
}
}
}
return nil
}
// MARK: keychain functions
class SecurityError: MunkiError {}
/// Runs the security binary with args. Returns stdout.
/// Raises SecurityError for a non-zero return code
/// This version allows variadic args which look nicer
func security(_ args: String..., environment: [String: String] = [:]) throws -> String {
try security(args, environment: environment)
}
/// Runs the security binary with args. Returns stdout.
/// Raises SecurityError for a non-zero return code
func security(_ args: [String], environment: [String: String] = [:]) throws -> String {
let result = runCLI("/usr/bin/security", arguments: args, environment: environment)
if result.exitcode != 0 {
throw SecurityError(result.error)
}
if !result.output.isEmpty {
return result.output
} else {
return result.error
}
}
/// Returns an absolute path for our Munki keychain
func getKeychainPath() -> String {
var keychainName = pref("KeychainName") as? String ?? DEFAULT_KEYCHAIN_NAME
// We only care about the filename, not the path
// if we have an odd path that appears to be all directory and no
// file name, revert to default filename
keychainName = baseName(keychainName)
if keychainName.isEmpty {
keychainName = DEFAULT_KEYCHAIN_NAME
}
// Correct the filename to include '.keychain' if not already present
if !["keychain", "keychain-db"].contains((keychainName as NSString).pathExtension) {
keychainName = keychainName + ".keychain"
}
return getAbsolutePath(KEYCHAIN_DIRECTORY.appendingPathComponent(keychainName))
}
/// Debugging output for keychain
func debugKeychainOutput() {
do {
displayDebug2("***Keychain search list for common domain***")
try displayDebug2(security("list-keychains", "-d", "common"))
displayDebug2("***Default keychain info***")
try displayDebug2(security("default-keychain", "-d", "common"))
let keychainfile = getKeychainPath()
if pathExists(keychainfile) {
displayDebug2("***Info for \(keychainfile)***")
try displayDebug2(security("show-keychain-info", keychainfile))
}
} catch {
displayError("Error: \(error.localizedDescription)")
}
}
/// Ensure the keychain is in the search path. Returns boolean to indicate if the keychain was added
func addToKeychainList(_ keychainPath: String, environment: [String: String] = [:]) -> Bool {
var addedKeychain = false
guard let output = try? security(
"list-keychains", "-d", "common",
environment: environment
) else { return false }
// Split the output and strip it of whitespace and leading/trailing
// quotes, the result are absolute paths to keychains
// Preserve the order in case we need to append to them
var searchKeychains: [String] = []
var quoteChar = CharacterSet()
quoteChar.insert("\"")
for line in output.split(separator: "\n") {
let trimmedLine = line.trimmingCharacters(in: .whitespaces).trimmingCharacters(in: quoteChar)
if !trimmedLine.isEmpty {
searchKeychains.append(trimmedLine)
}
}
if !searchKeychains.contains(keychainPath) {
// Keychain is not in the search paths, let's add it
displayDebug2("Adding client keychain to search path...")
searchKeychains.append(keychainPath)
do {
let output = try security(
["list-keychains", "-d", "common", "-s"] + searchKeychains,
environment: environment
)
if !output.isEmpty {
displayDebug2(output)
}
addedKeychain = true
} catch {
displayError("Could not add keychain \(keychainPath) to keychain list: \(error.localizedDescription)")
}
}
if loggingLevel() > 2 {
debugKeychainOutput()
}
return addedKeychain
}
/// Remove keychain from the list of keychains
func removeFromKeychainList(_ keychainPath: String, environment: [String: String] = [:]) {
guard let output = try? security("list-keychains", "-d", "common", environment: environment) else {
return
}
// Split the output and strip it of whitespace and leading/trailing
// quotes, the result are absolute paths to keychains
// Preserve the order in case we need to append to them
var searchKeychains: [String] = []
var quoteChar = CharacterSet()
quoteChar.insert("\"")
for line in output.split(separator: "\n") {
let trimmedLine = line.trimmingCharacters(in: .whitespaces).trimmingCharacters(in: quoteChar)
if !trimmedLine.isEmpty {
searchKeychains.append(trimmedLine)
}
}
if searchKeychains.contains(keychainPath) {
// Keychain is in the search path
displayDebug2("Removing \(keychainPath) from search path...")
let filteredKeychains = searchKeychains.filter { $0 != keychainPath }
do {
let output = try security(
["list-keychains", "-d", "common", "-s"] + filteredKeychains,
environment: environment
)
if !output.isEmpty {
displayDebug2(output)
}
} catch {
displayError("Could not remove keychain \(keychainPath) from keychain list: \(error.localizedDescription)")
}
}
if loggingLevel() > 2 {
debugKeychainOutput()
}
}
/// Unlocks the keychain and sets it to non-locking
func unlockAndSetNonLocking(_ keychainPath: String, environment: [String: String] = [:]) {
let keychainPassword = pref("KeychainPassword") as? String ?? DEFAULT_KEYCHAIN_PASSWORD
do {
let output = try security(
"unlock-keychain", "-p", keychainPassword, keychainPath,
environment: environment
)
if !output.isEmpty {
displayDebug2(output)
}
} catch {
// some problem unlocking the keychain
displayError("Could not unlock \(keychainPath): \(error.localizedDescription)")
// just delete the keychain
do {
try FileManager.default.removeItem(atPath: keychainPath)
} catch {
displayError("Could not remove \(keychainPath): \(error.localizedDescription)")
}
return
}
do {
let output = try security(
"set-keychain-settings", keychainPath,
environment: environment
)
if !output.isEmpty {
displayDebug2(output)
}
} catch {
displayError("Could not set keychain settings for \(keychainPath): \(error.localizedDescription)")
}
}
/// Returns true if a client cert exists that we need to import into a keychain
func clientCertExists() -> Bool {
let certInfo = getMunkiClientCertInfo()
let client_cert_path = certInfo["client_cert_path"] as? String ?? ""
return !client_cert_path.isEmpty && pathExists(client_cert_path)
}
/// Builds a client cert keychain from existing client certs
/// If keychain was added to the search list, returns true
func makeClientKeychain(_ certInfo: [String: Any] = [:]) -> Bool {
var certInfo = certInfo
if certInfo.isEmpty {
// grab data from Munki's preferences/defaults
certInfo = getMunkiClientCertInfo()
}
let client_cert_path = certInfo["client_cert_path"] as? String ?? ""
let client_key_path = certInfo["client_key_path"] as? String ?? ""
if client_cert_path.isEmpty {
// no client cert, so nothing to do
displayDebug1("No client cert info provided, so no client keychain will be created.")
return false
} else {
displayDebug1("Client cert path: \(client_cert_path)")
displayDebug1("Client key path: \(client_key_path)")
}
// to do some of the following options correctly, we need to be root
// and have root's home.
// check to see if we're root
if NSUserName().lowercased() != "root" {
displayError("Can't make our client keychain unless we are root!")
return false
}
// make sure HOME has root's home
var env = ProcessInfo.processInfo.environment
env["HOME"] = NSHomeDirectoryForUser("root") ?? "/var/root"
let keychainPassword = pref("KeychainPassword") as? String ?? DEFAULT_KEYCHAIN_PASSWORD
let keychainPath = getKeychainPath()
if pathExists(keychainPath) {
try? FileManager.default.removeItem(atPath: keychainPath)
}
if !pathExists(dirName(keychainPath)) {
let attrs = [
FileAttributeKey.posixPermissions: 0o700,
] as [FileAttributeKey: Any]
try? FileManager.default.createDirectory(atPath: dirName(keychainPath), withIntermediateDirectories: true, attributes: attrs)
}
// create a new keychain
displayDebug1("Creating client keychain...")
do {
let output = try security(
"create-keychain", "-p", keychainPassword, keychainPath,
environment: env
)
if !output.isEmpty {
displayDebug2(output)
}
} catch {
displayError("Could not create keychain \(keychainPath): \(error)")
}
// Ensure the keychain is in the search path and unlocked
let addedKeychain = addToKeychainList(keychainPath, environment: env)
unlockAndSetNonLocking(keychainPath, environment: env)
// Add client cert (and optionally key)
var client_cert_file = ""
var combined_pem = ""
if !client_key_path.isEmpty {
// combine client cert and private key before we import
if let certData = try? Data(contentsOf: URL(fileURLWithPath: client_cert_path)),
let keyData = try? Data(contentsOf: URL(fileURLWithPath: client_key_path)),
let tempDir = TempDir.shared.path
{
// write the combined data
combined_pem = (tempDir as NSString).appendingPathComponent("combined.pem")
let combinedData = certData + keyData
do {
try combinedData.write(to: URL(fileURLWithPath: combined_pem))
client_cert_file = combined_pem
} catch {
displayError("Could not combine client cert and key for import!")
}
} else {
displayError("Could not read client cert or key file")
}
} else {
client_cert_file = client_cert_path
}
if !client_cert_file.isEmpty {
// client_cert_file is combined_pem or client_cert_file
displayDebug2("Importing client cert and key...")
do {
let output = try security(
"import", client_cert_file, "-A", "-k", keychainPath,
environment: env
)
if !output.isEmpty {
displayDebug2(output)
}
} catch {
displayError("Could not import \(client_cert_file): \(error.localizedDescription)")
}
}
if !combined_pem.isEmpty {
// we created this; we should clean it up
try? FileManager.default.removeItem(atPath: combined_pem)
}
// we're done
// if addedKeychain {
// removeFromKeychainList(keychainPath, environment: env)
// }
displayInfo("Completed creation of client keychain at \(keychainPath)")
return addedKeychain
}
/// Wrapper class for handling the client keychain
class MunkiKeychain {
var keychainPath = ""
var addedKeychain = false
/// Unlocks the munki.keychain if it exists.
/// Makes sure the munki.keychain is in the search list.
/// Creates a new client keychain if needed.
init() {
keychainPath = getKeychainPath()
if clientCertExists(), pathExists(keychainPath) {
do {
try FileManager.default.removeItem(atPath: keychainPath)
} catch {
displayError("Could not remove pre-existing \(keychainPath): \(error.localizedDescription)")
}
}
if pathExists(keychainPath) {
// ensure existing keychain is available for use
addedKeychain = addToKeychainList(keychainPath)
unlockAndSetNonLocking(keychainPath)
}
if !pathExists(keychainPath) {
// try making a new keychain
addedKeychain = makeClientKeychain()
}
if !pathExists(keychainPath) {
// give up
keychainPath = ""
addedKeychain = false
}
}
deinit {
// Remove our keychain from the keychain list if we added it
if addedKeychain {
removeFromKeychainList(keychainPath)
}
}
}