Skip to content

Commit 949cf2d

Browse files
authored
Add a TimedCertificateReloader (#269)
This PR adds a `CertificateReloader` protocol and a `TimedCertificateReloader` implementation. A `CertificateReloader` allows for certificate-key pairs to be observed and updated. A `TimedCertificateReloader` does this every set interval of time. This PR also provides an extension on `TLSConfiguration` to enable this reloading ability in NIO applications, so that updated certificates and keys can be used during SSL handshakes; as well as conformance of the `TimedCertificateReloader` to `swift-service-lifecycle`'s `Service` protocol, for easy composition.
1 parent 0fc472b commit 949cf2d

File tree

4 files changed

+1111
-0
lines changed

4 files changed

+1111
-0
lines changed

Package.swift

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,30 @@ var targets: [PackageDescription.Target] = [
257257
],
258258
swiftSettings: strictConcurrencySettings
259259
),
260+
.target(
261+
name: "NIOCertificateReloading",
262+
dependencies: [
263+
.product(name: "NIOCore", package: "swift-nio"),
264+
.product(name: "NIOSSL", package: "swift-nio-ssl"),
265+
.product(name: "X509", package: "swift-certificates"),
266+
.product(name: "SwiftASN1", package: "swift-asn1"),
267+
.product(name: "ServiceLifecycle", package: "swift-service-lifecycle"),
268+
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms"),
269+
.product(name: "Logging", package: "swift-log"),
270+
],
271+
swiftSettings: strictConcurrencySettings
272+
),
273+
.testTarget(
274+
name: "NIOCertificateReloadingTests",
275+
dependencies: [
276+
"NIOCertificateReloading",
277+
.product(name: "NIOCore", package: "swift-nio"),
278+
.product(name: "NIOSSL", package: "swift-nio-ssl"),
279+
.product(name: "X509", package: "swift-certificates"),
280+
.product(name: "SwiftASN1", package: "swift-asn1"),
281+
],
282+
swiftSettings: strictConcurrencySettings
283+
),
260284
]
261285

