Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
9ce1b9e
Implemented SPKIPinningConfiguration
o-nnerb Feb 1, 2026
eeb3726
Added Hashable conformance
o-nnerb Feb 1, 2026
8c6e782
Fix tlsPinning usage
o-nnerb Feb 2, 2026
5f1dfd9
Fix hash digestion
o-nnerb Feb 2, 2026
a224e7e
Lint code
o-nnerb Feb 2, 2026
32a4100
Provide support for various algorithms for comparing SPKI
o-nnerb Feb 2, 2026
26675d1
Improved properties names and applications
o-nnerb Feb 2, 2026
6c585a6
Implemented tests
o-nnerb Feb 2, 2026
8c673f6
Added back removed code
o-nnerb Feb 2, 2026
1e56600
Add SPKI pinning with runtime safety checks and explicit TLS requirement
o-nnerb Feb 4, 2026
c5c0198
Included length difference in the diff
o-nnerb Feb 4, 2026
b71085f
Merge branch 'main' into main
o-nnerb Feb 6, 2026
51b3015
Removed Executor, fix `constantTimeAnyMatch(_:_:)` and update context…
o-nnerb Feb 10, 2026
87fd63f
Updated `SPKIPinningPolicy` and adjust documentation
o-nnerb Feb 10, 2026
885a2bd
Merge branch 'main' into main
o-nnerb Feb 10, 2026
e3d0b27
Merge branch 'main' into main
o-nnerb Feb 18, 2026
d8d9767
Merge branch 'main' into main
o-nnerb Feb 21, 2026
e0baecb
Applied swift format
o-nnerb Mar 12, 2026
6cb7f2c
Merge branch 'main' into main
o-nnerb Mar 12, 2026
39f6b77
Added new init for HTTPHandler and undo some changes
o-nnerb Mar 12, 2026
8d6ec8a
Revert the modifications made to the method documentation
o-nnerb Mar 12, 2026
c599656
Added support for the old version of Swift Package configuration
o-nnerb Mar 12, 2026
362dc7d
Revert the modifications made to the method documentation
o-nnerb Mar 12, 2026
b73d62a
Added a new initializer for HTTPHandler that accepts a String url and…
o-nnerb Mar 12, 2026
3fd423f
Merge branch 'main' into main
o-nnerb Mar 15, 2026
9ffdfd7
Change code to import FoundationEssentials
o-nnerb Mar 15, 2026
30f8807
Merge branch 'main' into main
o-nnerb Mar 26, 2026
d8c153a
Adds missing license header
o-nnerb Mar 26, 2026
89ab903
Merge branch 'main' into main
o-nnerb Apr 6, 2026
b573359
Disable all traits to prevent linking Foundation for swift-crypto
o-nnerb Apr 6, 2026
e3f2b76
Updated Swift Crypto package
o-nnerb Apr 6, 2026
cdcfcbc
Updated swift-crypto to 4.5.0
o-nnerb Apr 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ extension HTTPClientRequest {
var head: HTTPRequestHead
var body: Body?
var tlsConfiguration: TLSConfiguration?
var tlsPinning: SPKIPinningConfiguration?
}
}

Expand Down Expand Up @@ -82,7 +83,8 @@ extension HTTPClientRequest.Prepared {
headers: headers
),
body: request.body.map { .init($0) },
tlsConfiguration: request.tlsConfiguration
tlsConfiguration: request.tlsConfiguration,
tlsPinning: request.tlsPinning
)
}
}
Expand Down
3 changes: 3 additions & 0 deletions Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions Sources/AsyncHTTPClient/AsyncAwait/Transaction.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,316 @@
//===----------------------------------------------------------------------===//
//
// 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
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.
///
/// 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 {

/// Raw hash digest bytes of the SPKI structure.
public let bytes: Data

fileprivate let algorithmID: ObjectIdentifier
private let algorithm: @Sendable (Data) -> any Sequence<UInt8>

// 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 {
Comment thread
o-nnerb marked this conversation as resolved.
Outdated
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: HashFunction>(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 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 {
Comment thread
o-nnerb marked this conversation as resolved.
Outdated
try self.init(algorithm: SHA256.self, bytes: bytes)
}

/// 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: HashFunction>(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.
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) {
Comment thread
o-nnerb marked this conversation as resolved.
Outdated
diff |= a ^ b
}
anyMatch |= (diff == 0) ? 1 : 0
}
return anyMatch != 0
}

/// 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.
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]

/// Policy for handling pin validation failures.
public let policy: SPKIPinningPolicy

private let pinsByAlgorithm: [ObjectIdentifier: [SPKIHash]]

/// Creates an SPKI pinning configuration.
///
/// - Parameters:
/// - 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: Empty `backupPins` in `.strict` mode risks catastrophic lockout during
/// certificate rotation.
public init(
activePins: [SPKIHash],
backupPins: [SPKIHash],
policy: SPKIPinningPolicy = .strict
Comment thread
o-nnerb marked this conversation as resolved.
) {
self.activePins = activePins
self.backupPins = backupPins
self.pinsByAlgorithm = Dictionary(grouping: Set(activePins + backupPins), by: \.algorithmID)
self.policy = policy
}

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
}
}

/// Policy for handling SPKI pin validation failures.
public enum SPKIPinningPolicy: Sendable, Hashable {
Comment thread
o-nnerb marked this conversation as resolved.
Outdated
/// Reject connections with untrusted certificates.
case strict

/// Permit connections with untrusted certificates for observability only.
case audit
}

/// 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, *)
final class SPKIPinningHandler: ChannelInboundHandler, RemovableChannelHandler {

typealias InboundIn = NIOAny

private let tlsPinning: SPKIPinningConfiguration
private let logger: Logger

init(
tlsPinning: SPKIPinningConfiguration,
logger: Logger
) {
self.tlsPinning = tlsPinning
self.logger = logger

if tlsPinning.backupPins.isEmpty && tlsPinning.policy == .strict {
logger.warning(
"SPKIPinningHandler deployed without backup pins in strict 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)
Comment thread
o-nnerb marked this conversation as resolved.
return
}

context.pipeline.handler(type: NIOSSLHandler.self).assumeIsolated().whenComplete {
Comment thread
o-nnerb marked this conversation as resolved.
Outdated
self.validateSPKI(
context: context,
event: tlsEvent,
peerCertificate: $0.map(\.peerCertificate)
)
}
}

func validateSPKI(
context: ChannelHandlerContext,
event: TLSUserEvent,
peerCertificate result: Result<NIOSSLCertificate?, Error>
) {
switch result {
case .success(let peerCertificate):
guard let leaf = peerCertificate else {
self.handlePinningFailure(
context: context,
reason: "Empty certificate chain",
event: event
)
return
}

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
)
}

} catch {
self.handlePinningFailure(
context: context,
reason: "SPKI extraction failed: \(error)",
event: event
)
}

case .failure(let error):
self.handlePinningFailure(
context: context,
reason: "SSL handler not found: \(error)",
event: event
)
}
}

private func handlePinningFailure(
context: ChannelHandlerContext,
reason: String,
event: TLSUserEvent
) {
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: ", "))
]

switch tlsPinning.policy {
case .strict:
logger.error("SPKI pinning failed — connection blocked", metadata: metadata)

let error = HTTPClientError.invalidCertificatePinning(reason)
context.fireErrorCaught(error)

context.close(promise: nil)

case .audit:
logger.warning("SPKI pinning failed — connection allowed for audit purposes", metadata: metadata)
context.fireUserInboundEventTriggered(event)
}
}
}
Loading