mirror of
https://github.com/apple/swift-nio-extras.git
synced 2025-05-14 17:02:43 +08:00
Merge bf7d1042a2cb45103d798a3a36a942572ead016d into 0fc472ba34b5d94d0811acec2bd01e16804bbf47
This commit is contained in:
commit
0d6050e0ed
@ -257,6 +257,30 @@ var targets: [PackageDescription.Target] = [
|
|||||||
],
|
],
|
||||||
swiftSettings: strictConcurrencySettings
|
swiftSettings: strictConcurrencySettings
|
||||||
),
|
),
|
||||||
|
.target(
|
||||||
|
name: "NIOCertificateReloading",
|
||||||
|
dependencies: [
|
||||||
|
.product(name: "NIOCore", package: "swift-nio"),
|
||||||
|
.product(name: "NIOSSL", package: "swift-nio-ssl"),
|
||||||
|
.product(name: "X509", package: "swift-certificates"),
|
||||||
|
.product(name: "SwiftASN1", package: "swift-asn1"),
|
||||||
|
.product(name: "ServiceLifecycle", package: "swift-service-lifecycle"),
|
||||||
|
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms"),
|
||||||
|
.product(name: "Logging", package: "swift-log"),
|
||||||
|
],
|
||||||
|
swiftSettings: strictConcurrencySettings
|
||||||
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "NIOCertificateReloadingTests",
|
||||||
|
dependencies: [
|
||||||
|
"NIOCertificateReloading",
|
||||||
|
.product(name: "NIOCore", package: "swift-nio"),
|
||||||
|
.product(name: "NIOSSL", package: "swift-nio-ssl"),
|
||||||
|
.product(name: "X509", package: "swift-certificates"),
|
||||||
|
.product(name: "SwiftASN1", package: "swift-asn1"),
|
||||||
|
],
|
||||||
|
swiftSettings: strictConcurrencySettings
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
let package = Package(
|
let package = Package(
|
||||||
@ -270,6 +294,7 @@ let package = Package(
|
|||||||
.library(name: "NIOHTTPTypesHTTP2", targets: ["NIOHTTPTypesHTTP2"]),
|
.library(name: "NIOHTTPTypesHTTP2", targets: ["NIOHTTPTypesHTTP2"]),
|
||||||
.library(name: "NIOResumableUpload", targets: ["NIOResumableUpload"]),
|
.library(name: "NIOResumableUpload", targets: ["NIOResumableUpload"]),
|
||||||
.library(name: "NIOHTTPResponsiveness", targets: ["NIOHTTPResponsiveness"]),
|
.library(name: "NIOHTTPResponsiveness", targets: ["NIOHTTPResponsiveness"]),
|
||||||
|
.library(name: "NIOCertificateReloading", targets: ["NIOCertificateReloading"]),
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(url: "https://github.com/apple/swift-nio.git", from: "2.81.0"),
|
.package(url: "https://github.com/apple/swift-nio.git", from: "2.81.0"),
|
||||||
@ -278,6 +303,12 @@ let package = Package(
|
|||||||
.package(url: "https://github.com/apple/swift-http-structured-headers.git", from: "1.2.0"),
|
.package(url: "https://github.com/apple/swift-http-structured-headers.git", from: "1.2.0"),
|
||||||
.package(url: "https://github.com/apple/swift-atomics.git", from: "1.2.0"),
|
.package(url: "https://github.com/apple/swift-atomics.git", from: "1.2.0"),
|
||||||
.package(url: "https://github.com/apple/swift-algorithms.git", from: "1.2.0"),
|
.package(url: "https://github.com/apple/swift-algorithms.git", from: "1.2.0"),
|
||||||
|
.package(url: "https://github.com/apple/swift-certificates.git", from: "1.10.0"),
|
||||||
|
.package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.29.3"),
|
||||||
|
.package(url: "https://github.com/apple/swift-asn1.git", from: "1.3.1"),
|
||||||
|
.package(url: "https://github.com/swift-server/swift-service-lifecycle.git", from: "2.8.0"),
|
||||||
|
.package(url: "https://github.com/apple/swift-async-algorithms.git", from: "1.0.0"),
|
||||||
|
.package(url: "https://github.com/apple/swift-log.git", from: "1.6.3"),
|
||||||
|
|
||||||
],
|
],
|
||||||
targets: targets
|
targets: targets
|
||||||
|
125
Sources/NIOCertificateReloading/CertificateReloader.swift
Normal file
125
Sources/NIOCertificateReloading/CertificateReloader.swift
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
//===----------------------------------------------------------------------===//
|
||||||
|
//
|
||||||
|
// This source file is part of the SwiftNIO open source project
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Apple Inc. and the SwiftNIO project authors
|
||||||
|
// Licensed under Apache License v2.0
|
||||||
|
//
|
||||||
|
// See LICENSE.txt for license information
|
||||||
|
// See CONTRIBUTORS.txt for the list of SwiftNIO project authors
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
//
|
||||||
|
//===----------------------------------------------------------------------===//
|
||||||
|
|
||||||
|
import NIOCore
|
||||||
|
import NIOSSL
|
||||||
|
|
||||||
|
/// A protocol that defines a certificate reloader.
|
||||||
|
///
|
||||||
|
/// A certificate reloader is a service that can provide you with updated versions of a certificate and private key pair, in
|
||||||
|
/// the form of a `NIOSSLContextConfigurationOverride`, which will be used when performing a TLS handshake in NIO.
|
||||||
|
/// Each implementation can choose how to observe for changes, but they all require an ``sslContextConfigurationOverride``
|
||||||
|
/// to be exposed.
|
||||||
|
public protocol CertificateReloader: Sendable {
|
||||||
|
/// A `NIOSSLContextConfigurationOverride` that will be used as part of the NIO application's TLS configuration.
|
||||||
|
/// Its certificate and private key will be kept up-to-date via whatever mechanism the specific ``CertificateReloader``
|
||||||
|
/// implementation provides.
|
||||||
|
var sslContextConfigurationOverride: NIOSSLContextConfigurationOverride { get }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TLSConfiguration {
|
||||||
|
/// Errors thrown when creating a ``NIOSSL/TLSConfiguration`` with a ``CertificateReloader``.
|
||||||
|
public struct CertificateReloaderError: Error, Hashable, CustomStringConvertible {
|
||||||
|
private enum _Backing: CustomStringConvertible {
|
||||||
|
case missingCertificateChain
|
||||||
|
case missingPrivateKey
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
switch self {
|
||||||
|
case .missingCertificateChain:
|
||||||
|
return "Missing certificate chain"
|
||||||
|
case .missingPrivateKey:
|
||||||
|
return "Missing private key"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private let _backing: _Backing
|
||||||
|
|
||||||
|
private init(backing: _Backing) {
|
||||||
|
self._backing = backing
|
||||||
|
}
|
||||||
|
|
||||||
|
public var description: String {
|
||||||
|
self._backing.description
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The given ``CertificateReloader`` could not provide a certificate chain with which to create this config.
|
||||||
|
public static var missingCertificateChain: Self { .init(backing: .missingCertificateChain) }
|
||||||
|
|
||||||
|
/// The given ``CertificateReloader`` could not provide a private key with which to create this config.
|
||||||
|
public static var missingPrivateKey: Self { .init(backing: .missingPrivateKey) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a ``NIOSSL/TLSConfiguration`` for use with server-side contexts, with certificate reloading enabled.
|
||||||
|
/// - Parameter certificateReloader: A ``CertificateReloader`` to watch for certificate and key pair updates.
|
||||||
|
/// - Returns: A ``NIOSSL/TLSConfiguration`` for use with server-side contexts, that reloads the certificate and key
|
||||||
|
/// used in its SSL handshake.
|
||||||
|
/// - Throws: This method will throw if an override isn't present. This may happen if a certificate or private key could not be
|
||||||
|
/// loaded from the given paths.
|
||||||
|
public static func makeServerConfiguration(
|
||||||
|
certificateReloader: some CertificateReloader
|
||||||
|
) throws -> Self {
|
||||||
|
let override = certificateReloader.sslContextConfigurationOverride
|
||||||
|
|
||||||
|
guard let certificateChain = override.certificateChain else {
|
||||||
|
throw CertificateReloaderError.missingCertificateChain
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let privateKey = override.privateKey else {
|
||||||
|
throw CertificateReloaderError.missingPrivateKey
|
||||||
|
}
|
||||||
|
|
||||||
|
var configuration = Self.makeServerConfiguration(
|
||||||
|
certificateChain: certificateChain,
|
||||||
|
privateKey: privateKey
|
||||||
|
)
|
||||||
|
configuration.setCertificateReloader(certificateReloader)
|
||||||
|
return configuration
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a ``NIOSSL/TLSConfiguration`` for use with client-side contexts, with certificate reloading enabled.
|
||||||
|
/// - Parameter certificateReloader: A ``CertificateReloader`` to watch for certificate and key pair updates.
|
||||||
|
/// - Returns: A ``NIOSSL/TLSConfiguration`` for use with client-side contexts, that reloads the certificate and key
|
||||||
|
/// used in its SSL handshake.
|
||||||
|
/// - Throws: This method will throw if an override isn't present. This may happen if a certificate or private key could not be
|
||||||
|
/// loaded from the given paths.
|
||||||
|
public static func makeClientConfiguration(
|
||||||
|
certificateReloader: some CertificateReloader
|
||||||
|
) throws -> Self {
|
||||||
|
let override = certificateReloader.sslContextConfigurationOverride
|
||||||
|
|
||||||
|
guard let certificateChain = override.certificateChain else {
|
||||||
|
throw CertificateReloaderError.missingCertificateChain
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let privateKey = override.privateKey else {
|
||||||
|
throw CertificateReloaderError.missingPrivateKey
|
||||||
|
}
|
||||||
|
|
||||||
|
var configuration = Self.makeClientConfiguration()
|
||||||
|
configuration.certificateChain = certificateChain
|
||||||
|
configuration.privateKey = privateKey
|
||||||
|
configuration.setCertificateReloader(certificateReloader)
|
||||||
|
return configuration
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configure a ``CertificateReloader`` to observe updates for the certificate and key pair used.
|
||||||
|
/// - Parameter reloader: A ``CertificateReloader`` to watch for certificate and key pair updates.
|
||||||
|
public mutating func setCertificateReloader(_ reloader: some CertificateReloader) {
|
||||||
|
self.sslContextCallback = { _, promise in
|
||||||
|
promise.succeed(reloader.sslContextConfigurationOverride)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
440
Sources/NIOCertificateReloading/TimedCertificateReloader.swift
Normal file
440
Sources/NIOCertificateReloading/TimedCertificateReloader.swift
Normal file
@ -0,0 +1,440 @@
|
|||||||
|
//===----------------------------------------------------------------------===//
|
||||||
|
//
|
||||||
|
// This source file is part of the SwiftNIO open source project
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Apple Inc. and the SwiftNIO project authors
|
||||||
|
// Licensed under Apache License v2.0
|
||||||
|
//
|
||||||
|
// See LICENSE.txt for license information
|
||||||
|
// See CONTRIBUTORS.txt for the list of SwiftNIO project authors
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
//
|
||||||
|
//===----------------------------------------------------------------------===//
|
||||||
|
|
||||||
|
import AsyncAlgorithms
|
||||||
|
import Logging
|
||||||
|
import NIOConcurrencyHelpers
|
||||||
|
import NIOSSL
|
||||||
|
import ServiceLifecycle
|
||||||
|
import SwiftASN1
|
||||||
|
import X509
|
||||||
|
|
||||||
|
import struct NIOCore.TimeAmount
|
||||||
|
|
||||||
|
#if canImport(FoundationEssentials)
|
||||||
|
import FoundationEssentials
|
||||||
|
#else
|
||||||
|
import Foundation
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// A ``TimedCertificateReloader`` is an implementation of a ``CertificateReloader``, where the certificate and private
|
||||||
|
/// key pair is updated at a fixed interval from the file path or memory location configured.
|
||||||
|
///
|
||||||
|
/// You initialize a ``TimedCertificateReloader`` by providing a refresh interval, and locations for the certificate and the private
|
||||||
|
/// key via ``init(refreshInterval:certificateSource:privateKeySource:logger:)``.
|
||||||
|
/// Simply creating a timed reloader won't validate that the sources provide valid certificate and private key pairs. If you want this to be
|
||||||
|
/// validated at creation time, you may instead use
|
||||||
|
/// ``makeReloaderValidatingSources(refreshInterval:certificateSource:privateKeySource:logger:)``.
|
||||||
|
///
|
||||||
|
/// You may then set the timed reloader on your ``NIOSSL/TLSConfiguration`` using
|
||||||
|
/// ``NIOSSL/TLSConfiguration/setCertificateReloader(_:)``:
|
||||||
|
///
|
||||||
|
/// ```swift
|
||||||
|
/// var configuration = TLSConfiguration.makeServerConfiguration(
|
||||||
|
/// certificateChain: chain,
|
||||||
|
/// privateKey: key
|
||||||
|
/// )
|
||||||
|
/// let reloader = TimedCertificateReloader(
|
||||||
|
/// refreshInterval: .seconds(500),
|
||||||
|
/// certificateSource: TimedCertificateReloader.CertificateSource(...),
|
||||||
|
/// privateKeySource: TimedCertificateReloader.PrivateKeySource(...)
|
||||||
|
/// )
|
||||||
|
/// configuration.setCertificateReloader(reloader)
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// Finally, you must call ``run()`` on the reloader for it to start observing changes.
|
||||||
|
/// If you want to trigger a manual reload at any point, you may call ``reload()``.
|
||||||
|
///
|
||||||
|
/// If you're creating a server configuration, you can instead opt to use
|
||||||
|
/// ``NIOSSL/TLSConfiguration/makeServerConfiguration(certificateReloader:)``, which will set the initial
|
||||||
|
/// certificate chain and private key, as well as set the reloader:
|
||||||
|
///
|
||||||
|
/// ```swift
|
||||||
|
/// let configuration = TLSConfiguration.makeServerConfiguration(
|
||||||
|
/// certificateReloader: reloader
|
||||||
|
/// )
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// If you're creating a client configuration, you can instead opt to use
|
||||||
|
/// ``NIOSSL/TLSConfiguration/makeClientConfiguration(certificateReloader:)`` which will set the reloader:
|
||||||
|
/// ```swift
|
||||||
|
/// let configuration = TLSConfiguration.makeClientConfiguration(
|
||||||
|
/// certificateReloader: reloader
|
||||||
|
/// )
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// In both cases, make sure you've either called ``run()`` or created the ``TimedCertificateReloader`` using
|
||||||
|
/// ``makeReloaderValidatingSources(refreshInterval:certificateSource:privateKeySource:logger:)``
|
||||||
|
/// _before_ creating the ``NIOSSL/TLSConfiguration``, as otherwise the validation will fail.
|
||||||
|
///
|
||||||
|
/// Once the reloader is running, you can manually access its ``sslContextConfigurationOverride`` property to get a
|
||||||
|
/// `NIOSSLContextConfigurationOverride`, although this will typically not be necessary, as it's the NIO channel that will
|
||||||
|
/// handle the override when initiating TLS handshakes.
|
||||||
|
///
|
||||||
|
/// ```swift
|
||||||
|
/// try await withThrowingTaskGroup(of: Void.self) { group in
|
||||||
|
/// group.addTask {
|
||||||
|
/// reloader.run()
|
||||||
|
/// }
|
||||||
|
/// // ...
|
||||||
|
/// let override = reloader.sslContextConfigurationOverride
|
||||||
|
/// // ...
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// ``TimedCertificateReloader`` conforms to `ServiceLifecycle`'s `Service` protocol, meaning you can simply create
|
||||||
|
/// the reloader and add it to your `ServiceGroup` without having to manually run it.
|
||||||
|
///
|
||||||
|
/// If any errors occur during a reload attempt (such as: being unable to find the file(s) containing the certificate or the key; the format
|
||||||
|
/// not being recognized or not matching the configured one; not being able to verify a certificate's signature against the given
|
||||||
|
/// private key; etc), then that attempt will be aborted but the service will keep on trying at the configured interval.
|
||||||
|
/// The last-valid certificate-key pair (if any) will be returned as the ``sslContextConfigurationOverride``.
|
||||||
|
#if compiler(>=6.0)
|
||||||
|
@available(macOS 13, iOS 16, watchOS 9, tvOS 16, macCatalyst 16, visionOS 1, *)
|
||||||
|
#else
|
||||||
|
@available(macOS 13, iOS 16, watchOS 9, tvOS 16, macCatalyst 16, *)
|
||||||
|
#endif
|
||||||
|
public struct TimedCertificateReloader: CertificateReloader {
|
||||||
|
/// The encoding for the certificate or the key.
|
||||||
|
public struct Encoding: Sendable, Hashable {
|
||||||
|
fileprivate enum _Backing {
|
||||||
|
case der
|
||||||
|
case pem
|
||||||
|
}
|
||||||
|
fileprivate let _backing: _Backing
|
||||||
|
|
||||||
|
private init(_ backing: _Backing) {
|
||||||
|
self._backing = backing
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The encoding of this certificate/key is DER bytes.
|
||||||
|
public static var der: Self { .init(.der) }
|
||||||
|
|
||||||
|
/// The encoding of this certificate/key is PEM.
|
||||||
|
public static var pem: Self { .init(.pem) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A location specification for a certificate or key.
|
||||||
|
public struct Location: Sendable, CustomStringConvertible {
|
||||||
|
fileprivate enum _Backing: CustomStringConvertible {
|
||||||
|
case file(path: String)
|
||||||
|
case memory(provider: @Sendable () throws -> [UInt8])
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
switch self {
|
||||||
|
case .file(let path):
|
||||||
|
return "Filepath: \(path)"
|
||||||
|
case .memory:
|
||||||
|
return "<in-memory location>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate let _backing: _Backing
|
||||||
|
|
||||||
|
private init(_ backing: _Backing) {
|
||||||
|
self._backing = backing
|
||||||
|
}
|
||||||
|
|
||||||
|
public var description: String {
|
||||||
|
self._backing.description
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This certificate/key can be found at the given filepath.
|
||||||
|
/// - Parameter path: The filepath where the certificate/key can be found.
|
||||||
|
/// - Returns: A `Location`.
|
||||||
|
public static func file(path: String) -> Self { Self(_Backing.file(path: path)) }
|
||||||
|
|
||||||
|
/// This certificate/key is available in memory, and will be provided by the given closure.
|
||||||
|
/// - Parameter provider: A closure providing the bytes for the given certificate or key. This closure should return
|
||||||
|
/// `nil` if a certificate/key isn't currently available for whatever reason.
|
||||||
|
/// - Returns: A `Location`.
|
||||||
|
public static func memory(provider: @Sendable @escaping () throws -> [UInt8]) -> Self {
|
||||||
|
Self(_Backing.memory(provider: provider))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A description of a certificate, in terms of its ``TimedCertificateReloader/Location`` and
|
||||||
|
/// ``TimedCertificateReloader/Encoding``.
|
||||||
|
public struct CertificateSource: Sendable {
|
||||||
|
|
||||||
|
/// The certificate's ``TimedCertificateReloader/Location``.
|
||||||
|
public var location: Location
|
||||||
|
|
||||||
|
/// The certificate's ``TimedCertificateReloader/Encoding``.
|
||||||
|
public var format: Encoding
|
||||||
|
|
||||||
|
/// Initialize a new ``TimedCertificateReloader/CertificateSource``.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - location: A ``TimedCertificateReloader/Location``.
|
||||||
|
/// - format: A ``TimedCertificateReloader/Encoding``.
|
||||||
|
public init(location: Location, format: Encoding) {
|
||||||
|
self.location = location
|
||||||
|
self.format = format
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A description of a private key, in terms of its ``TimedCertificateReloader/Location`` and
|
||||||
|
/// ``TimedCertificateReloader/Encoding``.
|
||||||
|
public struct PrivateKeySource: Sendable {
|
||||||
|
|
||||||
|
/// The key's ``TimedCertificateReloader/Location``.
|
||||||
|
public var location: Location
|
||||||
|
|
||||||
|
/// The key's ``TimedCertificateReloader/Encoding``.
|
||||||
|
public var format: Encoding
|
||||||
|
|
||||||
|
/// Initialize a new ``TimedCertificateReloader/PrivateKeySource``.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - location: A ``TimedCertificateReloader/Location``.
|
||||||
|
/// - format: A ``TimedCertificateReloader/Encoding``.
|
||||||
|
public init(location: Location, format: Encoding) {
|
||||||
|
self.location = location
|
||||||
|
self.format = format
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Errors specific to the ``TimedCertificateReloader``.
|
||||||
|
public struct Error: Swift.Error, Hashable, CustomStringConvertible {
|
||||||
|
private enum _Backing: Hashable, CustomStringConvertible {
|
||||||
|
case certificatePathNotFound(String)
|
||||||
|
case privateKeyPathNotFound(String)
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
switch self {
|
||||||
|
case .certificatePathNotFound(let path):
|
||||||
|
return "Certificate path not found: \(path)"
|
||||||
|
case .privateKeyPathNotFound(let path):
|
||||||
|
return "Private key path not found: \(path)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private let _backing: _Backing
|
||||||
|
|
||||||
|
private init(_ backing: _Backing) {
|
||||||
|
self._backing = backing
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The file path given for the certificate cannot be found.
|
||||||
|
/// - Parameter path: The file path given for the certificate.
|
||||||
|
/// - Returns: A ``TimedCertificateReloader/Error``.
|
||||||
|
public static func certificatePathNotFound(_ path: String) -> Self {
|
||||||
|
Self(.certificatePathNotFound(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The file path given for the private key cannot be found.
|
||||||
|
/// - Parameter path: The file path given for the private key.
|
||||||
|
/// - Returns: A ``TimedCertificateReloader/Error``.
|
||||||
|
public static func privateKeyPathNotFound(_ path: String) -> Self {
|
||||||
|
Self(.privateKeyPathNotFound(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
public var description: String {
|
||||||
|
self._backing.description
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct CertificateKeyPair {
|
||||||
|
var certificate: NIOSSLCertificateSource
|
||||||
|
var privateKey: NIOSSLPrivateKeySource
|
||||||
|
}
|
||||||
|
|
||||||
|
private let refreshInterval: Duration
|
||||||
|
private let certificateSource: CertificateSource
|
||||||
|
private let privateKeySource: PrivateKeySource
|
||||||
|
private let state: NIOLockedValueBox<CertificateKeyPair?>
|
||||||
|
private let logger: Logger?
|
||||||
|
|
||||||
|
/// A `NIOSSLContextConfigurationOverride` that will be used as part of the NIO application's TLS configuration.
|
||||||
|
/// Its certificate and private key will be kept up-to-date via the reload mechanism the ``TimedCertificateReloader``
|
||||||
|
/// implementation provides.
|
||||||
|
/// - Note: If no reload attempt has yet been tried (either by creating the reloader with
|
||||||
|
/// ``makeReloaderValidatingSources(refreshInterval:certificateSource:privateKeySource:logger:)``,
|
||||||
|
/// manually calling ``reload()``, or by calling ``run()``), `NIOSSLContextConfigurationOverride/noChanges`
|
||||||
|
/// will be returned.
|
||||||
|
public var sslContextConfigurationOverride: NIOSSLContextConfigurationOverride {
|
||||||
|
get {
|
||||||
|
guard let certificateKeyPair = self.state.withLockedValue({ $0 }) else {
|
||||||
|
return .noChanges
|
||||||
|
}
|
||||||
|
var override = NIOSSLContextConfigurationOverride()
|
||||||
|
override.certificateChain = [certificateKeyPair.certificate]
|
||||||
|
override.privateKey = certificateKeyPair.privateKey
|
||||||
|
return override
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize a new ``TimedCertificateReloader``.
|
||||||
|
/// - Important: ``TimedCertificateReloader/sslContextConfigurationOverride`` will return
|
||||||
|
/// `NIOSSLContextConfigurationOverride/noChanges` until ``TimedCertificateReloader/run()`` or
|
||||||
|
/// ``TimedCertificateReloader/reload()`` are called.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - refreshInterval: The interval at which attempts to update the certificate and private key should be made.
|
||||||
|
/// - certificateSource: A ``TimedCertificateReloader/CertificateSource``.
|
||||||
|
/// - privateKeySource: A ``TimedCertificateReloader/PrivateKeySource``.
|
||||||
|
/// - logger: An optional logger.
|
||||||
|
public init(
|
||||||
|
refreshInterval: Duration,
|
||||||
|
certificateSource: CertificateSource,
|
||||||
|
privateKeySource: PrivateKeySource,
|
||||||
|
logger: Logger? = nil
|
||||||
|
) {
|
||||||
|
self.refreshInterval = refreshInterval
|
||||||
|
self.certificateSource = certificateSource
|
||||||
|
self.privateKeySource = privateKeySource
|
||||||
|
self.state = NIOLockedValueBox(nil)
|
||||||
|
self.logger = logger
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize a new ``TimedCertificateReloader``, and attempt to reload the certificate and private key pair from the given
|
||||||
|
/// sources. If the reload fails (because e.g. the paths aren't valid), this method will throw.
|
||||||
|
/// - Important: If this method does not throw, it is guaranteed that
|
||||||
|
/// ``TimedCertificateReloader/sslContextConfigurationOverride`` will contain the configured certificate and
|
||||||
|
/// private key pair, even before the first reload is triggered or ``TimedCertificateReloader/run()`` is called.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - refreshInterval: The interval at which attempts to update the certificate and private key should be made.
|
||||||
|
/// - certificateSource: A ``TimedCertificateReloader/CertificateSource``.
|
||||||
|
/// - privateKeySource: A ``TimedCertificateReloader/PrivateKeySource``.
|
||||||
|
/// - logger: An optional logger.
|
||||||
|
/// - Returns: The newly created ``TimedCertificateReloader``.
|
||||||
|
/// - Throws: If either the certificate or private key sources cannot be loaded, an error will be thrown.
|
||||||
|
public static func makeReloaderValidatingSources(
|
||||||
|
refreshInterval: Duration,
|
||||||
|
certificateSource: CertificateSource,
|
||||||
|
privateKeySource: PrivateKeySource,
|
||||||
|
logger: Logger? = nil
|
||||||
|
) throws -> Self {
|
||||||
|
let reloader = Self.init(
|
||||||
|
refreshInterval: refreshInterval,
|
||||||
|
certificateSource: certificateSource,
|
||||||
|
privateKeySource: privateKeySource,
|
||||||
|
logger: logger
|
||||||
|
)
|
||||||
|
try reloader.reload()
|
||||||
|
return reloader
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A long-running method to run the ``TimedCertificateReloader`` and start observing updates for the certificate and
|
||||||
|
/// private key pair.
|
||||||
|
/// - Important: You *must* call this method to get certificate and key updates.
|
||||||
|
public func run() async throws {
|
||||||
|
for try await _ in AsyncTimerSequence.repeating(every: self.refreshInterval).cancelOnGracefulShutdown() {
|
||||||
|
do {
|
||||||
|
try self.reload()
|
||||||
|
} catch {
|
||||||
|
self.logger?.debug(
|
||||||
|
"Failed to reload certificate and private key.",
|
||||||
|
metadata: [
|
||||||
|
"error": "\(error)",
|
||||||
|
"certificatePath": "\(self.certificateSource.location)",
|
||||||
|
"privateKeyPath": "\(self.privateKeySource.location)",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manually attempt a certificate and private key pair update.
|
||||||
|
public func reload() throws {
|
||||||
|
let certificateBytes = try self.loadCertificate()
|
||||||
|
let keyBytes = try self.loadPrivateKey()
|
||||||
|
if let certificate = try self.parseCertificate(from: certificateBytes),
|
||||||
|
let key = try self.parsePrivateKey(from: keyBytes),
|
||||||
|
key.publicKey.isValidSignature(certificate.signature, for: certificate)
|
||||||
|
{
|
||||||
|
try self.attemptToUpdatePair(certificate: certificate, key: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadCertificate() throws -> [UInt8] {
|
||||||
|
let certificateBytes: [UInt8]
|
||||||
|
switch self.certificateSource.location._backing {
|
||||||
|
case .file(let path):
|
||||||
|
guard let bytes = FileManager.default.contents(atPath: path) else {
|
||||||
|
throw Error.certificatePathNotFound(path)
|
||||||
|
}
|
||||||
|
certificateBytes = Array(bytes)
|
||||||
|
|
||||||
|
case .memory(let bytesProvider):
|
||||||
|
certificateBytes = try bytesProvider()
|
||||||
|
}
|
||||||
|
return certificateBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadPrivateKey() throws -> [UInt8] {
|
||||||
|
let keyBytes: [UInt8]
|
||||||
|
switch self.privateKeySource.location._backing {
|
||||||
|
case .file(let path):
|
||||||
|
guard let bytes = FileManager.default.contents(atPath: path) else {
|
||||||
|
throw Error.privateKeyPathNotFound(path)
|
||||||
|
}
|
||||||
|
keyBytes = Array(bytes)
|
||||||
|
|
||||||
|
case .memory(let bytesProvider):
|
||||||
|
keyBytes = try bytesProvider()
|
||||||
|
}
|
||||||
|
return keyBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
private func parseCertificate(from certificateBytes: [UInt8]) throws -> Certificate? {
|
||||||
|
let certificate: Certificate?
|
||||||
|
switch self.certificateSource.format._backing {
|
||||||
|
case .der:
|
||||||
|
certificate = try Certificate(derEncoded: certificateBytes)
|
||||||
|
|
||||||
|
case .pem:
|
||||||
|
certificate = try String(bytes: certificateBytes, encoding: .utf8)
|
||||||
|
.flatMap { try Certificate(pemEncoded: $0) }
|
||||||
|
}
|
||||||
|
return certificate
|
||||||
|
}
|
||||||
|
|
||||||
|
private func parsePrivateKey(from keyBytes: [UInt8]) throws -> Certificate.PrivateKey? {
|
||||||
|
let key: Certificate.PrivateKey?
|
||||||
|
switch self.privateKeySource.format._backing {
|
||||||
|
case .der:
|
||||||
|
key = try Certificate.PrivateKey(derBytes: keyBytes)
|
||||||
|
|
||||||
|
case .pem:
|
||||||
|
key = try String(bytes: keyBytes, encoding: .utf8)
|
||||||
|
.flatMap { try Certificate.PrivateKey(pemEncoded: $0) }
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
private func attemptToUpdatePair(certificate: Certificate, key: Certificate.PrivateKey) throws {
|
||||||
|
let nioSSLCertificate = try NIOSSLCertificate(
|
||||||
|
bytes: certificate.serializeAsPEM().derBytes,
|
||||||
|
format: .der
|
||||||
|
)
|
||||||
|
let nioSSLPrivateKey = try NIOSSLPrivateKey(
|
||||||
|
bytes: key.serializeAsPEM().derBytes,
|
||||||
|
format: .der
|
||||||
|
)
|
||||||
|
self.state.withLockedValue {
|
||||||
|
$0 = CertificateKeyPair(
|
||||||
|
certificate: .certificate(nioSSLCertificate),
|
||||||
|
privateKey: .privateKey(nioSSLPrivateKey)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if compiler(>=6.0)
|
||||||
|
@available(macOS 13, iOS 16, watchOS 9, tvOS 16, macCatalyst 16, visionOS 1, *)
|
||||||
|
#else
|
||||||
|
@available(macOS 13, iOS 16, watchOS 9, tvOS 16, macCatalyst 16, *)
|
||||||
|
#endif
|
||||||
|
extension TimedCertificateReloader: Service {}
|
@ -0,0 +1,512 @@
|
|||||||
|
//===----------------------------------------------------------------------===//
|
||||||
|
//
|
||||||
|
// This source file is part of the SwiftNIO open source project
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Apple Inc. and the SwiftNIO project authors
|
||||||
|
// Licensed under Apache License v2.0
|
||||||
|
//
|
||||||
|
// See LICENSE.txt for license information
|
||||||
|
// See CONTRIBUTORS.txt for the list of SwiftNIO project authors
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
//
|
||||||
|
//===----------------------------------------------------------------------===//
|
||||||
|
|
||||||
|
@preconcurrency import Crypto
|
||||||
|
import NIOCertificateReloading
|
||||||
|
import NIOConcurrencyHelpers
|
||||||
|
import NIOSSL
|
||||||
|
import SwiftASN1
|
||||||
|
import X509
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
#if canImport(FoundationEssentials)
|
||||||
|
import FoundationEssentials
|
||||||
|
#else
|
||||||
|
import Foundation
|
||||||
|
#endif
|
||||||
|
|
||||||
|
final class TimedCertificateReloaderTests: XCTestCase {
|
||||||
|
func testCertificatePathDoesNotExist() async throws {
|
||||||
|
try await runTimedCertificateReloaderTest(
|
||||||
|
certificate: .init(location: .file(path: "doesnotexist"), format: .der),
|
||||||
|
privateKey: .init(
|
||||||
|
location: .memory(provider: { Array(Self.samplePrivateKey.derRepresentation) }),
|
||||||
|
format: .der
|
||||||
|
),
|
||||||
|
validateSources: false
|
||||||
|
) { reloader in
|
||||||
|
let override = reloader.sslContextConfigurationOverride
|
||||||
|
XCTAssertNil(override.certificateChain)
|
||||||
|
XCTAssertNil(override.privateKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCertificatePathDoesNotExist_ValidatingSource() async throws {
|
||||||
|
do {
|
||||||
|
try await runTimedCertificateReloaderTest(
|
||||||
|
certificate: .init(location: .file(path: "doesnotexist"), format: .der),
|
||||||
|
privateKey: .init(
|
||||||
|
location: .memory(provider: { Array(Self.samplePrivateKey.derRepresentation) }),
|
||||||
|
format: .der
|
||||||
|
)
|
||||||
|
) { _ in
|
||||||
|
XCTFail("Test should have failed before reaching this point.")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
XCTAssertEqual(
|
||||||
|
error as? TimedCertificateReloader.Error,
|
||||||
|
TimedCertificateReloader.Error.certificatePathNotFound("doesnotexist")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testKeyPathDoesNotExist() async throws {
|
||||||
|
try await runTimedCertificateReloaderTest(
|
||||||
|
certificate: .init(
|
||||||
|
location: .memory(provider: { try Self.sampleCert.serializeAsPEM().derBytes }),
|
||||||
|
format: .der
|
||||||
|
),
|
||||||
|
privateKey: .init(
|
||||||
|
location: .file(path: "doesnotexist"),
|
||||||
|
format: .der
|
||||||
|
),
|
||||||
|
validateSources: false
|
||||||
|
) { reloader in
|
||||||
|
let override = reloader.sslContextConfigurationOverride
|
||||||
|
XCTAssertNil(override.certificateChain)
|
||||||
|
XCTAssertNil(override.privateKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testKeyPathDoesNotExist_ValidatingSource() async throws {
|
||||||
|
do {
|
||||||
|
try await runTimedCertificateReloaderTest(
|
||||||
|
certificate: .init(
|
||||||
|
location: .memory(provider: { try Self.sampleCert.serializeAsPEM().derBytes }),
|
||||||
|
format: .der
|
||||||
|
),
|
||||||
|
privateKey: .init(
|
||||||
|
location: .file(path: "doesnotexist"),
|
||||||
|
format: .der
|
||||||
|
)
|
||||||
|
) { _ in
|
||||||
|
XCTFail("Test should have failed before reaching this point.")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
XCTAssertEqual(
|
||||||
|
error as? TimedCertificateReloader.Error,
|
||||||
|
TimedCertificateReloader.Error.privateKeyPathNotFound("doesnotexist")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCertificateIsInUnexpectedFormat_FromMemory() async throws {
|
||||||
|
try await runTimedCertificateReloaderTest(
|
||||||
|
certificate: .init(
|
||||||
|
location: .memory(provider: { try Self.sampleCert.serializeAsPEM().derBytes }),
|
||||||
|
format: .pem
|
||||||
|
),
|
||||||
|
privateKey: .init(
|
||||||
|
location: .memory(provider: { Array(Self.samplePrivateKey.derRepresentation) }),
|
||||||
|
format: .der
|
||||||
|
)
|
||||||
|
) { reloader in
|
||||||
|
let override = reloader.sslContextConfigurationOverride
|
||||||
|
XCTAssertNil(override.certificateChain)
|
||||||
|
XCTAssertNil(override.privateKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createTempFile(contents: Data) throws -> URL {
|
||||||
|
let directory = FileManager.default.temporaryDirectory
|
||||||
|
let filename = UUID().uuidString
|
||||||
|
let fileURL = directory.appendingPathComponent(filename)
|
||||||
|
FileManager.default.createFile(atPath: fileURL.path(), contents: contents)
|
||||||
|
return fileURL
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCertificateIsInUnexpectedFormat_FromFile() async throws {
|
||||||
|
let certBytes = try Self.sampleCert.serializeAsPEM().derBytes
|
||||||
|
let file = try self.createTempFile(contents: Data(certBytes))
|
||||||
|
try await runTimedCertificateReloaderTest(
|
||||||
|
certificate: .init(
|
||||||
|
location: .file(path: file.path()),
|
||||||
|
format: .pem
|
||||||
|
),
|
||||||
|
privateKey: .init(
|
||||||
|
location: .memory(provider: { Array(Self.samplePrivateKey.derRepresentation) }),
|
||||||
|
format: .der
|
||||||
|
)
|
||||||
|
) { reloader in
|
||||||
|
let override = reloader.sslContextConfigurationOverride
|
||||||
|
XCTAssertNil(override.certificateChain)
|
||||||
|
XCTAssertNil(override.privateKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testKeyIsInUnexpectedFormat_FromMemory() async throws {
|
||||||
|
try await runTimedCertificateReloaderTest(
|
||||||
|
certificate: .init(
|
||||||
|
location: .memory(provider: { try Self.sampleCert.serializeAsPEM().derBytes }),
|
||||||
|
format: .der
|
||||||
|
),
|
||||||
|
privateKey: .init(
|
||||||
|
location: .memory(provider: { Array(Self.samplePrivateKey.derRepresentation) }),
|
||||||
|
format: .pem
|
||||||
|
)
|
||||||
|
) { reloader in
|
||||||
|
let override = reloader.sslContextConfigurationOverride
|
||||||
|
XCTAssertNil(override.certificateChain)
|
||||||
|
XCTAssertNil(override.privateKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testKeyIsInUnexpectedFormat_FromFile() async throws {
|
||||||
|
let keyBytes = Self.samplePrivateKey.derRepresentation
|
||||||
|
let file = try self.createTempFile(contents: keyBytes)
|
||||||
|
try await runTimedCertificateReloaderTest(
|
||||||
|
certificate: .init(
|
||||||
|
location: .memory(provider: { try Self.sampleCert.serializeAsPEM().derBytes }),
|
||||||
|
format: .der
|
||||||
|
),
|
||||||
|
privateKey: .init(
|
||||||
|
location: .file(path: file.path()),
|
||||||
|
format: .pem
|
||||||
|
)
|
||||||
|
) { reloader in
|
||||||
|
let override = reloader.sslContextConfigurationOverride
|
||||||
|
XCTAssertNil(override.certificateChain)
|
||||||
|
XCTAssertNil(override.privateKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCertificateAndKeyDoNotMatch() async throws {
|
||||||
|
try await runTimedCertificateReloaderTest(
|
||||||
|
certificate: .init(
|
||||||
|
location: .memory(provider: { try Self.sampleCert.serializeAsPEM().derBytes }),
|
||||||
|
format: .der
|
||||||
|
),
|
||||||
|
privateKey: .init(
|
||||||
|
location: .memory(provider: { Array(P384.Signing.PrivateKey().derRepresentation) }),
|
||||||
|
format: .der
|
||||||
|
)
|
||||||
|
) { reloader in
|
||||||
|
let override = reloader.sslContextConfigurationOverride
|
||||||
|
XCTAssertNil(override.certificateChain)
|
||||||
|
XCTAssertNil(override.privateKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TestError: Error {
|
||||||
|
case emptyCertificate
|
||||||
|
case emptyPrivateKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func testReloadSuccessfully_FromMemory() async throws {
|
||||||
|
let certificateBox: NIOLockedValueBox<[UInt8]> = NIOLockedValueBox([])
|
||||||
|
try await runTimedCertificateReloaderTest(
|
||||||
|
certificate: .init(
|
||||||
|
location: .memory(provider: {
|
||||||
|
let cert = certificateBox.withLockedValue({ $0 })
|
||||||
|
if cert.isEmpty {
|
||||||
|
throw TestError.emptyCertificate
|
||||||
|
}
|
||||||
|
return cert
|
||||||
|
}),
|
||||||
|
format: .der
|
||||||
|
),
|
||||||
|
privateKey: .init(
|
||||||
|
location: .memory(provider: { Array(Self.samplePrivateKey.derRepresentation) }),
|
||||||
|
format: .der
|
||||||
|
),
|
||||||
|
// We need to disable validation because the provider will initially be empty.
|
||||||
|
validateSources: false
|
||||||
|
) { reloader in
|
||||||
|
// On first attempt, we should have no certificate or private key overrides available,
|
||||||
|
// since the certificate box is empty.
|
||||||
|
var override = reloader.sslContextConfigurationOverride
|
||||||
|
XCTAssertNil(override.certificateChain)
|
||||||
|
XCTAssertNil(override.privateKey)
|
||||||
|
|
||||||
|
// Update the box to contain a valid certificate.
|
||||||
|
certificateBox.withLockedValue({ $0 = try! Self.sampleCert.serializeAsPEM().derBytes })
|
||||||
|
|
||||||
|
// Give the reload loop some time to run and update the cert-key pair.
|
||||||
|
try await Task.sleep(for: .milliseconds(100), tolerance: .zero)
|
||||||
|
|
||||||
|
// Now the overrides should be present.
|
||||||
|
override = reloader.sslContextConfigurationOverride
|
||||||
|
XCTAssertEqual(
|
||||||
|
override.certificateChain,
|
||||||
|
[.certificate(try .init(bytes: Self.sampleCert.serializeAsPEM().derBytes, format: .der))]
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
override.privateKey,
|
||||||
|
.privateKey(try .init(bytes: Array(Self.samplePrivateKey.derRepresentation), format: .der))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testReloadSuccessfully_FromFile() async throws {
|
||||||
|
// Start with empty files.
|
||||||
|
let certificateFile = try self.createTempFile(contents: Data())
|
||||||
|
let privateKeyFile = try self.createTempFile(contents: Data())
|
||||||
|
try await runTimedCertificateReloaderTest(
|
||||||
|
certificate: .init(
|
||||||
|
location: .file(path: certificateFile.path()),
|
||||||
|
format: .der
|
||||||
|
),
|
||||||
|
privateKey: .init(
|
||||||
|
location: .file(path: privateKeyFile.path()),
|
||||||
|
format: .der
|
||||||
|
),
|
||||||
|
// We need to disable validation because the files will not initially have any contents.
|
||||||
|
validateSources: false
|
||||||
|
) { reloader in
|
||||||
|
// On first attempt, we should have no certificate or private key overrides available,
|
||||||
|
// since the certificate box is empty.
|
||||||
|
var override = reloader.sslContextConfigurationOverride
|
||||||
|
XCTAssertNil(override.certificateChain)
|
||||||
|
XCTAssertNil(override.privateKey)
|
||||||
|
|
||||||
|
// Update the files to contain data
|
||||||
|
try Data(try Self.sampleCert.serializeAsPEM().derBytes).write(to: certificateFile)
|
||||||
|
try Self.samplePrivateKey.derRepresentation.write(to: privateKeyFile)
|
||||||
|
|
||||||
|
// Give the reload loop some time to run and update the cert-key pair.
|
||||||
|
try await Task.sleep(for: .milliseconds(100), tolerance: .zero)
|
||||||
|
|
||||||
|
// Now the overrides should be present.
|
||||||
|
override = reloader.sslContextConfigurationOverride
|
||||||
|
XCTAssertEqual(
|
||||||
|
override.certificateChain,
|
||||||
|
[.certificate(try .init(bytes: Self.sampleCert.serializeAsPEM().derBytes, format: .der))]
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
override.privateKey,
|
||||||
|
.privateKey(try .init(bytes: Array(Self.samplePrivateKey.derRepresentation), format: .der))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCertificateNotFoundAtReload() async throws {
|
||||||
|
let certificateBox: NIOLockedValueBox<[UInt8]> = NIOLockedValueBox(
|
||||||
|
try! Self.sampleCert.serializeAsPEM().derBytes
|
||||||
|
)
|
||||||
|
try await runTimedCertificateReloaderTest(
|
||||||
|
certificate: .init(
|
||||||
|
location: .memory(provider: {
|
||||||
|
let cert = certificateBox.withLockedValue({ $0 })
|
||||||
|
if cert.isEmpty {
|
||||||
|
throw TestError.emptyCertificate
|
||||||
|
}
|
||||||
|
return cert
|
||||||
|
}),
|
||||||
|
format: .der
|
||||||
|
),
|
||||||
|
privateKey: .init(
|
||||||
|
location: .memory(provider: { Array(Self.samplePrivateKey.derRepresentation) }),
|
||||||
|
format: .der
|
||||||
|
)
|
||||||
|
) { reloader in
|
||||||
|
// On first attempt, the overrides should be correctly present.
|
||||||
|
var override = reloader.sslContextConfigurationOverride
|
||||||
|
XCTAssertEqual(
|
||||||
|
override.certificateChain,
|
||||||
|
[.certificate(try .init(bytes: Self.sampleCert.serializeAsPEM().derBytes, format: .der))]
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
override.privateKey,
|
||||||
|
.privateKey(try .init(bytes: Array(Self.samplePrivateKey.derRepresentation), format: .der))
|
||||||
|
)
|
||||||
|
|
||||||
|
// Update the box to contain empty bytes: this will cause the provider to throw.
|
||||||
|
certificateBox.withLockedValue({ $0 = [] })
|
||||||
|
|
||||||
|
// Give the reload loop some time to run and update the cert-key pair.
|
||||||
|
try await Task.sleep(for: .milliseconds(100), tolerance: .zero)
|
||||||
|
|
||||||
|
// We should still be offering the previously valid cert-key pair.
|
||||||
|
override = reloader.sslContextConfigurationOverride
|
||||||
|
XCTAssertEqual(
|
||||||
|
override.certificateChain,
|
||||||
|
[.certificate(try .init(bytes: Self.sampleCert.serializeAsPEM().derBytes, format: .der))]
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
override.privateKey,
|
||||||
|
.privateKey(try .init(bytes: Array(Self.samplePrivateKey.derRepresentation), format: .der))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testKeyNotFoundAtReload() async throws {
|
||||||
|
let keyBox: NIOLockedValueBox<[UInt8]> = NIOLockedValueBox(
|
||||||
|
Array(Self.samplePrivateKey.derRepresentation)
|
||||||
|
)
|
||||||
|
try await runTimedCertificateReloaderTest(
|
||||||
|
certificate: .init(
|
||||||
|
location: .memory(provider: { try Self.sampleCert.serializeAsPEM().derBytes }),
|
||||||
|
format: .der
|
||||||
|
),
|
||||||
|
privateKey: .init(
|
||||||
|
location: .memory(provider: {
|
||||||
|
let key = keyBox.withLockedValue({ $0 })
|
||||||
|
if key.isEmpty {
|
||||||
|
throw TestError.emptyPrivateKey
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}),
|
||||||
|
format: .der
|
||||||
|
)
|
||||||
|
) { reloader in
|
||||||
|
// On first attempt, the overrides should be correctly present.
|
||||||
|
var override = reloader.sslContextConfigurationOverride
|
||||||
|
XCTAssertEqual(
|
||||||
|
override.certificateChain,
|
||||||
|
[.certificate(try .init(bytes: Self.sampleCert.serializeAsPEM().derBytes, format: .der))]
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
override.privateKey,
|
||||||
|
.privateKey(try .init(bytes: Array(Self.samplePrivateKey.derRepresentation), format: .der))
|
||||||
|
)
|
||||||
|
|
||||||
|
// Update the box to contain empty bytes: this will cause the provider to throw.
|
||||||
|
keyBox.withLockedValue({ $0 = [] })
|
||||||
|
|
||||||
|
// Give the reload loop some time to run and update the cert-key pair.
|
||||||
|
try await Task.sleep(for: .milliseconds(100), tolerance: .zero)
|
||||||
|
|
||||||
|
// We should still be offering the previously valid cert-key pair.
|
||||||
|
override = reloader.sslContextConfigurationOverride
|
||||||
|
XCTAssertEqual(
|
||||||
|
override.certificateChain,
|
||||||
|
[.certificate(try .init(bytes: Self.sampleCert.serializeAsPEM().derBytes, format: .der))]
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
override.privateKey,
|
||||||
|
.privateKey(try .init(bytes: Array(Self.samplePrivateKey.derRepresentation), format: .der))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCertificateAndKeyDoNotMatchOnReload() async throws {
|
||||||
|
let keyBox: NIOLockedValueBox<[UInt8]> = NIOLockedValueBox(
|
||||||
|
Array(Self.samplePrivateKey.derRepresentation)
|
||||||
|
)
|
||||||
|
try await runTimedCertificateReloaderTest(
|
||||||
|
certificate: .init(
|
||||||
|
location: .memory(provider: { try Self.sampleCert.serializeAsPEM().derBytes }),
|
||||||
|
format: .der
|
||||||
|
),
|
||||||
|
privateKey: .init(
|
||||||
|
location: .memory(provider: { keyBox.withLockedValue({ $0 }) }),
|
||||||
|
format: .der
|
||||||
|
)
|
||||||
|
) { reloader in
|
||||||
|
// On first attempt, the overrides should be correctly present.
|
||||||
|
var override = reloader.sslContextConfigurationOverride
|
||||||
|
XCTAssertEqual(
|
||||||
|
override.certificateChain,
|
||||||
|
[.certificate(try .init(bytes: Self.sampleCert.serializeAsPEM().derBytes, format: .der))]
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
override.privateKey,
|
||||||
|
.privateKey(try .init(bytes: Array(Self.samplePrivateKey.derRepresentation), format: .der))
|
||||||
|
)
|
||||||
|
|
||||||
|
// Update the box to contain a key that does not match the given certificate.
|
||||||
|
keyBox.withLockedValue({ $0 = Array(P384.Signing.PrivateKey().derRepresentation) })
|
||||||
|
|
||||||
|
// Give the reload loop some time to run and update the cert-key pair.
|
||||||
|
try await Task.sleep(for: .milliseconds(100), tolerance: .zero)
|
||||||
|
|
||||||
|
// We should still be offering the previously valid cert-key pair.
|
||||||
|
override = reloader.sslContextConfigurationOverride
|
||||||
|
XCTAssertEqual(
|
||||||
|
override.certificateChain,
|
||||||
|
[.certificate(try .init(bytes: Self.sampleCert.serializeAsPEM().derBytes, format: .der))]
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
override.privateKey,
|
||||||
|
.privateKey(try .init(bytes: Array(Self.samplePrivateKey.derRepresentation), format: .der))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCertificateReloaderErrorDescription() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
"\(TLSConfiguration.CertificateReloaderError.missingCertificateChain)",
|
||||||
|
"Missing certificate chain"
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
"\(TLSConfiguration.CertificateReloaderError.missingPrivateKey)",
|
||||||
|
"Missing private key"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTimedCertificateReloaderErrorDescription() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
"\(TimedCertificateReloader.Error.certificatePathNotFound("some/path"))",
|
||||||
|
"Certificate path not found: some/path"
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
"\(TimedCertificateReloader.Error.privateKeyPathNotFound("some/path"))",
|
||||||
|
"Private key path not found: some/path"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static let startDate = Date()
|
||||||
|
static let samplePrivateKey = P384.Signing.PrivateKey()
|
||||||
|
static let sampleCertName = try! DistinguishedName {
|
||||||
|
CountryName("US")
|
||||||
|
OrganizationName("Apple")
|
||||||
|
CommonName("Swift Certificate Test")
|
||||||
|
}
|
||||||
|
static let sampleCert: Certificate = {
|
||||||
|
try! Certificate(
|
||||||
|
version: .v3,
|
||||||
|
serialNumber: .init(),
|
||||||
|
publicKey: .init(samplePrivateKey.publicKey),
|
||||||
|
notValidBefore: startDate.advanced(by: -60 * 60 * 24 * 360),
|
||||||
|
notValidAfter: startDate.advanced(by: 60 * 60 * 24 * 360),
|
||||||
|
issuer: sampleCertName,
|
||||||
|
subject: sampleCertName,
|
||||||
|
signatureAlgorithm: .ecdsaWithSHA384,
|
||||||
|
extensions: Certificate.Extensions {
|
||||||
|
Critical(
|
||||||
|
BasicConstraints.isCertificateAuthority(maxPathLength: nil)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
issuerPrivateKey: .init(samplePrivateKey)
|
||||||
|
)
|
||||||
|
}()
|
||||||
|
|
||||||
|
private func runTimedCertificateReloaderTest(
|
||||||
|
certificate: TimedCertificateReloader.CertificateSource,
|
||||||
|
privateKey: TimedCertificateReloader.PrivateKeySource,
|
||||||
|
validateSources: Bool = true,
|
||||||
|
_ body: @escaping @Sendable (TimedCertificateReloader) async throws -> Void
|
||||||
|
) async throws {
|
||||||
|
let reloader = TimedCertificateReloader(
|
||||||
|
refreshInterval: .milliseconds(50),
|
||||||
|
certificateSource: .init(
|
||||||
|
location: certificate.location,
|
||||||
|
format: certificate.format
|
||||||
|
),
|
||||||
|
privateKeySource: .init(location: privateKey.location, format: privateKey.format)
|
||||||
|
)
|
||||||
|
|
||||||
|
if validateSources {
|
||||||
|
try reloader.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
try await withThrowingTaskGroup(of: Void.self) { group in
|
||||||
|
group.addTask {
|
||||||
|
try await reloader.run()
|
||||||
|
}
|
||||||
|
try await body(reloader)
|
||||||
|
group.cancelAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user