Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import Foundation

// MARK: - ClientCertificateConfiguration

/// Configuration for client certificate authentication.
///
struct ClientCertificateConfiguration: Codable, Equatable {
// MARK: Type Properties

/// Creates a disabled client certificate configuration.
static let disabled = ClientCertificateConfiguration(
isEnabled: false,
certificateData: nil,
password: nil,
subject: nil,
issuer: nil,
expirationDate: nil
)

// MARK: Properties

/// Whether client certificate authentication is enabled.
let isEnabled: Bool

/// The certificate data (PKCS#12 format).
let certificateData: Data?

/// The certificate password.
let password: String?

/// The certificate subject (for display purposes).
let subject: String?

/// The certificate issuer (for display purposes).
let issuer: String?

/// The certificate expiration date.
let expirationDate: Date?

// MARK: Type Methods

/// Creates an enabled client certificate configuration.
///
/// - Parameters:
/// - certificateData: The certificate data in PKCS#12 format.
/// - password: The certificate password.
/// - subject: The certificate subject.
/// - issuer: The certificate issuer.
/// - expirationDate: The certificate expiration date.
///
static func enabled(
certificateData: Data,
password: String,
subject: String,
issuer: String,
expirationDate: Date
) -> ClientCertificateConfiguration {
ClientCertificateConfiguration(
isEnabled: true,
certificateData: certificateData,
password: password,
subject: subject,
issuer: issuer,
expirationDate: expirationDate
)
}
}
165 changes: 165 additions & 0 deletions BitwardenShared/Core/Platform/Services/CertificateHTTPClient.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import Foundation
import Networking

/// An HTTP client that supports client certificate authentication for mTLS.
///
final class CertificateHTTPClient: NSObject, HTTPClient, @unchecked Sendable {
// MARK: Properties

/// The certificate service for retrieving client certificates.
private let certificateService: ClientCertificateService

/// The underlying URL session.
private var urlSession: URLSession!

// MARK: Initialization

/// Initialize a `CertificateHTTPClient`.
///
/// - Parameter certificateService: The service used to retrieve client certificates.
///
init(certificateService: ClientCertificateService) {
self.certificateService = certificateService
super.init()

// Create a session configuration with a delegate
let configuration = URLSessionConfiguration.default
urlSession = URLSession(
configuration: configuration,
delegate: self,
delegateQueue: nil
)
}

// MARK: HTTPClient

func download(from urlRequest: URLRequest) async throws -> URL {
try await withCheckedThrowingContinuation { continuation in
urlSession.downloadTask(with: urlRequest) { url, _, error in
guard let url else {
return continuation.resume(with: .failure(error ?? URLError(.badURL)))
}

do {
let temporaryURL = try FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
.appendingPathComponent("temp")
.appendingPathComponent(url.lastPathComponent)

try FileManager.default.createDirectory(at: temporaryURL, withIntermediateDirectories: true)

// Remove any existing document at file
if FileManager.default.fileExists(atPath: temporaryURL.path) {
try FileManager.default.removeItem(at: temporaryURL)
}

// Copy the newly downloaded file to the temporary url.
try FileManager.default.copyItem(
at: url,
to: temporaryURL
)

continuation.resume(with: .success(temporaryURL))
} catch {
continuation.resume(with: .failure(error))
}
}.resume()
}
}

func send(_ request: HTTPRequest) async throws -> HTTPResponse {
var urlRequest = URLRequest(url: request.url)
urlRequest.httpMethod = request.method.rawValue
urlRequest.httpBody = request.body

for (field, value) in request.headers {
urlRequest.addValue(value, forHTTPHeaderField: field)
}

let (data, urlResponse) = try await urlSession.data(for: urlRequest)

guard let httpResponse = urlResponse as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}

guard let responseURL = httpResponse.url else {
throw URLError(.badURL)
}

return HTTPResponse(
url: responseURL,
statusCode: httpResponse.statusCode,
headers: httpResponse.allHeaderFields as? [String: String] ?? [:],
body: data,
requestID: request.requestID
)
}
}

// MARK: - URLSessionDelegate

extension CertificateHTTPClient: URLSessionDelegate {
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
// Handle client certificate authentication challenges
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodClientCertificate else {
completionHandler(.performDefaultHandling, nil)
return
}

Task {
guard let certificateInfo = await certificateService.getClientCertificateForTLS() else {
completionHandler(.performDefaultHandling, nil)
return
}

// Try to create the identity directly using Keychain Services
var importResult: CFArray?
let importOptions: [String: Any] = [
kSecImportExportPassphrase as String: certificateInfo.password,
]

let status = SecPKCS12Import(
certificateInfo.data as CFData,
importOptions as CFDictionary,
&importResult
)

guard status == errSecSuccess,
let importArray = importResult as? [[String: Any]],
!importArray.isEmpty else {
completionHandler(.performDefaultHandling, nil)
return
}

// Get the first import result
let firstImport = importArray[0]

// Extract the identity using the Security framework constant
guard let identityAny = firstImport[kSecImportItemIdentity as String] else {
completionHandler(.performDefaultHandling, nil)
return
}

// Use Unmanaged to safely bridge the CoreFoundation type
let identityRef = Unmanaged<SecIdentity>.fromOpaque(
UnsafeRawPointer(Unmanaged.passUnretained(identityAny as AnyObject).toOpaque())
).takeUnretainedValue()

// Create the credential with the identity
let credential = URLCredential(
identity: identityRef,
certificates: nil,
persistence: .forSession
)
completionHandler(.useCredential, credential)
}
}
}
Loading
Loading