262286
let package = Package(
@@ -270,6 +294,7 @@ let package = Package(
270294
.library(name: "NIOHTTPTypesHTTP2", targets: ["NIOHTTPTypesHTTP2"]),
271295
.library(name: "NIOResumableUpload", targets: ["NIOResumableUpload"]),
272296
.library(name: "NIOHTTPResponsiveness", targets: ["NIOHTTPResponsiveness"]),
297+
.library(name: "NIOCertificateReloading", targets: ["NIOCertificateReloading"]),
273298
],
274299
dependencies: [
275300
.package(url: "https://github.com/apple/swift-nio.git", from: "2.81.0"),
@@ -278,6 +303,12 @@ let package = Package(
278303
.package(url: "https://github.com/apple/swift-http-structured-headers.git", from: "1.2.0"),
279304
.package(url: "https://github.com/apple/swift-atomics.git", from: "1.2.0"),
280305
.package(url: "https://github.com/apple/swift-algorithms.git", from: "1.2.0"),
306+
.package(url: "https://github.com/apple/swift-certificates.git", from: "1.10.0"),
307+
.package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.29.3"),
308+
.package(url: "https://github.com/apple/swift-asn1.git", from: "1.3.1"),
309+
.package(url: "https://github.com/swift-server/swift-service-lifecycle.git", from: "2.8.0"),
310+
.package(url: "https://github.com/apple/swift-async-algorithms.git", from: "1.0.0"),
311+
.package(url: "https://github.com/apple/swift-log.git", from: "1.6.3"),
281312

282313
],
283314
targets: targets
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the SwiftNIO open source project
4+
//
5+
// Copyright (c) 2025 Apple Inc. and the SwiftNIO project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of SwiftNIO project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
import NIOCore
16+
import NIOSSL
17+
18+
/// A protocol that defines a certificate reloader.
19+
///
20+
/// A certificate reloader is a service that can provide you with updated versions of a certificate and private key pair, in
21+
/// the form of a `NIOSSLContextConfigurationOverride`, which will be used when performing a TLS handshake in NIO.
22+
/// Each implementation can choose how to observe for changes, but they all require an ``sslContextConfigurationOverride``
23+
/// to be exposed.
24+
public protocol CertificateReloader: Sendable {
25+
/// A `NIOSSLContextConfigurationOverride` that will be used as part of the NIO application's TLS configuration.
26+
/// Its certificate and private key will be kept up-to-date via whatever mechanism the specific ``CertificateReloader``
27+
/// implementation provides.
28+
var sslContextConfigurationOverride: NIOSSLContextConfigurationOverride { get }
29+
}
30+
31+
extension TLSConfiguration {
32+
/// Errors thrown when creating a ``NIOSSL/TLSConfiguration`` with a ``CertificateReloader``.
33+
public struct CertificateReloaderError: Error, Hashable, CustomStringConvertible {
34+
private enum _Backing: CustomStringConvertible {
35+
case missingCertificateChain
36+
case missingPrivateKey
37+
38+
var description: String {
39+
switch self {
40+
case .missingCertificateChain:
41+
return "Missing certificate chain"
42+
case .missingPrivateKey:
43+
return "Missing private key"
44+
}
45+
}
46+
}
47+
48+
private let _backing: _Backing
49+
50+
private init(backing: _Backing) {
51+
self._backing = backing
52+
}
53+
54+
public var description: String {
55+
self._backing.description
56+
}
57+
58+
/// The given ``CertificateReloader`` could not provide a certificate chain with which to create this config.
59+
public static var missingCertificateChain: Self { .init(backing: .missingCertificateChain) }
60+
61+
/// The given ``CertificateReloader`` could not provide a private key with which to create this config.
62+
public static var missingPrivateKey: Self { .init(backing: .missingPrivateKey) }
63+
}
64+
65+
/// Create a ``NIOSSL/TLSConfiguration`` for use with server-side contexts, with certificate reloading enabled.
66+
/// - Parameter certificateReloader: A ``CertificateReloader`` to watch for certificate and key pair updates.
67+
/// - Returns: A ``NIOSSL/TLSConfiguration`` for use with server-side contexts, that reloads the certificate and key
68+
/// used in its SSL handshake.
69+
/// - Throws: This method will throw if an override isn't present. This may happen if a certificate or private key could not be
70+
/// loaded from the given paths.
71+
public static func makeServerConfiguration(
72+
certificateReloader: some CertificateReloader
73+
) throws -> Self {
74+
let override = certificateReloader.sslContextConfigurationOverride
75+
76+
guard let certificateChain = override.certificateChain else {
77+
throw CertificateReloaderError.missingCertificateChain
78+
}
79+
80+
guard let privateKey = override.privateKey else {
81+
throw CertificateReloaderError.missingPrivateKey
82+
}
83+
84+
var configuration = Self.makeServerConfiguration(
85+
certificateChain: certificateChain,
86+
privateKey: privateKey
87+
)
88+
configuration.setCertificateReloader(certificateReloader)
89+
return configuration
90+
}
91+
92+
/// Create a ``NIOSSL/TLSConfiguration`` for use with client-side contexts, with certificate reloading enabled.
93+
/// - Parameter certificateReloader: A ``CertificateReloader`` to watch for certificate and key pair updates.
94+
/// - Returns: A ``NIOSSL/TLSConfiguration`` for use with client-side contexts, that reloads the certificate and key
95+
/// used in its SSL handshake.
96+
/// - Throws: This method will throw if an override isn't present. This may happen if a certificate or private key could not be
97+
/// loaded from the given paths.
98+
public static func makeClientConfiguration(
99+
certificateReloader: some CertificateReloader
100+
) throws -> Self {
101+
let override = certificateReloader.sslContextConfigurationOverride
102+
103+
guard let certificateChain = override.certificateChain else {
104+
throw CertificateReloaderError.missingCertificateChain
105+
}
106+
107+
guard let privateKey = override.privateKey else {
108+
throw CertificateReloaderError.missingPrivateKey
109+
}
110+
111+
var configuration = Self.makeClientConfiguration()
112+
configuration.certificateChain = certificateChain
113+
configuration.privateKey = privateKey
114+
configuration.setCertificateReloader(certificateReloader)
115+
return configuration
116+
}
117+
118+
/// Configure a ``CertificateReloader`` to observe updates for the certificate and key pair used.
119+
/// - Parameter reloader: A ``CertificateReloader`` to watch for certificate and key pair updates.
120+
public mutating func setCertificateReloader(_ reloader: some CertificateReloader) {
121+
self.sslContextCallback = { _, promise in
122+
promise.succeed(reloader.sslContextConfigurationOverride)
123+
}
124+
}
125+
}

0 commit comments

Comments
 (0)