这是一个用 Swift 编写的 正在开发中 的 Let's Encrypt (ACME v2) 客户端。
它充分利用了 Swift 5.5 引入的 Swift 并发特性 (async
/await
)。
尽管它可能适用于其他实现了 ACMEv2 的证书提供商,但这一点尚未经过任何测试。
这个库本身不处理任何 ACME 挑战。发布挑战,无论是通过创建 DNS 记录还是通过 HTTP 公开值,完全是您自己的责任。
import PackageDescription
let package = Package(
dependencies: [
...
.package(url: "https://github.com/m-barthelemy/AcmeSwift.git", from: "1.0.0-beta3"),
],
targets: [
.target(name: "App", dependencies: [
...
.product(name: "AcmeSwift", package: "AcmeSwift")
]),
...
]
)
创建一个客户端实例
import AcmeSwift
let acme = try await AcmeSwift()
在测试时,最好使用 Let's Encrypt 的暂存端点
import AcmeSwift
let acme = try await AcmeSwift(acmeEndpoint: .letsEncryptStaging)
let account = acme.account.create(contacts: ["my.email@domain.com"], validateTOS: true)
此方法返回的信息是一个 AcmeAccountInfo
对象,可以直接用于身份验证。 例如,您可以将其编码为 JSON,将其保存在某个地方,然后对其进行解码,以便以后登录您的帐户。
警告
此帐户信息包含私钥,因此必须安全存储。
方案 1:直接使用 account.create(...)
返回的对象
try acme.account.use(account)
方案 2:手动传递凭据
let credentials = try AccountCredentials(contacts: ["my.email@domain.tld"], pemKey: "private key in PEM format")
try acme.account.use(credentials)
如果您使用 AcmeSwift 创建了帐户,则 PEM 格式的私钥将存储在 AccountInfo.privateKeyPem
属性中。
注意
只有在您绝对确定该帐户需要永久停用时才使用此功能。 没有回头路!
try await acme.account.deactivate()
通过 URL 获取订单
let latest = try await acme.orders.get(url: order.url!)
使用服务器的最新信息刷新订单实例
try await acme.orders.refresh(&order)
为新证书创建订单
let order = try await acme.orders.create(domains: ["mydomain.com", "www.mydomain.com"])
获取订单授权和挑战
let authorizations = try await acme.orders.getAuthorizations(from: order)
您将需要发布挑战。 AcmeSwift 提供了一种列出待处理的 HTTP 或 DNS 挑战的方法
let challengeDescs = try await acme.orders.describePendingChallenges(from: order, preferring: .http)
for desc in challengeDescs {
if desc.type == .http {
print("\n • The URL \(desc.endpoint) needs to return \(desc.value)")
}
else if desc.type == .dns {
print("\n • Create the following DNS record: \(desc.endpoint) TXT \(desc.value)")
}
}
实现这一点取决于您的 DNS 提供商和/或 Web 托管解决方案,并且超出 AcmeSwift 的范围。
注意:如果您正在请求通配符证书并选择
.http
作为首选验证方法,您仍然需要完成 DNS 挑战。 Let's Encrypt 仅允许对通配符证书进行 DNS 验证。
发布挑战后,我们可以要求 Let's Encrypt 验证它们
let updatedChallenges = try await acme.orders.validateChallenges(from: order, preferring: .http)
一旦所有授权/挑战都有效,我们就可以通过发送 PEM 格式的 CSR 来完成订单。
如果您已经有一个 CSR
let finalizedOrder = try await acme.orders.finalize(order: order, withPemCsr: "...")
如果您希望 AcmeSwift 为您生成一个
// ECDSA key and certificate
let (privateKey, csr, finalizedOrder) = try await acme.orders.finalizeWithEcdsa(order: order, domains: ["mydomain.com", "www.mydomain.com"])
// .. or, good old RSA
let (privateKey, csr, finalizedOrder) = try await acme.orders.finalizeWithRsa(order: order, domains: ["mydomain.com", "www.mydomain.com"])
// You can access the private key used to generate the CSR (and to use once you get the certificate)
print("\n• Private key: \(try privateKey.serializeAsPEM().pemString)")
注意
CSR 必须在其 SAN (subjectAltName) 字段中包含订单请求的所有 DNS 名称。
这假设相应的订单已成功完成,这意味着订单的
status
字段为valid
。
let certs = try await acme.certificates.download(for: finalizedOrder)
for var cert in certs {
print("\n • cert: \(cert)")
}
这返回一个 PEM 编码证书的列表。 第一个项目是所请求域的实际证书。 以下项目是建立完整认证链所需的其他证书(颁发 CA,根 CA ...)。
列表中项目的顺序与 SwiftNIO 和 Nginx 期望它们的方式直接兼容; 您可以将所有项目连接到单个文件中,并将此文件传递给 ssl_certificate
指令
try certs.joined(separator: "\n")
.write(to: URL(fileURLWithPath: "cert.pem"), atomically: true, encoding: .utf8)
try await acme.certificates.revoke(certificatePem: "....")
由于 Let's Encrypt 建议仅在 60 天后更新证书,因此在请求新证书之前,检查现有证书的有效性通常很有用
import NIOSSL
let certURL = URL(fileURLWithPath: "cert.pem").absoluteURL
let domains = ["*.ponies.com", "ponies.com"]
logger.notice("Refreshing certificate for \(domains.joined(separator: ", "))")
do {
let existingCerts = try NIOSSLCertificate.fromPEMFile(certURL.path(percentEncoded: false))
logger.notice("Found existing certificates: \(existingCerts)")
if let certificate = existingCerts.first {
let expirationDate = Date(timeIntervalSince1970: TimeInterval(certificate.notValidAfter))
/// Get the names gregistered in the current certificate to see if they changed
let allNames = Set(certificate._subjectAlternativeNames().map { name -> String? in
guard case .dnsName = name.nameType else { return nil }
return String(decoding: name.contents, as: UTF8.self)
}.compactMap { $0 })
/// If the expiration date is more than 2 months away and contains all the domains we are interested in, stop renewing.
if expirationDate.timeIntervalSinceNow > 60*24*60*60 && allNames.isSuperset(of: domains) {
logger.notice("Certificate for \(domains.joined(separator: ", ")) still valid. Expires on \(expirationDate). Renewing on \(expirationDate.advanced(by: -30*24*60*60))")
return
}
}
} catch {
// Catch any errors here to log them, but otherwise continue
logger.notice("An issue occured loading existing certificates: \(error)")
}
// ... Continue renewing certificate
假设我们拥有 ponies.com
域,并且我们想要它的通配符证书。 我们还假设我们有一个现有的 Let's Encrypt 帐户。
import AcmeSwift
// Create the client and load Let's Encrypt credentials
let acme = try await AcmeSwift()
let accountKey = try String(contentsOf: URL(fileURLWithPath: "letsEncryptAccountKey.pem"), encoding: .utf8)
let credentials = try AccountCredentials(contacts: ["email@domain.tld"], pemKey: accountKey)
try acme.account.use(credentials)
let domains: [String] = ["*.ponies.com", "ponies.com"]
// Create a certificate order for *.ponies.com
let order = try await acme.orders.create(domains: domains)
// ... after that, now we can fetch the challenges we need to complete
for desc in try await acme.orders.describePendingChallenges(from: order, preferring: .dns) {
if desc.type == .http {
print("\n • The URL \(desc.endpoint) needs to return \(desc.value)")
}
else if desc.type == .dns {
print("\n • Create the following DNS record: \(desc.endpoint) TXT \(desc.value)")
}
}
// At this point, we could programmatically create the challenge DNS records using our DNS provider's API
[.... publish the DNS challenge records ....]
// Assuming the challenges have been published, we can now ask Let's Encrypt to validate them.
// If some challenges fail to validate, it is safe to call validateChallenges() again after fixing the underlying issue. Note that challenges may take a while to complete, and the ACME specification recommends polling as soon as you recieve a request or know the challenge can be verified: https://www.rfc-editor.org/rfc/rfc8555.html#section-7.5.1
var remainingChallenges = try await acme.orders.validateChallenges(from: order, preferring: .dns)
// Poll with progressively longer timeouts. These are arbitrary and may be modified to suit your needs (certbot tries every second, but this seems more kind if there is no rush).
for timeout in [5, 10, 10, 10, 30] {
guard !remainingChallenges.isEmpty else { break }
try await Task.sleep(for: .seconds(timeout))
remainingChallenges = try await acme.orders.validateChallenges(from: order, preferring: .dns)
}
// Give up if we still haven't satisfied the request:
guard remainingChallenges.isEmpty else {
struct ChallengeValidationError: Error {}
throw ChallengeValidationError()
}
// Let's create a private key and CSR using the rudimentary feature provided by AcmeSwift
// If the validation didn't throw any error, we can now send our Certificate Signing Request...
let (privateKey, csr, finalized) = try await acme.orders.finalizeWithRsa(order: order, domains: domains)
// ... and the certificate is ready to download!
let certs = try await acme.certificates.download(for: finalized)
// Let's save the full certificates chain to a file
try certs.joined(separator: "\n").write(to: URL(fileURLWithPath: "cert.pem"), atomically: true, encoding: .utf8)
// Now we also need to export the private key, encoded as PEM
// If your server doesn't accept it, append a line return to it.
try privateKey.serializeAsPEM().pemString.write(to: URL(fileURLWithPath: "key.pem"), atomically: true, encoding: .utf8)
CSR 功能的一部分灵感来自和/或取自优秀的 Shield 项目 (https://github.com/outfoxx/Shield)