GithubHelp home page GithubHelp logo

acmeswift's Introduction

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", .branch("master")),
    ],
    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 somwewhere and then decode it in order to log into your account later.

⚠️ 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:

⚠️ 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 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: "....")

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.
let failed = try await acme.orders.validateChallenges(from: order, preferring: .dns)
guard failed.count == 0 else {
    fatalError("Some validations failed! \(failed)")
}

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

acmeswift's People

Contributors

dimitribouniol avatar fizker avatar florentmorin avatar m-barthelemy avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar

acmeswift's Issues

AcmeOrderInfo is missing url in some cases

After calling finalize, the order information passed back does not contain a url.

Eg:

let (privateKey, _, finalized) = try await acme.orders.finalizeWithRsa(order: order, domains: [domain])
let refreshed = try await acme.orders.get(url: finalized.url!) //url is not set

Also, acme.orders.get(url:) and acme.orders.refresh(&order) do not seem to include the url in their response.

That means that one cannot write a loop like this ATM:

let (privateKey, _, finalized) = try await acme.orders.finalizeWithRsa(order: order, domains: [domain])

var orderForDownload = finalized

while orderForDownload.status == .processing {
    try await Task.sleep(for: .seconds(1))
    try await acme.orders.refresh(&orderForDownload) //this will fail
    // as a work-around the url of the original order data must be used 
    // orderForDownload = try await acme.orders.get(url: order.url!)
}

Awkward API to get token and value for http challenge

tl;dr: Should we add the token to the ChallengeDescription type?

First: Thank you for this package!
When I stumbled over the need to self-manage public certificates I couldn't believe my luck: There is a swift package for ACME!

A tiny little thing I think could be improved:

Currently, you can either call acme.orders.getAuthorizations(from: order) to get the "raw data", or use the acme.orders.describePendingChallenges(from: order, preferring: .http) API to get the little value encoding dance for free.

Getting the correct value for the challenges is clearly nicer, but on ChallengeDescription you can not access the token directly.
Based on how you'd implement "placing" the http value on you server, you'll now have to extract it out of the endpoint URL again.

Additionally, we could also move the get me the value for this challenge code to some reusable place to decouple it from the describePendingChallenges API.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.