Skip to content

m-barthelemy/AcmeSwift

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

AcmeSwift

Language Platforms

This is a work in progress Let's Encrypt (ACME v2) client written in Swift.

It fully uses the Swift concurrency features introduced with Swift 5.5 (async/await).

Although it might work with other certificate providers implementing ACMEv2, this has not been tested at all.

Note

This library doesn't handle any ACME challenge at all by itself. Publishing the challenge, either by creating DNS record or exposing the value over HTTP, is your full responsibility.

Installation

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")
        ]),
    ...
    ]
)

Usage

Create an instance of the client:

import AcmeSwift

let acme = try await AcmeSwift()

When testing, preferably use the Let's Encrypt staging endpoint:

import AcmeSwift

let acme = try await AcmeSwift(acmeEndpoint: .letsEncryptStaging)

Account

  • Create a new Let's Encrypt account:
let account = acme.account.create(contacts: ["[email protected]"], validateTOS: true)

The information returned by this method is an AcmeAccountInfo object that can be directly reused for authentication. For example, you can encode it to JSON, save it somewhere and then decode it in order to log into your account later.

Warning

This Account information contains a private key and as such, must be stored securely.


  • Reuse a previously created account:

Option 1: Directly use the object returned by account.create(...)

try acme.account.use(account)

Option 2: Pass credentials "manually"

let credentials = try AccountCredentials(contacts: ["[email protected]"], pemKey: "private key in PEM format")
try acme.account.use(credentials)

If you created your account using AcmeSwift, the private key in PEM format is stored into the AccountInfo.privateKeyPem property.


  • Deactivate an existing account:

Caution

Only use this if you are absolutely certain that the account needs to be permanently deactivated. There is no going back!

try await acme.account.deactivate()

Orders (certificate requests)

Fetch an Order by its URL:

let latest = try await acme.orders.get(url: order.url!)

Refresh an Order instance with latest information from the server:

try await acme.orders.refresh(&order)

Create an Order for a new certificate:

 
let order = try await acme.orders.create(domains: ["mydomain.com", "www.mydomain.com"])

Get the Order authorizations and challenges:

let authorizations = try await acme.orders.getAuthorizations(from: order)

You will need to publish the challenges. AcmeSwift provides a way to list the pending HTTP or DNS challenges:

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

Achieving this depends on your DNS provider and/or web hosting solution and is outside the scope of AcmeSwift.

Note: if you are requesting a wildcard certificate and choose .http as the preferred validation method, you will still get a DNS challenge to complete. Let's Encrypt only allows DNS validation for wildcard certificates.


Once the challenges are published, we can ask Let's Encrypt to validate them:

let updatedChallenges = try await acme.orders.validateChallenges(from: order, preferring: .http)

Once all the authorizations/challenges are valid, we can finalize the Order by sending the CSR in PEM format.

If you already have a CSR:

let finalizedOrder = try await acme.orders.finalize(order: order, withPemCsr: "...")

If you want AcmeSwift to generate one for you:

// 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)")

Note

The CSR must contain all the DNS names requested by the Order in its SAN (subjectAltName) field.


Certificates

  • Download a certificate:

This assumes that the corresponding Order has been finalized successfully, meaning that the Order status field is valid.

let certs = try await acme.certificates.download(for: finalizedOrder)
for var cert in certs {
    print("\n • cert: \(cert)")
}

This return a list of PEM-encoded certificates. The first item is the actual certificate for the requested domains. The following items are the other certificates required to establish the full certification chain (issuing CA, root CA...).

The order of the items in the list is directly compatible with the way SwiftNIO and Nginx expects them; you can concatenate all the items into a single file and pass this file to the ssl_certificate directive:

try certs.joined(separator: "\n")
    .write(to: URL(fileURLWithPath: "cert.pem"), atomically: true, encoding: .utf8)

  • Revoke a certificate:
try await acme.certificates.revoke(certificatePem: "....")

Validating Existing Certificates

Since Let's Encrypt recommends only renewing certificates after 60 days, it's often useful to check existing certificates for validity before requesting a new one:

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

Example

Let's suppose that we own the ponies.com domain and that we want a wildcard certificate for it. We also assume that we have an existing Let's Encrypt account.

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 protected]"], 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)

Credits

Part of the CSR feature is inspired by and/or taken from the excellent Shield project (https://github.com/outfoxx/Shield)