Skip to content

Commit 2f519aa

Browse files
authored
Merge pull request #55 from GoodRequest/feature/ssl-pinning
feat: Authenticate with custom SSL/TLS certificate
2 parents ed3ee0e + 653aacc commit 2f519aa

File tree

3 files changed

+212
-27
lines changed

3 files changed

+212
-27
lines changed
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
//
2+
// Certificate.swift
3+
// GoodNetworking
4+
//
5+
// Created by te075262 on 30/10/2025.
6+
//
7+
8+
import Foundation
9+
10+
/// Describes how a server certificate should be handled during trust evaluation.
11+
///
12+
/// Returned by `Certificate.certificateDisposition(using:)` to instruct the library
13+
/// on whether to use custom certificates, system trust, or deny the connection.
14+
///
15+
/// Typical use cases:
16+
/// - SSL pinning by validating certificates or public keys
17+
/// - Trusting self-signed certificates
18+
/// - Fallback to system trust evaluation
19+
public enum CertificateDisposition {
20+
21+
/// Evaluate trust using the provided certificate(s).
22+
///
23+
/// The library will:
24+
/// 1. Restrict the trust evaluation to these certificates as anchors.
25+
/// 2. Evaluate the server trust using `SecTrustEvaluateWithError`.
26+
/// 3. Proceed only if the evaluation succeeds.
27+
///
28+
/// - Parameter certificates: One or more certificates to use for trust evaluation.
29+
///
30+
/// Use this when:
31+
/// - Implementing certificate or public-key pinning
32+
/// - Using self-signed or non-system certificates
33+
case evaluate(certificates: [SecCertificate])
34+
35+
/// Defer to system trust evaluation.
36+
///
37+
/// The library will perform the default trust evaluation and ignore any
38+
/// custom certificate logic from this protocol implementation.
39+
///
40+
/// Use this when:
41+
/// - No custom validation is required
42+
/// - You want to rely on the system trust store
43+
case useSystemTrustEvaluation
44+
45+
/// Deny the authentication challenge.
46+
///
47+
/// The library will reject the connection immediately.
48+
///
49+
/// - Parameter reason: Optional description of why the connection is denied, useful for logging or debugging.
50+
///
51+
/// Use this when:
52+
/// - Validation fails (e.g., pin mismatch, expired certificate)
53+
/// - Security policy mandates refusal
54+
case deny(reason: String? = nil)
55+
56+
}
57+
58+
/// Provides a strategy for validating server certificates.
59+
///
60+
/// Implementers may perform asynchronous operations (e.g., loading a certificate
61+
/// from disk or network) and decide whether to:
62+
/// - Evaluate using custom certificates
63+
/// - Use system trust
64+
/// - Deny the connection
65+
public protocol Certificate: Sendable {
66+
67+
/// Determines how the server trust should be handled for a connection.
68+
///
69+
/// - Parameter serverTrust: The `SecTrust` object provided by the system.
70+
/// Implementations may inspect it to perform pinning or other validations.
71+
///
72+
/// - Returns: A `CertificateDisposition` indicating the desired action:
73+
/// `.evaluate(certificates:)` to perform custom evaluation,
74+
/// `.useSystemTrustEvaluation` to defer to system handling,
75+
/// `.deny(reason:)` to reject the connection.
76+
///
77+
/// - Throws: If an unrecoverable error occurs (e.g., certificate failed to load),
78+
/// implementations may throw to fail the authentication challenge.
79+
///
80+
/// ### Example Implementations
81+
///
82+
/// **Pinned certificate from bundle**
83+
/// ```swift
84+
/// func certificateDisposition(using serverTrust: SecTrust) async throws -> CertificateDisposition {
85+
/// let certificate = try loadPinnedCertificate()
86+
/// return .evaluate(certificates: [certificate])
87+
/// }
88+
/// ```
89+
///
90+
/// **Public key pinning with fallback**
91+
/// ```swift
92+
/// func certificateDisposition(using serverTrust: SecTrust) async throws -> CertificateDisposition {
93+
/// guard publicKeyMatches(serverTrust) else { return .deny(reason: "Public key mismatch") }
94+
/// return .useSystemTrustEvaluation
95+
/// }
96+
/// ```
97+
///
98+
/// **Load certificate remotely**
99+
/// ```swift
100+
/// func certificateDisposition(using serverTrust: SecTrust) async throws -> CertificateDisposition {
101+
/// let cert = try await downloadCertificate()
102+
/// return .evaluate(certificates: [cert])
103+
/// }
104+
/// ```
105+
func certificateDisposition(using serverTrust: SecTrust) async throws -> CertificateDisposition
106+
107+
}
108+
109+
/// Default implementation with no specific behaviour
110+
public struct NoPinnedCertificate: Certificate {
111+
112+
public init() {}
113+
114+
public func certificateDisposition(using serverTrust: SecTrust) async throws -> CertificateDisposition {
115+
return .useSystemTrustEvaluation
116+
}
117+
118+
}

