Files
iOS/Sources/Shared/API/mTLS/ClientCertificate.swift
Bruno Pantaleão Gonçalves bbdcd09309 Allow same mTLS cert for multiple servers (#4603)
<!-- Thank you for submitting a Pull Request and helping to improve Home
Assistant. Please complete the following sections to help the processing
and review of your changes. Please do not delete anything from this
template. -->

## Summary
<!-- Provide a brief summary of the changes you have made and most
importantly what they aim to achieve -->
Fixes mTLS client certificate sharing across multiple servers when the
same .p12 / SecIdentity is assigned to more than one server.

- Store imported client certificate identities using a stable SHA-256
fingerprint of the leaf certificate instead of a server-specific
identifier.
- Update duplicate Keychain identity handling so re-importing the same
certificate migrates the existing item to the stable fingerprint-based
label.
- Avoid deleting a shared Keychain identity when removing or replacing a
certificate on one server if another configured server still references
it.

## Screenshots
<!-- If this is a user-facing change not in the frontend, please include
screenshots in light and dark mode. -->

## Link to pull request in Documentation repository
<!-- Pull requests that add, change or remove functionality must have a
corresponding pull request in the Companion App Documentation repository
(https://github.com/home-assistant/companion.home-assistant). Please add
the number of this pull request after the "#" -->
Documentation: home-assistant/companion.home-assistant#

## Any other notes
<!-- If there is any other information of note, like if this Pull
Request is part of a bigger change, please include it here. -->
2026-05-04 11:58:19 +02:00

364 lines
15 KiB
Swift

import CryptoKit
import Foundation
import Security
/// Represents a client certificate stored in the Keychain for mTLS authentication
public struct ClientCertificate: Codable, Equatable {
/// Unique identifier for the certificate in Keychain
public let keychainIdentifier: String
/// Display name for the certificate (extracted from CN or user-provided)
public let displayName: String
/// Date when the certificate was imported
public let importedAt: Date
/// Certificate expiration date (if available)
public let expiresAt: Date?
public init(keychainIdentifier: String, displayName: String, importedAt: Date = Date(), expiresAt: Date? = nil) {
self.keychainIdentifier = keychainIdentifier
self.displayName = displayName
self.importedAt = importedAt
self.expiresAt = expiresAt
}
/// Check if the certificate is expired
public var isExpired: Bool {
guard let expiresAt else { return false }
return expiresAt < Date()
}
}
#if !os(watchOS)
// MARK: - Keychain Operations
public enum ClientCertificateError: LocalizedError {
case invalidP12Data
case invalidPassword
case keychainError(OSStatus)
case identityNotFound
case certificateNotFound
public var errorDescription: String? {
switch self {
case .invalidP12Data:
return "The certificate file is invalid or corrupted"
case .invalidPassword:
return "The password is incorrect"
case let .keychainError(status):
return "Keychain error: \(status)"
case .identityNotFound:
return "No identity found in the certificate"
case .certificateNotFound:
return "Certificate not found in Keychain"
}
}
}
public final class ClientCertificateManager {
public static let shared = ClientCertificateManager()
private init() {}
static func pkcs12ImportOptions(password: String) -> [String: Any] {
// On macOS, passwordless PKCS#12 imports fail when an explicit empty-string passphrase
// is supplied. Omitting the option lets Security treat the bundle as unprotected.
guard !password.isEmpty else {
return [:]
}
return [
kSecImportExportPassphrase as String: password,
]
}
/// Import a PKCS#12 file into the Keychain
/// - Parameters:
/// - p12Data: The raw .p12 file data
/// - password: The password to decrypt the .p12 file
/// - identifier: A fallback identifier for storing in Keychain if the certificate cannot be fingerprinted
/// - Returns: A ClientCertificate reference
public func importP12(data p12Data: Data, password: String, identifier: String) throws -> ClientCertificate {
let options = Self.pkcs12ImportOptions(password: password)
var items: CFArray?
let status = SecPKCS12Import(p12Data as CFData, options as CFDictionary, &items)
guard status == errSecSuccess else {
if status == errSecAuthFailed {
throw ClientCertificateError.invalidPassword
}
throw ClientCertificateError.keychainError(status)
}
guard let itemsArray = items as? [[String: Any]],
let firstItem = itemsArray.first,
let identity = firstItem[kSecImportItemIdentity as String] else {
throw ClientCertificateError.identityNotFound
}
// Extract certificate from identity to get info
// swiftlint:disable:next force_cast
let secIdentity = identity as! SecIdentity
var certificate: SecCertificate?
SecIdentityCopyCertificate(secIdentity, &certificate)
// Get certificate details
var displayName = "Client Certificate"
var expiresAt: Date?
if let cert = certificate {
if let summary = SecCertificateCopySubjectSummary(cert) as String? {
displayName = summary
}
// Try to get expiration date
if let certData = SecCertificateCopyData(cert) as Data? {
expiresAt = extractExpirationDate(from: certData)
}
}
// Extract intermediate certificates from the chain returned by SecPKCS12Import.
// kSecImportItemCertChain contains all certs in the P12 ordered leaf-first; everything
// after the leaf (index > 0) is an intermediate (or root) that must be sent during the
// TLS handshake so the server can build the trust chain when an intermediate CA is used.
let certChain = firstItem[kSecImportItemCertChain as String] as? [SecCertificate] ?? []
let intermediateCerts = Array(certChain.dropFirst())
// Store identities by certificate fingerprint rather than by server. The iOS Keychain
// treats a certificate/private-key pair as one identity, so labeling the same identity
// with different server IDs makes later imports move the item away from older servers.
let keychainIdentifier = Self.keychainIdentifier(for: certificate, fallbackIdentifier: identifier)
try storeIdentity(secIdentity, identifier: keychainIdentifier)
storeIntermediateCertificates(intermediateCerts, for: keychainIdentifier)
return ClientCertificate(
keychainIdentifier: keychainIdentifier,
displayName: displayName,
importedAt: Date(),
expiresAt: expiresAt
)
}
private static func keychainIdentifier(
for certificate: SecCertificate?,
fallbackIdentifier: String
) -> String {
guard let certificate,
let certData = SecCertificateCopyData(certificate) as Data? else {
return "com.ha-ios.mtls.\(fallbackIdentifier)"
}
let fingerprint = SHA256.hash(data: certData)
.map { String(format: "%02x", $0) }
.joined()
return "com.ha-ios.mtls.identity.\(fingerprint)"
}
/// Store a SecIdentity in the Keychain
private func storeIdentity(_ identity: SecIdentity, identifier: String) throws {
// First, delete any existing identity with this identifier
let deleteQuery: [String: Any] = [
kSecClass as String: kSecClassIdentity,
kSecAttrLabel as String: identifier,
]
SecItemDelete(deleteQuery as CFDictionary)
// Add the new identity
let addQuery: [String: Any] = [
kSecClass as String: kSecClassIdentity,
kSecValueRef as String: identity,
kSecAttrLabel as String: identifier,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
]
let status = SecItemAdd(addQuery as CFDictionary, nil)
// Handle duplicate - the same identity might already exist under a legacy server label.
if status == errSecDuplicateItem {
// Move the existing identity to the certificate-derived label so every server that
// imports the same P12 can resolve the same Keychain item.
let updateQuery: [String: Any] = [
kSecValueRef as String: identity,
]
let updateAttrs: [String: Any] = [
kSecAttrLabel as String: identifier,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
]
let updateStatus = SecItemUpdate(updateQuery as CFDictionary, updateAttrs as CFDictionary)
if updateStatus != errSecSuccess {
Current.Log.error("Failed to update duplicate client certificate identity label: \(updateStatus)")
throw ClientCertificateError.keychainError(updateStatus)
}
return
}
guard status == errSecSuccess else {
throw ClientCertificateError.keychainError(status)
}
}
/// Retrieve a SecIdentity from the Keychain
public func retrieveIdentity(for certificate: ClientCertificate) throws -> SecIdentity {
let query: [String: Any] = [
kSecClass as String: kSecClassIdentity,
kSecAttrLabel as String: certificate.keychainIdentifier,
kSecReturnRef as String: true,
]
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let identity = result else {
throw ClientCertificateError.certificateNotFound
}
// swiftlint:disable:next force_cast
return identity as! SecIdentity
}
/// Create a URLCredential from a stored certificate
public func urlCredential(for certificate: ClientCertificate) throws -> URLCredential {
let identity = try retrieveIdentity(for: certificate)
var certChain: [SecCertificate] = []
// Include the leaf certificate.
var leafCertificate: SecCertificate?
let leafStatus = SecIdentityCopyCertificate(identity, &leafCertificate)
if leafStatus == errSecSuccess, let leafCertificate {
certChain.append(leafCertificate)
}
// Include intermediate certificates so the server can verify the full chain when the
// client certificate was signed by an intermediate CA rather than the root directly.
let intermediates = retrieveIntermediateCertificates(for: certificate.keychainIdentifier)
certChain.append(contentsOf: intermediates)
if !certChain.isEmpty {
return URLCredential(identity: identity, certificates: certChain, persistence: .forSession)
}
Current.Log
.warning(
"Failed to copy client certificate from identity (SecIdentityCopyCertificate status: \(leafStatus)); falling back to identity-only credential"
)
return URLCredential(identity: identity, certificates: nil, persistence: .forSession)
}
// MARK: - Intermediate Certificate Chain
// Service key used to store the ordered intermediate chain as a single generic-password blob.
// Storing as kSecClassGenericPassword (rather than individual kSecClassCertificate items) avoids
// two pitfalls: (1) errSecDuplicateItem when the same intermediate is already in the keychain
// under a different label, and (2) non-deterministic ordering from kSecMatchLimitAll queries.
private static let chainServiceKey = "com.ha-ios.mtls.chain"
/// Persist intermediate (and/or root) certificates from the P12 chain as an ordered DER blob.
///
/// Certs are serialized in their original order so the chain can be reconstructed identically
/// on retrieval, regardless of keychain internals.
private func storeIntermediateCertificates(_ certs: [SecCertificate], for identifier: String) {
// Always delete the existing entry first so re-imports start clean.
deleteIntermediateCertificates(for: identifier)
guard !certs.isEmpty else { return }
let derArray = certs.compactMap { SecCertificateCopyData($0) as Data? }
guard let serialized = try? PropertyListSerialization.data(
fromPropertyList: derArray,
format: .binary,
options: 0
) else {
Current.Log.warning("Failed to serialize intermediate certificate chain")
return
}
let addQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: Self.chainServiceKey,
kSecAttrAccount as String: identifier,
kSecValueData as String: serialized,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
]
let status = SecItemAdd(addQuery as CFDictionary, nil)
if status != errSecSuccess {
Current.Log.warning("Failed to store intermediate certificate chain in keychain: \(status)")
}
}
/// Retrieve the ordered intermediate certificates previously stored for this identity.
private func retrieveIntermediateCertificates(for identifier: String) -> [SecCertificate] {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: Self.chainServiceKey,
kSecAttrAccount as String: identifier,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var result: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
let data = result as? Data,
let derArray = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [Data] else {
return []
}
return derArray.compactMap { SecCertificateCreateWithData(nil, $0 as CFData) }
}
private func deleteIntermediateCertificates(for identifier: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: Self.chainServiceKey,
kSecAttrAccount as String: identifier,
]
SecItemDelete(query as CFDictionary)
}
/// Delete a certificate from the Keychain
public func delete(certificate: ClientCertificate) throws {
let identityQuery: [String: Any] = [
kSecClass as String: kSecClassIdentity,
kSecAttrLabel as String: certificate.keychainIdentifier,
]
let status = SecItemDelete(identityQuery as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw ClientCertificateError.keychainError(status)
}
// Also delete any stored intermediate certificates for this identity.
deleteIntermediateCertificates(for: certificate.keychainIdentifier)
}
/// Extract expiration date from certificate data (simplified)
private func extractExpirationDate(from data: Data) -> Date? {
// This is a simplified implementation
// In production, you might want to use a proper ASN.1 parser
// For now, we'll return nil and handle expiration checking differently
nil
}
}
// MARK: - URLSessionDelegate Extension
public extension ClientCertificateManager {
/// Handle client certificate authentication challenge
func handleClientCertificateChallenge(
_ challenge: URLAuthenticationChallenge,
certificate: ClientCertificate?
) -> (URLSession.AuthChallengeDisposition, URLCredential?) {
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodClientCertificate,
let certificate else {
return (.performDefaultHandling, nil)
}
do {
let credential = try urlCredential(for: certificate)
return (.useCredential, credential)
} catch {
Current.Log.error("Failed to get credential for client certificate: \(error)")
return (.cancelAuthenticationChallenge, nil)
}
}
}
#endif