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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,16 +43,21 @@ let package = Package(
traits: ["UnstableAsyncStreaming"]
),
.package(url: "https://github.com/apple/swift-http-types.git", from: "1.6.0"),
.package(url: "https://github.com/apple/swift-certificates.git", from: "1.19.1"),
// TODO: Temporarily pointing at a fork while `SecKey.makeWithPrivateKey(_:)` is upstreamed.
.package(url: "https://github.com/guoye-zhang/swift-certificates.git", branch: "private-key-to-seckey"),
.package(url: "https://github.com/apple/swift-asn1.git", from: "1.7.2"),
.package(url: "https://github.com/apple/swift-crypto.git", from: "4.5.2"),
.package(url: "https://github.com/apple/swift-log.git", from: "1.13.2"),
.package(url: "https://github.com/apple/swift-nio.git", from: "2.101.3"),
.package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.37.0"),
.package(url: "https://github.com/apple/swift-nio-extras.git", from: "1.34.1"),
.package(url: "https://github.com/apple/swift-nio-http2.git", from: "1.44.0"),
.package(url: "https://github.com/apple/swift-configuration.git", from: "1.2.0", traits: []),
// TODO: Temporarily pointing at a fork while the per-request TLS hooks
// (`HTTPClientRequest._tlsVerificationHandler` and `_tlsClientCertificateHandler`) are upstreamed.
.package(
url: "https://github.com/swift-server/async-http-client.git",
exact: "1.35.0",
url: "https://github.com/guoye-zhang/async-http-client.git",
branch: "per-request-tls-verification",
traits: ["UnstableHTTPAPIsSupport"]
),
],
Expand All @@ -64,6 +69,7 @@ let package = Package(
.product(name: "AsyncStreaming", package: "swift-async-algorithms"),
"NetworkTypes",
.product(name: "HTTPTypes", package: "swift-http-types"),
.product(name: "X509", package: "swift-certificates"),
],
swiftSettings: extraSettings
),
Expand Down Expand Up @@ -93,6 +99,9 @@ let package = Package(
.product(name: "HTTPTypes", package: "swift-http-types"),
.product(name: "AsyncHTTPClient", package: "async-http-client"),
.product(name: "NIOHTTP1", package: "swift-nio"),
.product(name: "NIOSSL", package: "swift-nio-ssl"),
.product(name: "X509", package: "swift-certificates"),
.product(name: "SwiftASN1", package: "swift-asn1"),
],
swiftSettings: extraSettings
),
Expand All @@ -104,6 +113,8 @@ let package = Package(
"NetworkTypes",
.product(name: "HTTPTypes", package: "swift-http-types"),
.product(name: "HTTPTypesFoundation", package: "swift-http-types"),
.product(name: "X509", package: "swift-certificates"),
.product(name: "SwiftASN1", package: "swift-asn1"),
],
swiftSettings: extraSettings
),
Expand All @@ -119,6 +130,8 @@ let package = Package(
.product(name: "DequeModule", package: "swift-collections"),
.product(name: "BasicContainers", package: "swift-collections"),
.product(name: "X509", package: "swift-certificates"),
.product(name: "SwiftASN1", package: "swift-asn1"),
.product(name: "Crypto", package: "swift-crypto"),
.product(name: "NIOCore", package: "swift-nio"),
.product(name: "NIOPosix", package: "swift-nio"),
.product(name: "NIOHTTP1", package: "swift-nio"),
Expand Down Expand Up @@ -168,7 +181,11 @@ let package = Package(
name: "HTTPClientTests",
dependencies: [
"HTTPClient",
"AHCHTTPClient",
"URLSessionHTTPClient",
"HTTPClientConformance",
.product(name: "AsyncHTTPClient", package: "async-http-client"),
.product(name: "X509", package: "swift-certificates"),
],
swiftSettings: extraSettings
),
Expand Down
7 changes: 2 additions & 5 deletions Sources/AHCHTTPClient/AHC+HTTPClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,6 @@ import Synchronization

@available(anyAppleOS 26.0, *)
extension AsyncHTTPClient.HTTPClient: HTTPAPIs.HTTPClient {
public struct RequestOptions: HTTPClientCapability.RequestOptions {

}

public struct Writer: CallerAsyncWriter, ~Copyable {
public typealias WriteElement = UInt8
public typealias WriteFailure = any Error
Expand Down Expand Up @@ -171,7 +167,6 @@ extension AsyncHTTPClient.HTTPClient: HTTPAPIs.HTTPClient {
let sequence = request.headerFields.lazy.map({ ($0.name.rawName, $0.value) })
ahcRequest.headers.add(contentsOf: sequence)
}

if let body, body.knownLength != 0 {
let (asyncStream, startUploadContinuation) = AsyncStream.makeStream(of: HTTPClientRequest.Body.RequestWriter.self)

Expand All @@ -196,6 +191,8 @@ extension AsyncHTTPClient.HTTPClient: HTTPAPIs.HTTPClient {
}

do {
options.apply(to: &ahcRequest, serverHostname: url.host(percentEncoded: false))

let ahcResponse = try await self.execute(ahcRequest, timeout: .seconds(30))

var responseFields = HTTPFields()
Expand Down
189 changes: 189 additions & 0 deletions Sources/AHCHTTPClient/AHCRequestOptions+TLSHandler.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift HTTP API Proposal open source project
//
// Copyright (c) 2026 Apple Inc. and the Swift HTTP API Proposal project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

public import AsyncHTTPClient
public import HTTPAPIs
import NIOSSL
import SwiftASN1
import X509

#if canImport(Security)
import Security
#endif

@available(anyAppleOS 26.0, *)
extension AsyncHTTPClient.HTTPClient {
/// The options for the AsyncHTTPClient HTTP client implementation.
public struct RequestOptions: HTTPClientCapability.TLSHandler {
public var serverTrustHandler: (any HTTPClientServerTrustHandler)? = nil

/// - Note: When AsyncHTTPClient runs on SwiftNIO SSL rather than Network.framework, it
/// does not surface the certificate authorities the server accepts, so the handler
/// receives an empty `distinguishedNames` list.
public var clientCertificateHandler: (any HTTPClientClientCertificateHandler)? = nil

public init() {}
}
}

/// Identifies the verification logic of a request so that the connection pool only shares a
/// connection between requests that verify the peer identically.
@available(anyAppleOS 26.0, *)
private struct ServerTrustIdentity: Hashable, Sendable {
var handlerID: AnyHashableSendable
var serverHostname: String?
}

/// Identifies the client certificate selection logic of a request, so that the connection pool
/// only shares a connection between requests that would present the same certificate.
@available(anyAppleOS 26.0, *)
private struct ClientCertificateIdentity: Hashable, Sendable {
var handlerID: AnyHashableSendable
}

/// A `Hashable & Sendable` box, so that a handler's `ID` can key the connection pool.
@available(anyAppleOS 26.0, *)
private struct AnyHashableSendable: Hashable, Sendable {
private let base: any Hashable & Sendable

init(_ base: any Hashable & Sendable) {
self.base = base
}

static func == (lhs: Self, rhs: Self) -> Bool {
Self.erase(lhs.base) == Self.erase(rhs.base)
}

func hash(into hasher: inout Hasher) {
hasher.combine(Self.erase(self.base))
}

private static func erase(_ value: some Hashable) -> AnyHashable {
AnyHashable(value)
}
}

@available(anyAppleOS 26.0, *)
extension AsyncHTTPClient.HTTPClient.RequestOptions {
/// Applies the TLS options to `request`.
///
/// The handlers become per-request callbacks and leave the client-wide TLS configuration
/// alone.
func apply(to request: inout HTTPClientRequest, serverHostname: String?) {
if let certificateHandler = self.clientCertificateHandler {
request._tlsClientCertificateHandler = _TLSClientCertificateHandler(
identity: ClientCertificateIdentity(handlerID: AnyHashableSendable(certificateHandler.id))
) { challenge in
guard
let (privateKey, certificateChain) =
try await certificateHandler.handleClientCertificateChallenge(
distinguishedNames: DistinguishedName.decoding(derEncoded: challenge.distinguishedNames)
)
else {
return nil
}
return try _TLSClientCertificateHandler.Identity(
privateKey: privateKey,
certificateChain: certificateChain,
transport: challenge.transport
)
}
}

guard let trustHandler = self.serverTrustHandler else {
return
}

request._tlsVerificationHandler = _TLSVerificationHandler(
identity: ServerTrustIdentity(
handlerID: AnyHashableSendable(trustHandler.id),
serverHostname: serverHostname
)
) { presentedCertificates in
let certificateChain = try presentedCertificates.map { certificate in
try Certificate(derEncoded: certificate.toDERBytes())
}
switch try await trustHandler.evaluateServerTrust(certificateChain: certificateChain) {
case .allow:
return .certificateVerified
case .deny:
return .failed
case .default:
let isTrusted = try await DefaultServerTrustEvaluation.isTrusted(
certificateChain: certificateChain,
serverHostname: serverHostname
)
return isTrusted ? .certificateVerified : .failed
}
}
}
}

@available(anyAppleOS 26.0, *)
extension _TLSClientCertificateHandler.Identity {
/// Converts a swift-certificates key and chain to the form `transport` consumes.
///
/// Keys without a representation in that form cannot be converted: on NIOSSL, keys without a
/// PEM encoding, such as Secure Enclave keys; on Network.framework, keys without a `SecKey`
/// equivalent, such as Ed25519 keys.
init(
privateKey: Certificate.PrivateKey,
certificateChain: [Certificate],
transport: _TLSClientCertificateHandler.Challenge.Transport
) throws {
do {
switch transport {
case .nioSSL:
let certificates = try certificateChain.map { certificate in
var serializer = DER.Serializer()
try serializer.serialize(certificate)
return try NIOSSLCertificate(bytes: serializer.serializedBytes, format: .der)
}
let keyBytes = Array(try privateKey.serializeAsPEM().pemString.utf8)
self.init(
certificateChain: certificates,
privateKey: try NIOSSLPrivateKey(bytes: keyBytes, format: .pem)
)
case .networkFramework:
#if canImport(Security)
let certificates = try certificateChain.map(SecCertificate.makeWithCertificate)
guard let leaf = certificates.first,
// Returns nil if the key does not match the certificate's public key.
let identity = SecIdentityCreate(nil, leaf, try SecKey.makeWithPrivateKey(privateKey))
else {
throw TLSHandlerError.couldNotSerializeClientCertificate
}
self.init(secIdentity: identity, certificateChain: certificates)
#else
throw TLSHandlerError.couldNotSerializeClientCertificate
#endif
}
} catch {
throw TLSHandlerError.couldNotSerializeClientCertificate
}
}
}

@available(anyAppleOS 26.0, *)
extension DistinguishedName {
/// Decodes the DER-encoded distinguished names offered in a client certificate challenge.
///
/// Names that fail to decode are dropped: a handler is better served by the subset it can
/// understand than by a failed handshake.
fileprivate static func decoding(derEncoded names: [[UInt8]]) -> [DistinguishedName] {
// Binding the initializer to an explicit function type picks the `DERParseable` overload,
// rather than the deprecated `DERImplicitlyTaggable` one Swift otherwise prefers.
let parse: ([UInt8]) throws -> DistinguishedName = DistinguishedName.init(derEncoded:)
return names.compactMap { try? parse($0) }
}
}
69 changes: 69 additions & 0 deletions Sources/AHCHTTPClient/DefaultServerTrustEvaluation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift HTTP API Proposal open source project
//
// Copyright (c) 2026 Apple Inc. and the Swift HTTP API Proposal project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

import Foundation
import HTTPAPIs
import SwiftASN1
import X509

#if canImport(Darwin)
import Security
#endif

/// The default trust evaluation used when a server trust handler returns
/// ``TrustEvaluationResult/default``.
///
/// A custom verification callback replaces *all* of the transport's verification logic, so once a
/// caller-provided handler is installed there is no way to ask the TLS stack to finish the job.
/// This type reproduces the platform's own decision instead: Security.framework on Apple platforms,
/// and swift-certificates against the system trust roots elsewhere.
@available(anyAppleOS 26.0, *)
enum DefaultServerTrustEvaluation {
/// Whether `certificateChain` is a valid chain for `serverHostname`.
static func isTrusted(
certificateChain: [Certificate],
serverHostname: String?
) async throws -> Bool {
#if canImport(Darwin)
let secCertificates = try certificateChain.map { certificate -> SecCertificate in
let derBytes = try certificate.serializeAsPEM().derBytes
guard let secCertificate = SecCertificateCreateWithData(nil, Data(derBytes) as CFData) else {
throw TLSHandlerError.couldNotParseCertificateChain
}
return secCertificate
}

var trust: SecTrust?
let policy = SecPolicyCreateSSL(true, serverHostname as CFString?)
let status = unsafe SecTrustCreateWithCertificates(secCertificates as CFArray, policy, &trust)
guard status == errSecSuccess, let trust else {
throw TLSHandlerError.couldNotParseCertificateChain
}
return SecTrustEvaluateWithError(trust, nil)
#else
guard let leaf = certificateChain.first else {
return false
}
var verifier = Verifier(rootCertificates: CertificateStore.systemTrustRoots) {
RFC5280Policy(validationTime: Date())
ServerIdentityPolicy(serverHostname: serverHostname, serverIP: nil)
}
switch await verifier.validate(leaf: leaf, intermediates: CertificateStore(certificateChain.dropFirst())) {
case .validCertificate:
return true
case .couldNotValidate:
return false
}
#endif
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@
//
//===----------------------------------------------------------------------===//

/// An error raised while applying TLS handler options to a request.
@available(anyAppleOS 26.0, *)
extension HTTPClientCapability {
/// A protocol for HTTP request options that support TLS policies.
public protocol DeclarativeTLS: RequestOptions {
/// The policy for the server trust evaluation during TLS handshakes.
var serverTrustPolicy: TrustEvaluationPolicy { get set }
}
enum TLSHandlerError: Error {
/// The certificates presented by the peer could not be decoded.
case couldNotParseCertificateChain
/// A client certificate or private key could not be encoded for the TLS stack.
case couldNotSerializeClientCertificate
}
Loading