Sources/GoodNetworking/Models/JSON.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,12 @@ import Foundation
7676
/// - data: Raw `Data` of JSON object
7777
/// - options: Optional serialization options
7878
public init(data: Data, options: JSONSerialization.ReadingOptions = .allowFragments) throws {
79-
let object = try JSONSerialization.jsonObject(with: data, options: options)
80-
self = JSON(object)
79+
if data.isEmpty {
80+
self = JSON.null
81+
} else {
82+
let object = try JSONSerialization.jsonObject(with: data, options: options)
83+
self = JSON(object)
84+
}
8185
}
8286

8387
/// Create JSON from an encodable model, for example to be sent between

Sources/GoodNetworking/Session/NetworkSession.swift

Lines changed: 88 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import Foundation
2929
private let sessionHeaders: HTTPHeaders
3030
private let interceptor: any Interceptor
3131
private let logger: any NetworkLogger
32+
private let certificate: any Certificate
3233

3334
private let configuration: URLSessionConfiguration
3435
private let delegateQueue: OperationQueue
@@ -49,6 +50,7 @@ import Foundation
4950
baseHeaders: HTTPHeaders = [],
5051
interceptor: any Interceptor = DefaultInterceptor(),
5152
logger: any NetworkLogger = PrintNetworkLogger(),
53+
certificate: any Certificate = NoPinnedCertificate(),
5254
name: String? = nil
5355
) {
5456
self.name = name ?? "NetworkSession"
@@ -57,6 +59,7 @@ import Foundation
5759
self.sessionHeaders = baseHeaders
5860
self.interceptor = interceptor
5961
self.logger = logger
62+
self.certificate = certificate
6063

6164
let operationQueue = OperationQueue()
6265
operationQueue.name = "NetworkActorSerialExecutorOperationQueue"
@@ -90,6 +93,14 @@ internal extension NetworkSession {
9093
self.activeTasks.removeValue(forKey: task.taskIdentifier)
9194
}
9295

96+
func getPinnedCertificate() -> any Certificate {
97+
self.certificate
98+
}
99+
100+
func getLogger() -> any NetworkLogger {
101+
self.logger
102+
}
103+
93104
}
94105

