From 9ce1b9ec767be7c82fd35f3cb0931acad730c902 Mon Sep 17 00:00:00 2001 From: brennobemoura <37243584+brennobemoura@users.noreply.github.com> Date: Sun, 1 Feb 2026 18:37:59 -0300 Subject: [PATCH 01/13] Implemented SPKIPinningConfiguration --- Package.swift | 2 + .../HTTPClientRequest+Prepared.swift | 1 + .../AsyncAwait/Transaction.swift | 1 + .../ChannelHandler/SPKIPinningHandler.swift | 273 ++++++++++++++++++ .../HTTPConnectionPool+Factory.swift | 26 +- .../HTTPConnectionPool+Manager.swift | 1 + .../ConnectionPool/HTTPConnectionPool.swift | 2 + .../HTTPExecutableRequest.swift | 3 + Sources/AsyncHTTPClient/HTTPHandler.swift | 50 +++- Sources/AsyncHTTPClient/RequestBag.swift | 2 + .../HTTP2ConnectionTests.swift | 2 + .../HTTPClientTests.swift | 21 +- .../HTTPConnectionPool+FactoryTests.swift | 4 + ...HTTPConnectionPool+RequestQueueTests.swift | 1 + .../HTTPConnectionPoolTests.swift | 9 + .../Mocks/MockConnectionPool.swift | 2 + 16 files changed, 384 insertions(+), 16 deletions(-) create mode 100644 Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/SPKIPinningHandler.swift diff --git a/Package.swift b/Package.swift index aad0c1c53..64866aa59 100644 --- a/Package.swift +++ b/Package.swift @@ -44,6 +44,7 @@ let package = Package( .package(url: "https://github.com/apple/swift-atomics.git", from: "1.0.2"), .package(url: "https://github.com/apple/swift-algorithms.git", from: "1.0.0"), .package(url: "https://github.com/apple/swift-distributed-tracing.git", from: "1.3.0"), + .package(url: "https://github.com/apple/swift-crypto.git", from: "4.2.0"), ], targets: [ .target( @@ -69,6 +70,7 @@ let package = Package( .product(name: "NIOTransportServices", package: "swift-nio-transport-services"), .product(name: "Atomics", package: "swift-atomics"), .product(name: "Algorithms", package: "swift-algorithms"), + .product(name: "Crypto", package: "swift-crypto"), // Observability support .product(name: "Logging", package: "swift-log"), .product(name: "Tracing", package: "swift-distributed-tracing"), diff --git a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+Prepared.swift b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+Prepared.swift index e57406e2b..c42f4e18c 100644 --- a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+Prepared.swift +++ b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+Prepared.swift @@ -42,6 +42,7 @@ extension HTTPClientRequest { var head: HTTPRequestHead var body: Body? var tlsConfiguration: TLSConfiguration? + var tlsPinning: SPKIPinningConfiguration? } } diff --git a/Sources/AsyncHTTPClient/AsyncAwait/Transaction.swift b/Sources/AsyncHTTPClient/AsyncAwait/Transaction.swift index c8bf54b09..343a0f8a8 100644 --- a/Sources/AsyncHTTPClient/AsyncAwait/Transaction.swift +++ b/Sources/AsyncHTTPClient/AsyncAwait/Transaction.swift @@ -173,6 +173,7 @@ final class Transaction: extension Transaction: HTTPSchedulableRequest { var poolKey: ConnectionPool.Key { self.request.poolKey } var tlsConfiguration: TLSConfiguration? { self.request.tlsConfiguration } + var tlsPinning: SPKIPinningConfiguration? { self.request.tlsPinning } var requiredEventLoop: EventLoop? { nil } func requestWasQueued(_ scheduler: HTTPRequestScheduler) { diff --git a/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/SPKIPinningHandler.swift b/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/SPKIPinningHandler.swift new file mode 100644 index 000000000..077f89d86 --- /dev/null +++ b/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/SPKIPinningHandler.swift @@ -0,0 +1,273 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the AsyncHTTPClient open source project +// +// Copyright (c) 2021 Apple Inc. and the AsyncHTTPClient project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of AsyncHTTPClient project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Foundation +import NIOCore +import NIOTLS +import NIOSSL +import Logging +import Crypto + +/// Configuration for SPKI (SubjectPublicKeyInfo) pinning. +/// +/// SPKI pinning validates the cryptographic identity of the server by hashing the +/// SubjectPublicKeyInfo structure (RFC 5280, Section 4.1) rather than the entire certificate. +/// +/// Why SPKI instead of certificate pinning? +/// - ✅ Survives legitimate certificate rotations (same key, new expiration) +/// - ✅ Prevents downgrade attacks (algorithm identifier is included in the hash) +/// - ❌ Certificate pinning fails on routine renewals and provides false security +/// +/// - Note: Despite the industry term "certificate pinning", cryptographic best practices +/// (OWASP MSTG, NIST SP 800-52 Rev. 2) mandate SPKI-based pinning. +/// - Warning: Always deploy with non-empty `backupPins` to avoid lockout during rotation. +/// - SeeAlso: https://datatracker.ietf.org/doc/html/rfc5280#section-4.1 +/// - SeeAlso: https://owasp.org/www-project-mobile-security-testing-guide/latest/0x05g-Testing-Network-Communication.html +public struct SPKIPinningConfiguration: Sendable { + /// Base64-encoded SHA-256 hashes of current production SPKI values. + public var primaryPins: Set + + /// Base64-encoded SHA-256 hashes for upcoming certificate rotations. + /// Required in production to prevent catastrophic lockout. + public var backupPins: Set + + /// Failure behavior policy on pin mismatch. + public var verification: SPKIPinningVerification + + /// Creates an SPKI pinning configuration with primary and backup pins. + /// + /// - Parameters: + /// - primaryPins: Base64-encoded SHA-256 hashes of the current production certificate's + /// SubjectPublicKeyInfo. These pins are actively validated against incoming + /// connections. + /// - backupPins: Base64-encoded SHA-256 hashes for certificates scheduled for future deployment. + /// Required in production environments to prevent service disruption during + /// certificate rotation. Must contain at least one pin when using + /// `.failRequest` verification mode. + /// - verification: Policy for handling pin validation failures. Use `.failRequest` for + /// production (security-critical) environments and `.logAndProceed` only for + /// staging/debugging. + /// + /// - Warning: Deploying with empty `backupPins` in `.failRequest` mode risks catastrophic + /// lockout when certificates rotate. Always deploy backup pins at least 30 days before + /// certificate expiration. + /// + /// - Important: Hashes must be generated from the SPKI structure (not the full certificate) + /// using SHA-256 and Base64 encoding. Example OpenSSL command: + /// ``` + /// openssl s_client -connect example.com:443 -servername example.com 2>/dev/null | \ + /// openssl x509 -pubkey -noout | \ + /// openssl pkey -pubin -outform der | \ + /// openssl dgst -sha256 -binary | \ + /// openssl base64 -A + /// ``` + public init( + primaryPins: Set, + backupPins: Set, + verification: SPKIPinningVerification = .failRequest + ) { + self.primaryPins = primaryPins + self.backupPins = backupPins + self.verification = verification + } +} + +/// Defines the behavior when SPKI pin validation fails. +/// +/// Pinning failures indicate the server presented a certificate with an unexpected public key. +/// This typically occurs during certificate rotation (expected) or MITM attacks (malicious). +/// The verification policy determines whether to block the connection or allow it with warnings. +public enum SPKIPinningVerification: Sendable { + /// Immediately terminate the connection on pin validation failure. + /// + /// Use this policy in production environments where security is paramount. + /// Connections will fail if: + /// - Certificate was rotated without deploying corresponding backup pins + /// - Server presents unexpected certificate (potential MITM attack) + /// - Network interception by corporate proxies/security appliances + /// + /// - Warning: Deploy only with valid backup pins to avoid service disruption during rotation. + case failRequest + + /// Allow the connection to proceed but log a structured warning. + /// + /// Use this policy exclusively in staging, development, or debugging environments. + /// Never use in production — this effectively disables pinning security guarantees. + /// + /// - Warning: This mode provides auditability without security enforcement. + /// Connections proceed even with unexpected certificates. + case logAndProceed +} + +/// A ChannelHandler that implements certificate pinning using SPKI (SubjectPublicKeyInfo) hashes. +/// +/// This handler validates the server's leaf certificate public key against a set of pre-configured +/// SHA-256 hashes after TLS handshake completion. Pinning provides protection against compromised +/// Certificate Authorities by enforcing explicit trust in specific public keys. +/// +/// - Warning: Never deploy without backup pins in production environments. Missing backup pins +/// risk catastrophic lockout during certificate rotation. +/// - SeeAlso: OWASP MSTG-NETWORK-4, NIST SP 800-52 Rev. 2 Section 3.4.3 +@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) +final class SPKIPinningHandler: ChannelInboundHandler { + + typealias InboundIn = Any + typealias OutboundOut = Any + + private let logger: Logger + private let validPins: Set + private let backupPins: Set + private let verification: SPKIPinningVerification + + /// Creates a pinning handler with SHA-256 hashes of SPKI structures. + /// + /// - Parameters: + /// - primaryPins: Base64-encoded SHA-256 hashes of current production SPKI values. + /// - backupPins: Base64-encoded SHA-256 hashes for upcoming certificate rotations. + /// Required in production to prevent lockout during certificate renewal. + /// - verification: Failure behavior policy: + /// - `.failRequest`: Immediately terminate connections with invalid pins (production) + /// - `.logAndProceed`: Allow connection but log warning (staging/debugging only) + /// - logger: Structured logger for audit trails and monitoring integration. + /// + /// - Warning: Production deployments must include non-empty backupPins. Certificate rotation + /// without pre-deployed backup pins will cause complete service outage. + /// - SeeAlso: https://owasp.org/www-project-mobile-security-testing-guide/latest/0x05g-Testing-Network-Communication.html + init( + primaryPins: Set, + backupPins: Set = [], + verification: SPKIPinningVerification, + logger: Logger + ) { + self.validPins = Set(primaryPins.compactMap { + Data(base64Encoded: $0).map(SHA256.hash(data:)) + }) + self.backupPins = Set(backupPins.compactMap { + Data(base64Encoded: $0).map(SHA256.hash(data:)) + }) + self.verification = verification + self.logger = logger + + if backupPins.isEmpty && verification == .failRequest { + logger.warning( + "SPKIPinningHandler deployed without backup pins in failRequest mode - catastrophic lockout risk!", + metadata: [ + "recommendation": .string("Deploy backup pins 30+ days before certificate expiration") + ] + ) + } + } + + func userInboundEventTriggered(context: ChannelHandlerContext, event: Any) { + guard let tlsEvent = event as? TLSUserEvent, + case .handshakeCompleted = tlsEvent else { + context.fireUserInboundEventTriggered(event) + return + } + + validateSPKI(context: context) + } + + private func validateSPKI(context: ChannelHandlerContext) { + context.channel.pipeline.handler(type: NIOSSLHandler.self).assumeIsolated().whenComplete { result in + switch result { + case .success(let sslHandler): + guard let leaf = sslHandler.peerCertificate else { + self.handlePinningFailure( + context: context, + reason: "Empty certificate chain", + receivedSPKIHash: nil + ) + return + } + + do { + let publicKey = try leaf.extractPublicKey() + let spkiBytes = try publicKey.toSPKIBytes() + let spkiData = Data(spkiBytes) + let receivedHash = SHA256.hash(data: spkiData) + + let isValid = self.validPins.contains(receivedHash) || + self.backupPins.contains(receivedHash) + + if isValid { + context.fireUserInboundEventTriggered(TLSUserEvent.handshakeCompleted) + self.logger.debug( + "SPKI pin validation succeeded", + metadata: [ + "spki_hash": .string(receivedHash.base64EncodedString()), + "matched_type": .string(self.validPins.contains(receivedHash) ? "primary" : "backup") + ] + ) + } else { + self.handlePinningFailure( + context: context, + reason: "SPKI pin mismatch", + receivedSPKIHash: receivedHash + ) + } + + } catch { + self.handlePinningFailure( + context: context, + reason: "SPKI extraction failed: \(error)", + receivedSPKIHash: nil + ) + } + + case .failure(let error): + self.handlePinningFailure( + context: context, + reason: "SSL handler not found: \(error)", + receivedSPKIHash: nil + ) + } + } + } + + private func handlePinningFailure( + context: ChannelHandlerContext, + reason: String, + receivedSPKIHash: SHA256Digest? + ) { + let metadata: Logger.Metadata = [ + "pinning_action": .string(verification == .failRequest ? "blocked" : "allowed_with_warning"), + "received_spki_hash": .string(receivedSPKIHash?.base64EncodedString() ?? "unknown"), + "expected_primary_pins": .string(validPins.map { $0.base64EncodedString() }.joined(separator: ", ")), + "expected_backup_pins": .string(backupPins.map { $0.base64EncodedString() }.joined(separator: ", ")) + ] + + switch verification { + case .failRequest: + logger.error("SPKI pinning failed — connection blocked", metadata: metadata) + context.close(mode: .all, promise: nil) + + case .logAndProceed: + logger.warning("SPKI pinning failed — connection allowed (staging mode)", metadata: metadata) + context.fireUserInboundEventTriggered(TLSUserEvent.handshakeCompleted) + } + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) +extension SHA256Digest { + func base64EncodedString() -> String { + Data(self).base64EncodedString() + } +} + +private enum PinningError: Error { + case publicKeyExtractionFailed + case spkiSerializationFailed +} diff --git a/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift b/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift index 3dc47c5ae..64d9d128a 100644 --- a/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift +++ b/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift @@ -30,12 +30,14 @@ extension HTTPConnectionPool { struct ConnectionFactory { let key: ConnectionPool.Key let clientConfiguration: HTTPClient.Configuration + let tlsPinning: SPKIPinningConfiguration? let tlsConfiguration: TLSConfiguration let sslContextCache: SSLContextCache init( key: ConnectionPool.Key, tlsConfiguration: TLSConfiguration?, + tlsPinning: SPKIPinningConfiguration?, clientConfiguration: HTTPClient.Configuration, sslContextCache: SSLContextCache ) { @@ -44,6 +46,7 @@ extension HTTPConnectionPool { self.sslContextCache = sslContextCache self.tlsConfiguration = tlsConfiguration ?? clientConfiguration.tlsConfiguration ?? .makeClientConfiguration() + self.tlsPinning = tlsPinning } } } @@ -396,8 +399,16 @@ extension HTTPConnectionPool.ConnectionFactory { let tlsEventHandler = TLSEventsHandler(deadline: deadline) try channel.pipeline.syncOperations.addHandler(tlsEventHandler) - // The tlsEstablishedFuture is set as soon as the TLSEventsHandler is in a - // pipeline. It is created in TLSEventsHandler's handlerAdded method. + if #available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *), let tlsPinning { + let pinningHandler = SPKIPinningHandler( + primaryPins: tlsPinning.primaryPins, + backupPins: tlsPinning.backupPins, + verification: tlsPinning.verification, + logger: logger + ) + try channel.pipeline.syncOperations.addHandler(pinningHandler) + } + return tlsEventHandler.tlsEstablishedFuture! } catch { return channel.eventLoop.makeFailedFuture(error) @@ -597,6 +608,17 @@ extension HTTPConnectionPool.ConnectionFactory { try sync.addHandler(sslHandler) try sync.addHandler(tlsEventHandler) + + if #available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *), let tlsPinning { + let pinningHandler = SPKIPinningHandler( + primaryPins: tlsPinning.primaryPins, + backupPins: tlsPinning.backupPins, + verification: tlsPinning.verification, + logger: logger + ) + try sync.addHandler(pinningHandler) + } + return channel.eventLoop.makeSucceededVoidFuture() } catch { return channel.eventLoop.makeFailedFuture(error) diff --git a/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Manager.swift b/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Manager.swift index 4c313e92b..d2321221a 100644 --- a/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Manager.swift +++ b/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Manager.swift @@ -64,6 +64,7 @@ extension HTTPConnectionPool { eventLoopGroup: self.eventLoopGroup, sslContextCache: self.sslContextCache, tlsConfiguration: request.tlsConfiguration, + tlsPinning: request.tlsPinning, clientConfiguration: self.configuration, key: poolKey, delegate: self, diff --git a/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool.swift b/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool.swift index 676df915a..edb9f3247 100644 --- a/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool.swift +++ b/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool.swift @@ -51,6 +51,7 @@ final class HTTPConnectionPool: eventLoopGroup: EventLoopGroup, sslContextCache: SSLContextCache, tlsConfiguration: TLSConfiguration?, + tlsPinning: SPKIPinningConfiguration?, clientConfiguration: HTTPClient.Configuration, key: ConnectionPool.Key, delegate: HTTPConnectionPoolDelegate, @@ -61,6 +62,7 @@ final class HTTPConnectionPool: self.connectionFactory = ConnectionFactory( key: key, tlsConfiguration: tlsConfiguration, + tlsPinning: tlsPinning, clientConfiguration: clientConfiguration, sslContextCache: sslContextCache ) diff --git a/Sources/AsyncHTTPClient/ConnectionPool/HTTPExecutableRequest.swift b/Sources/AsyncHTTPClient/ConnectionPool/HTTPExecutableRequest.swift index 0635c7978..7149df090 100644 --- a/Sources/AsyncHTTPClient/ConnectionPool/HTTPExecutableRequest.swift +++ b/Sources/AsyncHTTPClient/ConnectionPool/HTTPExecutableRequest.swift @@ -151,6 +151,9 @@ protocol HTTPSchedulableRequest: HTTPExecutableRequest { /// If you want to override the default `TLSConfiguration` ensure that this property is non nil var tlsConfiguration: TLSConfiguration? { get } + /// Optional SPKI pinning configuration for TLS certificate validation. + var tlsPinning: SPKIPinningConfiguration? { get } + /// The task's logger var logger: Logger { get } diff --git a/Sources/AsyncHTTPClient/HTTPHandler.swift b/Sources/AsyncHTTPClient/HTTPHandler.swift index 20df597ca..5f535a51a 100644 --- a/Sources/AsyncHTTPClient/HTTPHandler.swift +++ b/Sources/AsyncHTTPClient/HTTPHandler.swift @@ -232,6 +232,22 @@ extension HTTPClient { /// Request-specific TLS configuration, defaults to no request-specific TLS configuration. public var tlsConfiguration: TLSConfiguration? + /// Optional SPKI (SubjectPublicKeyInfo) pinning configuration for TLS certificate validation. + /// + /// When configured, the client validates the server's leaf certificate public key against the provided + /// SHA-256 hashes after TLS handshake completion. This provides protection against compromised + /// Certificate Authorities by enforcing explicit trust in specific cryptographic identities. + /// + /// - Warning: Always configure non-empty `backupPins` in production environments. Missing backup pins + /// during certificate rotation will cause complete service outage (catastrophic lockout). + /// + /// - Note: Despite the industry term "certificate pinning", this implementation pins the SPKI structure + /// (RFC 5280 Section 4.1) rather than the full certificate. This approach survives legitimate + /// certificate rotations while maintaining cryptographic security. + /// + /// - SeeAlso: https://owasp.org/www-project-mobile-security-testing-guide/latest/0x05g-Testing-Network-Communication.html + public var tlsPinning: SPKIPinningConfiguration? + /// Parsed, validated and deconstructed URL. let deconstructedURL: DeconstructedURL @@ -253,7 +269,14 @@ extension HTTPClient { headers: HTTPHeaders = HTTPHeaders(), body: Body? = nil ) throws { - try self.init(url: url, method: method, headers: headers, body: body, tlsConfiguration: nil) + try self.init( + url: url, + method: method, + headers: headers, + body: body, + tlsConfiguration: nil, + tlsPinning: nil + ) } /// Create HTTP request. @@ -274,13 +297,21 @@ extension HTTPClient { method: HTTPMethod = .GET, headers: HTTPHeaders = HTTPHeaders(), body: Body? = nil, - tlsConfiguration: TLSConfiguration? + tlsConfiguration: TLSConfiguration?, + tlsPinning: SPKIPinningConfiguration? ) throws { guard let url = URL(string: url) else { throw HTTPClientError.invalidURL } - try self.init(url: url, method: method, headers: headers, body: body, tlsConfiguration: tlsConfiguration) + try self.init( + url: url, + method: method, + headers: headers, + body: body, + tlsConfiguration: tlsConfiguration, + tlsPinning: tlsPinning + ) } /// Create an HTTP `Request`. @@ -297,7 +328,14 @@ extension HTTPClient { /// - `missingSocketPath` if URL does not contains a socketPath as an encoded host. public init(url: URL, method: HTTPMethod = .GET, headers: HTTPHeaders = HTTPHeaders(), body: Body? = nil) throws { - try self.init(url: url, method: method, headers: headers, body: body, tlsConfiguration: nil) + try self.init( + url: url, + method: method, + headers: headers, + body: body, + tlsConfiguration: nil, + tlsPinning: nil + ) } /// Create an HTTP `Request`. @@ -318,7 +356,8 @@ extension HTTPClient { method: HTTPMethod = .GET, headers: HTTPHeaders = HTTPHeaders(), body: Body? = nil, - tlsConfiguration: TLSConfiguration? + tlsConfiguration: TLSConfiguration?, + tlsPinning: SPKIPinningConfiguration? ) throws { self.deconstructedURL = try DeconstructedURL(url: url) @@ -327,6 +366,7 @@ extension HTTPClient { self.headers = headers self.body = body self.tlsConfiguration = tlsConfiguration + self.tlsPinning = tlsPinning } /// Remote host, resolved from `URL`. diff --git a/Sources/AsyncHTTPClient/RequestBag.swift b/Sources/AsyncHTTPClient/RequestBag.swift index a743f0814..ebc0cfad6 100644 --- a/Sources/AsyncHTTPClient/RequestBag.swift +++ b/Sources/AsyncHTTPClient/RequestBag.swift @@ -86,6 +86,7 @@ final class RequestBag: Sendabl let eventLoopPreference: HTTPClient.EventLoopPreference let tlsConfiguration: TLSConfiguration? + let tlsPinning: SPKIPinningConfiguration? init( request: HTTPClient.Request, @@ -116,6 +117,7 @@ final class RequestBag: Sendabl self.requestFramingMetadata = metadata self.tlsConfiguration = request.tlsConfiguration + self.tlsPinning = request.tlsPinning self.task.taskDelegate = self self.task.futureResult.whenComplete { _ in diff --git a/Tests/AsyncHTTPClientTests/HTTP2ConnectionTests.swift b/Tests/AsyncHTTPClientTests/HTTP2ConnectionTests.swift index 14a4d5630..555d2447d 100644 --- a/Tests/AsyncHTTPClientTests/HTTP2ConnectionTests.swift +++ b/Tests/AsyncHTTPClientTests/HTTP2ConnectionTests.swift @@ -417,6 +417,7 @@ final class TestConnectionCreator { let factory = HTTPConnectionPool.ConnectionFactory( key: .init(request), tlsConfiguration: tlsConfiguration, + tlsPinning: nil, clientConfiguration: config, sslContextCache: .init() ) @@ -460,6 +461,7 @@ final class TestConnectionCreator { let factory = HTTPConnectionPool.ConnectionFactory( key: .init(request), tlsConfiguration: tlsConfiguration, + tlsPinning: nil, clientConfiguration: config, sslContextCache: .init() ) diff --git a/Tests/AsyncHTTPClientTests/HTTPClientTests.swift b/Tests/AsyncHTTPClientTests/HTTPClientTests.swift index 054cf3487..c34efafef 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientTests.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientTests.swift @@ -4032,19 +4032,20 @@ final class HTTPClientTests: XCTestCaseHTTPClientTestsBaseClass { configuration: configuration ) let decoder = JSONDecoder() - + defer { XCTAssertNoThrow(try localClient.syncShutdown()) XCTAssertNoThrow(try localHTTPBin.shutdown()) } - + // First two requests use identical TLS configurations. var tlsConfig = TLSConfiguration.makeClientConfiguration() tlsConfig.certificateVerification = .none let firstRequest = try HTTPClient.Request( url: "https://localhost:\(localHTTPBin.port)/get", method: .GET, - tlsConfiguration: tlsConfig + tlsConfiguration: tlsConfig, + tlsPinning: nil ) let firstResponse = try localClient.execute(request: firstRequest).wait() guard let firstBody = firstResponse.body else { @@ -4052,11 +4053,12 @@ final class HTTPClientTests: XCTestCaseHTTPClientTestsBaseClass { return } let firstConnectionNumber = try decoder.decode(RequestInfo.self, from: firstBody).connectionNumber - + let secondRequest = try HTTPClient.Request( url: "https://localhost:\(localHTTPBin.port)/get", method: .GET, - tlsConfiguration: tlsConfig + tlsConfiguration: tlsConfig, + tlsPinning: nil ) let secondResponse = try localClient.execute(request: secondRequest).wait() guard let secondBody = secondResponse.body else { @@ -4064,15 +4066,16 @@ final class HTTPClientTests: XCTestCaseHTTPClientTestsBaseClass { return } let secondConnectionNumber = try decoder.decode(RequestInfo.self, from: secondBody).connectionNumber - - // Uses a differrent TLS config. + + // Uses a different TLS config. var tlsConfig2 = TLSConfiguration.makeClientConfiguration() tlsConfig2.certificateVerification = .none tlsConfig2.maximumTLSVersion = .tlsv1 let thirdRequest = try HTTPClient.Request( url: "https://localhost:\(localHTTPBin.port)/get", method: .GET, - tlsConfiguration: tlsConfig2 + tlsConfiguration: tlsConfig2, + tlsPinning: nil ) let thirdResponse = try localClient.execute(request: thirdRequest).wait() guard let thirdBody = thirdResponse.body else { @@ -4080,7 +4083,7 @@ final class HTTPClientTests: XCTestCaseHTTPClientTestsBaseClass { return } let thirdConnectionNumber = try decoder.decode(RequestInfo.self, from: thirdBody).connectionNumber - + XCTAssertEqual(firstResponse.status, .ok) XCTAssertEqual(secondResponse.status, .ok) XCTAssertEqual(thirdResponse.status, .ok) diff --git a/Tests/AsyncHTTPClientTests/HTTPConnectionPool+FactoryTests.swift b/Tests/AsyncHTTPClientTests/HTTPConnectionPool+FactoryTests.swift index 37ff3a1ef..4afea1878 100644 --- a/Tests/AsyncHTTPClientTests/HTTPConnectionPool+FactoryTests.swift +++ b/Tests/AsyncHTTPClientTests/HTTPConnectionPool+FactoryTests.swift @@ -44,6 +44,7 @@ class HTTPConnectionPool_FactoryTests: XCTestCase { let factory = HTTPConnectionPool.ConnectionFactory( key: .init(request), tlsConfiguration: nil, + tlsPinning: nil, clientConfiguration: .init(proxy: .socksServer(host: "127.0.0.1", port: server!.localAddress!.port!)), sslContextCache: .init() ) @@ -86,6 +87,7 @@ class HTTPConnectionPool_FactoryTests: XCTestCase { let factory = HTTPConnectionPool.ConnectionFactory( key: .init(request), tlsConfiguration: nil, + tlsPinning: nil, clientConfiguration: .init(proxy: .socksServer(host: "127.0.0.1", port: server!.localAddress!.port!)) .enableFastFailureModeForTesting(), sslContextCache: .init() @@ -126,6 +128,7 @@ class HTTPConnectionPool_FactoryTests: XCTestCase { let factory = HTTPConnectionPool.ConnectionFactory( key: .init(request), tlsConfiguration: nil, + tlsPinning: nil, clientConfiguration: .init(proxy: .server(host: "127.0.0.1", port: server!.localAddress!.port!)) .enableFastFailureModeForTesting(), sslContextCache: .init() @@ -168,6 +171,7 @@ class HTTPConnectionPool_FactoryTests: XCTestCase { let factory = HTTPConnectionPool.ConnectionFactory( key: .init(request), tlsConfiguration: nil, + tlsPinning: nil, clientConfiguration: .init(tlsConfiguration: tlsConfig) .enableFastFailureModeForTesting(), sslContextCache: .init() diff --git a/Tests/AsyncHTTPClientTests/HTTPConnectionPool+RequestQueueTests.swift b/Tests/AsyncHTTPClientTests/HTTPConnectionPool+RequestQueueTests.swift index 99a61fe47..ffd3cefd5 100644 --- a/Tests/AsyncHTTPClientTests/HTTPConnectionPool+RequestQueueTests.swift +++ b/Tests/AsyncHTTPClientTests/HTTPConnectionPool+RequestQueueTests.swift @@ -92,6 +92,7 @@ final private class MockScheduledRequest: HTTPSchedulableRequest { var poolKey: ConnectionPool.Key { preconditionFailure("Unimplemented") } var tlsConfiguration: TLSConfiguration? { nil } + var tlsPinning: SPKIPinningConfiguration? { nil } var logger: Logger { preconditionFailure("Unimplemented") } var connectionDeadline: NIODeadline { preconditionFailure("Unimplemented") } var preferredEventLoop: EventLoop { preconditionFailure("Unimplemented") } diff --git a/Tests/AsyncHTTPClientTests/HTTPConnectionPoolTests.swift b/Tests/AsyncHTTPClientTests/HTTPConnectionPoolTests.swift index a40703456..4ba67ff35 100644 --- a/Tests/AsyncHTTPClientTests/HTTPConnectionPoolTests.swift +++ b/Tests/AsyncHTTPClientTests/HTTPConnectionPoolTests.swift @@ -35,6 +35,7 @@ class HTTPConnectionPoolTests: XCTestCase { eventLoopGroup: eventLoopGroup, sslContextCache: .init(), tlsConfiguration: .none, + tlsPinning: .none, clientConfiguration: .init(), key: .init(request), delegate: poolDelegate, @@ -89,6 +90,7 @@ class HTTPConnectionPoolTests: XCTestCase { eventLoopGroup: eventLoopGroup, sslContextCache: .init(), tlsConfiguration: .none, + tlsPinning: .none, clientConfiguration: .init(), key: .init(request), delegate: poolDelegate, @@ -157,6 +159,7 @@ class HTTPConnectionPoolTests: XCTestCase { eventLoopGroup: eventLoopGroup, sslContextCache: .init(), tlsConfiguration: .none, + tlsPinning: .none, clientConfiguration: configuration, key: .init(request), delegate: poolDelegate, @@ -214,6 +217,7 @@ class HTTPConnectionPoolTests: XCTestCase { eventLoopGroup: eventLoopGroup, sslContextCache: .init(), tlsConfiguration: .none, + tlsPinning: .none, clientConfiguration: .init(connectionPool: .init(idleTimeout: .milliseconds(500))), key: .init(request), delegate: poolDelegate, @@ -274,6 +278,7 @@ class HTTPConnectionPoolTests: XCTestCase { eventLoopGroup: eventLoopGroup, sslContextCache: .init(), tlsConfiguration: .none, + tlsPinning: .none, clientConfiguration: .init( proxy: .init(host: "localhost", port: httpBin.port, type: .http(.basic(credentials: "invalid"))) ), @@ -328,6 +333,7 @@ class HTTPConnectionPoolTests: XCTestCase { eventLoopGroup: eventLoopGroup, sslContextCache: .init(), tlsConfiguration: .none, + tlsPinning: .none, clientConfiguration: .init( proxy: .init(host: "localhost", port: httpBin.port, type: .http(.basic(credentials: "invalid"))) ), @@ -382,6 +388,7 @@ class HTTPConnectionPoolTests: XCTestCase { eventLoopGroup: eventLoopGroup, sslContextCache: .init(), tlsConfiguration: .none, + tlsPinning: .none, clientConfiguration: .init( proxy: .init(host: "localhost", port: httpBin.port, type: .http(.basic(credentials: "invalid"))) ), @@ -438,6 +445,7 @@ class HTTPConnectionPoolTests: XCTestCase { eventLoopGroup: eventLoopGroup, sslContextCache: .init(), tlsConfiguration: .none, + tlsPinning: .none, clientConfiguration: .init(), key: .init(request), delegate: poolDelegate, @@ -494,6 +502,7 @@ class HTTPConnectionPoolTests: XCTestCase { eventLoopGroup: eventLoopGroup, sslContextCache: .init(), tlsConfiguration: nil, + tlsPinning: nil, clientConfiguration: .init(), key: .init(request), delegate: poolDelegate, diff --git a/Tests/AsyncHTTPClientTests/Mocks/MockConnectionPool.swift b/Tests/AsyncHTTPClientTests/Mocks/MockConnectionPool.swift index 756758131..777504c4a 100644 --- a/Tests/AsyncHTTPClientTests/Mocks/MockConnectionPool.swift +++ b/Tests/AsyncHTTPClientTests/Mocks/MockConnectionPool.swift @@ -715,6 +715,8 @@ final class MockHTTPScheduableRequest: HTTPSchedulableRequest { var tlsConfiguration: TLSConfiguration? { nil } + var tlsPinning: SPKIPinningConfiguration? { nil } + func requestWasQueued(_: HTTPRequestScheduler) { preconditionFailure("Unimplemented") } From eeb37262948a5aafb6174df3c7d10104be229155 Mon Sep 17 00:00:00 2001 From: brennobemoura <37243584+brennobemoura@users.noreply.github.com> Date: Sun, 1 Feb 2026 18:55:06 -0300 Subject: [PATCH 02/13] Added Hashable conformance --- .../ConnectionPool/ChannelHandler/SPKIPinningHandler.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/SPKIPinningHandler.swift b/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/SPKIPinningHandler.swift index 077f89d86..fd419e1e2 100644 --- a/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/SPKIPinningHandler.swift +++ b/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/SPKIPinningHandler.swift @@ -34,7 +34,7 @@ import Crypto /// - Warning: Always deploy with non-empty `backupPins` to avoid lockout during rotation. /// - SeeAlso: https://datatracker.ietf.org/doc/html/rfc5280#section-4.1 /// - SeeAlso: https://owasp.org/www-project-mobile-security-testing-guide/latest/0x05g-Testing-Network-Communication.html -public struct SPKIPinningConfiguration: Sendable { +public struct SPKIPinningConfiguration: Sendable, Hashable { /// Base64-encoded SHA-256 hashes of current production SPKI values. public var primaryPins: Set @@ -88,7 +88,7 @@ public struct SPKIPinningConfiguration: Sendable { /// Pinning failures indicate the server presented a certificate with an unexpected public key. /// This typically occurs during certificate rotation (expected) or MITM attacks (malicious). /// The verification policy determines whether to block the connection or allow it with warnings. -public enum SPKIPinningVerification: Sendable { +public enum SPKIPinningVerification: Sendable, Hashable { /// Immediately terminate the connection on pin validation failure. /// /// Use this policy in production environments where security is paramount. From 8c6e782f76e19a4e42592870ed62d0017226040f Mon Sep 17 00:00:00 2001 From: brennobemoura <37243584+brennobemoura@users.noreply.github.com> Date: Sun, 1 Feb 2026 21:06:29 -0300 Subject: [PATCH 03/13] Fix tlsPinning usage --- .../AsyncAwait/HTTPClientRequest+Prepared.swift | 3 ++- .../AsyncAwait/HTTPClientRequest.swift | 3 +++ .../HTTPConnectionPool+Factory.swift | 2 +- Sources/AsyncHTTPClient/HTTPClient.swift | 17 +++++++++++++++++ Sources/AsyncHTTPClient/HTTPHandler.swift | 15 +-------------- 5 files changed, 24 insertions(+), 16 deletions(-) diff --git a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+Prepared.swift b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+Prepared.swift index c42f4e18c..9f4741a3f 100644 --- a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+Prepared.swift +++ b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+Prepared.swift @@ -83,7 +83,8 @@ extension HTTPClientRequest.Prepared { headers: headers ), body: request.body.map { .init($0) }, - tlsConfiguration: request.tlsConfiguration + tlsConfiguration: request.tlsConfiguration, + tlsPinning: request.tlsPinning ) } } diff --git a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest.swift b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest.swift index dca7de0ef..125fe69ee 100644 --- a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest.swift +++ b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest.swift @@ -53,6 +53,9 @@ public struct HTTPClientRequest: Sendable { /// Request-specific TLS configuration, defaults to no request-specific TLS configuration. public var tlsConfiguration: TLSConfiguration? + /// Optional SPKI pinning configuration for TLS certificate validation. + public var tlsPinning: SPKIPinningConfiguration? + public init(url: String) { self.url = url self.method = .GET diff --git a/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift b/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift index 64d9d128a..4449e7754 100644 --- a/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift +++ b/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift @@ -46,7 +46,7 @@ extension HTTPConnectionPool { self.sslContextCache = sslContextCache self.tlsConfiguration = tlsConfiguration ?? clientConfiguration.tlsConfiguration ?? .makeClientConfiguration() - self.tlsPinning = tlsPinning + self.tlsPinning = tlsPinning ?? clientConfiguration.tlsPinning } } } diff --git a/Sources/AsyncHTTPClient/HTTPClient.swift b/Sources/AsyncHTTPClient/HTTPClient.swift index 80df3b946..218f94e8b 100644 --- a/Sources/AsyncHTTPClient/HTTPClient.swift +++ b/Sources/AsyncHTTPClient/HTTPClient.swift @@ -828,6 +828,9 @@ public final class HTTPClient: Sendable { /// TLS configuration, defaults to `TLSConfiguration.makeClientConfiguration()`. public var tlsConfiguration: Optional + /// Optional SPKI pinning configuration for TLS certificate validation. + public var tlsPinning: SPKIPinningConfiguration? + /// Sometimes it can be useful to connect to one host e.g. `x.example.com` but /// request and validate the certificate chain as if we would connect to `y.example.com`. /// ``dnsOverride`` allows to do just that by mapping host names which we will request and validate the certificate chain, to a different @@ -909,6 +912,7 @@ public final class HTTPClient: Sendable { public init( tlsConfiguration: TLSConfiguration? = nil, + tlsPinning: SPKIPinningConfiguration? = nil, redirectConfiguration: RedirectConfiguration? = nil, timeout: Timeout = Timeout(), connectionPool: ConnectionPool = ConnectionPool(), @@ -917,6 +921,7 @@ public final class HTTPClient: Sendable { decompression: Decompression = .disabled ) { self.tlsConfiguration = tlsConfiguration + self.tlsPinning = tlsPinning self.redirectConfiguration = redirectConfiguration ?? RedirectConfiguration() self.timeout = timeout self.connectionPool = connectionPool @@ -929,6 +934,7 @@ public final class HTTPClient: Sendable { public init( tlsConfiguration: TLSConfiguration? = nil, + tlsPinning: SPKIPinningConfiguration? = nil, redirectConfiguration: RedirectConfiguration? = nil, timeout: Timeout = Timeout(), proxy: Proxy? = nil, @@ -937,6 +943,7 @@ public final class HTTPClient: Sendable { ) { self.init( tlsConfiguration: tlsConfiguration, + tlsPinning: tlsPinning, redirectConfiguration: redirectConfiguration, timeout: timeout, connectionPool: ConnectionPool(), @@ -948,6 +955,7 @@ public final class HTTPClient: Sendable { public init( certificateVerification: CertificateVerification, + tlsPinning: SPKIPinningConfiguration? = nil, redirectConfiguration: RedirectConfiguration? = nil, timeout: Timeout = Timeout(), maximumAllowedIdleTimeInConnectionPool: TimeAmount = .seconds(60), @@ -959,6 +967,7 @@ public final class HTTPClient: Sendable { tlsConfig.certificateVerification = certificateVerification self.init( tlsConfiguration: tlsConfig, + tlsPinning: tlsPinning, redirectConfiguration: redirectConfiguration, timeout: timeout, connectionPool: ConnectionPool(idleTimeout: maximumAllowedIdleTimeInConnectionPool), @@ -970,6 +979,7 @@ public final class HTTPClient: Sendable { public init( certificateVerification: CertificateVerification, + tlsPinning: SPKIPinningConfiguration? = nil, redirectConfiguration: RedirectConfiguration? = nil, timeout: Timeout = Timeout(), connectionPool: TimeAmount = .seconds(60), @@ -982,6 +992,7 @@ public final class HTTPClient: Sendable { tlsConfig.certificateVerification = certificateVerification self.init( tlsConfiguration: tlsConfig, + tlsPinning: tlsPinning, redirectConfiguration: redirectConfiguration, timeout: timeout, connectionPool: ConnectionPool(idleTimeout: connectionPool), @@ -993,6 +1004,7 @@ public final class HTTPClient: Sendable { public init( certificateVerification: CertificateVerification, + tlsPinning: SPKIPinningConfiguration? = nil, redirectConfiguration: RedirectConfiguration? = nil, timeout: Timeout = Timeout(), proxy: Proxy? = nil, @@ -1001,6 +1013,7 @@ public final class HTTPClient: Sendable { ) { self.init( certificateVerification: certificateVerification, + tlsPinning: tlsPinning, redirectConfiguration: redirectConfiguration, timeout: timeout, maximumAllowedIdleTimeInConnectionPool: .seconds(60), @@ -1012,6 +1025,7 @@ public final class HTTPClient: Sendable { public init( tlsConfiguration: TLSConfiguration? = nil, + tlsPinning: SPKIPinningConfiguration? = nil, redirectConfiguration: RedirectConfiguration? = nil, timeout: Timeout = Timeout(), connectionPool: ConnectionPool = ConnectionPool(), @@ -1024,6 +1038,7 @@ public final class HTTPClient: Sendable { ) { self.init( tlsConfiguration: tlsConfiguration, + tlsPinning: tlsPinning, redirectConfiguration: redirectConfiguration, timeout: timeout, connectionPool: connectionPool, @@ -1038,6 +1053,7 @@ public final class HTTPClient: Sendable { public init( tlsConfiguration: TLSConfiguration? = nil, + tlsPinning: SPKIPinningConfiguration? = nil, redirectConfiguration: RedirectConfiguration? = nil, timeout: Timeout = Timeout(), connectionPool: ConnectionPool = ConnectionPool(), @@ -1051,6 +1067,7 @@ public final class HTTPClient: Sendable { ) { self.init( tlsConfiguration: tlsConfiguration, + tlsPinning: tlsPinning, redirectConfiguration: redirectConfiguration, timeout: timeout, connectionPool: connectionPool, diff --git a/Sources/AsyncHTTPClient/HTTPHandler.swift b/Sources/AsyncHTTPClient/HTTPHandler.swift index 5f535a51a..a8fa6516b 100644 --- a/Sources/AsyncHTTPClient/HTTPHandler.swift +++ b/Sources/AsyncHTTPClient/HTTPHandler.swift @@ -232,20 +232,7 @@ extension HTTPClient { /// Request-specific TLS configuration, defaults to no request-specific TLS configuration. public var tlsConfiguration: TLSConfiguration? - /// Optional SPKI (SubjectPublicKeyInfo) pinning configuration for TLS certificate validation. - /// - /// When configured, the client validates the server's leaf certificate public key against the provided - /// SHA-256 hashes after TLS handshake completion. This provides protection against compromised - /// Certificate Authorities by enforcing explicit trust in specific cryptographic identities. - /// - /// - Warning: Always configure non-empty `backupPins` in production environments. Missing backup pins - /// during certificate rotation will cause complete service outage (catastrophic lockout). - /// - /// - Note: Despite the industry term "certificate pinning", this implementation pins the SPKI structure - /// (RFC 5280 Section 4.1) rather than the full certificate. This approach survives legitimate - /// certificate rotations while maintaining cryptographic security. - /// - /// - SeeAlso: https://owasp.org/www-project-mobile-security-testing-guide/latest/0x05g-Testing-Network-Communication.html + /// Optional SPKI pinning configuration for TLS certificate validation. public var tlsPinning: SPKIPinningConfiguration? /// Parsed, validated and deconstructed URL. From 5f1dfd9ba146965050f723dae006fe3d634567fb Mon Sep 17 00:00:00 2001 From: brennobemoura <37243584+brennobemoura@users.noreply.github.com> Date: Sun, 1 Feb 2026 22:01:17 -0300 Subject: [PATCH 04/13] Fix hash digestion --- .../ChannelHandler/SPKIPinningHandler.swift | 54 ++++++++++--------- .../HTTPConnectionPool+Factory.swift | 8 +-- Sources/AsyncHTTPClient/HTTPClient.swift | 7 +++ 3 files changed, 41 insertions(+), 28 deletions(-) diff --git a/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/SPKIPinningHandler.swift b/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/SPKIPinningHandler.swift index fd419e1e2..2e0033ed3 100644 --- a/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/SPKIPinningHandler.swift +++ b/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/SPKIPinningHandler.swift @@ -120,14 +120,13 @@ public enum SPKIPinningVerification: Sendable, Hashable { /// risk catastrophic lockout during certificate rotation. /// - SeeAlso: OWASP MSTG-NETWORK-4, NIST SP 800-52 Rev. 2 Section 3.4.3 @available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) -final class SPKIPinningHandler: ChannelInboundHandler { +final class SPKIPinningHandler: ChannelInboundHandler, RemovableChannelHandler { - typealias InboundIn = Any - typealias OutboundOut = Any + typealias InboundIn = NIOAny private let logger: Logger - private let validPins: Set - private let backupPins: Set + private let validPins: Set + private let backupPins: Set private let verification: SPKIPinningVerification /// Creates a pinning handler with SHA-256 hashes of SPKI structures. @@ -151,10 +150,10 @@ final class SPKIPinningHandler: ChannelInboundHandler { logger: Logger ) { self.validPins = Set(primaryPins.compactMap { - Data(base64Encoded: $0).map(SHA256.hash(data:)) + Data(base64Encoded: $0) }) self.backupPins = Set(backupPins.compactMap { - Data(base64Encoded: $0).map(SHA256.hash(data:)) + Data(base64Encoded: $0) }) self.verification = verification self.logger = logger @@ -176,10 +175,10 @@ final class SPKIPinningHandler: ChannelInboundHandler { return } - validateSPKI(context: context) + validateSPKI(context: context, event: tlsEvent) } - private func validateSPKI(context: ChannelHandlerContext) { + private func validateSPKI(context: ChannelHandlerContext, event: TLSUserEvent) { context.channel.pipeline.handler(type: NIOSSLHandler.self).assumeIsolated().whenComplete { result in switch result { case .success(let sslHandler): @@ -187,7 +186,8 @@ final class SPKIPinningHandler: ChannelInboundHandler { self.handlePinningFailure( context: context, reason: "Empty certificate chain", - receivedSPKIHash: nil + receivedSPKIHash: nil, + event: event ) return } @@ -195,14 +195,17 @@ final class SPKIPinningHandler: ChannelInboundHandler { do { let publicKey = try leaf.extractPublicKey() let spkiBytes = try publicKey.toSPKIBytes() - let spkiData = Data(spkiBytes) - let receivedHash = SHA256.hash(data: spkiData) + let receivedHash = Data(SHA256.hash(data: spkiBytes)) + + self.validPins.forEach { + print($0.base64EncodedString()) + } let isValid = self.validPins.contains(receivedHash) || self.backupPins.contains(receivedHash) if isValid { - context.fireUserInboundEventTriggered(TLSUserEvent.handshakeCompleted) + context.fireUserInboundEventTriggered(event) self.logger.debug( "SPKI pin validation succeeded", metadata: [ @@ -214,7 +217,8 @@ final class SPKIPinningHandler: ChannelInboundHandler { self.handlePinningFailure( context: context, reason: "SPKI pin mismatch", - receivedSPKIHash: receivedHash + receivedSPKIHash: receivedHash, + event: event ) } @@ -222,7 +226,8 @@ final class SPKIPinningHandler: ChannelInboundHandler { self.handlePinningFailure( context: context, reason: "SPKI extraction failed: \(error)", - receivedSPKIHash: nil + receivedSPKIHash: nil, + event: event ) } @@ -230,7 +235,8 @@ final class SPKIPinningHandler: ChannelInboundHandler { self.handlePinningFailure( context: context, reason: "SSL handler not found: \(error)", - receivedSPKIHash: nil + receivedSPKIHash: nil, + event: event ) } } @@ -239,7 +245,8 @@ final class SPKIPinningHandler: ChannelInboundHandler { private func handlePinningFailure( context: ChannelHandlerContext, reason: String, - receivedSPKIHash: SHA256Digest? + receivedSPKIHash: Data?, + event: TLSUserEvent ) { let metadata: Logger.Metadata = [ "pinning_action": .string(verification == .failRequest ? "blocked" : "allowed_with_warning"), @@ -251,22 +258,19 @@ final class SPKIPinningHandler: ChannelInboundHandler { switch verification { case .failRequest: logger.error("SPKI pinning failed — connection blocked", metadata: metadata) + + let error = HTTPClientError.invalidCertificatePinning(reason) + context.fireErrorCaught(error) + context.close(mode: .all, promise: nil) case .logAndProceed: logger.warning("SPKI pinning failed — connection allowed (staging mode)", metadata: metadata) - context.fireUserInboundEventTriggered(TLSUserEvent.handshakeCompleted) + context.fireUserInboundEventTriggered(event) } } } -@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) -extension SHA256Digest { - func base64EncodedString() -> String { - Data(self).base64EncodedString() - } -} - private enum PinningError: Error { case publicKeyExtractionFailed case spkiSerializationFailed diff --git a/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift b/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift index 4449e7754..cae25c816 100644 --- a/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift +++ b/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift @@ -396,8 +396,6 @@ extension HTTPConnectionPool.ConnectionFactory { serverHostname: sslServerHostname ) try channel.pipeline.syncOperations.addHandler(sslHandler) - let tlsEventHandler = TLSEventsHandler(deadline: deadline) - try channel.pipeline.syncOperations.addHandler(tlsEventHandler) if #available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *), let tlsPinning { let pinningHandler = SPKIPinningHandler( @@ -409,6 +407,9 @@ extension HTTPConnectionPool.ConnectionFactory { try channel.pipeline.syncOperations.addHandler(pinningHandler) } + let tlsEventHandler = TLSEventsHandler(deadline: deadline) + try channel.pipeline.syncOperations.addHandler(tlsEventHandler) + return tlsEventHandler.tlsEstablishedFuture! } catch { return channel.eventLoop.makeFailedFuture(error) @@ -607,7 +608,6 @@ extension HTTPConnectionPool.ConnectionFactory { let tlsEventHandler = TLSEventsHandler(deadline: deadline) try sync.addHandler(sslHandler) - try sync.addHandler(tlsEventHandler) if #available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *), let tlsPinning { let pinningHandler = SPKIPinningHandler( @@ -619,6 +619,8 @@ extension HTTPConnectionPool.ConnectionFactory { try sync.addHandler(pinningHandler) } + try sync.addHandler(tlsEventHandler) + return channel.eventLoop.makeSucceededVoidFuture() } catch { return channel.eventLoop.makeFailedFuture(error) diff --git a/Sources/AsyncHTTPClient/HTTPClient.swift b/Sources/AsyncHTTPClient/HTTPClient.swift index 218f94e8b..af2222cd0 100644 --- a/Sources/AsyncHTTPClient/HTTPClient.swift +++ b/Sources/AsyncHTTPClient/HTTPClient.swift @@ -1405,6 +1405,7 @@ public struct HTTPClientError: Error, Equatable, CustomStringConvertible { case socksHandshakeTimeout case httpProxyHandshakeTimeout case tlsHandshakeTimeout + case invalidCertificatePinning(String) case serverOfferedUnsupportedApplicationProtocol(String) case requestStreamCancelled case getConnectionFromPoolTimeout @@ -1484,6 +1485,8 @@ public struct HTTPClientError: Error, Equatable, CustomStringConvertible { return "HTTP proxy handshake timeout" case .tlsHandshakeTimeout: return "TLS handshake timeout" + case .invalidCertificatePinning: + return "Invalid certificate pinning" case .serverOfferedUnsupportedApplicationProtocol: return "Server offered unsupported application protocol" case .requestStreamCancelled: @@ -1563,6 +1566,10 @@ public struct HTTPClientError: Error, Equatable, CustomStringConvertible { public static let httpProxyHandshakeTimeout = HTTPClientError(code: .httpProxyHandshakeTimeout) /// The tls handshake timed out. public static let tlsHandshakeTimeout = HTTPClientError(code: .tlsHandshakeTimeout) + /// Certificate pinning validation failed + public static func invalidCertificatePinning(_ reason: String) -> HTTPClientError { + HTTPClientError(code: .invalidCertificatePinning(reason)) + } /// The remote server only offered an unsupported application protocol public static func serverOfferedUnsupportedApplicationProtocol(_ proto: String) -> HTTPClientError { HTTPClientError(code: .serverOfferedUnsupportedApplicationProtocol(proto)) From a224e7ee07510750463da359dedd32ab7fff9380 Mon Sep 17 00:00:00 2001 From: brennobemoura <37243584+brennobemoura@users.noreply.github.com> Date: Sun, 1 Feb 2026 22:11:07 -0300 Subject: [PATCH 05/13] Lint code --- Tests/AsyncHTTPClientTests/HTTPClientTests.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Tests/AsyncHTTPClientTests/HTTPClientTests.swift b/Tests/AsyncHTTPClientTests/HTTPClientTests.swift index c34efafef..9b4d02e10 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientTests.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientTests.swift @@ -4032,12 +4032,12 @@ final class HTTPClientTests: XCTestCaseHTTPClientTestsBaseClass { configuration: configuration ) let decoder = JSONDecoder() - + defer { XCTAssertNoThrow(try localClient.syncShutdown()) XCTAssertNoThrow(try localHTTPBin.shutdown()) } - + // First two requests use identical TLS configurations. var tlsConfig = TLSConfiguration.makeClientConfiguration() tlsConfig.certificateVerification = .none @@ -4053,7 +4053,7 @@ final class HTTPClientTests: XCTestCaseHTTPClientTestsBaseClass { return } let firstConnectionNumber = try decoder.decode(RequestInfo.self, from: firstBody).connectionNumber - + let secondRequest = try HTTPClient.Request( url: "https://localhost:\(localHTTPBin.port)/get", method: .GET, @@ -4066,7 +4066,7 @@ final class HTTPClientTests: XCTestCaseHTTPClientTestsBaseClass { return } let secondConnectionNumber = try decoder.decode(RequestInfo.self, from: secondBody).connectionNumber - + // Uses a different TLS config. var tlsConfig2 = TLSConfiguration.makeClientConfiguration() tlsConfig2.certificateVerification = .none @@ -4083,7 +4083,7 @@ final class HTTPClientTests: XCTestCaseHTTPClientTestsBaseClass { return } let thirdConnectionNumber = try decoder.decode(RequestInfo.self, from: thirdBody).connectionNumber - + XCTAssertEqual(firstResponse.status, .ok) XCTAssertEqual(secondResponse.status, .ok) XCTAssertEqual(thirdResponse.status, .ok) From 32a4100fab8628ed849fc1088174ac62147940d3 Mon Sep 17 00:00:00 2001 From: brennobemoura <37243584+brennobemoura@users.noreply.github.com> Date: Mon, 2 Feb 2026 10:12:53 -0300 Subject: [PATCH 06/13] Provide support for various algorithms for comparing SPKI --- .../ChannelHandler/SPKIPinningHandler.swift | 273 ++++++++++-------- .../HTTPConnectionPool+Factory.swift | 8 +- Sources/AsyncHTTPClient/HTTPClient.swift | 7 +- Sources/AsyncHTTPClient/HTTPHandler.swift | 6 +- 4 files changed, 160 insertions(+), 134 deletions(-) diff --git a/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/SPKIPinningHandler.swift b/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/SPKIPinningHandler.swift index 2e0033ed3..1efd2fe23 100644 --- a/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/SPKIPinningHandler.swift +++ b/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/SPKIPinningHandler.swift @@ -18,147 +18,193 @@ import NIOTLS import NIOSSL import Logging import Crypto +import Algorithms -/// Configuration for SPKI (SubjectPublicKeyInfo) pinning. +/// A hash of a SubjectPublicKeyInfo (SPKI) structure for certificate pinning. /// -/// SPKI pinning validates the cryptographic identity of the server by hashing the -/// SubjectPublicKeyInfo structure (RFC 5280, Section 4.1) rather than the entire certificate. +/// Validates server identity by hashing the DER-encoded public key structure +/// (RFC 5280, Section 4.1) rather than the full certificate. This approach: +/// - Survives legitimate certificate rotations (same key, new expiration) +/// - Prevents algorithm downgrade attacks +/// - Provides stronger security guarantees than full-certificate pinning /// -/// Why SPKI instead of certificate pinning? -/// - ✅ Survives legitimate certificate rotations (same key, new expiration) -/// - ✅ Prevents downgrade attacks (algorithm identifier is included in the hash) -/// - ❌ Certificate pinning fails on routine renewals and provides false security +/// Equality considers both digest bytes and hash algorithm — hashes with identical +/// bytes but different algorithms (e.g., SHA-256 vs SHA-384) are distinct values. /// -/// - Note: Despite the industry term "certificate pinning", cryptographic best practices -/// (OWASP MSTG, NIST SP 800-52 Rev. 2) mandate SPKI-based pinning. -/// - Warning: Always deploy with non-empty `backupPins` to avoid lockout during rotation. /// - SeeAlso: https://datatracker.ietf.org/doc/html/rfc5280#section-4.1 /// - SeeAlso: https://owasp.org/www-project-mobile-security-testing-guide/latest/0x05g-Testing-Network-Communication.html +public struct SPKIHash: Sendable, Hashable { + + /// The raw hash digest bytes of the SPKI structure. + public let bytes: Data + + fileprivate let algorithmID: ObjectIdentifier + private let algorithm: @Sendable (Data) -> any Sequence + + // MARK: - Initialization + + /// Creates an SPKI hash from a base64-encoded string using SHA-256. + /// + /// - Parameters: + /// - base64: Base64-encoded hash digest. Whitespace is automatically stripped. + /// + /// - Throws: `HTTPClientError.invalidDigestLength` if decoded data isn't 32 bytes. + @available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) + public init(base64: String) throws { + guard let data = Data(base64Encoded: base64) else { + throw HTTPClientError.invalidDigestLength + } + try self.init(algorithm: SHA256.self, bytes: data) + } + + /// Creates an SPKI hash using a custom hash algorithm and base64-encoded string. + /// + /// - Parameters: + /// - algorithm: Hash algorithm used to generate the digest. + /// - base64: Base64-encoded hash digest. + /// + /// - Throws: `HTTPClientError.invalidDigestLength` if length doesn't match algorithm. + @available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) + public init(algorithm: Algorithm.Type, base64: String) throws { + guard let data = Data(base64Encoded: base64) else { + throw HTTPClientError.invalidDigestLength + } + try self.init(algorithm: algorithm, bytes: data) + } + + /// Creates an SPKI hash from raw digest bytes using a specified hash algorithm. + /// + /// - Parameters: + /// - algorithm: Hash algorithm that generated the digest bytes. + /// - bytes: Raw digest bytes. + /// + /// - Throws: `HTTPClientError.invalidDigestLength` if byte count doesn't match algorithm. + @available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) + public init(algorithm: Algorithm.Type, bytes: Data) throws { + guard bytes.count == Algorithm.Digest.byteCount else { + throw HTTPClientError.invalidDigestLength + } + self.bytes = bytes + self.algorithm = Algorithm.hash(data:) + self.algorithmID = .init(algorithm) + } + + // MARK: - Equality and Hashing + + public static func ==(lhs: Self, rhs: Self) -> Bool { + lhs.bytes == rhs.bytes && lhs.algorithmID == rhs.algorithmID + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(bytes) + hasher.combine(algorithmID) + } + + fileprivate func hash(_ spkiData: Data) -> Data { + Data(algorithm(spkiData)) + } +} + +/// Constant-time comparison to prevent timing attacks. +/// Always iterates all candidates and bytes regardless of match position. +internal func constantTimeAnyMatch(_ target: Data, _ candidates: [SPKIHash]) -> Bool { + guard !candidates.isEmpty else { return false } + + var anyMatch: UInt8 = 0 + for candidate in candidates { + var diff: UInt8 = 0 + for (a, b) in zip(target, candidate.bytes) { + diff |= a ^ b + } + anyMatch |= (diff == 0) ? 1 : 0 + } + return anyMatch != 0 +} + +/// Configuration for SPKI (SubjectPublicKeyInfo) pinning. +/// +/// Validates server identity by hashing the public key structure rather than the +/// full certificate. Supports multiple hash algorithms simultaneously. +/// +/// - Warning: Always deploy with non-empty `backupPins` to avoid lockout during rotation. public struct SPKIPinningConfiguration: Sendable, Hashable { - /// Base64-encoded SHA-256 hashes of current production SPKI values. - public var primaryPins: Set + /// Current production SPKI hashes. + public let primaryPins: [SPKIHash] - /// Base64-encoded SHA-256 hashes for upcoming certificate rotations. - /// Required in production to prevent catastrophic lockout. - public var backupPins: Set + /// SPKI hashes for upcoming certificate rotations. + public let backupPins: [SPKIHash] /// Failure behavior policy on pin mismatch. - public var verification: SPKIPinningVerification + public let verification: SPKIPinningVerification + + private let pinsByAlgorithm: [ObjectIdentifier: [SPKIHash]] /// Creates an SPKI pinning configuration with primary and backup pins. /// /// - Parameters: - /// - primaryPins: Base64-encoded SHA-256 hashes of the current production certificate's - /// SubjectPublicKeyInfo. These pins are actively validated against incoming - /// connections. - /// - backupPins: Base64-encoded SHA-256 hashes for certificates scheduled for future deployment. - /// Required in production environments to prevent service disruption during - /// certificate rotation. Must contain at least one pin when using - /// `.failRequest` verification mode. - /// - verification: Policy for handling pin validation failures. Use `.failRequest` for - /// production (security-critical) environments and `.logAndProceed` only for - /// staging/debugging. - /// - /// - Warning: Deploying with empty `backupPins` in `.failRequest` mode risks catastrophic - /// lockout when certificates rotate. Always deploy backup pins at least 30 days before - /// certificate expiration. + /// - primaryPins: Hashes of current production certificates. + /// - backupPins: Hashes for certificates scheduled for future deployment. + /// Required in production to prevent service disruption during rotation. + /// - verification: Policy for handling pin validation failures. /// - /// - Important: Hashes must be generated from the SPKI structure (not the full certificate) - /// using SHA-256 and Base64 encoding. Example OpenSSL command: - /// ``` - /// openssl s_client -connect example.com:443 -servername example.com 2>/dev/null | \ - /// openssl x509 -pubkey -noout | \ - /// openssl pkey -pubin -outform der | \ - /// openssl dgst -sha256 -binary | \ - /// openssl base64 -A - /// ``` + /// - Warning: Deploying with empty `backupPins` in `.failRequest` mode risks + /// catastrophic lockout when certificates rotate. public init( - primaryPins: Set, - backupPins: Set, + primaryPins: [SPKIHash], + backupPins: [SPKIHash], verification: SPKIPinningVerification = .failRequest ) { self.primaryPins = primaryPins self.backupPins = backupPins + self.pinsByAlgorithm = Dictionary(grouping: Set(primaryPins + backupPins), by: \.algorithmID) self.verification = verification } + + internal func contains(spkiBytes: [UInt8]) -> Bool { + let spkiData = Data(spkiBytes) + + var anyMatch: UInt8 = 0 + for hashes in pinsByAlgorithm.values { + guard let first = hashes.first else { continue } + let computedHash = first.hash(spkiData) + let isMatch = constantTimeAnyMatch(computedHash, hashes) + anyMatch |= isMatch ? 1 : 0 + } + return anyMatch != 0 + } } -/// Defines the behavior when SPKI pin validation fails. -/// -/// Pinning failures indicate the server presented a certificate with an unexpected public key. -/// This typically occurs during certificate rotation (expected) or MITM attacks (malicious). -/// The verification policy determines whether to block the connection or allow it with warnings. +/// Behavior when SPKI pin validation fails. public enum SPKIPinningVerification: Sendable, Hashable { /// Immediately terminate the connection on pin validation failure. - /// - /// Use this policy in production environments where security is paramount. - /// Connections will fail if: - /// - Certificate was rotated without deploying corresponding backup pins - /// - Server presents unexpected certificate (potential MITM attack) - /// - Network interception by corporate proxies/security appliances - /// - /// - Warning: Deploy only with valid backup pins to avoid service disruption during rotation. case failRequest - /// Allow the connection to proceed but log a structured warning. - /// - /// Use this policy exclusively in staging, development, or debugging environments. - /// Never use in production — this effectively disables pinning security guarantees. - /// - /// - Warning: This mode provides auditability without security enforcement. - /// Connections proceed even with unexpected certificates. + /// Allow the connection to proceed but log a warning. case logAndProceed } -/// A ChannelHandler that implements certificate pinning using SPKI (SubjectPublicKeyInfo) hashes. +/// ChannelHandler that implements certificate pinning using SPKI hashes. /// -/// This handler validates the server's leaf certificate public key against a set of pre-configured -/// SHA-256 hashes after TLS handshake completion. Pinning provides protection against compromised -/// Certificate Authorities by enforcing explicit trust in specific public keys. +/// Validates the server's leaf certificate public key against pre-configured hashes +/// after TLS handshake completion. /// -/// - Warning: Never deploy without backup pins in production environments. Missing backup pins -/// risk catastrophic lockout during certificate rotation. -/// - SeeAlso: OWASP MSTG-NETWORK-4, NIST SP 800-52 Rev. 2 Section 3.4.3 +/// - Warning: Never deploy without backup pins in production environments. @available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) final class SPKIPinningHandler: ChannelInboundHandler, RemovableChannelHandler { typealias InboundIn = NIOAny + private let tlsPinning: SPKIPinningConfiguration private let logger: Logger - private let validPins: Set - private let backupPins: Set - private let verification: SPKIPinningVerification - /// Creates a pinning handler with SHA-256 hashes of SPKI structures. - /// - /// - Parameters: - /// - primaryPins: Base64-encoded SHA-256 hashes of current production SPKI values. - /// - backupPins: Base64-encoded SHA-256 hashes for upcoming certificate rotations. - /// Required in production to prevent lockout during certificate renewal. - /// - verification: Failure behavior policy: - /// - `.failRequest`: Immediately terminate connections with invalid pins (production) - /// - `.logAndProceed`: Allow connection but log warning (staging/debugging only) - /// - logger: Structured logger for audit trails and monitoring integration. - /// - /// - Warning: Production deployments must include non-empty backupPins. Certificate rotation - /// without pre-deployed backup pins will cause complete service outage. - /// - SeeAlso: https://owasp.org/www-project-mobile-security-testing-guide/latest/0x05g-Testing-Network-Communication.html init( - primaryPins: Set, - backupPins: Set = [], - verification: SPKIPinningVerification, + tlsPinning: SPKIPinningConfiguration, logger: Logger ) { - self.validPins = Set(primaryPins.compactMap { - Data(base64Encoded: $0) - }) - self.backupPins = Set(backupPins.compactMap { - Data(base64Encoded: $0) - }) - self.verification = verification + self.tlsPinning = tlsPinning self.logger = logger - if backupPins.isEmpty && verification == .failRequest { + if tlsPinning.backupPins.isEmpty && tlsPinning.verification == .failRequest { logger.warning( "SPKIPinningHandler deployed without backup pins in failRequest mode - catastrophic lockout risk!", metadata: [ @@ -186,7 +232,6 @@ final class SPKIPinningHandler: ChannelInboundHandler, RemovableChannelHandler { self.handlePinningFailure( context: context, reason: "Empty certificate chain", - receivedSPKIHash: nil, event: event ) return @@ -195,29 +240,16 @@ final class SPKIPinningHandler: ChannelInboundHandler, RemovableChannelHandler { do { let publicKey = try leaf.extractPublicKey() let spkiBytes = try publicKey.toSPKIBytes() - let receivedHash = Data(SHA256.hash(data: spkiBytes)) - - self.validPins.forEach { - print($0.base64EncodedString()) - } - let isValid = self.validPins.contains(receivedHash) || - self.backupPins.contains(receivedHash) + let isValid = self.tlsPinning.contains(spkiBytes: spkiBytes) if isValid { context.fireUserInboundEventTriggered(event) - self.logger.debug( - "SPKI pin validation succeeded", - metadata: [ - "spki_hash": .string(receivedHash.base64EncodedString()), - "matched_type": .string(self.validPins.contains(receivedHash) ? "primary" : "backup") - ] - ) + self.logger.debug("SPKI pin validation succeeded") } else { self.handlePinningFailure( context: context, reason: "SPKI pin mismatch", - receivedSPKIHash: receivedHash, event: event ) } @@ -226,7 +258,6 @@ final class SPKIPinningHandler: ChannelInboundHandler, RemovableChannelHandler { self.handlePinningFailure( context: context, reason: "SPKI extraction failed: \(error)", - receivedSPKIHash: nil, event: event ) } @@ -235,7 +266,6 @@ final class SPKIPinningHandler: ChannelInboundHandler, RemovableChannelHandler { self.handlePinningFailure( context: context, reason: "SSL handler not found: \(error)", - receivedSPKIHash: nil, event: event ) } @@ -245,17 +275,15 @@ final class SPKIPinningHandler: ChannelInboundHandler, RemovableChannelHandler { private func handlePinningFailure( context: ChannelHandlerContext, reason: String, - receivedSPKIHash: Data?, event: TLSUserEvent ) { let metadata: Logger.Metadata = [ - "pinning_action": .string(verification == .failRequest ? "blocked" : "allowed_with_warning"), - "received_spki_hash": .string(receivedSPKIHash?.base64EncodedString() ?? "unknown"), - "expected_primary_pins": .string(validPins.map { $0.base64EncodedString() }.joined(separator: ", ")), - "expected_backup_pins": .string(backupPins.map { $0.base64EncodedString() }.joined(separator: ", ")) + "pinning_action": .string(tlsPinning.verification == .failRequest ? "blocked" : "allowed_with_warning"), + "expected_primary_pins": .string(tlsPinning.primaryPins.map { $0.bytes.base64EncodedString() }.joined(separator: ", ")), + "expected_backup_pins": .string(tlsPinning.backupPins.map { $0.bytes.base64EncodedString() }.joined(separator: ", ")) ] - switch verification { + switch tlsPinning.verification { case .failRequest: logger.error("SPKI pinning failed — connection blocked", metadata: metadata) @@ -270,8 +298,3 @@ final class SPKIPinningHandler: ChannelInboundHandler, RemovableChannelHandler { } } } - -private enum PinningError: Error { - case publicKeyExtractionFailed - case spkiSerializationFailed -} diff --git a/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift b/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift index cae25c816..be118fb46 100644 --- a/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift +++ b/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift @@ -399,9 +399,7 @@ extension HTTPConnectionPool.ConnectionFactory { if #available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *), let tlsPinning { let pinningHandler = SPKIPinningHandler( - primaryPins: tlsPinning.primaryPins, - backupPins: tlsPinning.backupPins, - verification: tlsPinning.verification, + tlsPinning: tlsPinning, logger: logger ) try channel.pipeline.syncOperations.addHandler(pinningHandler) @@ -611,9 +609,7 @@ extension HTTPConnectionPool.ConnectionFactory { if #available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *), let tlsPinning { let pinningHandler = SPKIPinningHandler( - primaryPins: tlsPinning.primaryPins, - backupPins: tlsPinning.backupPins, - verification: tlsPinning.verification, + tlsPinning: tlsPinning, logger: logger ) try sync.addHandler(pinningHandler) diff --git a/Sources/AsyncHTTPClient/HTTPClient.swift b/Sources/AsyncHTTPClient/HTTPClient.swift index af2222cd0..04ce61d5a 100644 --- a/Sources/AsyncHTTPClient/HTTPClient.swift +++ b/Sources/AsyncHTTPClient/HTTPClient.swift @@ -1405,6 +1405,7 @@ public struct HTTPClientError: Error, Equatable, CustomStringConvertible { case socksHandshakeTimeout case httpProxyHandshakeTimeout case tlsHandshakeTimeout + case invalidDigestLength case invalidCertificatePinning(String) case serverOfferedUnsupportedApplicationProtocol(String) case requestStreamCancelled @@ -1485,6 +1486,8 @@ public struct HTTPClientError: Error, Equatable, CustomStringConvertible { return "HTTP proxy handshake timeout" case .tlsHandshakeTimeout: return "TLS handshake timeout" + case .invalidDigestLength: + return "Invalid digest length" case .invalidCertificatePinning: return "Invalid certificate pinning" case .serverOfferedUnsupportedApplicationProtocol: @@ -1566,7 +1569,9 @@ public struct HTTPClientError: Error, Equatable, CustomStringConvertible { public static let httpProxyHandshakeTimeout = HTTPClientError(code: .httpProxyHandshakeTimeout) /// The tls handshake timed out. public static let tlsHandshakeTimeout = HTTPClientError(code: .tlsHandshakeTimeout) - /// Certificate pinning validation failed + /// The hash digest length is invalid. + public static let invalidDigestLength = HTTPClientError(code: .invalidDigestLength) + /// The server's certificate did not match any pinned SPKI hash. public static func invalidCertificatePinning(_ reason: String) -> HTTPClientError { HTTPClientError(code: .invalidCertificatePinning(reason)) } diff --git a/Sources/AsyncHTTPClient/HTTPHandler.swift b/Sources/AsyncHTTPClient/HTTPHandler.swift index a8fa6516b..3b5a04860 100644 --- a/Sources/AsyncHTTPClient/HTTPHandler.swift +++ b/Sources/AsyncHTTPClient/HTTPHandler.swift @@ -273,7 +273,8 @@ extension HTTPClient { /// - method: HTTP method. /// - headers: Custom HTTP headers. /// - body: Request body. - /// - tlsConfiguration: Request TLS configuration + /// - tlsConfiguration: Request TLS configuration. + /// - tlsPinning: SPKI pinning configuration to validate server certificates. /// - throws: /// - `invalidURL` if URL cannot be parsed. /// - `emptyScheme` if URL does not contain HTTP scheme. @@ -332,7 +333,8 @@ extension HTTPClient { /// - method: HTTP method. /// - headers: Custom HTTP headers. /// - body: Request body. - /// - tlsConfiguration: Request TLS configuration + /// - tlsConfiguration: Request TLS configuration. + /// - tlsPinning: SPKI pinning configuration to validate server certificates. /// - throws: /// - `emptyScheme` if URL does not contain HTTP scheme. /// - `unsupportedScheme` if URL does contains unsupported HTTP scheme. From 26675d1fc13069998b9be9a37cd77a822436bcd0 Mon Sep 17 00:00:00 2001 From: brennobemoura <37243584+brennobemoura@users.noreply.github.com> Date: Mon, 2 Feb 2026 11:33:41 -0300 Subject: [PATCH 07/13] Improved properties names and applications --- .../ChannelHandler/SPKIPinningHandler.swift | 108 ++++++------ .../AsyncHTTPClientTests/SPKIHashTests.swift | 154 ++++++++++++++++++ 2 files changed, 211 insertions(+), 51 deletions(-) create mode 100644 Tests/AsyncHTTPClientTests/SPKIHashTests.swift diff --git a/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/SPKIPinningHandler.swift b/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/SPKIPinningHandler.swift index 1efd2fe23..ef59f2473 100644 --- a/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/SPKIPinningHandler.swift +++ b/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/SPKIPinningHandler.swift @@ -20,22 +20,20 @@ import Logging import Crypto import Algorithms -/// A hash of a SubjectPublicKeyInfo (SPKI) structure for certificate pinning. +/// SPKI hash for certificate pinning validation. /// -/// Validates server identity by hashing the DER-encoded public key structure -/// (RFC 5280, Section 4.1) rather than the full certificate. This approach: -/// - Survives legitimate certificate rotations (same key, new expiration) -/// - Prevents algorithm downgrade attacks -/// - Provides stronger security guarantees than full-certificate pinning +/// Validates server identity using the DER-encoded public key structure (RFC 5280, Section 4.1) +/// rather than the full certificate. This approach survives legitimate certificate rotations +/// and prevents algorithm downgrade attacks. /// -/// Equality considers both digest bytes and hash algorithm — hashes with identical -/// bytes but different algorithms (e.g., SHA-256 vs SHA-384) are distinct values. +/// Equality considers both digest bytes and hash algorithm — hashes with identical bytes +/// but different algorithms are distinct values. /// /// - SeeAlso: https://datatracker.ietf.org/doc/html/rfc5280#section-4.1 /// - SeeAlso: https://owasp.org/www-project-mobile-security-testing-guide/latest/0x05g-Testing-Network-Communication.html public struct SPKIHash: Sendable, Hashable { - /// The raw hash digest bytes of the SPKI structure. + /// Raw hash digest bytes of the SPKI structure. public let bytes: Data fileprivate let algorithmID: ObjectIdentifier @@ -43,10 +41,10 @@ public struct SPKIHash: Sendable, Hashable { // MARK: - Initialization - /// Creates an SPKI hash from a base64-encoded string using SHA-256. + /// Creates an SPKI hash from a base64-encoded SHA-256 digest. /// /// - Parameters: - /// - base64: Base64-encoded hash digest. Whitespace is automatically stripped. + /// - base64: Base64-encoded hash digest (whitespace is stripped). /// /// - Throws: `HTTPClientError.invalidDigestLength` if decoded data isn't 32 bytes. @available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) @@ -72,6 +70,17 @@ public struct SPKIHash: Sendable, Hashable { try self.init(algorithm: algorithm, bytes: data) } + /// Creates an SPKI hash from raw SHA-256 digest bytes. + /// + /// - Parameters: + /// - bytes: Raw SHA-256 digest bytes (must be 32 bytes). + /// + /// - Throws: `HTTPClientError.invalidDigestLength` if byte count isn't 32. + @available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) + public init(bytes: Data) throws { + try self.init(algorithm: SHA256.self, bytes: bytes) + } + /// Creates an SPKI hash from raw digest bytes using a specified hash algorithm. /// /// - Parameters: @@ -106,7 +115,6 @@ public struct SPKIHash: Sendable, Hashable { } /// Constant-time comparison to prevent timing attacks. -/// Always iterates all candidates and bytes regardless of match position. internal func constantTimeAnyMatch(_ target: Data, _ candidates: [SPKIHash]) -> Bool { guard !candidates.isEmpty else { return false } @@ -121,43 +129,44 @@ internal func constantTimeAnyMatch(_ target: Data, _ candidates: [SPKIHash]) -> return anyMatch != 0 } -/// Configuration for SPKI (SubjectPublicKeyInfo) pinning. +/// Configuration for SPKI pinning validation. /// -/// Validates server identity by hashing the public key structure rather than the -/// full certificate. Supports multiple hash algorithms simultaneously. +/// Maintains two pin sets: +/// - `activePins`: Certificates currently deployed in production +/// - `backupPins`: Pre-deployed hashes for upcoming certificate rotations /// -/// - Warning: Always deploy with non-empty `backupPins` to avoid lockout during rotation. +/// - Warning: Always deploy non-empty `backupPins` at least 30 days before certificate +/// expiration to prevent service disruption during rotation. public struct SPKIPinningConfiguration: Sendable, Hashable { - /// Current production SPKI hashes. - public let primaryPins: [SPKIHash] + /// SPKI hashes of certificates currently deployed in production. + public let activePins: [SPKIHash] - /// SPKI hashes for upcoming certificate rotations. + /// SPKI hashes pre-deployed for upcoming certificate rotations. public let backupPins: [SPKIHash] - /// Failure behavior policy on pin mismatch. - public let verification: SPKIPinningVerification + /// Policy for handling pin validation failures. + public let policy: SPKIPinningPolicy private let pinsByAlgorithm: [ObjectIdentifier: [SPKIHash]] - /// Creates an SPKI pinning configuration with primary and backup pins. + /// Creates an SPKI pinning configuration. /// /// - Parameters: - /// - primaryPins: Hashes of current production certificates. - /// - backupPins: Hashes for certificates scheduled for future deployment. - /// Required in production to prevent service disruption during rotation. - /// - verification: Policy for handling pin validation failures. + /// - activePins: Hashes of currently deployed certificates. + /// - backupPins: Hashes for upcoming certificate rotations (required in production). + /// - policy: Validation failure policy (`.strict` for production, `.audit` for debugging). /// - /// - Warning: Deploying with empty `backupPins` in `.failRequest` mode risks - /// catastrophic lockout when certificates rotate. + /// - Warning: Empty `backupPins` in `.strict` mode risks catastrophic lockout during + /// certificate rotation. public init( - primaryPins: [SPKIHash], + activePins: [SPKIHash], backupPins: [SPKIHash], - verification: SPKIPinningVerification = .failRequest + policy: SPKIPinningPolicy = .strict ) { - self.primaryPins = primaryPins + self.activePins = activePins self.backupPins = backupPins - self.pinsByAlgorithm = Dictionary(grouping: Set(primaryPins + backupPins), by: \.algorithmID) - self.verification = verification + self.pinsByAlgorithm = Dictionary(grouping: Set(activePins + backupPins), by: \.algorithmID) + self.policy = policy } internal func contains(spkiBytes: [UInt8]) -> Bool { @@ -174,19 +183,16 @@ public struct SPKIPinningConfiguration: Sendable, Hashable { } } -/// Behavior when SPKI pin validation fails. -public enum SPKIPinningVerification: Sendable, Hashable { - /// Immediately terminate the connection on pin validation failure. - case failRequest +/// Policy for handling SPKI pin validation failures. +public enum SPKIPinningPolicy: Sendable, Hashable { + /// Reject connections with untrusted certificates. + case strict - /// Allow the connection to proceed but log a warning. - case logAndProceed + /// Permit connections with untrusted certificates for observability only. + case audit } -/// ChannelHandler that implements certificate pinning using SPKI hashes. -/// -/// Validates the server's leaf certificate public key against pre-configured hashes -/// after TLS handshake completion. +/// ChannelHandler that validates server certificates using SPKI pinning. /// /// - Warning: Never deploy without backup pins in production environments. @available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) @@ -204,9 +210,9 @@ final class SPKIPinningHandler: ChannelInboundHandler, RemovableChannelHandler { self.tlsPinning = tlsPinning self.logger = logger - if tlsPinning.backupPins.isEmpty && tlsPinning.verification == .failRequest { + if tlsPinning.backupPins.isEmpty && tlsPinning.policy == .strict { logger.warning( - "SPKIPinningHandler deployed without backup pins in failRequest mode - catastrophic lockout risk!", + "SPKIPinningHandler deployed without backup pins in strict mode - catastrophic lockout risk!", metadata: [ "recommendation": .string("Deploy backup pins 30+ days before certificate expiration") ] @@ -278,13 +284,13 @@ final class SPKIPinningHandler: ChannelInboundHandler, RemovableChannelHandler { event: TLSUserEvent ) { let metadata: Logger.Metadata = [ - "pinning_action": .string(tlsPinning.verification == .failRequest ? "blocked" : "allowed_with_warning"), - "expected_primary_pins": .string(tlsPinning.primaryPins.map { $0.bytes.base64EncodedString() }.joined(separator: ", ")), + "pinning_action": .string(tlsPinning.policy == .strict ? "blocked" : "allowed_for_audit"), + "expected_active_pins": .string(tlsPinning.activePins.map { $0.bytes.base64EncodedString() }.joined(separator: ", ")), "expected_backup_pins": .string(tlsPinning.backupPins.map { $0.bytes.base64EncodedString() }.joined(separator: ", ")) ] - switch tlsPinning.verification { - case .failRequest: + switch tlsPinning.policy { + case .strict: logger.error("SPKI pinning failed — connection blocked", metadata: metadata) let error = HTTPClientError.invalidCertificatePinning(reason) @@ -292,8 +298,8 @@ final class SPKIPinningHandler: ChannelInboundHandler, RemovableChannelHandler { context.close(mode: .all, promise: nil) - case .logAndProceed: - logger.warning("SPKI pinning failed — connection allowed (staging mode)", metadata: metadata) + case .audit: + logger.warning("SPKI pinning failed — connection allowed for audit purposes", metadata: metadata) context.fireUserInboundEventTriggered(event) } } diff --git a/Tests/AsyncHTTPClientTests/SPKIHashTests.swift b/Tests/AsyncHTTPClientTests/SPKIHashTests.swift new file mode 100644 index 000000000..ef58bceea --- /dev/null +++ b/Tests/AsyncHTTPClientTests/SPKIHashTests.swift @@ -0,0 +1,154 @@ +import XCTest +@testable import AsyncHTTPClient +import Crypto + +final class SPKIHashTests: XCTestCase { + + // MARK: - Initialization (base64) + + func testInitWithValidSHA256Base64() throws { + let base64 = Data(repeating: 0, count: 32).base64EncodedString() + let hash = try SPKIHash(base64: base64) + XCTAssertEqual(hash.bytes.count, 32) + XCTAssertEqual(hash.bytes, Data(repeating: 0, count: 32)) + } + + func testInitWithInvalidBase64Throws() throws { + XCTAssertThrowsError(try SPKIHash(base64: "!!!invalid!!!")) { error in + XCTAssertEqual(error as? HTTPClientError, .invalidDigestLength) + } + } + + func testInitWithWrongLengthBase64Throws() throws { + let base64 = Data(repeating: 0, count: 31).base64EncodedString() + XCTAssertThrowsError(try SPKIHash(base64: base64)) { error in + XCTAssertEqual(error as? HTTPClientError, .invalidDigestLength) + } + } + + // MARK: - Initialization (custom algorithm + base64) + + func testInitWithSHA384AndValidBase64() throws { + let base64 = Data(repeating: 0, count: 48).base64EncodedString() + let hash = try SPKIHash(algorithm: SHA384.self, base64: base64) + XCTAssertEqual(hash.bytes.count, 48) + XCTAssertEqual(hash.bytes, Data(repeating: 0, count: 48)) + } + + func testInitWithSHA384AndWrongLengthThrows() throws { + let base64 = Data(repeating: 0, count: 32).base64EncodedString() + XCTAssertThrowsError(try SPKIHash(algorithm: SHA384.self, base64: base64)) { error in + XCTAssertEqual(error as? HTTPClientError, .invalidDigestLength) + } + } + + // MARK: - Initialization (raw bytes) + + func testInitWithSHA256AndValidBytes() throws { + let bytes = Data(repeating: 0, count: 32) + let hash = try SPKIHash(algorithm: SHA256.self, bytes: bytes) + XCTAssertEqual(hash.bytes, bytes) + } + + func testInitWithSHA512AndValidBytes() throws { + let bytes = Data(repeating: 0, count: 64) + let hash = try SPKIHash(algorithm: SHA512.self, bytes: bytes) + XCTAssertEqual(hash.bytes, bytes) + } + + func testInitWithWrongByteCountThrows() throws { + let bytes = Data(repeating: 0, count: 31) + XCTAssertThrowsError(try SPKIHash(algorithm: SHA256.self, bytes: bytes)) { error in + XCTAssertEqual(error as? HTTPClientError, .invalidDigestLength) + } + } + + // MARK: - Initialization (convenience bytes initializer) + + func testInitWithValidSHA256Bytes() throws { + let bytes = Data(repeating: 0, count: 32) + let hash = try SPKIHash(bytes: bytes) + XCTAssertEqual(hash.bytes.count, 32) + XCTAssertEqual(hash.bytes, bytes) + } + + func testInitWithInvalidLengthBytesThrows() throws { + let bytes = Data(repeating: 0, count: 31) + XCTAssertThrowsError(try SPKIHash(bytes: bytes)) { error in + XCTAssertEqual(error as? HTTPClientError, .invalidDigestLength) + } + } + + func testConvenienceBytesInitializerUsesSHA256() throws { + let bytes = Data([UInt8](0..<32)) + let hash1 = try SPKIHash(bytes: bytes) + let hash2 = try SPKIHash(algorithm: SHA256.self, bytes: bytes) + XCTAssertEqual(hash1, hash2) + } + + // MARK: - Equality + + func testEqualityWithSameBytesAndAlgorithm() throws { + let bytes = Data(repeating: 0, count: 32) + let hash1 = try SPKIHash(algorithm: SHA256.self, bytes: bytes) + let hash2 = try SPKIHash(algorithm: SHA256.self, bytes: bytes) + XCTAssertEqual(hash1, hash2) + } + + func testInequalityWithSameBytesDifferentAlgorithm() throws { + let hash1 = try SPKIHash(algorithm: SHA256.self, bytes: Data(repeating: 0, count: 32)) + let hash2 = try SPKIHash(algorithm: SHA384.self, bytes: Data(repeating: 0, count: 48)) + XCTAssertNotEqual(hash1, hash2) + } + + func testInequalityWithDifferentBytesSameAlgorithm() throws { + let hash1 = try SPKIHash(algorithm: SHA256.self, bytes: Data(repeating: 0, count: 32)) + let hash2 = try SPKIHash(algorithm: SHA256.self, bytes: Data(repeating: 1, count: 32)) + XCTAssertNotEqual(hash1, hash2) + } + + // MARK: - Hashable + + func testHashableWithEqualValues() throws { + let bytes = Data(repeating: 0, count: 32) + let hash1 = try SPKIHash(algorithm: SHA256.self, bytes: bytes) + let hash2 = try SPKIHash(algorithm: SHA256.self, bytes: bytes) + + var set = Set() + set.insert(hash1) + set.insert(hash2) + XCTAssertEqual(set.count, 1) + } + + func testHashableWithDifferentAlgorithms() throws { + let hash1 = try SPKIHash(algorithm: SHA256.self, bytes: Data(repeating: 0, count: 32)) + let hash2 = try SPKIHash(algorithm: SHA384.self, bytes: Data(repeating: 0, count: 48)) + + var set = Set() + set.insert(hash1) + set.insert(hash2) + XCTAssertEqual(set.count, 2) + } + + // MARK: - Real-world test vectors + + func testSHA256EmptyInputHash() throws { + let expectedBase64 = "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=" + let hash = try SPKIHash(base64: expectedBase64) + + let expectedBytes = Data([ + 0xe3, 0xb0, 0xc4, 0x42, 0x98, 0xfc, 0x1c, 0x14, + 0x9a, 0xfb, 0xf4, 0xc8, 0x99, 0x6f, 0xb9, 0x24, + 0x27, 0xae, 0x41, 0xe4, 0x64, 0x9b, 0x93, 0x4c, + 0xa4, 0x95, 0x99, 0x1b, 0x78, 0x52, 0xb8, 0x55 + ]) + XCTAssertEqual(hash.bytes, expectedBytes) + } + + func testSHA384EmptyInputHash() throws { + let emptyHash = Data(SHA384.hash(data: Data())) + let base64 = emptyHash.base64EncodedString() + let hash = try SPKIHash(algorithm: SHA384.self, base64: base64) + XCTAssertEqual(hash.bytes, emptyHash) + } +} From 6c585a6c210e52f37a4f15b89e066d69ceb7da4f Mon Sep 17 00:00:00 2001 From: brennobemoura <37243584+brennobemoura@users.noreply.github.com> Date: Mon, 2 Feb 2026 18:14:27 -0300 Subject: [PATCH 08/13] Implemented tests --- .../ChannelHandler/SPKIPinningHandler.swift | 74 ++-- .../AsyncAwaitEndToEndTests.swift | 99 ++++++ .../SPKIPinningTests.swift | 323 ++++++++++++++++++ 3 files changed, 463 insertions(+), 33 deletions(-) create mode 100644 Tests/AsyncHTTPClientTests/SPKIPinningTests.swift diff --git a/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/SPKIPinningHandler.swift b/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/SPKIPinningHandler.swift index ef59f2473..65240b230 100644 --- a/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/SPKIPinningHandler.swift +++ b/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/SPKIPinningHandler.swift @@ -227,54 +227,62 @@ final class SPKIPinningHandler: ChannelInboundHandler, RemovableChannelHandler { return } - validateSPKI(context: context, event: tlsEvent) + context.pipeline.handler(type: NIOSSLHandler.self).assumeIsolated().whenComplete { + self.validateSPKI( + context: context, + event: tlsEvent, + peerCertificate: $0.map(\.peerCertificate) + ) + } } - private func validateSPKI(context: ChannelHandlerContext, event: TLSUserEvent) { - context.channel.pipeline.handler(type: NIOSSLHandler.self).assumeIsolated().whenComplete { result in - switch result { - case .success(let sslHandler): - guard let leaf = sslHandler.peerCertificate else { - self.handlePinningFailure( - context: context, - reason: "Empty certificate chain", - event: event - ) - return - } - - do { - let publicKey = try leaf.extractPublicKey() - let spkiBytes = try publicKey.toSPKIBytes() + func validateSPKI( + context: ChannelHandlerContext, + event: TLSUserEvent, + peerCertificate result: Result + ) { + switch result { + case .success(let peerCertificate): + guard let leaf = peerCertificate else { + self.handlePinningFailure( + context: context, + reason: "Empty certificate chain", + event: event + ) + return + } - let isValid = self.tlsPinning.contains(spkiBytes: spkiBytes) + do { + let publicKey = try leaf.extractPublicKey() + let spkiBytes = try publicKey.toSPKIBytes() - if isValid { - context.fireUserInboundEventTriggered(event) - self.logger.debug("SPKI pin validation succeeded") - } else { - self.handlePinningFailure( - context: context, - reason: "SPKI pin mismatch", - event: event - ) - } + let isValid = self.tlsPinning.contains(spkiBytes: spkiBytes) - } catch { + if isValid { + context.fireUserInboundEventTriggered(event) + self.logger.debug("SPKI pin validation succeeded") + } else { self.handlePinningFailure( context: context, - reason: "SPKI extraction failed: \(error)", + reason: "SPKI pin mismatch", event: event ) } - case .failure(let error): + } catch { self.handlePinningFailure( context: context, - reason: "SSL handler not found: \(error)", + reason: "SPKI extraction failed: \(error)", event: event ) } + + case .failure(let error): + self.handlePinningFailure( + context: context, + reason: "SSL handler not found: \(error)", + event: event + ) } } @@ -296,7 +304,7 @@ final class SPKIPinningHandler: ChannelInboundHandler, RemovableChannelHandler { let error = HTTPClientError.invalidCertificatePinning(reason) context.fireErrorCaught(error) - context.close(mode: .all, promise: nil) + context.close(promise: nil) case .audit: logger.warning("SPKI pinning failed — connection allowed for audit purposes", metadata: metadata) diff --git a/Tests/AsyncHTTPClientTests/AsyncAwaitEndToEndTests.swift b/Tests/AsyncHTTPClientTests/AsyncAwaitEndToEndTests.swift index 56a08b852..13462196c 100644 --- a/Tests/AsyncHTTPClientTests/AsyncAwaitEndToEndTests.swift +++ b/Tests/AsyncHTTPClientTests/AsyncAwaitEndToEndTests.swift @@ -19,6 +19,7 @@ import NIOHTTP1 import NIOPosix import NIOSSL import XCTest +import Crypto @testable import AsyncHTTPClient @@ -1019,6 +1020,104 @@ final class AsyncAwaitEndToEndTests: XCTestCase { } } } + + func testSPKIPinning_ValidPin_AllowsConnection() { + XCTAsyncTest { + let certificate = TestTLS.certificate + let privateKey = TestTLS.privateKey + + let tlsConfig = TLSConfiguration.makeServerConfiguration( + certificateChain: [.certificate(certificate)], + privateKey: .privateKey(privateKey) + ) + + let bin = HTTPBin(.http2(tlsConfiguration: tlsConfig)) + defer { XCTAssertNoThrow(try bin.shutdown()) } + + let publicKey = try certificate.extractPublicKey() + let spkiBytes = try publicKey.toSPKIBytes() + let spkiHash = SHA256.hash(data: Data(spkiBytes)) + let pinBase64 = Data(spkiHash).base64EncodedString() + + var config = HTTPClient.Configuration().enableFastFailureModeForTesting() + config.tlsConfiguration = TLSConfiguration.makeClientConfiguration() + config.tlsConfiguration?.trustRoots = .certificates([certificate]) + config.tlsConfiguration?.certificateVerification = .noHostnameVerification + config.httpVersion = .automatic + + config.tlsPinning = SPKIPinningConfiguration( + activePins: [try SPKIHash(base64: pinBase64)], + backupPins: [], + policy: .strict + ) + + let localClient = HTTPClient( + eventLoopGroupProvider: .shared(MultiThreadedEventLoopGroup.singleton), + configuration: config + ) + defer { XCTAssertNoThrow(try localClient.syncShutdown()) } + + let request = HTTPClientRequest(url: "https://localhost:\(bin.port)/get") + + guard let response = await XCTAssertNoThrowWithResult( + try await localClient.execute(request, deadline: .now() + .seconds(10)) + ) else { return } + + XCTAssertEqual(response.status, .ok) + XCTAssertEqual(response.version, .http2) + } + } + + func testSPKIPinning_InvalidPin_RejectsConnection() { + XCTAsyncTest { + let certificate = TestTLS.certificate + let privateKey = TestTLS.privateKey + + let tlsConfig = TLSConfiguration.makeServerConfiguration( + certificateChain: [.certificate(certificate)], + privateKey: .privateKey(privateKey) + ) + + let bin = HTTPBin(.http2(tlsConfiguration: tlsConfig)) + defer { XCTAssertNoThrow(try bin.shutdown()) } + + let spkiHash = SHA256.hash(data: Data(UUID().uuidString.utf8)) + let pinBase64 = Data(spkiHash).base64EncodedString() + + var config = HTTPClient.Configuration().enableFastFailureModeForTesting() + config.tlsConfiguration = TLSConfiguration.makeClientConfiguration() + config.tlsConfiguration?.trustRoots = .certificates([certificate]) + config.tlsConfiguration?.certificateVerification = .noHostnameVerification + config.httpVersion = .automatic + + config.tlsPinning = SPKIPinningConfiguration( + activePins: [try SPKIHash(base64: pinBase64)], + backupPins: [], + policy: .strict + ) + + let localClient = HTTPClient( + eventLoopGroupProvider: .shared(MultiThreadedEventLoopGroup.singleton), + configuration: config + ) + defer { XCTAssertNoThrow(try localClient.syncShutdown()) } + + let request = HTTPClientRequest(url: "https://localhost:\(bin.port)/get") + + await XCTAssertThrowsError( + try await localClient.execute(request, deadline: .now() + .seconds(10)) + ) { error in + guard let httpClientError = error as? HTTPClientError else { + return XCTFail("Expecting HTTPClientError, received: \(type(of: error))") + } + XCTAssertTrue( + httpClientError.description.contains("pinning") || + httpClientError.description.contains("SPKI"), + "Unexpected error: \(httpClientError.description)" + ) + } + } + } } struct AnySendableSequence: @unchecked Sendable { diff --git a/Tests/AsyncHTTPClientTests/SPKIPinningTests.swift b/Tests/AsyncHTTPClientTests/SPKIPinningTests.swift new file mode 100644 index 000000000..0875518d5 --- /dev/null +++ b/Tests/AsyncHTTPClientTests/SPKIPinningTests.swift @@ -0,0 +1,323 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the AsyncHTTPClient open source project +// +// Copyright (c) 2021 Apple Inc. and the AsyncHTTPClient project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of AsyncHTTPClient project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Crypto +import XCTest +import NIOSSL +import NIOTLS +import Logging +import NIOCore +import NIOEmbedded + +@testable import AsyncHTTPClient + +class SPKIPinningTests: XCTestCase { + + // MARK: - SPKIPinningConfiguration.contains(spkiBytes:) + + func testContains_WithMatchingActivePin_ReturnsTrue() throws { + let (certificate, spkiHash) = try Self.testCertificateAndSPKIHash() + let pin = try SPKIHash(algorithm: SHA256.self, bytes: Data(spkiHash)) + let config = SPKIPinningConfiguration( + activePins: [pin], + backupPins: [], + policy: .strict + ) + + let publicKey = try certificate.extractPublicKey() + let spkiBytes = try publicKey.toSPKIBytes() + + XCTAssertTrue(config.contains(spkiBytes: spkiBytes)) + } + + func testContains_WithMatchingBackupPin_ReturnsTrue() throws { + let (certificate, spkiHash) = try Self.testCertificateAndSPKIHash() + let pin = try SPKIHash(algorithm: SHA256.self, bytes: Data(spkiHash)) + let config = SPKIPinningConfiguration( + activePins: [], + backupPins: [pin], + policy: .strict + ) + + let publicKey = try certificate.extractPublicKey() + let spkiBytes = try publicKey.toSPKIBytes() + + XCTAssertTrue(config.contains(spkiBytes: spkiBytes)) + } + + func testContains_WithMismatchedPin_ReturnsFalse() throws { + let (certificate, _) = try Self.testCertificateAndSPKIHash() + let mismatchedPin = try SPKIHash(base64: "9uO07DlRgCzpXEaC2+ZiqB0VFcjdn43d6h+U2lUHORo=") + let config = SPKIPinningConfiguration( + activePins: [mismatchedPin], + backupPins: [], + policy: .strict + ) + + let publicKey = try certificate.extractPublicKey() + let spkiBytes = try publicKey.toSPKIBytes() + + XCTAssertFalse(config.contains(spkiBytes: spkiBytes)) + } + + func testContains_WithEmptyInput_ReturnsFalse() throws { + let pin = try SPKIHash(base64: "9uO07DlRgCzpXEaC2+ZiqB0VFcjdn43d6h+U2lUHORo=") + let config = SPKIPinningConfiguration( + activePins: [pin], + backupPins: [], + policy: .strict + ) + + XCTAssertFalse(config.contains(spkiBytes: [])) + } + + // MARK: - SPKIPinningHandler.validateSPKI(...) + + func testValidateSPKI_WithValidActivePin_PropagatesEvent() throws { + let (certificate, spkiHash) = try Self.testCertificateAndSPKIHash() + let pin = try SPKIHash(algorithm: SHA256.self, bytes: Data(spkiHash)) + let config = SPKIPinningConfiguration( + activePins: [pin], + backupPins: [], + policy: .strict + ) + let handler = try makeHandler(config: config) + let event = TLSUserEvent.handshakeCompleted(negotiatedProtocol: nil) + + let embedded = EmbeddedChannel(handlers: [ContextHandler { context in + handler.validateSPKI( + context: context, + event: event, + peerCertificate: .success(certificate) + ) + }]) + + embedded.pipeline.fireUserInboundEventTriggered(event) + try embedded.throwIfErrorCaught() + } + + func testValidateSPKI_WithValidBackupPin_PropagatesEvent() throws { + let (certificate, spkiHash) = try Self.testCertificateAndSPKIHash() + let pin = try SPKIHash(algorithm: SHA256.self, bytes: Data(spkiHash)) + let config = SPKIPinningConfiguration( + activePins: [], + backupPins: [pin], + policy: .strict + ) + let handler = try makeHandler(config: config) + let event = TLSUserEvent.handshakeCompleted(negotiatedProtocol: nil) + + let embedded = EmbeddedChannel(handlers: [ContextHandler { context in + handler.validateSPKI( + context: context, + event: event, + peerCertificate: .success(certificate) + ) + }]) + + embedded.pipeline.fireUserInboundEventTriggered(event) + try embedded.throwIfErrorCaught() + } + + func testValidateSPKI_WithMismatchedPin_InStrictMode_ClosesConnection() throws { + let (certificate, _) = try Self.testCertificateAndSPKIHash() + let mismatchedPin = try SPKIHash(base64: "9uO07DlRgCzpXEaC2+ZiqB0VFcjdn43d6h+U2lUHORo=") + let config = SPKIPinningConfiguration( + activePins: [mismatchedPin], + backupPins: [], + policy: .strict + ) + let handler = try makeHandler(config: config) + let event = TLSUserEvent.handshakeCompleted(negotiatedProtocol: nil) + + let embedded = EmbeddedChannel(handlers: [ContextHandler { context in + handler.validateSPKI( + context: context, + event: event, + peerCertificate: .success(certificate) + ) + }]) + + embedded.pipeline.fireUserInboundEventTriggered(event) + + XCTAssertThrowsError(try embedded.throwIfErrorCaught()) { + if let error = $0 as? HTTPClientError { + XCTAssertTrue(error.description.contains("SPKI pin mismatch")) + } + } + } + + func testValidateSPKI_WithMismatchedPin_InAuditMode_PropagatesEvent() throws { + let (certificate, _) = try Self.testCertificateAndSPKIHash() + let mismatchedPin = try SPKIHash(base64: "9uO07DlRgCzpXEaC2+ZiqB0VFcjdn43d6h+U2lUHORo=") + let config = SPKIPinningConfiguration( + activePins: [mismatchedPin], + backupPins: [], + policy: .audit + ) + let handler = try makeHandler(config: config) + let event = TLSUserEvent.handshakeCompleted(negotiatedProtocol: nil) + + let embedded = EmbeddedChannel(handlers: [ContextHandler { context in + handler.validateSPKI( + context: context, + event: event, + peerCertificate: .success(certificate) + ) + }]) + + embedded.pipeline.fireUserInboundEventTriggered(event) + try embedded.throwIfErrorCaught() + } + + func testValidateSPKI_WithNilCertificate_InStrictMode_ClosesConnection() throws { + let pin = try SPKIHash(base64: "9uO07DlRgCzpXEaC2+ZiqB0VFcjdn43d6h+U2lUHORo=") + let config = SPKIPinningConfiguration( + activePins: [pin], + backupPins: [], + policy: .strict + ) + let handler = try makeHandler(config: config) + let event = TLSUserEvent.handshakeCompleted(negotiatedProtocol: nil) + + let embedded = EmbeddedChannel(handlers: [ContextHandler { context in + handler.validateSPKI( + context: context, + event: event, + peerCertificate: .success(nil) + ) + }]) + + embedded.pipeline.fireUserInboundEventTriggered(event) + + XCTAssertThrowsError(try embedded.throwIfErrorCaught()) { + if let error = $0 as? HTTPClientError { + XCTAssertTrue(error.description.contains("Empty certificate chain")) + } + } + } + + func testValidateSPKI_WithNilCertificate_InAuditMode_PropagatesEvent() throws { + let pin = try SPKIHash(base64: "9uO07DlRgCzpXEaC2+ZiqB0VFcjdn43d6h+U2lUHORo=") + let config = SPKIPinningConfiguration( + activePins: [pin], + backupPins: [], + policy: .audit + ) + let handler = try makeHandler(config: config) + let event = TLSUserEvent.handshakeCompleted(negotiatedProtocol: nil) + + let embedded = EmbeddedChannel(handlers: [ContextHandler { context in + handler.validateSPKI( + context: context, + event: event, + peerCertificate: .success(nil) + ) + }]) + + embedded.pipeline.fireUserInboundEventTriggered(event) + try embedded.throwIfErrorCaught() + } + + func testValidateSPKI_WithHandlerLookupFailure_InStrictMode_ClosesConnection() throws { + let pin = try SPKIHash(base64: "9uO07DlRgCzpXEaC2+ZiqB0VFcjdn43d6h+U2lUHORo=") + let config = SPKIPinningConfiguration( + activePins: [pin], + backupPins: [], + policy: .strict + ) + let handler = try makeHandler(config: config) + let event = TLSUserEvent.handshakeCompleted(negotiatedProtocol: nil) + let extractionError = NSError(domain: "TestError", code: 1, userInfo: nil) + + let embedded = EmbeddedChannel(handlers: [ContextHandler { context in + handler.validateSPKI( + context: context, + event: event, + peerCertificate: .failure(extractionError) + ) + }]) + + embedded.pipeline.fireUserInboundEventTriggered(event) + + XCTAssertThrowsError(try embedded.throwIfErrorCaught()) { + if let error = $0 as? HTTPClientError { + XCTAssertTrue(error.description.contains("SSL handler not found:")) + } + } + } + + // MARK: - SPKIPinningHandler.userInboundEventTriggered(...) + + func testUserInboundEventTriggered_IgnoresNonHandshakeEvents() throws { + let config = SPKIPinningConfiguration( + activePins: [], + backupPins: [], + policy: .strict + ) + let handler = try makeHandler(config: config) + let event = TLSUserEvent.shutdownCompleted + + let embedded = EmbeddedChannel(handlers: [handler]) + embedded.pipeline.fireUserInboundEventTriggered(event) + try embedded.throwIfErrorCaught() + } + + func testUserInboundEventTriggered_OnHandshakeInitiatesValidation() throws { + let config = SPKIPinningConfiguration( + activePins: [], + backupPins: [], + policy: .strict + ) + let handler = try makeHandler(config: config) + let event = TLSUserEvent.handshakeCompleted(negotiatedProtocol: nil) + + let embedded = EmbeddedChannel(handlers: [handler]) + embedded.pipeline.fireUserInboundEventTriggered(event) + + XCTAssertThrowsError(try embedded.throwIfErrorCaught()) { + if let error = $0 as? HTTPClientError { + XCTAssertTrue(error.description.contains("SSL handler not found: notFound")) + } + } + } + + // MARK: - Helpers + + private func makeHandler(config: SPKIPinningConfiguration) throws -> SPKIPinningHandler { + let logger = Logger(label: "test", factory: SwiftLogNoOpLogHandler.init) + return SPKIPinningHandler(tlsPinning: config, logger: logger) + } + + private static func testCertificateAndSPKIHash() throws -> (NIOSSLCertificate, SHA256Digest) { + let certificate = TestTLS.certificate + let publicKey = try certificate.extractPublicKey() + let spkiBytes = try publicKey.toSPKIBytes() + let spkiHash = SHA256.hash(data: Data(spkiBytes)) + return (certificate, spkiHash) + } +} + +private final class ContextHandler: ChannelInboundHandler { + typealias InboundIn = NIOAny + let context: (ChannelHandlerContext) -> Void + + init(context: @escaping (ChannelHandlerContext) -> Void) { + self.context = context + } + + func userInboundEventTriggered(context: ChannelHandlerContext, event: Any) { + self.context(context) + } +} From 8c673f6d37b60a1cc3e4a5aae287704b86c14b3c Mon Sep 17 00:00:00 2001 From: brennobemoura <37243584+brennobemoura@users.noreply.github.com> Date: Mon, 2 Feb 2026 18:19:25 -0300 Subject: [PATCH 09/13] Added back removed code --- .../ConnectionPool/ChannelHandler/SPKIPinningHandler.swift | 6 ++++-- .../ConnectionPool/HTTPConnectionPool+Factory.swift | 2 ++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/SPKIPinningHandler.swift b/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/SPKIPinningHandler.swift index 65240b230..7721af2c1 100644 --- a/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/SPKIPinningHandler.swift +++ b/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/SPKIPinningHandler.swift @@ -221,8 +221,10 @@ final class SPKIPinningHandler: ChannelInboundHandler, RemovableChannelHandler { } func userInboundEventTriggered(context: ChannelHandlerContext, event: Any) { - guard let tlsEvent = event as? TLSUserEvent, - case .handshakeCompleted = tlsEvent else { + guard + let tlsEvent = event as? TLSUserEvent, + case .handshakeCompleted = tlsEvent + else { context.fireUserInboundEventTriggered(event) return } diff --git a/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift b/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift index be118fb46..a5b856ad9 100644 --- a/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift +++ b/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift @@ -408,6 +408,8 @@ extension HTTPConnectionPool.ConnectionFactory { let tlsEventHandler = TLSEventsHandler(deadline: deadline) try channel.pipeline.syncOperations.addHandler(tlsEventHandler) + // The tlsEstablishedFuture is set as soon as the TLSEventsHandler is in a + // pipeline. It is created in TLSEventsHandler's handlerAdded method. return tlsEventHandler.tlsEstablishedFuture! } catch { return channel.eventLoop.makeFailedFuture(error) From 1e566001d354e36f7c6559bb6f8c9d14a78524cb Mon Sep 17 00:00:00 2001 From: brennobemoura <37243584+brennobemoura@users.noreply.github.com> Date: Wed, 4 Feb 2026 11:01:05 -0300 Subject: [PATCH 10/13] Add SPKI pinning with runtime safety checks and explicit TLS requirement --- .../ChannelHandler/SPKIPinningHandler.swift | 280 ++++++++++-------- .../HTTPConnectionPool+Factory.swift | 32 +- Sources/AsyncHTTPClient/HTTPClient.swift | 44 ++- .../AsyncAwaitEndToEndTests.swift | 96 +++++- .../AsyncHTTPClientTests/SPKIHashTests.swift | 40 +-- .../SPKIPinningTests.swift | 265 ++++++++--------- 6 files changed, 424 insertions(+), 333 deletions(-) diff --git a/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/SPKIPinningHandler.swift b/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/SPKIPinningHandler.swift index 7721af2c1..1947d184e 100644 --- a/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/SPKIPinningHandler.swift +++ b/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/SPKIPinningHandler.swift @@ -22,12 +22,11 @@ import Algorithms /// SPKI hash for certificate pinning validation. /// -/// Validates server identity using the DER-encoded public key structure (RFC 5280, Section 4.1) -/// rather than the full certificate. This approach survives legitimate certificate rotations -/// and prevents algorithm downgrade attacks. +/// Validates server identity using the DER-encoded public key structure (RFC 5280, Section 4.1). +/// Survives legitimate certificate rotations and prevents algorithm downgrade attacks. /// -/// Equality considers both digest bytes and hash algorithm — hashes with identical bytes -/// but different algorithms are distinct values. +/// Equality requires matching digest bytes *and* hash algorithm. Length mismatches are treated +/// as inequality — critical for constant-time security guarantees. /// /// - SeeAlso: https://datatracker.ietf.org/doc/html/rfc5280#section-4.1 /// - SeeAlso: https://owasp.org/www-project-mobile-security-testing-guide/latest/0x05g-Testing-Network-Communication.html @@ -41,20 +40,6 @@ public struct SPKIHash: Sendable, Hashable { // MARK: - Initialization - /// Creates an SPKI hash from a base64-encoded SHA-256 digest. - /// - /// - Parameters: - /// - base64: Base64-encoded hash digest (whitespace is stripped). - /// - /// - Throws: `HTTPClientError.invalidDigestLength` if decoded data isn't 32 bytes. - @available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) - public init(base64: String) throws { - guard let data = Data(base64Encoded: base64) else { - throw HTTPClientError.invalidDigestLength - } - try self.init(algorithm: SHA256.self, bytes: data) - } - /// Creates an SPKI hash using a custom hash algorithm and base64-encoded string. /// /// - Parameters: @@ -70,17 +55,6 @@ public struct SPKIHash: Sendable, Hashable { try self.init(algorithm: algorithm, bytes: data) } - /// Creates an SPKI hash from raw SHA-256 digest bytes. - /// - /// - Parameters: - /// - bytes: Raw SHA-256 digest bytes (must be 32 bytes). - /// - /// - Throws: `HTTPClientError.invalidDigestLength` if byte count isn't 32. - @available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) - public init(bytes: Data) throws { - try self.init(algorithm: SHA256.self, bytes: bytes) - } - /// Creates an SPKI hash from raw digest bytes using a specified hash algorithm. /// /// - Parameters: @@ -114,14 +88,26 @@ public struct SPKIHash: Sendable, Hashable { } } -/// Constant-time comparison to prevent timing attacks. +/// Constant-time digest comparison preventing timing attacks. +/// +/// Compares digests without truncation — length mismatches and byte differences are incorporated +/// into a single constant-time result. Prevents false positives from partial matches (e.g., SHA-256 +/// vs SHA-1). +/// +/// - Warning: Never use truncating comparisons (like `zip`) for cryptographic equality — they +/// silently accept partial matches. internal func constantTimeAnyMatch(_ target: Data, _ candidates: [SPKIHash]) -> Bool { guard !candidates.isEmpty else { return false } var anyMatch: UInt8 = 0 for candidate in candidates { - var diff: UInt8 = 0 - for (a, b) in zip(target, candidate.bytes) { + let lengthDiff = UInt8(bitPattern: Int8(target.count - candidate.bytes.count)) + var diff = lengthDiff + + let maxLength = max(target.count, candidate.bytes.count) + for i in 0 ..< maxLength { + let a = i < target.count ? target[i] : 0 + let b = i < candidate.bytes.count ? candidate.bytes[i] : 0 diff |= a ^ b } anyMatch |= (diff == 0) ? 1 : 0 @@ -131,18 +117,11 @@ internal func constantTimeAnyMatch(_ target: Data, _ candidates: [SPKIHash]) -> /// Configuration for SPKI pinning validation. /// -/// Maintains two pin sets: -/// - `activePins`: Certificates currently deployed in production -/// - `backupPins`: Pre-deployed hashes for upcoming certificate rotations -/// -/// - Warning: Always deploy non-empty `backupPins` at least 30 days before certificate -/// expiration to prevent service disruption during rotation. +/// - Warning: Always deploy backup pins ≥ 30 days before certificate expiration. Empty pin sets +/// in `.strict` mode will reject all connections during rotation. public struct SPKIPinningConfiguration: Sendable, Hashable { - /// SPKI hashes of certificates currently deployed in production. - public let activePins: [SPKIHash] - - /// SPKI hashes pre-deployed for upcoming certificate rotations. - public let backupPins: [SPKIHash] + /// SPKI hashes of trusted certificates. + public let pins: [SPKIHash] /// Policy for handling pin validation failures. public let policy: SPKIPinningPolicy @@ -152,20 +131,14 @@ public struct SPKIPinningConfiguration: Sendable, Hashable { /// Creates an SPKI pinning configuration. /// /// - Parameters: - /// - activePins: Hashes of currently deployed certificates. - /// - backupPins: Hashes for upcoming certificate rotations (required in production). + /// - pins: Hashes of trusted certificates (must not be empty in production). /// - policy: Validation failure policy (`.strict` for production, `.audit` for debugging). - /// - /// - Warning: Empty `backupPins` in `.strict` mode risks catastrophic lockout during - /// certificate rotation. public init( - activePins: [SPKIHash], - backupPins: [SPKIHash], + pins: [SPKIHash], policy: SPKIPinningPolicy = .strict ) { - self.activePins = activePins - self.backupPins = backupPins - self.pinsByAlgorithm = Dictionary(grouping: Set(activePins + backupPins), by: \.algorithmID) + self.pins = pins + self.pinsByAlgorithm = Dictionary(grouping: Set(pins), by: \.algorithmID) self.policy = policy } @@ -184,17 +157,34 @@ public struct SPKIPinningConfiguration: Sendable, Hashable { } /// Policy for handling SPKI pin validation failures. -public enum SPKIPinningPolicy: Sendable, Hashable { - /// Reject connections with untrusted certificates. - case strict +/// +/// SPKI pinning can fail due to certificate mismatch, invalid SPKI extraction, or missing SSL handler. +/// This policy determines whether the connection proceeds or is terminated. +public struct SPKIPinningPolicy: Sendable, Hashable { /// Permit connections with untrusted certificates for observability only. - case audit + public static let audit = SPKIPinningPolicy(name: "audit", rawValue: 1 << 0) + + /// Reject connections with untrusted certificates. + public static let strict = SPKIPinningPolicy(name: "strict", rawValue: 1 << 1) + + public var description: String { + return name + } + + private let name: String + private let rawValue: UInt8 + + private init(name: String, rawValue: UInt8) { + self.name = name + self.rawValue = rawValue + } } /// ChannelHandler that validates server certificates using SPKI pinning. /// -/// - Warning: Never deploy without backup pins in production environments. +/// Performs constant-time comparison of the server's public key hash against trusted pins. +/// Rejects connections in `.strict` mode on mismatch; permits in `.audit` mode for observability. @available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) final class SPKIPinningHandler: ChannelInboundHandler, RemovableChannelHandler { @@ -202,19 +192,23 @@ final class SPKIPinningHandler: ChannelInboundHandler, RemovableChannelHandler { private let tlsPinning: SPKIPinningConfiguration private let logger: Logger + private let executor: SPKIPinningExecutor init( tlsPinning: SPKIPinningConfiguration, - logger: Logger + logger: Logger, + executor: SPKIPinningExecutor = DefaultSPKIPinningExecutor() ) { self.tlsPinning = tlsPinning self.logger = logger + self.executor = executor - if tlsPinning.backupPins.isEmpty && tlsPinning.policy == .strict { + if tlsPinning.pins.count < 2 && tlsPinning.policy == .strict { logger.warning( - "SPKIPinningHandler deployed without backup pins in strict mode - catastrophic lockout risk!", + "SPKIPinningHandler deployed with < 2 pins in strict mode — catastrophic lockout risk on certificate rotation!", metadata: [ - "recommendation": .string("Deploy backup pins 30+ days before certificate expiration") + "current_pin_count": .stringConvertible(tlsPinning.pins.count), + "recommendation": .string("Deploy backup pins ≥ 30 days before certificate expiration") ] ) } @@ -229,88 +223,130 @@ final class SPKIPinningHandler: ChannelInboundHandler, RemovableChannelHandler { return } + // ⚠️ Security-critical: handshake propagation is delayed until validation completes context.pipeline.handler(type: NIOSSLHandler.self).assumeIsolated().whenComplete { - self.validateSPKI( - context: context, - event: tlsEvent, - peerCertificate: $0.map(\.peerCertificate) - ) + let result = self.validatePinning(for: $0.map(\.peerCertificate)) + + switch result { + case .accepted: + self.executor.propagateHandshakeEvent(context: context, event: tlsEvent) + + case .auditWarning(let error): + self.executor.logAuditWarning(logger: self.logger, error: error, policy: self.tlsPinning.policy) + self.executor.propagateHandshakeEvent(context: context, event: tlsEvent) + + case .rejected(let error): + self.executor.logErrorAndClose(context: context, logger: self.logger, error: error, tlsPinning: self.tlsPinning) + } } } - func validateSPKI( - context: ChannelHandlerContext, - event: TLSUserEvent, - peerCertificate result: Result - ) { - switch result { + func validatePinning(for peerCertificate: Result) -> PinningValidationResult { + switch peerCertificate { case .success(let peerCertificate): guard let leaf = peerCertificate else { - self.handlePinningFailure( - context: context, - reason: "Empty certificate chain", - event: event - ) - return + let error = SPKIPinningHandlerError.emptyCertificateChain + return tlsPinning.policy == .audit + ? .auditWarning(error) + : .rejected(error) } + let spkiBytes: [UInt8] do { let publicKey = try leaf.extractPublicKey() - let spkiBytes = try publicKey.toSPKIBytes() - - let isValid = self.tlsPinning.contains(spkiBytes: spkiBytes) - - if isValid { - context.fireUserInboundEventTriggered(event) - self.logger.debug("SPKI pin validation succeeded") - } else { - self.handlePinningFailure( - context: context, - reason: "SPKI pin mismatch", - event: event - ) - } - + spkiBytes = try publicKey.toSPKIBytes() } catch { - self.handlePinningFailure( - context: context, - reason: "SPKI extraction failed: \(error)", - event: event - ) + let error = SPKIPinningHandlerError.extractionFailed(String(describing: error)) + return tlsPinning.policy == .audit + ? .auditWarning(error) + : .rejected(error) + } + + if tlsPinning.contains(spkiBytes: spkiBytes) { + return .accepted } + let error = SPKIPinningHandlerError.pinMismatch + return tlsPinning.policy == .audit + ? .auditWarning(error) + : .rejected(error) + case .failure(let error): - self.handlePinningFailure( - context: context, - reason: "SSL handler not found: \(error)", - event: event - ) + let handlerError = SPKIPinningHandlerError.handlerNotFound(String(describing: error)) + return tlsPinning.policy == .audit + ? .auditWarning(handlerError) + : .rejected(handlerError) } } +} - private func handlePinningFailure( - context: ChannelHandlerContext, - reason: String, - event: TLSUserEvent - ) { +protocol SPKIPinningExecutor { + func propagateHandshakeEvent(context: ChannelHandlerContext, event: TLSUserEvent) + func logAuditWarning(logger: Logger, error: Error, policy: SPKIPinningPolicy) + func logErrorAndClose(context: ChannelHandlerContext, logger: Logger, error: Error, tlsPinning: SPKIPinningConfiguration) +} + +struct DefaultSPKIPinningExecutor: SPKIPinningExecutor { + + func propagateHandshakeEvent(context: ChannelHandlerContext, event: TLSUserEvent) { + context.fireUserInboundEventTriggered(event) + } + + func logAuditWarning(logger: Logger, error: Error, policy: SPKIPinningPolicy) { + logger.warning( + "SPKI pinning failed — connection allowed for audit purposes", + metadata: [ + "error": .string(String(describing: error)), + "policy": .string(policy.description) + ] + ) + } + + func logErrorAndClose(context: ChannelHandlerContext, logger: Logger, error: Error, tlsPinning: SPKIPinningConfiguration) { let metadata: Logger.Metadata = [ - "pinning_action": .string(tlsPinning.policy == .strict ? "blocked" : "allowed_for_audit"), - "expected_active_pins": .string(tlsPinning.activePins.map { $0.bytes.base64EncodedString() }.joined(separator: ", ")), - "expected_backup_pins": .string(tlsPinning.backupPins.map { $0.bytes.base64EncodedString() }.joined(separator: ", ")) + "policy": .string(tlsPinning.policy.description), + "expected_pins": .string( + tlsPinning.pins.map { $0.bytes.base64EncodedString() }.joined(separator: ", ") + ) ] - switch tlsPinning.policy { - case .strict: - logger.error("SPKI pinning failed — connection blocked", metadata: metadata) + logger.error("SPKI pinning failed — connection blocked", metadata: metadata) - let error = HTTPClientError.invalidCertificatePinning(reason) - context.fireErrorCaught(error) + let error = HTTPClientError.invalidCertificatePinning(String(describing: error)) + context.fireErrorCaught(error) + context.close(promise: nil) + } +} - context.close(promise: nil) +/// Result of SPKI pinning validation — decoupled from pipeline side effects. +enum PinningValidationResult { + /// Pin matched or audit mode allowed mismatch — propagate handshake event. + case accepted - case .audit: - logger.warning("SPKI pinning failed — connection allowed for audit purposes", metadata: metadata) - context.fireUserInboundEventTriggered(event) + /// Pin mismatch in strict mode or critical error — close connection. + case rejected(Error) + + /// Pin mismatch in audit mode — propagate with warning (still accepted). + case auditWarning(Error) +} + +enum SPKIPinningHandlerError: Error, CustomStringConvertible { + + case emptyCertificateChain + case pinMismatch + case extractionFailed(String) + case handlerNotFound(String) + + var description: String { + switch self { + case .emptyCertificateChain: + return "Empty certificate chain" + case .pinMismatch: + return "SPKI pin mismatch" + case .extractionFailed(let error): + return "SPKI extraction failed: \(error)" + case .handlerNotFound(let error): + return "SSL handler not found: \(error)" } } } diff --git a/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift b/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift index a5b856ad9..4d17a01bc 100644 --- a/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift +++ b/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift @@ -397,12 +397,16 @@ extension HTTPConnectionPool.ConnectionFactory { ) try channel.pipeline.syncOperations.addHandler(sslHandler) - if #available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *), let tlsPinning { - let pinningHandler = SPKIPinningHandler( - tlsPinning: tlsPinning, - logger: logger - ) - try channel.pipeline.syncOperations.addHandler(pinningHandler) + if let tlsPinning { + if #available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) { + let pinningHandler = SPKIPinningHandler( + tlsPinning: tlsPinning, + logger: logger + ) + try channel.pipeline.syncOperations.addHandler(pinningHandler) + } else { + fatalError("SPKI pinning requires minimum OS version 10.15/13.0. Cannot proceed with pinning disabled.") + } } let tlsEventHandler = TLSEventsHandler(deadline: deadline) @@ -609,12 +613,16 @@ extension HTTPConnectionPool.ConnectionFactory { try sync.addHandler(sslHandler) - if #available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *), let tlsPinning { - let pinningHandler = SPKIPinningHandler( - tlsPinning: tlsPinning, - logger: logger - ) - try sync.addHandler(pinningHandler) + if let tlsPinning { + if #available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) { + let pinningHandler = SPKIPinningHandler( + tlsPinning: tlsPinning, + logger: logger + ) + try sync.addHandler(pinningHandler) + } else { + fatalError("SPKI pinning requires minimum OS version 10.15/13.0. Cannot proceed with pinning disabled.") + } } try sync.addHandler(tlsEventHandler) diff --git a/Sources/AsyncHTTPClient/HTTPClient.swift b/Sources/AsyncHTTPClient/HTTPClient.swift index 04ce61d5a..411c00d7e 100644 --- a/Sources/AsyncHTTPClient/HTTPClient.swift +++ b/Sources/AsyncHTTPClient/HTTPClient.swift @@ -912,7 +912,6 @@ public final class HTTPClient: Sendable { public init( tlsConfiguration: TLSConfiguration? = nil, - tlsPinning: SPKIPinningConfiguration? = nil, redirectConfiguration: RedirectConfiguration? = nil, timeout: Timeout = Timeout(), connectionPool: ConnectionPool = ConnectionPool(), @@ -921,7 +920,7 @@ public final class HTTPClient: Sendable { decompression: Decompression = .disabled ) { self.tlsConfiguration = tlsConfiguration - self.tlsPinning = tlsPinning + self.tlsPinning = nil self.redirectConfiguration = redirectConfiguration ?? RedirectConfiguration() self.timeout = timeout self.connectionPool = connectionPool @@ -934,7 +933,6 @@ public final class HTTPClient: Sendable { public init( tlsConfiguration: TLSConfiguration? = nil, - tlsPinning: SPKIPinningConfiguration? = nil, redirectConfiguration: RedirectConfiguration? = nil, timeout: Timeout = Timeout(), proxy: Proxy? = nil, @@ -943,7 +941,6 @@ public final class HTTPClient: Sendable { ) { self.init( tlsConfiguration: tlsConfiguration, - tlsPinning: tlsPinning, redirectConfiguration: redirectConfiguration, timeout: timeout, connectionPool: ConnectionPool(), @@ -955,7 +952,6 @@ public final class HTTPClient: Sendable { public init( certificateVerification: CertificateVerification, - tlsPinning: SPKIPinningConfiguration? = nil, redirectConfiguration: RedirectConfiguration? = nil, timeout: Timeout = Timeout(), maximumAllowedIdleTimeInConnectionPool: TimeAmount = .seconds(60), @@ -967,7 +963,6 @@ public final class HTTPClient: Sendable { tlsConfig.certificateVerification = certificateVerification self.init( tlsConfiguration: tlsConfig, - tlsPinning: tlsPinning, redirectConfiguration: redirectConfiguration, timeout: timeout, connectionPool: ConnectionPool(idleTimeout: maximumAllowedIdleTimeInConnectionPool), @@ -979,7 +974,6 @@ public final class HTTPClient: Sendable { public init( certificateVerification: CertificateVerification, - tlsPinning: SPKIPinningConfiguration? = nil, redirectConfiguration: RedirectConfiguration? = nil, timeout: Timeout = Timeout(), connectionPool: TimeAmount = .seconds(60), @@ -992,7 +986,6 @@ public final class HTTPClient: Sendable { tlsConfig.certificateVerification = certificateVerification self.init( tlsConfiguration: tlsConfig, - tlsPinning: tlsPinning, redirectConfiguration: redirectConfiguration, timeout: timeout, connectionPool: ConnectionPool(idleTimeout: connectionPool), @@ -1004,7 +997,6 @@ public final class HTTPClient: Sendable { public init( certificateVerification: CertificateVerification, - tlsPinning: SPKIPinningConfiguration? = nil, redirectConfiguration: RedirectConfiguration? = nil, timeout: Timeout = Timeout(), proxy: Proxy? = nil, @@ -1013,7 +1005,6 @@ public final class HTTPClient: Sendable { ) { self.init( certificateVerification: certificateVerification, - tlsPinning: tlsPinning, redirectConfiguration: redirectConfiguration, timeout: timeout, maximumAllowedIdleTimeInConnectionPool: .seconds(60), @@ -1025,7 +1016,6 @@ public final class HTTPClient: Sendable { public init( tlsConfiguration: TLSConfiguration? = nil, - tlsPinning: SPKIPinningConfiguration? = nil, redirectConfiguration: RedirectConfiguration? = nil, timeout: Timeout = Timeout(), connectionPool: ConnectionPool = ConnectionPool(), @@ -1038,7 +1028,6 @@ public final class HTTPClient: Sendable { ) { self.init( tlsConfiguration: tlsConfiguration, - tlsPinning: tlsPinning, redirectConfiguration: redirectConfiguration, timeout: timeout, connectionPool: connectionPool, @@ -1053,7 +1042,6 @@ public final class HTTPClient: Sendable { public init( tlsConfiguration: TLSConfiguration? = nil, - tlsPinning: SPKIPinningConfiguration? = nil, redirectConfiguration: RedirectConfiguration? = nil, timeout: Timeout = Timeout(), connectionPool: ConnectionPool = ConnectionPool(), @@ -1067,7 +1055,6 @@ public final class HTTPClient: Sendable { ) { self.init( tlsConfiguration: tlsConfiguration, - tlsPinning: tlsPinning, redirectConfiguration: redirectConfiguration, timeout: timeout, connectionPool: connectionPool, @@ -1080,6 +1067,35 @@ public final class HTTPClient: Sendable { self.http2StreamChannelDebugInitializer = http2StreamChannelDebugInitializer self.tracing = tracing } + + public init( + tlsConfiguration: TLSConfiguration? = nil, + tlsPinning: SPKIPinningConfiguration?, + redirectConfiguration: RedirectConfiguration? = nil, + timeout: Timeout = Timeout(), + connectionPool: ConnectionPool = ConnectionPool(), + proxy: Proxy? = nil, + decompression: Decompression = .disabled, + http1_1ConnectionDebugInitializer: (@Sendable (Channel) -> EventLoopFuture)? = nil, + http2ConnectionDebugInitializer: (@Sendable (Channel) -> EventLoopFuture)? = nil, + http2StreamChannelDebugInitializer: (@Sendable (Channel) -> EventLoopFuture)? = nil, + tracing: TracingConfiguration = .init() + ) { + self.init( + tlsConfiguration: tlsConfiguration, + redirectConfiguration: redirectConfiguration, + timeout: timeout, + connectionPool: connectionPool, + proxy: proxy, + ignoreUncleanSSLShutdown: false, + decompression: decompression, + http1_1ConnectionDebugInitializer: http1_1ConnectionDebugInitializer, + http2ConnectionDebugInitializer: http2ConnectionDebugInitializer, + http2StreamChannelDebugInitializer: http2StreamChannelDebugInitializer, + tracing: tracing + ) + self.tlsPinning = tlsPinning + } } public struct TracingConfiguration: Sendable { diff --git a/Tests/AsyncHTTPClientTests/AsyncAwaitEndToEndTests.swift b/Tests/AsyncHTTPClientTests/AsyncAwaitEndToEndTests.swift index 13462196c..8c87b0e36 100644 --- a/Tests/AsyncHTTPClientTests/AsyncAwaitEndToEndTests.swift +++ b/Tests/AsyncHTTPClientTests/AsyncAwaitEndToEndTests.swift @@ -1046,8 +1046,7 @@ final class AsyncAwaitEndToEndTests: XCTestCase { config.httpVersion = .automatic config.tlsPinning = SPKIPinningConfiguration( - activePins: [try SPKIHash(base64: pinBase64)], - backupPins: [], + pins: [try SPKIHash(algorithm: SHA256.self, base64: pinBase64)], policy: .strict ) @@ -1091,8 +1090,7 @@ final class AsyncAwaitEndToEndTests: XCTestCase { config.httpVersion = .automatic config.tlsPinning = SPKIPinningConfiguration( - activePins: [try SPKIHash(base64: pinBase64)], - backupPins: [], + pins: [try SPKIHash(algorithm: SHA256.self, base64: pinBase64)], policy: .strict ) @@ -1118,6 +1116,96 @@ final class AsyncAwaitEndToEndTests: XCTestCase { } } } + + func testSPKIPinning_ValidPin_AuditMode_AllowsConnection() { + XCTAsyncTest { + let certificate = TestTLS.certificate + let privateKey = TestTLS.privateKey + + let tlsConfig = TLSConfiguration.makeServerConfiguration( + certificateChain: [.certificate(certificate)], + privateKey: .privateKey(privateKey) + ) + + let bin = HTTPBin(.http2(tlsConfiguration: tlsConfig)) + defer { XCTAssertNoThrow(try bin.shutdown()) } + + let publicKey = try certificate.extractPublicKey() + let spkiBytes = try publicKey.toSPKIBytes() + let spkiHash = SHA256.hash(data: Data(spkiBytes)) + let pinBase64 = Data(spkiHash).base64EncodedString() + + var config = HTTPClient.Configuration().enableFastFailureModeForTesting() + config.tlsConfiguration = TLSConfiguration.makeClientConfiguration() + config.tlsConfiguration?.trustRoots = .certificates([certificate]) + config.tlsConfiguration?.certificateVerification = .noHostnameVerification + config.httpVersion = .automatic + + config.tlsPinning = SPKIPinningConfiguration( + pins: [try SPKIHash(algorithm: SHA256.self, base64: pinBase64)], + policy: .audit // <-- AUDIT MODE + ) + + let localClient = HTTPClient( + eventLoopGroupProvider: .shared(MultiThreadedEventLoopGroup.singleton), + configuration: config + ) + defer { XCTAssertNoThrow(try localClient.syncShutdown()) } + + let request = HTTPClientRequest(url: "https://localhost:\(bin.port)/get") + + guard let response = await XCTAssertNoThrowWithResult( + try await localClient.execute(request, deadline: .now() + .seconds(10)) + ) else { return } + + XCTAssertEqual(response.status, .ok) + XCTAssertEqual(response.version, .http2) + } + } + + func testSPKIPinning_InvalidPin_AuditMode_AllowsConnection() { + XCTAsyncTest { + let certificate = TestTLS.certificate + let privateKey = TestTLS.privateKey + + let tlsConfig = TLSConfiguration.makeServerConfiguration( + certificateChain: [.certificate(certificate)], + privateKey: .privateKey(privateKey) + ) + + let bin = HTTPBin(.http2(tlsConfiguration: tlsConfig)) + defer { XCTAssertNoThrow(try bin.shutdown()) } + + let spkiHash = SHA256.hash(data: Data(UUID().uuidString.utf8)) + let pinBase64 = Data(spkiHash).base64EncodedString() + + var config = HTTPClient.Configuration().enableFastFailureModeForTesting() + config.tlsConfiguration = TLSConfiguration.makeClientConfiguration() + config.tlsConfiguration?.trustRoots = .certificates([certificate]) + config.tlsConfiguration?.certificateVerification = .noHostnameVerification + config.httpVersion = .automatic + + config.tlsPinning = SPKIPinningConfiguration( + pins: [try SPKIHash(algorithm: SHA256.self, base64: pinBase64)], + policy: .audit // <-- AUDIT MODE + ) + + let localClient = HTTPClient( + eventLoopGroupProvider: .shared(MultiThreadedEventLoopGroup.singleton), + configuration: config + ) + defer { XCTAssertNoThrow(try localClient.syncShutdown()) } + + let request = HTTPClientRequest(url: "https://localhost:\(bin.port)/get") + + guard let response = await XCTAssertNoThrowWithResult( + try await localClient.execute(request, deadline: .now() + .seconds(10)) + ) else { return } + + XCTAssertEqual(response.status, .ok) + XCTAssertEqual(response.version, .http2) + } + } } struct AnySendableSequence: @unchecked Sendable { diff --git a/Tests/AsyncHTTPClientTests/SPKIHashTests.swift b/Tests/AsyncHTTPClientTests/SPKIHashTests.swift index ef58bceea..68ffa4b1f 100644 --- a/Tests/AsyncHTTPClientTests/SPKIHashTests.swift +++ b/Tests/AsyncHTTPClientTests/SPKIHashTests.swift @@ -4,28 +4,6 @@ import Crypto final class SPKIHashTests: XCTestCase { - // MARK: - Initialization (base64) - - func testInitWithValidSHA256Base64() throws { - let base64 = Data(repeating: 0, count: 32).base64EncodedString() - let hash = try SPKIHash(base64: base64) - XCTAssertEqual(hash.bytes.count, 32) - XCTAssertEqual(hash.bytes, Data(repeating: 0, count: 32)) - } - - func testInitWithInvalidBase64Throws() throws { - XCTAssertThrowsError(try SPKIHash(base64: "!!!invalid!!!")) { error in - XCTAssertEqual(error as? HTTPClientError, .invalidDigestLength) - } - } - - func testInitWithWrongLengthBase64Throws() throws { - let base64 = Data(repeating: 0, count: 31).base64EncodedString() - XCTAssertThrowsError(try SPKIHash(base64: base64)) { error in - XCTAssertEqual(error as? HTTPClientError, .invalidDigestLength) - } - } - // MARK: - Initialization (custom algorithm + base64) func testInitWithSHA384AndValidBase64() throws { @@ -65,23 +43,9 @@ final class SPKIHashTests: XCTestCase { // MARK: - Initialization (convenience bytes initializer) - func testInitWithValidSHA256Bytes() throws { - let bytes = Data(repeating: 0, count: 32) - let hash = try SPKIHash(bytes: bytes) - XCTAssertEqual(hash.bytes.count, 32) - XCTAssertEqual(hash.bytes, bytes) - } - - func testInitWithInvalidLengthBytesThrows() throws { - let bytes = Data(repeating: 0, count: 31) - XCTAssertThrowsError(try SPKIHash(bytes: bytes)) { error in - XCTAssertEqual(error as? HTTPClientError, .invalidDigestLength) - } - } - func testConvenienceBytesInitializerUsesSHA256() throws { let bytes = Data([UInt8](0..<32)) - let hash1 = try SPKIHash(bytes: bytes) + let hash1 = try SPKIHash(algorithm: SHA256.self, bytes: bytes) let hash2 = try SPKIHash(algorithm: SHA256.self, bytes: bytes) XCTAssertEqual(hash1, hash2) } @@ -134,7 +98,7 @@ final class SPKIHashTests: XCTestCase { func testSHA256EmptyInputHash() throws { let expectedBase64 = "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=" - let hash = try SPKIHash(base64: expectedBase64) + let hash = try SPKIHash(algorithm: SHA256.self, base64: expectedBase64) let expectedBytes = Data([ 0xe3, 0xb0, 0xc4, 0x42, 0x98, 0xfc, 0x1c, 0x14, diff --git a/Tests/AsyncHTTPClientTests/SPKIPinningTests.swift b/Tests/AsyncHTTPClientTests/SPKIPinningTests.swift index 0875518d5..ab6b15013 100644 --- a/Tests/AsyncHTTPClientTests/SPKIPinningTests.swift +++ b/Tests/AsyncHTTPClientTests/SPKIPinningTests.swift @@ -26,27 +26,11 @@ class SPKIPinningTests: XCTestCase { // MARK: - SPKIPinningConfiguration.contains(spkiBytes:) - func testContains_WithMatchingActivePin_ReturnsTrue() throws { + func testContains_WithMatchingPin_ReturnsTrue() throws { let (certificate, spkiHash) = try Self.testCertificateAndSPKIHash() let pin = try SPKIHash(algorithm: SHA256.self, bytes: Data(spkiHash)) let config = SPKIPinningConfiguration( - activePins: [pin], - backupPins: [], - policy: .strict - ) - - let publicKey = try certificate.extractPublicKey() - let spkiBytes = try publicKey.toSPKIBytes() - - XCTAssertTrue(config.contains(spkiBytes: spkiBytes)) - } - - func testContains_WithMatchingBackupPin_ReturnsTrue() throws { - let (certificate, spkiHash) = try Self.testCertificateAndSPKIHash() - let pin = try SPKIHash(algorithm: SHA256.self, bytes: Data(spkiHash)) - let config = SPKIPinningConfiguration( - activePins: [], - backupPins: [pin], + pins: [pin], policy: .strict ) @@ -58,10 +42,9 @@ class SPKIPinningTests: XCTestCase { func testContains_WithMismatchedPin_ReturnsFalse() throws { let (certificate, _) = try Self.testCertificateAndSPKIHash() - let mismatchedPin = try SPKIHash(base64: "9uO07DlRgCzpXEaC2+ZiqB0VFcjdn43d6h+U2lUHORo=") + let mismatchedPin = try SPKIHash(algorithm: SHA256.self, base64: "9uO07DlRgCzpXEaC2+ZiqB0VFcjdn43d6h+U2lUHORo=") let config = SPKIPinningConfiguration( - activePins: [mismatchedPin], - backupPins: [], + pins: [mismatchedPin], policy: .strict ) @@ -72,198 +55,184 @@ class SPKIPinningTests: XCTestCase { } func testContains_WithEmptyInput_ReturnsFalse() throws { - let pin = try SPKIHash(base64: "9uO07DlRgCzpXEaC2+ZiqB0VFcjdn43d6h+U2lUHORo=") + let pin = try SPKIHash(algorithm: SHA256.self, base64: "9uO07DlRgCzpXEaC2+ZiqB0VFcjdn43d6h+U2lUHORo=") let config = SPKIPinningConfiguration( - activePins: [pin], - backupPins: [], + pins: [pin], policy: .strict ) XCTAssertFalse(config.contains(spkiBytes: [])) } - // MARK: - SPKIPinningHandler.validateSPKI(...) + // MARK: - SPKIPinningHandler.validatePinning(for:) - func testValidateSPKI_WithValidActivePin_PropagatesEvent() throws { + func testValidatePinning_WithValidPin_InStrictMode_ReturnsAccepted() throws { let (certificate, spkiHash) = try Self.testCertificateAndSPKIHash() let pin = try SPKIHash(algorithm: SHA256.self, bytes: Data(spkiHash)) let config = SPKIPinningConfiguration( - activePins: [pin], - backupPins: [], + pins: [pin], policy: .strict ) let handler = try makeHandler(config: config) - let event = TLSUserEvent.handshakeCompleted(negotiatedProtocol: nil) - let embedded = EmbeddedChannel(handlers: [ContextHandler { context in - handler.validateSPKI( - context: context, - event: event, - peerCertificate: .success(certificate) - ) - }]) + let result = handler.validatePinning(for: .success(certificate)) - embedded.pipeline.fireUserInboundEventTriggered(event) - try embedded.throwIfErrorCaught() + if case .accepted = result { + return + } + + XCTFail("Expected validation to succeed") } - func testValidateSPKI_WithValidBackupPin_PropagatesEvent() throws { + func testValidatePinning_WithValidPin_InAuditMode_ReturnsAccepted() throws { let (certificate, spkiHash) = try Self.testCertificateAndSPKIHash() let pin = try SPKIHash(algorithm: SHA256.self, bytes: Data(spkiHash)) let config = SPKIPinningConfiguration( - activePins: [], - backupPins: [pin], - policy: .strict + pins: [pin], + policy: .audit ) let handler = try makeHandler(config: config) - let event = TLSUserEvent.handshakeCompleted(negotiatedProtocol: nil) - let embedded = EmbeddedChannel(handlers: [ContextHandler { context in - handler.validateSPKI( - context: context, - event: event, - peerCertificate: .success(certificate) - ) - }]) + let result = handler.validatePinning(for: .success(certificate)) - embedded.pipeline.fireUserInboundEventTriggered(event) - try embedded.throwIfErrorCaught() + if case .accepted = result { + return + } + + XCTFail("Expected validation to succeed, got \(result)") } - func testValidateSPKI_WithMismatchedPin_InStrictMode_ClosesConnection() throws { + func testValidatePinning_WithMismatchedPin_InStrictMode_ReturnsRejected() throws { let (certificate, _) = try Self.testCertificateAndSPKIHash() - let mismatchedPin = try SPKIHash(base64: "9uO07DlRgCzpXEaC2+ZiqB0VFcjdn43d6h+U2lUHORo=") + let mismatchedPin = try SPKIHash(algorithm: SHA256.self, base64: "9uO07DlRgCzpXEaC2+ZiqB0VFcjdn43d6h+U2lUHORo=") let config = SPKIPinningConfiguration( - activePins: [mismatchedPin], - backupPins: [], + pins: [mismatchedPin], policy: .strict ) let handler = try makeHandler(config: config) - let event = TLSUserEvent.handshakeCompleted(negotiatedProtocol: nil) - let embedded = EmbeddedChannel(handlers: [ContextHandler { context in - handler.validateSPKI( - context: context, - event: event, - peerCertificate: .success(certificate) - ) - }]) + let result = handler.validatePinning(for: .success(certificate)) - embedded.pipeline.fireUserInboundEventTriggered(event) + guard case .rejected(let error) = result else { + XCTFail("Expected .rejected, got \(result)") + return + } - XCTAssertThrowsError(try embedded.throwIfErrorCaught()) { - if let error = $0 as? HTTPClientError { - XCTAssertTrue(error.description.contains("SPKI pin mismatch")) - } + if case .pinMismatch = error as? SPKIPinningHandlerError { + return } + + XCTFail("Expected .pinMismatch, got \(error)") } - func testValidateSPKI_WithMismatchedPin_InAuditMode_PropagatesEvent() throws { + func testValidatePinning_WithMismatchedPin_InAuditMode_ReturnsAuditWarning() throws { let (certificate, _) = try Self.testCertificateAndSPKIHash() - let mismatchedPin = try SPKIHash(base64: "9uO07DlRgCzpXEaC2+ZiqB0VFcjdn43d6h+U2lUHORo=") + let mismatchedPin = try SPKIHash(algorithm: SHA256.self, base64: "9uO07DlRgCzpXEaC2+ZiqB0VFcjdn43d6h+U2lUHORo=") let config = SPKIPinningConfiguration( - activePins: [mismatchedPin], - backupPins: [], + pins: [mismatchedPin], policy: .audit ) let handler = try makeHandler(config: config) - let event = TLSUserEvent.handshakeCompleted(negotiatedProtocol: nil) - let embedded = EmbeddedChannel(handlers: [ContextHandler { context in - handler.validateSPKI( - context: context, - event: event, - peerCertificate: .success(certificate) - ) - }]) + let result = handler.validatePinning(for: .success(certificate)) - embedded.pipeline.fireUserInboundEventTriggered(event) - try embedded.throwIfErrorCaught() + guard case .auditWarning(let error) = result else { + XCTFail("Expected .auditWarning, got \(result)") + return + } + + if case .pinMismatch = error as? SPKIPinningHandlerError { + return + } + + XCTFail("Expected .pinMismatch, got \(error)") } - func testValidateSPKI_WithNilCertificate_InStrictMode_ClosesConnection() throws { - let pin = try SPKIHash(base64: "9uO07DlRgCzpXEaC2+ZiqB0VFcjdn43d6h+U2lUHORo=") + func testValidatePinning_WithNilCertificate_InStrictMode_ReturnsRejected() throws { + let pin = try SPKIHash(algorithm: SHA256.self, base64: "9uO07DlRgCzpXEaC2+ZiqB0VFcjdn43d6h+U2lUHORo=") let config = SPKIPinningConfiguration( - activePins: [pin], - backupPins: [], + pins: [pin], policy: .strict ) let handler = try makeHandler(config: config) - let event = TLSUserEvent.handshakeCompleted(negotiatedProtocol: nil) - let embedded = EmbeddedChannel(handlers: [ContextHandler { context in - handler.validateSPKI( - context: context, - event: event, - peerCertificate: .success(nil) - ) - }]) + let result = handler.validatePinning(for: .success(nil)) - embedded.pipeline.fireUserInboundEventTriggered(event) + guard case .rejected(let error) = result else { + XCTFail("Expected .rejected, got \(result)") + return + } - XCTAssertThrowsError(try embedded.throwIfErrorCaught()) { - if let error = $0 as? HTTPClientError { - XCTAssertTrue(error.description.contains("Empty certificate chain")) - } + if case .emptyCertificateChain = error as? SPKIPinningHandlerError { + return } + + XCTFail("Expected .emptyCertificateChain, got \(error)") } - func testValidateSPKI_WithNilCertificate_InAuditMode_PropagatesEvent() throws { - let pin = try SPKIHash(base64: "9uO07DlRgCzpXEaC2+ZiqB0VFcjdn43d6h+U2lUHORo=") + func testValidatePinning_WithNilCertificate_InAuditMode_ReturnsAuditWarning() throws { + let pin = try SPKIHash(algorithm: SHA256.self, base64: "9uO07DlRgCzpXEaC2+ZiqB0VFcjdn43d6h+U2lUHORo=") let config = SPKIPinningConfiguration( - activePins: [pin], - backupPins: [], + pins: [pin], policy: .audit ) let handler = try makeHandler(config: config) - let event = TLSUserEvent.handshakeCompleted(negotiatedProtocol: nil) - let embedded = EmbeddedChannel(handlers: [ContextHandler { context in - handler.validateSPKI( - context: context, - event: event, - peerCertificate: .success(nil) - ) - }]) + let result = handler.validatePinning(for: .success(nil)) - embedded.pipeline.fireUserInboundEventTriggered(event) - try embedded.throwIfErrorCaught() + guard case .auditWarning(let error) = result else { + XCTFail("Expected .auditWarning, got \(result)") + return + } + + if case .emptyCertificateChain = error as? SPKIPinningHandlerError { + return + } + + XCTFail("Expected .emptyCertificateChain, got \(error)") } - func testValidateSPKI_WithHandlerLookupFailure_InStrictMode_ClosesConnection() throws { - let pin = try SPKIHash(base64: "9uO07DlRgCzpXEaC2+ZiqB0VFcjdn43d6h+U2lUHORo=") + func testValidatePinning_WithExtractionFailure_InStrictMode_ReturnsRejected() throws { + let pin = try SPKIHash(algorithm: SHA256.self, base64: "9uO07DlRgCzpXEaC2+ZiqB0VFcjdn43d6h+U2lUHORo=") let config = SPKIPinningConfiguration( - activePins: [pin], - backupPins: [], + pins: [pin], policy: .strict ) let handler = try makeHandler(config: config) - let event = TLSUserEvent.handshakeCompleted(negotiatedProtocol: nil) let extractionError = NSError(domain: "TestError", code: 1, userInfo: nil) - let embedded = EmbeddedChannel(handlers: [ContextHandler { context in - handler.validateSPKI( - context: context, - event: event, - peerCertificate: .failure(extractionError) - ) - }]) + let result = handler.validatePinning(for: .failure(extractionError)) - embedded.pipeline.fireUserInboundEventTriggered(event) + guard case .rejected(let error) = result else { + XCTFail("Expected .rejected, got \(result)") + return + } + XCTAssertTrue((error as? SPKIPinningHandlerError)?.description.contains("SSL handler not found:") == true) + } - XCTAssertThrowsError(try embedded.throwIfErrorCaught()) { - if let error = $0 as? HTTPClientError { - XCTAssertTrue(error.description.contains("SSL handler not found:")) - } + func testValidatePinning_WithExtractionFailure_InAuditMode_ReturnsAuditWarning() throws { + let pin = try SPKIHash(algorithm: SHA256.self, base64: "9uO07DlRgCzpXEaC2+ZiqB0VFcjdn43d6h+U2lUHORo=") + let config = SPKIPinningConfiguration( + pins: [pin], + policy: .audit + ) + let handler = try makeHandler(config: config) + let extractionError = NSError(domain: "TestError", code: 1, userInfo: nil) + + let result = handler.validatePinning(for: .failure(extractionError)) + + guard case .auditWarning(let error) = result else { + XCTFail("Expected .auditWarning, got \(result)") + return } + XCTAssertTrue((error as? SPKIPinningHandlerError)?.description.contains("SSL handler not found:") == true) } // MARK: - SPKIPinningHandler.userInboundEventTriggered(...) func testUserInboundEventTriggered_IgnoresNonHandshakeEvents() throws { let config = SPKIPinningConfiguration( - activePins: [], - backupPins: [], + pins: [], policy: .strict ) let handler = try makeHandler(config: config) @@ -276,8 +245,7 @@ class SPKIPinningTests: XCTestCase { func testUserInboundEventTriggered_OnHandshakeInitiatesValidation() throws { let config = SPKIPinningConfiguration( - activePins: [], - backupPins: [], + pins: [], policy: .strict ) let handler = try makeHandler(config: config) @@ -288,7 +256,7 @@ class SPKIPinningTests: XCTestCase { XCTAssertThrowsError(try embedded.throwIfErrorCaught()) { if let error = $0 as? HTTPClientError { - XCTAssertTrue(error.description.contains("SSL handler not found: notFound")) + XCTAssertTrue(error.description.contains("SSL handler not found:")) } } } @@ -309,15 +277,26 @@ class SPKIPinningTests: XCTestCase { } } -private final class ContextHandler: ChannelInboundHandler { - typealias InboundIn = NIOAny - let context: (ChannelHandlerContext) -> Void +final class MockSPKIPinningExecutor: SPKIPinningExecutor { + + var propagateCallCount = 0 + var lastPropagatedEvent: TLSUserEvent? + var auditWarningLogged = false + var lastLoggedError: Error? + var closeCallCount = 0 + + func propagateHandshakeEvent(context: ChannelHandlerContext, event: TLSUserEvent) { + propagateCallCount += 1 + lastPropagatedEvent = event + } - init(context: @escaping (ChannelHandlerContext) -> Void) { - self.context = context + func logAuditWarning(logger: Logger, error: Error, policy: SPKIPinningPolicy) { + auditWarningLogged = true + lastLoggedError = error } - func userInboundEventTriggered(context: ChannelHandlerContext, event: Any) { - self.context(context) + func logErrorAndClose(context: ChannelHandlerContext, logger: Logger, error: Error, tlsPinning: SPKIPinningConfiguration) { + closeCallCount += 1 + lastLoggedError = error } } From c5c0198acc8f3361d8f721eab002332800a53d42 Mon Sep 17 00:00:00 2001 From: brennobemoura <37243584+brennobemoura@users.noreply.github.com> Date: Wed, 4 Feb 2026 11:56:48 -0300 Subject: [PATCH 11/13] Included length difference in the diff --- .../ConnectionPool/ChannelHandler/SPKIPinningHandler.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/SPKIPinningHandler.swift b/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/SPKIPinningHandler.swift index 1947d184e..40a8340f6 100644 --- a/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/SPKIPinningHandler.swift +++ b/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/SPKIPinningHandler.swift @@ -101,8 +101,7 @@ internal func constantTimeAnyMatch(_ target: Data, _ candidates: [SPKIHash]) -> var anyMatch: UInt8 = 0 for candidate in candidates { - let lengthDiff = UInt8(bitPattern: Int8(target.count - candidate.bytes.count)) - var diff = lengthDiff + var diff: UInt8 = (target.count == candidate.bytes.count) ? 0 : 1 let maxLength = max(target.count, candidate.bytes.count) for i in 0 ..< maxLength { @@ -110,6 +109,7 @@ internal func constantTimeAnyMatch(_ target: Data, _ candidates: [SPKIHash]) -> let b = i < candidate.bytes.count ? candidate.bytes[i] : 0 diff |= a ^ b } + anyMatch |= (diff == 0) ? 1 : 0 } return anyMatch != 0 From 51b30150230efccc5db3a353189c86864d077fb2 Mon Sep 17 00:00:00 2001 From: brennobemoura <37243584+brennobemoura@users.noreply.github.com> Date: Tue, 10 Feb 2026 09:06:46 -0300 Subject: [PATCH 12/13] Removed Executor, fix `constantTimeAnyMatch(_:_:)` and update context get handler logic --- .../ChannelHandler/SPKIPinningHandler.swift | 121 ++++++++---------- .../SPKIPinningTests.swift | 24 ---- 2 files changed, 51 insertions(+), 94 deletions(-) diff --git a/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/SPKIPinningHandler.swift b/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/SPKIPinningHandler.swift index 40a8340f6..dc2ee9de7 100644 --- a/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/SPKIPinningHandler.swift +++ b/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/SPKIPinningHandler.swift @@ -88,28 +88,35 @@ public struct SPKIHash: Sendable, Hashable { } } -/// Constant-time digest comparison preventing timing attacks. +/// Constant-time comparison to prevent timing attacks during SPKI pin validation. /// -/// Compares digests without truncation — length mismatches and byte differences are incorporated -/// into a single constant-time result. Prevents false positives from partial matches (e.g., SHA-256 -/// vs SHA-1). +/// Timing attacks exploit micro-variations in execution time to infer secret values. +/// This function eliminates such leaks by: +/// 1. Iterating a fixed number of times (determined by hash algorithm) +/// 2. Processing all candidates even after a match is found +/// 3. Performing all secret-dependent operations before any conditional branches /// -/// - Warning: Never use truncating comparisons (like `zip`) for cryptographic equality — they -/// silently accept partial matches. +/// SECURITY INVARIANT: All candidates share identical length (enforced by +/// SPKIPinningConfiguration grouping and SPKIHash validation). Length mismatches +/// are rejected early using public knowledge (algorithm-determined digest size), +/// which cannot leak secret information. internal func constantTimeAnyMatch(_ target: Data, _ candidates: [SPKIHash]) -> Bool { guard !candidates.isEmpty else { return false } + let expectedLength = candidates[0].bytes.count + guard target.count == expectedLength else { return false } + var anyMatch: UInt8 = 0 for candidate in candidates { - var diff: UInt8 = (target.count == candidate.bytes.count) ? 0 : 1 + precondition( + candidate.bytes.count == expectedLength, + "Algorithm grouping invariant violated: candidates must share identical length" + ) - let maxLength = max(target.count, candidate.bytes.count) - for i in 0 ..< maxLength { - let a = i < target.count ? target[i] : 0 - let b = i < candidate.bytes.count ? candidate.bytes[i] : 0 - diff |= a ^ b + var diff: UInt8 = 0 + for i in 0 ..< expectedLength { + diff |= target[i] ^ candidate.bytes[i] } - anyMatch |= (diff == 0) ? 1 : 0 } return anyMatch != 0 @@ -192,16 +199,13 @@ final class SPKIPinningHandler: ChannelInboundHandler, RemovableChannelHandler { private let tlsPinning: SPKIPinningConfiguration private let logger: Logger - private let executor: SPKIPinningExecutor init( tlsPinning: SPKIPinningConfiguration, - logger: Logger, - executor: SPKIPinningExecutor = DefaultSPKIPinningExecutor() + logger: Logger ) { self.tlsPinning = tlsPinning self.logger = logger - self.executor = executor if tlsPinning.pins.count < 2 && tlsPinning.policy == .strict { logger.warning( @@ -215,29 +219,44 @@ final class SPKIPinningHandler: ChannelInboundHandler, RemovableChannelHandler { } func userInboundEventTriggered(context: ChannelHandlerContext, event: Any) { - guard - let tlsEvent = event as? TLSUserEvent, - case .handshakeCompleted = tlsEvent - else { + guard case .handshakeCompleted = (event as? TLSUserEvent) else { context.fireUserInboundEventTriggered(event) return } // ⚠️ Security-critical: handshake propagation is delayed until validation completes - context.pipeline.handler(type: NIOSSLHandler.self).assumeIsolated().whenComplete { - let result = self.validatePinning(for: $0.map(\.peerCertificate)) + let result = self.validatePinning(for: Result { + try context.pipeline.syncOperations.handler(type: NIOSSLHandler.self).peerCertificate + }) - switch result { - case .accepted: - self.executor.propagateHandshakeEvent(context: context, event: tlsEvent) + switch result { + case .accepted: + context.fireUserInboundEventTriggered(event) - case .auditWarning(let error): - self.executor.logAuditWarning(logger: self.logger, error: error, policy: self.tlsPinning.policy) - self.executor.propagateHandshakeEvent(context: context, event: tlsEvent) + case .auditWarning(let error): + logger.warning( + "SPKI pinning failed — connection allowed for audit purposes", + metadata: [ + "error": .string(String(describing: error)), + "policy": .string(tlsPinning.policy.description) + ] + ) - case .rejected(let error): - self.executor.logErrorAndClose(context: context, logger: self.logger, error: error, tlsPinning: self.tlsPinning) - } + context.fireUserInboundEventTriggered(event) + + case .rejected(let error): + let metadata: Logger.Metadata = [ + "policy": .string(tlsPinning.policy.description), + "expected_pins": .string( + tlsPinning.pins.map { $0.bytes.base64EncodedString() }.joined(separator: ", ") + ) + ] + + logger.error("SPKI pinning failed — connection blocked", metadata: metadata) + + let error = HTTPClientError.invalidCertificatePinning(String(describing: error)) + context.fireErrorCaught(error) + context.close(promise: nil) } } @@ -280,44 +299,6 @@ final class SPKIPinningHandler: ChannelInboundHandler, RemovableChannelHandler { } } -protocol SPKIPinningExecutor { - func propagateHandshakeEvent(context: ChannelHandlerContext, event: TLSUserEvent) - func logAuditWarning(logger: Logger, error: Error, policy: SPKIPinningPolicy) - func logErrorAndClose(context: ChannelHandlerContext, logger: Logger, error: Error, tlsPinning: SPKIPinningConfiguration) -} - -struct DefaultSPKIPinningExecutor: SPKIPinningExecutor { - - func propagateHandshakeEvent(context: ChannelHandlerContext, event: TLSUserEvent) { - context.fireUserInboundEventTriggered(event) - } - - func logAuditWarning(logger: Logger, error: Error, policy: SPKIPinningPolicy) { - logger.warning( - "SPKI pinning failed — connection allowed for audit purposes", - metadata: [ - "error": .string(String(describing: error)), - "policy": .string(policy.description) - ] - ) - } - - func logErrorAndClose(context: ChannelHandlerContext, logger: Logger, error: Error, tlsPinning: SPKIPinningConfiguration) { - let metadata: Logger.Metadata = [ - "policy": .string(tlsPinning.policy.description), - "expected_pins": .string( - tlsPinning.pins.map { $0.bytes.base64EncodedString() }.joined(separator: ", ") - ) - ] - - logger.error("SPKI pinning failed — connection blocked", metadata: metadata) - - let error = HTTPClientError.invalidCertificatePinning(String(describing: error)) - context.fireErrorCaught(error) - context.close(promise: nil) - } -} - /// Result of SPKI pinning validation — decoupled from pipeline side effects. enum PinningValidationResult { /// Pin matched or audit mode allowed mismatch — propagate handshake event. diff --git a/Tests/AsyncHTTPClientTests/SPKIPinningTests.swift b/Tests/AsyncHTTPClientTests/SPKIPinningTests.swift index ab6b15013..a62fe4ac1 100644 --- a/Tests/AsyncHTTPClientTests/SPKIPinningTests.swift +++ b/Tests/AsyncHTTPClientTests/SPKIPinningTests.swift @@ -276,27 +276,3 @@ class SPKIPinningTests: XCTestCase { return (certificate, spkiHash) } } - -final class MockSPKIPinningExecutor: SPKIPinningExecutor { - - var propagateCallCount = 0 - var lastPropagatedEvent: TLSUserEvent? - var auditWarningLogged = false - var lastLoggedError: Error? - var closeCallCount = 0 - - func propagateHandshakeEvent(context: ChannelHandlerContext, event: TLSUserEvent) { - propagateCallCount += 1 - lastPropagatedEvent = event - } - - func logAuditWarning(logger: Logger, error: Error, policy: SPKIPinningPolicy) { - auditWarningLogged = true - lastLoggedError = error - } - - func logErrorAndClose(context: ChannelHandlerContext, logger: Logger, error: Error, tlsPinning: SPKIPinningConfiguration) { - closeCallCount += 1 - lastLoggedError = error - } -} From 87fd63f2fa314507dba0e9c4ad7e6d335ec6469e Mon Sep 17 00:00:00 2001 From: brennobemoura <37243584+brennobemoura@users.noreply.github.com> Date: Tue, 10 Feb 2026 09:30:11 -0300 Subject: [PATCH 13/13] Updated `SPKIPinningPolicy` and adjust documentation --- .../ChannelHandler/SPKIPinningHandler.swift | 73 ++++++++++++------- 1 file changed, 46 insertions(+), 27 deletions(-) diff --git a/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/SPKIPinningHandler.swift b/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/SPKIPinningHandler.swift index dc2ee9de7..5e32dd1c1 100644 --- a/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/SPKIPinningHandler.swift +++ b/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/SPKIPinningHandler.swift @@ -25,9 +25,6 @@ import Algorithms /// Validates server identity using the DER-encoded public key structure (RFC 5280, Section 4.1). /// Survives legitimate certificate rotations and prevents algorithm downgrade attacks. /// -/// Equality requires matching digest bytes *and* hash algorithm. Length mismatches are treated -/// as inequality — critical for constant-time security guarantees. -/// /// - SeeAlso: https://datatracker.ietf.org/doc/html/rfc5280#section-4.1 /// - SeeAlso: https://owasp.org/www-project-mobile-security-testing-guide/latest/0x05g-Testing-Network-Communication.html public struct SPKIHash: Sendable, Hashable { @@ -124,9 +121,10 @@ internal func constantTimeAnyMatch(_ target: Data, _ candidates: [SPKIHash]) -> /// Configuration for SPKI pinning validation. /// -/// - Warning: Always deploy backup pins ≥ 30 days before certificate expiration. Empty pin sets -/// in `.strict` mode will reject all connections during rotation. +/// - Warning: Always deploy multiple pins to enable safe certificate rotation. +/// Single-pin configurations in `.strict` mode risk catastrophic lockout. public struct SPKIPinningConfiguration: Sendable, Hashable { + /// SPKI hashes of trusted certificates. public let pins: [SPKIHash] @@ -138,7 +136,8 @@ public struct SPKIPinningConfiguration: Sendable, Hashable { /// Creates an SPKI pinning configuration. /// /// - Parameters: - /// - pins: Hashes of trusted certificates (must not be empty in production). + /// - pins: Hashes of trusted certificates. For production safety, include + /// pins for both current and upcoming certificates to enable rotation. /// - policy: Validation failure policy (`.strict` for production, `.audit` for debugging). public init( pins: [SPKIHash], @@ -163,29 +162,51 @@ public struct SPKIPinningConfiguration: Sendable, Hashable { } } -/// Policy for handling SPKI pin validation failures. +/// Security policy for SPKI pin validation failures. +/// +/// Determines the client's response when a server's certificate fails SPKI pin validation: +/// - `.audit`: Permit the connection for observability (staging/debugging only) +/// - `.strict`: Terminate the connection immediately (production environments) /// -/// SPKI pinning can fail due to certificate mismatch, invalid SPKI extraction, or missing SSL handler. -/// This policy determines whether the connection proceeds or is terminated. +/// - Warning: Never use `.audit` in production — it effectively disables pinning security +/// guarantees while maintaining audit visibility. public struct SPKIPinningPolicy: Sendable, Hashable { + private enum RawValue: Sendable, Hashable { + case audit + case strict + } + /// Permit connections with untrusted certificates for observability only. - public static let audit = SPKIPinningPolicy(name: "audit", rawValue: 1 << 0) + /// + /// Use exclusively for debugging, testing, or migration scenarios. Never use in production. + public static let audit = SPKIPinningPolicy(rawValue: .audit) /// Reject connections with untrusted certificates. - public static let strict = SPKIPinningPolicy(name: "strict", rawValue: 1 << 1) + /// + /// Use in production environments where security is paramount. + public static let strict = SPKIPinningPolicy(rawValue: .strict) public var description: String { - return name + switch self.rawValue { + case .audit: return "audit" + case .strict: return "strict" + } } - private let name: String - private let rawValue: UInt8 + private let rawValue: RawValue - private init(name: String, rawValue: UInt8) { - self.name = name + private init(rawValue: RawValue) { self.rawValue = rawValue } + + public static func ==(lhs: Self, rhs: Self) -> Bool { + lhs.rawValue == rhs.rawValue + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(rawValue) + } } /// ChannelHandler that validates server certificates using SPKI pinning. @@ -212,7 +233,7 @@ final class SPKIPinningHandler: ChannelInboundHandler, RemovableChannelHandler { "SPKIPinningHandler deployed with < 2 pins in strict mode — catastrophic lockout risk on certificate rotation!", metadata: [ "current_pin_count": .stringConvertible(tlsPinning.pins.count), - "recommendation": .string("Deploy backup pins ≥ 30 days before certificate expiration") + "recommendation": .string("Deploy multiple pins to enable safe certificate rotation") ] ) } @@ -241,7 +262,6 @@ final class SPKIPinningHandler: ChannelInboundHandler, RemovableChannelHandler { "policy": .string(tlsPinning.policy.description) ] ) - context.fireUserInboundEventTriggered(event) case .rejected(let error): @@ -266,8 +286,8 @@ final class SPKIPinningHandler: ChannelInboundHandler, RemovableChannelHandler { guard let leaf = peerCertificate else { let error = SPKIPinningHandlerError.emptyCertificateChain return tlsPinning.policy == .audit - ? .auditWarning(error) - : .rejected(error) + ? .auditWarning(error) + : .rejected(error) } let spkiBytes: [UInt8] @@ -277,8 +297,8 @@ final class SPKIPinningHandler: ChannelInboundHandler, RemovableChannelHandler { } catch { let error = SPKIPinningHandlerError.extractionFailed(String(describing: error)) return tlsPinning.policy == .audit - ? .auditWarning(error) - : .rejected(error) + ? .auditWarning(error) + : .rejected(error) } if tlsPinning.contains(spkiBytes: spkiBytes) { @@ -287,14 +307,14 @@ final class SPKIPinningHandler: ChannelInboundHandler, RemovableChannelHandler { let error = SPKIPinningHandlerError.pinMismatch return tlsPinning.policy == .audit - ? .auditWarning(error) - : .rejected(error) + ? .auditWarning(error) + : .rejected(error) case .failure(let error): let handlerError = SPKIPinningHandlerError.handlerNotFound(String(describing: error)) return tlsPinning.policy == .audit - ? .auditWarning(handlerError) - : .rejected(handlerError) + ? .auditWarning(handlerError) + : .rejected(handlerError) } } } @@ -312,7 +332,6 @@ enum PinningValidationResult { } enum SPKIPinningHandlerError: Error, CustomStringConvertible { - case emptyCertificateChain case pinMismatch case extractionFailed(String)