mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
589 lines
19 KiB
Swift
589 lines
19 KiB
Swift
//
|
|
// TAKCertificateManager.swift
|
|
// Meshtastic
|
|
//
|
|
// Created by niccellular 12/26/25
|
|
//
|
|
|
|
import Foundation
|
|
import Security
|
|
import OSLog
|
|
|
|
/// Manages TLS certificates for the TAK server
|
|
/// Handles server identity (PKCS#12) and client CA certificates (PEM)
|
|
final class TAKCertificateManager {
|
|
|
|
static let shared = TAKCertificateManager()
|
|
|
|
// Keychain tags for certificate storage
|
|
private let serverIdentityTag = "com.meshtastic.tak.server.identity"
|
|
private let serverIdentityCustomTag = "com.meshtastic.tak.server.identity.custom"
|
|
private let clientCATag = "com.meshtastic.tak.client.ca"
|
|
|
|
// Bundled certificate password
|
|
private let bundledPassword = "meshtastic"
|
|
|
|
// Storage keys for custom P12 data (for data package generation)
|
|
private let customServerP12DataKey = "tak.custom.server.p12.data"
|
|
private let customServerP12PasswordKey = "tak.custom.server.p12.password"
|
|
private let customClientP12DataKey = "tak.custom.client.p12.data"
|
|
private let customClientP12PasswordKey = "tak.custom.client.p12.password"
|
|
|
|
private init() {
|
|
// Load bundled defaults on first launch if no custom cert exists
|
|
loadBundledDefaultsIfNeeded()
|
|
}
|
|
|
|
/// Force reload all bundled certificates (useful after app update with new certs)
|
|
func reloadBundledCertificates() {
|
|
Logger.tak.info("Reloading bundled certificates...")
|
|
|
|
// Clear custom certificate data
|
|
clearCustomCertificateData()
|
|
|
|
// Delete existing certificates
|
|
deleteServerIdentity()
|
|
deleteClientCACertificates()
|
|
|
|
// Reload bundled defaults
|
|
loadBundledServerIdentity()
|
|
loadBundledClientCA()
|
|
|
|
Logger.tak.info("Bundled certificates reloaded")
|
|
}
|
|
|
|
// MARK: - Bundled Default Certificates
|
|
|
|
/// Load bundled default certificates if no custom certificates are configured
|
|
private func loadBundledDefaultsIfNeeded() {
|
|
// Only load if no custom server identity exists
|
|
if !hasCustomServerCertificate() && getServerIdentity() == nil {
|
|
loadBundledServerIdentity()
|
|
}
|
|
|
|
// Only load if no client CA exists
|
|
if !hasClientCACertificate() {
|
|
loadBundledClientCA()
|
|
}
|
|
}
|
|
|
|
/// Load the bundled server identity (p12)
|
|
private func loadBundledServerIdentity() {
|
|
// Try subdirectory first, then root level (Xcode may flatten folder structure)
|
|
let p12URL = Bundle.main.url(forResource: "server", withExtension: "p12", subdirectory: "Certificates")
|
|
?? Bundle.main.url(forResource: "server", withExtension: "p12")
|
|
|
|
guard let url = p12URL, let p12Data = try? Data(contentsOf: url) else {
|
|
Logger.tak.warning("Bundled server.p12 not found in app bundle")
|
|
return
|
|
}
|
|
|
|
do {
|
|
_ = try importServerIdentity(from: p12Data, password: bundledPassword, isCustom: false)
|
|
Logger.tak.info("Loaded bundled default server certificate")
|
|
} catch {
|
|
Logger.tak.error("Failed to load bundled server certificate: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
/// Load the bundled client CA certificate (pem)
|
|
private func loadBundledClientCA() {
|
|
// Try subdirectory first, then root level (Xcode may flatten folder structure)
|
|
let pemURL = Bundle.main.url(forResource: "ca", withExtension: "pem", subdirectory: "Certificates")
|
|
?? Bundle.main.url(forResource: "ca", withExtension: "pem")
|
|
|
|
guard let url = pemURL, let pemData = try? Data(contentsOf: url) else {
|
|
Logger.tak.warning("Bundled ca.pem not found in app bundle")
|
|
return
|
|
}
|
|
|
|
do {
|
|
_ = try importClientCACertificate(from: pemData)
|
|
Logger.tak.info("Loaded bundled default CA certificate")
|
|
} catch {
|
|
Logger.tak.error("Failed to load bundled CA certificate: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
/// Check if using custom (user-imported) server certificate
|
|
func hasCustomServerCertificate() -> Bool {
|
|
let query: [String: Any] = [
|
|
kSecClass as String: kSecClassIdentity,
|
|
kSecAttrLabel as String: serverIdentityCustomTag,
|
|
kSecReturnRef as String: true
|
|
]
|
|
var item: CFTypeRef?
|
|
return SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess
|
|
}
|
|
|
|
/// Get the bundled CA certificate data for sharing to ITAK
|
|
func getBundledCACertificateData() -> Data? {
|
|
let pemURL = Bundle.main.url(forResource: "ca", withExtension: "pem", subdirectory: "Certificates")
|
|
?? Bundle.main.url(forResource: "ca", withExtension: "pem")
|
|
|
|
guard let url = pemURL, let pemData = try? Data(contentsOf: url) else {
|
|
return nil
|
|
}
|
|
return pemData
|
|
}
|
|
|
|
/// Get URL to bundled CA certificate for sharing
|
|
func getBundledCACertificateURL() -> URL? {
|
|
return Bundle.main.url(forResource: "ca", withExtension: "pem", subdirectory: "Certificates")
|
|
?? Bundle.main.url(forResource: "ca", withExtension: "pem")
|
|
}
|
|
|
|
/// Get the bundled server P12 data for sharing to ITAK (used as truststore)
|
|
func getBundledServerP12Data() -> Data? {
|
|
let p12URL = Bundle.main.url(forResource: "server", withExtension: "p12", subdirectory: "Certificates")
|
|
?? Bundle.main.url(forResource: "server", withExtension: "p12")
|
|
|
|
guard let url = p12URL, let p12Data = try? Data(contentsOf: url) else {
|
|
return nil
|
|
}
|
|
return p12Data
|
|
}
|
|
|
|
/// Get the password for bundled certificates (for data package)
|
|
func getBundledCertificatePassword() -> String {
|
|
return bundledPassword
|
|
}
|
|
|
|
/// Get the bundled client P12 data for sharing to ITAK (for mutual TLS)
|
|
func getBundledClientP12Data() -> Data? {
|
|
let p12URL = Bundle.main.url(forResource: "client", withExtension: "p12", subdirectory: "Certificates")
|
|
?? Bundle.main.url(forResource: "client", withExtension: "p12")
|
|
|
|
guard let url = p12URL, let p12Data = try? Data(contentsOf: url) else {
|
|
return nil
|
|
}
|
|
return p12Data
|
|
}
|
|
|
|
/// Check if a bundled client certificate exists
|
|
func hasBundledClientCertificate() -> Bool {
|
|
return getBundledClientP12Data() != nil
|
|
}
|
|
|
|
// MARK: - Active Certificate Data (for Data Package)
|
|
|
|
/// Get the active server P12 data (custom if available, otherwise bundled)
|
|
/// Used for generating data packages
|
|
func getActiveServerP12Data() -> Data? {
|
|
// Check for custom certificate first
|
|
if hasCustomServerCertificate(),
|
|
let customData = UserDefaults.standard.data(forKey: customServerP12DataKey) {
|
|
Logger.tak.debug("Using custom server P12 for data package")
|
|
return customData
|
|
}
|
|
// Fall back to bundled
|
|
Logger.tak.debug("Using bundled server P12 for data package")
|
|
return getBundledServerP12Data()
|
|
}
|
|
|
|
/// Get the active client P12 data (custom if available, otherwise bundled)
|
|
/// Used for generating data packages
|
|
func getActiveClientP12Data() -> Data? {
|
|
// Check for custom certificate first
|
|
if let customData = UserDefaults.standard.data(forKey: customClientP12DataKey) {
|
|
Logger.tak.debug("Using custom client P12 for data package")
|
|
return customData
|
|
}
|
|
// Fall back to bundled
|
|
Logger.tak.debug("Using bundled client P12 for data package")
|
|
return getBundledClientP12Data()
|
|
}
|
|
|
|
/// Get the password for the active server certificate
|
|
func getActiveServerCertificatePassword() -> String {
|
|
if hasCustomServerCertificate(),
|
|
let customPassword = UserDefaults.standard.string(forKey: customServerP12PasswordKey) {
|
|
return customPassword
|
|
}
|
|
return bundledPassword
|
|
}
|
|
|
|
/// Get the password for the active client certificate
|
|
func getActiveClientCertificatePassword() -> String {
|
|
if let customPassword = UserDefaults.standard.string(forKey: customClientP12PasswordKey) {
|
|
return customPassword
|
|
}
|
|
return bundledPassword
|
|
}
|
|
|
|
/// Import a custom client P12 certificate (for data package generation)
|
|
func importCustomClientP12(data: Data, password: String) {
|
|
UserDefaults.standard.set(data, forKey: customClientP12DataKey)
|
|
UserDefaults.standard.set(password, forKey: customClientP12PasswordKey)
|
|
Logger.tak.info("Custom client P12 imported for data package")
|
|
}
|
|
|
|
/// Check if custom client P12 is available
|
|
func hasCustomClientP12() -> Bool {
|
|
return UserDefaults.standard.data(forKey: customClientP12DataKey) != nil
|
|
}
|
|
|
|
/// Clear custom certificate data (called when resetting to defaults)
|
|
private func clearCustomCertificateData() {
|
|
UserDefaults.standard.removeObject(forKey: customServerP12DataKey)
|
|
UserDefaults.standard.removeObject(forKey: customServerP12PasswordKey)
|
|
UserDefaults.standard.removeObject(forKey: customClientP12DataKey)
|
|
UserDefaults.standard.removeObject(forKey: customClientP12PasswordKey)
|
|
Logger.tak.debug("Cleared custom certificate data")
|
|
}
|
|
|
|
// MARK: - Server Identity (PKCS#12)
|
|
|
|
/// Import server identity from PKCS#12 (.p12) file data
|
|
/// - Parameters:
|
|
/// - p12Data: The raw PKCS#12 file data
|
|
/// - password: Password to decrypt the PKCS#12 file
|
|
/// - isCustom: Whether this is a user-imported custom certificate (default: true)
|
|
/// - Returns: The imported SecIdentity
|
|
func importServerIdentity(from p12Data: Data, password: String, isCustom: Bool = true) throws -> SecIdentity {
|
|
let options: [String: Any] = [kSecImportExportPassphrase as String: password]
|
|
var items: CFArray?
|
|
|
|
let status = SecPKCS12Import(p12Data as CFData, options as CFDictionary, &items)
|
|
|
|
guard status == errSecSuccess else {
|
|
Logger.tak.error("Failed to import PKCS#12: \(status)")
|
|
throw TAKCertificateError.importFailed(status)
|
|
}
|
|
|
|
guard let itemArray = items as? [[String: Any]],
|
|
let firstItem = itemArray.first,
|
|
let identity = firstItem[kSecImportItemIdentity as String] as! SecIdentity? else {
|
|
throw TAKCertificateError.noIdentityFound
|
|
}
|
|
|
|
// Store in Keychain for persistence
|
|
try storeServerIdentity(identity, isCustom: isCustom)
|
|
|
|
// Store the raw P12 data and password for data package generation (only for custom certs)
|
|
if isCustom {
|
|
UserDefaults.standard.set(p12Data, forKey: customServerP12DataKey)
|
|
UserDefaults.standard.set(password, forKey: customServerP12PasswordKey)
|
|
Logger.tak.debug("Stored custom server P12 data for data package generation")
|
|
}
|
|
|
|
Logger.tak.info("Server identity imported successfully (custom: \(isCustom))")
|
|
return identity
|
|
}
|
|
|
|
/// Store server identity in Keychain
|
|
private func storeServerIdentity(_ identity: SecIdentity, isCustom: Bool = true) throws {
|
|
let tag = isCustom ? serverIdentityCustomTag : serverIdentityTag
|
|
|
|
// First delete any existing identity with this tag
|
|
let deleteQuery: [String: Any] = [
|
|
kSecClass as String: kSecClassIdentity,
|
|
kSecAttrLabel as String: tag
|
|
]
|
|
SecItemDelete(deleteQuery as CFDictionary)
|
|
|
|
// If storing custom cert, also delete the bundled one (custom takes precedence)
|
|
if isCustom {
|
|
let deleteBundledQuery: [String: Any] = [
|
|
kSecClass as String: kSecClassIdentity,
|
|
kSecAttrLabel as String: serverIdentityTag
|
|
]
|
|
SecItemDelete(deleteBundledQuery as CFDictionary)
|
|
}
|
|
|
|
// Add new identity
|
|
let addQuery: [String: Any] = [
|
|
kSecValueRef as String: identity,
|
|
kSecAttrLabel as String: tag,
|
|
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
|
|
]
|
|
|
|
let status = SecItemAdd(addQuery as CFDictionary, nil)
|
|
guard status == errSecSuccess else {
|
|
Logger.tak.error("Failed to store server identity in Keychain: \(status)")
|
|
throw TAKCertificateError.keychainError(status)
|
|
}
|
|
}
|
|
|
|
/// Retrieve stored server identity from Keychain
|
|
/// Custom certificates take precedence over bundled ones
|
|
func getServerIdentity() -> SecIdentity? {
|
|
// First try custom certificate
|
|
let customQuery: [String: Any] = [
|
|
kSecClass as String: kSecClassIdentity,
|
|
kSecAttrLabel as String: serverIdentityCustomTag,
|
|
kSecReturnRef as String: true
|
|
]
|
|
|
|
var item: CFTypeRef?
|
|
var status = SecItemCopyMatching(customQuery as CFDictionary, &item)
|
|
|
|
if status == errSecSuccess {
|
|
return (item as! SecIdentity)
|
|
}
|
|
|
|
// Fall back to bundled certificate
|
|
let bundledQuery: [String: Any] = [
|
|
kSecClass as String: kSecClassIdentity,
|
|
kSecAttrLabel as String: serverIdentityTag,
|
|
kSecReturnRef as String: true
|
|
]
|
|
|
|
status = SecItemCopyMatching(bundledQuery as CFDictionary, &item)
|
|
|
|
guard status == errSecSuccess else {
|
|
if status != errSecItemNotFound {
|
|
Logger.tak.warning("Failed to retrieve server identity: \(status)")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
return (item as! SecIdentity)
|
|
}
|
|
|
|
/// Check if server certificate is configured
|
|
func hasServerCertificate() -> Bool {
|
|
return getServerIdentity() != nil
|
|
}
|
|
|
|
/// Delete custom server identity and reload bundled default
|
|
func deleteServerIdentity() {
|
|
// Delete custom certificate
|
|
let customQuery: [String: Any] = [
|
|
kSecClass as String: kSecClassIdentity,
|
|
kSecAttrLabel as String: serverIdentityCustomTag
|
|
]
|
|
let customStatus = SecItemDelete(customQuery as CFDictionary)
|
|
|
|
// Delete bundled certificate too
|
|
let bundledQuery: [String: Any] = [
|
|
kSecClass as String: kSecClassIdentity,
|
|
kSecAttrLabel as String: serverIdentityTag
|
|
]
|
|
let bundledStatus = SecItemDelete(bundledQuery as CFDictionary)
|
|
|
|
if customStatus == errSecSuccess || bundledStatus == errSecSuccess {
|
|
Logger.tak.info("Server identity deleted")
|
|
}
|
|
|
|
// Reload bundled default
|
|
loadBundledServerIdentity()
|
|
}
|
|
|
|
/// Reset to bundled default certificate (deletes custom certificate)
|
|
func resetToDefaultServerCertificate() {
|
|
// Clear custom certificate data from UserDefaults
|
|
clearCustomCertificateData()
|
|
|
|
// Delete custom certificate
|
|
let customQuery: [String: Any] = [
|
|
kSecClass as String: kSecClassIdentity,
|
|
kSecAttrLabel as String: serverIdentityCustomTag
|
|
]
|
|
SecItemDelete(customQuery as CFDictionary)
|
|
|
|
// Delete existing bundled and reload
|
|
let bundledQuery: [String: Any] = [
|
|
kSecClass as String: kSecClassIdentity,
|
|
kSecAttrLabel as String: serverIdentityTag
|
|
]
|
|
SecItemDelete(bundledQuery as CFDictionary)
|
|
|
|
loadBundledServerIdentity()
|
|
Logger.tak.info("Reset to bundled default server certificate")
|
|
}
|
|
|
|
/// Get certificate info for display purposes
|
|
func getServerCertificateInfo() -> String? {
|
|
guard let identity = getServerIdentity() else { return nil }
|
|
|
|
var certificate: SecCertificate?
|
|
let status = SecIdentityCopyCertificate(identity, &certificate)
|
|
guard status == errSecSuccess, let cert = certificate else { return nil }
|
|
|
|
let isCustom = hasCustomServerCertificate()
|
|
let prefix = isCustom ? "Custom: " : "Default: "
|
|
|
|
if let summary = SecCertificateCopySubjectSummary(cert) as String? {
|
|
return prefix + summary
|
|
}
|
|
|
|
return prefix + "Certificate loaded"
|
|
}
|
|
|
|
// MARK: - Client CA Certificates (PEM)
|
|
|
|
/// Import client CA certificate from PEM file data
|
|
/// - Parameter pemData: The raw PEM file data
|
|
/// - Returns: The imported SecCertificate
|
|
func importClientCACertificate(from pemData: Data) throws -> SecCertificate {
|
|
// Extract DER data from PEM format
|
|
let derData = try extractDERFromPEM(pemData)
|
|
|
|
guard let certificate = SecCertificateCreateWithData(nil, derData as CFData) else {
|
|
throw TAKCertificateError.invalidCertificate
|
|
}
|
|
|
|
// Store in Keychain
|
|
try storeClientCACertificate(certificate)
|
|
|
|
Logger.tak.info("Client CA certificate imported successfully")
|
|
return certificate
|
|
}
|
|
|
|
/// Extract DER-encoded certificate data from PEM format
|
|
private func extractDERFromPEM(_ pemData: Data) throws -> Data {
|
|
guard let pemString = String(data: pemData, encoding: .utf8) else {
|
|
throw TAKCertificateError.invalidPEM
|
|
}
|
|
|
|
// Remove PEM headers and whitespace
|
|
let base64 = pemString
|
|
.replacingOccurrences(of: "-----BEGIN CERTIFICATE-----", with: "")
|
|
.replacingOccurrences(of: "-----END CERTIFICATE-----", with: "")
|
|
.replacingOccurrences(of: "\n", with: "")
|
|
.replacingOccurrences(of: "\r", with: "")
|
|
.trimmingCharacters(in: .whitespaces)
|
|
|
|
guard let derData = Data(base64Encoded: base64) else {
|
|
throw TAKCertificateError.invalidPEM
|
|
}
|
|
|
|
return derData
|
|
}
|
|
|
|
/// Store client CA certificate in Keychain
|
|
private func storeClientCACertificate(_ certificate: SecCertificate) throws {
|
|
let addQuery: [String: Any] = [
|
|
kSecClass as String: kSecClassCertificate,
|
|
kSecValueRef as String: certificate,
|
|
kSecAttrLabel as String: clientCATag,
|
|
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
|
|
]
|
|
|
|
let status = SecItemAdd(addQuery as CFDictionary, nil)
|
|
|
|
// Ignore duplicate item errors (certificate already imported)
|
|
guard status == errSecSuccess || status == errSecDuplicateItem else {
|
|
Logger.tak.error("Failed to store client CA certificate: \(status)")
|
|
throw TAKCertificateError.keychainError(status)
|
|
}
|
|
}
|
|
|
|
/// Get all stored client CA certificates
|
|
func getClientCACertificates() -> [SecCertificate] {
|
|
let query: [String: Any] = [
|
|
kSecClass as String: kSecClassCertificate,
|
|
kSecAttrLabel as String: clientCATag,
|
|
kSecReturnRef as String: true,
|
|
kSecMatchLimit as String: kSecMatchLimitAll
|
|
]
|
|
|
|
var items: CFTypeRef?
|
|
let status = SecItemCopyMatching(query as CFDictionary, &items)
|
|
|
|
guard status == errSecSuccess else {
|
|
if status != errSecItemNotFound {
|
|
Logger.tak.warning("Failed to retrieve client CA certificates: \(status)")
|
|
}
|
|
return []
|
|
}
|
|
|
|
// Handle both single item and array returns
|
|
if let certificates = items as? [SecCertificate] {
|
|
return certificates
|
|
} else if let certificate = items as! SecCertificate? {
|
|
return [certificate]
|
|
}
|
|
|
|
return []
|
|
}
|
|
|
|
/// Check if at least one client CA certificate is configured
|
|
func hasClientCACertificate() -> Bool {
|
|
return !getClientCACertificates().isEmpty
|
|
}
|
|
|
|
/// Delete all client CA certificates from Keychain
|
|
func deleteClientCACertificates() {
|
|
let query: [String: Any] = [
|
|
kSecClass as String: kSecClassCertificate,
|
|
kSecAttrLabel as String: clientCATag
|
|
]
|
|
let status = SecItemDelete(query as CFDictionary)
|
|
if status == errSecSuccess || status == errSecItemNotFound {
|
|
Logger.tak.info("Client CA certificates deleted")
|
|
}
|
|
}
|
|
|
|
/// Get info about stored client CA certificates for display
|
|
func getClientCACertificateInfo() -> [String] {
|
|
let certificates = getClientCACertificates()
|
|
return certificates.compactMap { cert in
|
|
SecCertificateCopySubjectSummary(cert) as String?
|
|
}
|
|
}
|
|
|
|
// MARK: - Certificate Validation
|
|
|
|
/// Validate a client certificate against the stored CA certificates
|
|
func validateClientCertificate(_ trust: SecTrust) -> Bool {
|
|
let caCertificates = getClientCACertificates()
|
|
|
|
guard !caCertificates.isEmpty else {
|
|
Logger.tak.warning("No client CA certificates configured for validation")
|
|
return false
|
|
}
|
|
|
|
// Set the anchor certificates (trusted CAs)
|
|
SecTrustSetAnchorCertificates(trust, caCertificates as CFArray)
|
|
SecTrustSetAnchorCertificatesOnly(trust, true)
|
|
|
|
var error: CFError?
|
|
let isValid = SecTrustEvaluateWithError(trust, &error)
|
|
|
|
if !isValid {
|
|
Logger.tak.warning("Client certificate validation failed: \(error?.localizedDescription ?? "unknown")")
|
|
}
|
|
|
|
return isValid
|
|
}
|
|
}
|
|
|
|
// MARK: - Certificate Errors
|
|
|
|
enum TAKCertificateError: LocalizedError {
|
|
case importFailed(OSStatus)
|
|
case noIdentityFound
|
|
case invalidCertificate
|
|
case invalidPEM
|
|
case keychainError(OSStatus)
|
|
case certificateExpired
|
|
case certificateNotYetValid
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .importFailed(let status):
|
|
return "Failed to import PKCS#12: \(securityErrorMessage(status))"
|
|
case .noIdentityFound:
|
|
return "No identity (certificate + private key) found in PKCS#12 file"
|
|
case .invalidCertificate:
|
|
return "Invalid certificate data"
|
|
case .invalidPEM:
|
|
return "Invalid PEM format - ensure file contains a valid certificate"
|
|
case .keychainError(let status):
|
|
return "Keychain error: \(securityErrorMessage(status))"
|
|
case .certificateExpired:
|
|
return "Certificate has expired"
|
|
case .certificateNotYetValid:
|
|
return "Certificate is not yet valid"
|
|
}
|
|
}
|
|
|
|
private func securityErrorMessage(_ status: OSStatus) -> String {
|
|
if let message = SecCopyErrorMessageString(status, nil) {
|
|
return message as String
|
|
}
|
|
return "Error code: \(status)"
|
|
}
|
|
}
|