95106
// MARK: - Network session delegate
@@ -106,37 +117,89 @@ final class NetworkSessionDelegate: NSObject {
106117

107118
extension NetworkSessionDelegate: URLSessionDelegate {
108119

120+
/// SSL Pinning: Compare server certificate with local (pinned) certificate
121+
/// https://developer.apple.com/documentation/foundation/performing-manual-server-trust-authentication
109122
public func urlSession(
110123
_ session: URLSession,
111124
didReceive challenge: URLAuthenticationChallenge,
112125
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
113126
) {
114-
// // SSL Pinning: Compare server certificate with local (pinned) certificate
115-
// guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
116-
// let serverTrust = challenge.protectionSpace.serverTrust,
117-
// let serverCertificate = SecTrustGetCertificateAtIndex(serverTrust, 0),
118-
// let pinnedCertificateData = Self.pinnedCertificateData()
119-
// else {
120-
// completionHandler(.cancelAuthenticationChallenge, nil)
121-
// return
122-
// }
123-
//
124-
// // Extract server certificate data
125-
// let serverCertificateData = SecCertificateCopyData(serverCertificate) as Data
126-
//
127-
// if serverCertificateData == pinnedCertificateData {
128-
// let credential = URLCredential(trust: serverTrust)
129-
// completionHandler(.useCredential, credential)
130-
// } else {
131-
// completionHandler(.cancelAuthenticationChallenge, nil)
132-
// }
133-
134-
#warning("TODO: Enable SSL pinning")
135-
// https://developer.apple.com/documentation/foundation/performing-manual-server-trust-authentication
136-
137-
// remove
138-
completionHandler(.performDefaultHandling, nil)
127+
/// Mark completionHandler as isolated to ``NetworkActor``.
128+
///
129+
/// This delegate function is always called on NetworkActor's operation queue, but is marked as `nonisolated`. Completion handler
130+
/// would usually be called on the same thread.
131+
///
132+
/// NetworkSessionDelegate cannot conform to `URLSessionDelegate` on `@NetworkActor` because of erroneous
133+
/// `Sendable Self` requirement preventing marking the conformance .
134+
typealias YesActor = @NetworkActor (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
135+
typealias NoActor = (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
136+
let completionHandler = unsafeBitCast(completionHandler as NoActor, to: YesActor.self)
137+
138+
Task { @NetworkActor in
139+
// Evaluate only SSL/TLS certificates
140+
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust else {
141+
completionHandler(.performDefaultHandling, nil)
142+
return
143+
}
144+
145+
guard let serverTrust = challenge.protectionSpace.serverTrust else {
146+
completionHandler(.performDefaultHandling, nil)
147+
return
148+
}
149+
150+
// Load and validate local certificate
151+
let error: (any Error)?
152+
let certificateDisposition: CertificateDisposition?
153+
do {
154+
certificateDisposition = try await networkSession.getPinnedCertificate()
155+
.certificateDisposition(using: serverTrust)
156+
error = nil
157+
} catch let certificateError {
158+
certificateDisposition = nil
159+
error = certificateError
160+
}
161+
162+
switch certificateDisposition {
163+
case .evaluate(let certificates):
164+
// Set local certificate as serverTrust anchor
165+
SecTrustSetAnchorCertificates(serverTrust, certificates as CFArray)
166+
SecTrustSetAnchorCertificatesOnly(serverTrust, true)
167+
168+
// Evaluate certificate chain manually
169+
var error: CFError?
170+
let trusted = SecTrustEvaluateWithError(serverTrust, &error)
171+
if trusted {
172+
let credential = URLCredential(trust: serverTrust)
173+
completionHandler(.useCredential, credential)
174+
} else {
175+
completionHandler(.cancelAuthenticationChallenge, nil)
176+
}
177+
178+
case .useSystemTrustEvaluation:
179+
completionHandler(.performDefaultHandling, nil)
180+
181+
case .deny(let reason):
182+
networkSession.getLogger().logNetworkEvent(
183+
message: reason,
184+
level: .error,
185+
file: #file,
186+
line: #line
187+
)
188+
189+
completionHandler(.cancelAuthenticationChallenge, nil)
190+
191+
// call throws
192+
case .none:
193+
networkSession.getLogger().logNetworkEvent(
194+
message: error?.localizedDescription ?? "nil",
195+
level: .error,
196+
file: #file,
197+
line: #line
198+
)
199+
}
200+
}
139201
}
202+
140203
}
141204

142205
extension NetworkSessionDelegate: URLSessionTaskDelegate {

0 commit comments

Comments
 (0)