├── Sources └── AcmeSwift │ ├── Models │ ├── Account │ │ ├── AccountOrdersUrls.swift │ │ ├── AccountCredentials.swift │ │ ├── AcmeAccountSpec.swift │ │ └── AcmeAccountInfo.swift │ ├── Order │ │ ├── AcmeAttestationSpec.swift │ │ ├── AcmeFinalizeOrderSpec.swift │ │ ├── ChallengeDescription.swift │ │ ├── AcmeOrderSpec.swift │ │ ├── AcmeOrderInfo.swift │ │ └── AcmeAuthorization.swift │ ├── Certificate │ │ └── RevokeSpec.swift │ ├── AcmeDirectory.swift │ └── AcmeError.swift │ ├── Endpoints │ ├── Order │ │ ├── GetOrderEndpoint.swift │ │ ├── ValidateChallengeEndpoint.swift │ │ ├── CreateOrderEndpoint.swift │ │ ├── FinalizeOrderEndpoint.swift │ │ └── ValidateAttestationChallangeEndpoint.swift │ ├── Authorization │ │ └── GetAuthorizationEndpoint.swift │ ├── Certificate │ │ ├── DownloadCertificateEndpoint.swift │ │ └── RevokeCertificateEndpoint.swift │ ├── Account │ │ ├── ListOrdersEndpoint.swift │ │ ├── CreateAccountEndpoint.swift │ │ └── DeactivateAccountEndpoint.swift │ └── EndpointProtocol.swift │ ├── Helpers │ ├── HttpClient+Decode.swift │ ├── String+base64Url.swift │ └── AcmeRequestBody.swift │ └── APIs │ ├── AcmeSwift+Csr.swift │ ├── AcmeSwift+Certificates.swift │ ├── AcmeSwift+Account.swift │ ├── AcmeSwift.swift │ └── AcmeSwift+Orders.swift ├── Examples └── acme-da │ ├── attestation.tpl │ └── AcmeDA.swift ├── .gitignore ├── LICENSE ├── Tests └── AcmeSwiftTests │ ├── MiscTemp.swift │ ├── AccountTests.swift │ └── OrderTests.swift ├── Package.swift └── README.md /Sources/AcmeSwift/Models/Account/AccountOrdersUrls.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct AccountOrdersUrls: Codable { 4 | public let orders: [URL] 5 | } 6 | -------------------------------------------------------------------------------- /Examples/acme-da/attestation.tpl: -------------------------------------------------------------------------------- 1 | { 2 | "subject": {{ toJson .Subject }}, 3 | "sans": [{ 4 | "type": "permanentIdentifier", 5 | "value": {{ toJson .Subject.CommonName }} 6 | }] 7 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Build directory 2 | .build/ 3 | 4 | ## Xcode ser settings 5 | xcuserdata/ 6 | .swiftpm/xcode 7 | 8 | ## macOS files 9 | .DS_Store 10 | 11 | ## Resolved package dependencies 12 | Package.resolved 13 | -------------------------------------------------------------------------------- /Sources/AcmeSwift/Models/Order/AcmeAttestationSpec.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct AcmeAttestationSpec: Codable { 4 | init(attObj: String) { 5 | self.attObj = attObj 6 | } 7 | 8 | var attObj: String 9 | } 10 | -------------------------------------------------------------------------------- /Sources/AcmeSwift/Endpoints/Order/GetOrderEndpoint.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import NIOHTTP1 3 | 4 | struct GetOrderEndpoint: EndpointProtocol { 5 | var body: Body? = "" 6 | 7 | typealias Response = AcmeOrderInfo 8 | typealias Body = String 9 | let url: URL 10 | 11 | init(url: URL) { 12 | self.url = url 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/AcmeSwift/Endpoints/Authorization/GetAuthorizationEndpoint.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import NIOHTTP1 3 | 4 | struct GetAuthorizationEndpoint: EndpointProtocol { 5 | var body: Body? = "" 6 | 7 | typealias Response = AcmeAuthorization 8 | typealias Body = String 9 | let url: URL 10 | 11 | init(url: URL) { 12 | self.url = url 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/AcmeSwift/Endpoints/Certificate/DownloadCertificateEndpoint.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import NIOHTTP1 3 | 4 | struct DownloadCertificateEndpoint: EndpointProtocol { 5 | var body: Body? = "" 6 | 7 | typealias Response = String 8 | typealias Body = String 9 | let url: URL 10 | 11 | init(certURL: URL) { 12 | self.url = certURL 13 | } 14 | } 15 | 16 | -------------------------------------------------------------------------------- /Sources/AcmeSwift/Endpoints/Account/ListOrdersEndpoint.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import NIOHTTP1 3 | 4 | struct ListOrdersEndpoint: EndpointProtocol { 5 | var body: Body? = NoBody() 6 | var method: HTTPMethod = .GET 7 | typealias Response = AccountOrdersUrls 8 | typealias Body = NoBody 9 | let url: URL 10 | 11 | init(url: URL) { 12 | self.url = url 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/AcmeSwift/Endpoints/Order/ValidateChallengeEndpoint.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import NIOHTTP1 3 | 4 | struct ValidateChallengeEndpoint: EndpointProtocol { 5 | var body: Body? = NoBody() 6 | 7 | typealias Response = AcmeAuthorization.Challenge 8 | typealias Body = NoBody 9 | let url: URL 10 | 11 | init(challengeURL: URL) { 12 | self.url = challengeURL 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/AcmeSwift/Endpoints/Order/CreateOrderEndpoint.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import NIOHTTP1 3 | 4 | struct CreateOrderEndpoint: EndpointProtocol { 5 | var body: Body? 6 | 7 | typealias Response = AcmeOrderInfo 8 | typealias Body = AcmeOrderSpec 9 | let url: URL 10 | 11 | init(directory: AcmeDirectory, spec: AcmeOrderSpec) { 12 | self.body = spec 13 | self.url = directory.newOrder 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/AcmeSwift/Endpoints/Order/FinalizeOrderEndpoint.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import NIOHTTP1 3 | 4 | struct FinalizeOrderEndpoint: EndpointProtocol { 5 | var body: Body? 6 | 7 | typealias Response = AcmeOrderInfo 8 | typealias Body = AcmeFinalizeOrderSpec 9 | let url: URL 10 | 11 | init(orderURL: URL, spec: AcmeFinalizeOrderSpec) { 12 | self.body = spec 13 | self.url = orderURL 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/AcmeSwift/Endpoints/Account/CreateAccountEndpoint.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import NIOHTTP1 3 | 4 | struct CreateAccountEndpoint: EndpointProtocol { 5 | var body: Body? 6 | 7 | typealias Response = AcmeAccountInfo 8 | typealias Body = AcmeAccountSpec 9 | let url: URL 10 | 11 | init(directory: AcmeDirectory, spec: AcmeAccountSpec) { 12 | self.body = spec 13 | self.url = directory.newAccount 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/AcmeSwift/Models/Order/AcmeFinalizeOrderSpec.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct AcmeFinalizeOrderSpec: Codable { 4 | init(csr: String) { 5 | self.csr = csr 6 | } 7 | 8 | /// The CSR (Certificate Signing Request) for this order. 9 | /// The CSR is sent in the base64url-encoded version of the DER format. 10 | /// Note: Because this field uses base64url, and does not include headers, it is different from PEM. 11 | var csr: String 12 | } 13 | -------------------------------------------------------------------------------- /Sources/AcmeSwift/Endpoints/Certificate/RevokeCertificateEndpoint.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import NIOHTTP1 3 | 4 | struct RevokeCertificateEndpoint: EndpointProtocol { 5 | var body: Body? 6 | 7 | typealias Response = NoBody 8 | typealias Body = CertificateRevokeSpec 9 | let url: URL 10 | 11 | init(directory: AcmeDirectory,spec: CertificateRevokeSpec) { 12 | self.body = spec 13 | self.url = directory.revokeCert 14 | } 15 | } 16 | 17 | 18 | -------------------------------------------------------------------------------- /Sources/AcmeSwift/Endpoints/Order/ValidateAttestationChallangeEndpoint.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import NIOHTTP1 3 | 4 | struct ValidateAttestationChallengeEndpoint: EndpointProtocol { 5 | var body: Body? 6 | 7 | typealias Response = AcmeAuthorization.Challenge 8 | typealias Body = AcmeAttestationSpec 9 | let url: URL 10 | 11 | init(challengeURL: URL, spec: AcmeAttestationSpec) { 12 | self.body = spec 13 | self.url = challengeURL 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/AcmeSwift/Endpoints/Account/DeactivateAccountEndpoint.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import NIOHTTP1 3 | 4 | struct DeactivateAccountEndpoint: EndpointProtocol { 5 | var body: Body? 6 | 7 | typealias Response = AcmeAccountInfo 8 | typealias Body = DeactivateAccountRequest 9 | let url: URL 10 | 11 | init(accountURL: URL) { 12 | self.body = DeactivateAccountRequest() 13 | self.url = accountURL 14 | } 15 | 16 | struct DeactivateAccountRequest: Codable { 17 | var status: AcmeAccountInfo.Status = .deactivated 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/AcmeSwift/Endpoints/EndpointProtocol.swift: -------------------------------------------------------------------------------- 1 | import NIOHTTP1 2 | import NIO 3 | import NIOFoundationCompat 4 | import Foundation 5 | 6 | protocol EndpointProtocol { 7 | associatedtype Response: Codable 8 | associatedtype Body: Codable 9 | var url: URL{ get } 10 | var method: HTTPMethod { get } 11 | var headers: HTTPHeaders? {get} 12 | var body: Body? { get } 13 | } 14 | 15 | extension EndpointProtocol { 16 | public var body: Body? { 17 | return nil 18 | } 19 | 20 | public var headers: HTTPHeaders? { 21 | return nil 22 | } 23 | 24 | public var method: HTTPMethod { 25 | return .POST 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/AcmeSwift/Models/Account/AccountCredentials.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Crypto 3 | 4 | public struct AccountCredentials { 5 | private(set) public var contacts: [String] = [] 6 | private(set) public var key: Crypto.P256.Signing.PrivateKey 7 | 8 | public init(contacts: [String], pemKey: String) throws { 9 | let privateKey = try Crypto.P256.Signing.PrivateKey.init(pemRepresentation: pemKey) 10 | self.init(contacts: contacts, key: privateKey) 11 | } 12 | 13 | public init(contacts: [String], key: Crypto.P256.Signing.PrivateKey) { 14 | self.key = key 15 | self.contacts = contacts.map { $0.contains(":") ? $0 : "mailto:\($0)" } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/AcmeSwift/Models/Account/AcmeAccountSpec.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Configuration for creating or querying an ACMEv2 account. 4 | struct AcmeAccountSpec: Codable { 5 | var contact: [String] = [] 6 | var termsOfServiceAgreed: Bool = true 7 | 8 | /// If this field is present with the value "true", then the server MUST NOT create a new account if one does not already exist. 9 | /// This allows a client to look up an account URL based on an account key. 10 | var onlyReturnExisting: Bool = false 11 | 12 | var externalAccountBinding: ExternalAccountBinding? = nil 13 | 14 | /// This is in fact a JWS 15 | struct ExternalAccountBinding: Codable { 16 | 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/AcmeSwift/Models/Certificate/RevokeSpec.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | 4 | struct CertificateRevokeSpec: Codable { 5 | /// PEM representation of the certificate. 6 | public var certificate: String 7 | 8 | /// Reason for the revocation. 9 | public var reason: AcmeRevokeReason? 10 | } 11 | 12 | /// Reason why we request for a certificate to be revoked. 13 | public enum AcmeRevokeReason: Int, Codable, Sendable { 14 | case unspecified = 0 15 | case keyCompromise = 1 16 | case cACompromise = 2 17 | case affiliationChanged = 3 18 | case superseded = 4 19 | case cessationOfOperation = 5 20 | case certificateHold = 6 21 | case removeFromCRL = 8 22 | case privilegeWithdrawn = 9 23 | case aACompromise = 10 24 | } 25 | -------------------------------------------------------------------------------- /Sources/AcmeSwift/Models/Order/ChallengeDescription.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct ChallengeDescription: Codable, Sendable { 4 | /// The type of challenge. 5 | /// For a wildcard certificate, there will **always** be a at least one DNS challenge, even if your preferred method is HTTP. 6 | public let type: AcmeAuthorization.Challenge.ChallengeType 7 | 8 | /// For a DNS challenge, the full DNS record name. 9 | /// For an HTTP challenge, the full URL where the challenge must be published. **Must** be simple HTTP over port 80. 10 | public let endpoint: String 11 | 12 | /// For a DNS challenge, the **TXT** record value. 13 | /// For an HTTP challenge, the exact value that the `endpoint` must return over HTTP on port 80. 14 | /// For a device-attest-01 challenge, the data to be signed. 15 | public let value: String 16 | 17 | /// The ACMEv2 server URL for validating this challenge. 18 | internal let url: URL 19 | } 20 | -------------------------------------------------------------------------------- /Sources/AcmeSwift/Models/Order/AcmeOrderSpec.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | 4 | public struct AcmeOrderSpec: Codable, Sendable { 5 | public init(identifiers: [AcmeOrderSpec.Identifier], notBefore: Date? = nil, notAfter: Date? = nil) { 6 | self.identifiers = identifiers 7 | self.notBefore = notBefore 8 | self.notAfter = notAfter 9 | } 10 | 11 | public var identifiers: [Identifier] 12 | 13 | /// The requested value of the notBefore field in the certificate. 14 | public var notBefore: Date? = nil 15 | 16 | /// The requested value of the notAfter field in the certificate. 17 | public var notAfter: Date? = nil 18 | 19 | public struct Identifier: Codable, Sendable { 20 | 21 | public var `type`: IdentifierType = .dns 22 | 23 | public var value: String 24 | 25 | public enum IdentifierType: String, Codable, Sendable { 26 | case dns 27 | case permanentIdentifier = "permanent-identifier" 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/AcmeSwift/Models/AcmeDirectory.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// The Directory endpoint of the ACMEv2 service, providing discovery information about various endpoints 4 | public struct AcmeDirectory: Codable, Sendable { 5 | public let newAuthz: URL? 6 | public let newNonce: URL 7 | 8 | /// The endpoint to call in order to create a new `Account`. 9 | public let newAccount: URL 10 | public let newOrder: URL 11 | public let revokeCert: URL 12 | public let keyChange: URL 13 | public let meta: Meta? 14 | 15 | public struct Meta: Codable, Sendable { 16 | public let termsOfService: URL? 17 | 18 | /// The web page of the ACME provider. 19 | public let website: URL? 20 | 21 | /// The Certificate Authorities that can issue certificates via the ACMEv2 provider. 22 | /// This can be used to configure your domains CAA records. 23 | public let caaIdentities: [String]? 24 | 25 | public let externalAccountRequired: Bool? 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Matthieu Barthélemy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Sources/AcmeSwift/Models/Account/AcmeAccountInfo.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import JWTKit 3 | 4 | /// Account information returned when calling `get()` or `create()`. 5 | public struct AcmeAccountInfo: Codable, Sendable { 6 | 7 | /// URL containing the ID of the Account. 8 | /// 9 | /// - Note: This URL is used to perform some account management operations. 10 | internal(set) public var url: URL? 11 | 12 | /// Information about the Account public key in JWK format. 13 | public let key: JWK? 14 | 15 | /// The PEM representation of the private key for this Account. 16 | internal(set) public var privateKeyPem: String? 17 | 18 | /// The contact entries. 19 | /// 20 | /// An array of URLs that the server can use to contact the client for issues related to this account. For example, the server may wish to notify the client about server-initiated revocation or certificate expiration. For information on supported URL schemes, see [Section 7.3](https://datatracker.ietf.org/doc/html/rfc8555#section-7.3). 21 | /// 22 | /// - SeeAlso: [RFC 8555 Automatic Certificate Management Environment (ACME) §7.1.2. Acount Objects](https://datatracker.ietf.org/doc/html/rfc8555#section-7.1.2) 23 | public let contact: [String]? 24 | 25 | /// Date when the Account was created. 26 | public let createdAt: String? 27 | 28 | /// Current status of the Account. 29 | public let status: Status 30 | 31 | /// URL to the pending orders for this account. 32 | /// - Note: No provider seems to have this fully implemented. 33 | public let orders: URL? 34 | 35 | public enum Status: String, Codable, Sendable { 36 | case valid, deactivated, revoked 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Tests/AcmeSwiftTests/MiscTemp.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import AsyncHTTPClient 3 | import NIO 4 | import Logging 5 | import Crypto 6 | import _CryptoExtras 7 | import SwiftASN1 8 | import X509 9 | 10 | @testable import AcmeSwift 11 | 12 | final class MiscTempTests: XCTestCase { 13 | func testHahi() throws { 14 | let domains = ["www.nuw.run", "nuw.run"] 15 | 16 | let p256 = P256.Signing.PrivateKey() 17 | let privateKey = Certificate.PrivateKey(p256) 18 | let commonName = domains[0] 19 | let name = try DistinguishedName { 20 | CommonName(commonName) 21 | } 22 | let extensions = try Certificate.Extensions { 23 | SubjectAlternativeNames(domains.map({ GeneralName.dnsName($0) })) 24 | } 25 | let extensionRequest = ExtensionRequest(extensions: extensions) 26 | let attributes = try CertificateSigningRequest.Attributes( 27 | [.init(extensionRequest)] 28 | ) 29 | let csr = try CertificateSigningRequest( 30 | version: .v1, 31 | subject: name, 32 | privateKey: privateKey, 33 | attributes: attributes, 34 | signatureAlgorithm: .ecdsaWithSHA256 35 | ) 36 | 37 | print("\n ECDSA CSR='\(try! csr.serializeAsPEM().pemString)'") 38 | print("\n ECDSA private key = '\(try! privateKey.serializeAsPEM().pemString)'") 39 | 40 | let p256RSA = try _CryptoExtras._RSA.Signing.PrivateKey(keySize: .bits2048) 41 | let privateKeyRSA = Certificate.PrivateKey(p256RSA) 42 | let csr2 = try CertificateSigningRequest( 43 | version: .v1, 44 | subject: name, 45 | privateKey: privateKeyRSA, 46 | attributes: attributes, 47 | signatureAlgorithm: .sha256WithRSAEncryption 48 | ) 49 | 50 | print("\n RSA CSR='\(try! csr2.serializeAsPEM().pemString)'") 51 | print("\n RSA private key = '\(try! privateKeyRSA.serializeAsPEM().pemString)'") 52 | } 53 | 54 | 55 | } 56 | -------------------------------------------------------------------------------- /Sources/AcmeSwift/Helpers/HttpClient+Decode.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import NIO 3 | import AsyncHTTPClient 4 | import Logging 5 | 6 | extension HTTPClientResponse { 7 | 8 | public enum BodyError : Swift.Error, Sendable { 9 | case noBodyData 10 | } 11 | 12 | /// Decode the response body as T using the given decoder. 13 | /// 14 | /// - parameters: 15 | /// - type: The type to decode. Must conform to Decoable. 16 | /// - decoder: The decoder used to decode the reponse body. Defaults to JSONDecoder. 17 | /// - returns: A future decoded type. 18 | /// - throws: BodyError.noBodyData when no body is found in reponse. 19 | public func decode(as type: T.Type, decoder: Decoder = JSONDecoder()) async throws -> T { 20 | try await checkStatusCode() 21 | 22 | var body = try await self.body.collect(upTo: 2 * 1024 * 1024) // 2 MB 23 | if T.self == NoBody.self || T.self == NoBody?.self { 24 | return NoBody() as! T 25 | } 26 | 27 | guard let data = body.readData(length: body.readableBytes) else { 28 | throw AcmeError.dataCorrupted("Unable to read Data from response body buffer") 29 | } 30 | if T.self == String.self { 31 | return String(decoding: data, as: UTF8.self) as! T 32 | } 33 | 34 | return try decoder.decode(type, from: Data(data)) 35 | } 36 | 37 | fileprivate func checkStatusCode() async throws { 38 | guard 200...299 ~= self.status.code else { 39 | var body = try await self.body.collect(upTo: 1 * 1024 * 1024) 40 | 41 | if let data = body.readData(length: body.readableBytes) { 42 | if let error = try? JSONDecoder().decode(AcmeResponseError.self, from: data) { 43 | throw error 44 | } 45 | throw AcmeError.errorCode(self.status.code, String(decoding: data, as: UTF8.self)) 46 | } 47 | throw AcmeError.errorCode(self.status.code, self.status.reasonPhrase) 48 | } 49 | } 50 | 51 | } 52 | 53 | public protocol Decoder { 54 | func decode(_ type: T.Type, from: Data) throws -> T where T : Decodable 55 | } 56 | extension JSONDecoder : Decoder {} 57 | -------------------------------------------------------------------------------- /Sources/AcmeSwift/Models/Order/AcmeOrderInfo.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Information returned when creating a new Order. 4 | public struct AcmeOrderInfo: Codable, Sendable { 5 | 6 | /// The URL of this Order. 7 | internal(set) public var url: URL? 8 | 9 | /// The current status of the Order. 10 | public let status: OrderStatus 11 | 12 | /// Date after which the order must be started from scratch if still not valid. 13 | public let expires: Date 14 | 15 | /// Date when the certificate requested by this Order should start being valid. 16 | public let notBefore: Date? 17 | 18 | /// Desired expiry date of the certificate requested by this Order. 19 | public let notAfter: Date? 20 | 21 | /// DNS names for which we are requesting a certificate. 22 | public let identifiers: [AcmeOrderSpec.Identifier] 23 | 24 | public let authorizations: [URL] 25 | 26 | /// URL to call once all the authorizations (challenges) have been completed. 27 | public let finalize: URL 28 | 29 | /// URL to call to obtain the certificate when the Order has been finalized and has a `valid` status. 30 | public let certificate: URL? 31 | 32 | 33 | public enum OrderStatus: String, Codable, Sendable { 34 | /// The certificate will not be issued. Consider this order process abandoned. 35 | case invalid 36 | 37 | /// The server does not believe that the client has fulfilled all the requirements. 38 | /// 39 | /// Check the "authorizations" array for entries that are still pending. 40 | case pending 41 | 42 | /// The server agrees that the requirements have been fulfilled, and is awaiting finalization. 43 | /// 44 | /// Submit a finalization request. 45 | case ready 46 | 47 | /// The certificate is being issued (`finalize()` has been called). 48 | /// 49 | /// Send a POST-as-GET request after the time given in the Retry-After header field of the response, if any. 50 | case processing 51 | 52 | /// The server has issued the certificate and provisioned its URL to the "certificate" field of the order. 53 | /// 54 | /// Download the certificate. 55 | case valid 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.7 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "AcmeSwift", 8 | platforms: [.macOS(.v13)], 9 | products: [ 10 | // Products define the executables and libraries a package produces, and make them visible to other packages. 11 | .library( 12 | name: "AcmeSwift", 13 | targets: ["AcmeSwift"]), 14 | ], 15 | dependencies: [ 16 | .package(url: "https://github.com/swift-server/async-http-client.git", from: "1.19.0"), 17 | .package(url: "https://github.com/apple/swift-crypto.git", "2.1.0" ..< "4.0.0"), 18 | .package(url: "https://github.com/vapor/jwt-kit.git", "4.13.1" ..< "6.0.0"), 19 | .package(url: "https://github.com/apple/swift-certificates.git", from: "1.2.0"), 20 | .package(url: "https://github.com/apple/swift-asn1.git", from: "1.1.0"), 21 | .package(url: "https://github.com/apple/swift-argument-parser", from: "1.0.0"), 22 | ], 23 | targets: [ 24 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 25 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 26 | .target( 27 | name: "AcmeSwift", 28 | dependencies: [ 29 | .product(name: "AsyncHTTPClient", package: "async-http-client"), 30 | .product(name: "Crypto", package: "swift-crypto"), 31 | .product(name: "_CryptoExtras", package: "swift-crypto"), 32 | .product(name: "JWTKit", package: "jwt-kit"), 33 | .product(name: "X509", package: "swift-certificates"), 34 | .product(name: "SwiftASN1", package: "swift-asn1") 35 | ]), 36 | .testTarget( 37 | name: "AcmeSwiftTests", 38 | dependencies: [ 39 | "AcmeSwift" 40 | ]), 41 | .executableTarget( 42 | name: "acme-da", 43 | dependencies: [ 44 | "AcmeSwift", 45 | .product(name: "ArgumentParser", package: "swift-argument-parser"), 46 | ], 47 | path: "Examples/acme-da", 48 | ), 49 | ] 50 | ) 51 | -------------------------------------------------------------------------------- /Sources/AcmeSwift/APIs/AcmeSwift+Csr.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Crypto 3 | 4 | extension AcmeSwift { 5 | /// APIs related to CSRs. 6 | public var csr: CsrAPI { 7 | .init(client: self) 8 | } 9 | 10 | public struct CsrAPI { 11 | fileprivate var client: AcmeSwift 12 | 13 | /// Downloads the certificate chain for a finalized Order. 14 | /// The certificates are returned a a list of PEM strings. 15 | /// The first item is the final certificate for the domain. 16 | /// The second item, if any, is the issuer certificate. 17 | public func rsa(`for` order: AcmeOrderInfo) async throws -> [String] { 18 | try await self.client.ensureLoggedIn() 19 | 20 | guard order.status == .valid, let certURL = order.certificate else { 21 | throw AcmeError.certificateNotReady(order.status, "Order must have a `valid` status. Some challenges might not have been completed yet") 22 | } 23 | 24 | let separator = "-----END CERTIFICATE-----\n" 25 | let ep = DownloadCertificateEndpoint(certURL: certURL) 26 | let (certificateChain, _) = try await self.client.run(ep, privateKey: self.client.login!.key, accountURL: client.accountURL!) 27 | var certificates: [String] = [] 28 | for certificate in certificateChain.components(separatedBy: separator) { 29 | if certificate != "" { 30 | certificates.append("\(certificate)\(separator)".trimmingCharacters(in: .newlines)) 31 | } 32 | } 33 | return certificates 34 | } 35 | 36 | /// Revokes a previously issued certificate. 37 | /// - Parameters: 38 | /// - certificatePem: The Certificate **in PEM format**. 39 | public func ecdsa(certificatePem: String, reason: AcmeRevokeReason? = nil) async throws { 40 | try await self.client.ensureLoggedIn() 41 | 42 | let csrBytes = certificatePem.pemToData() 43 | let pemStr = csrBytes.toBase64UrlString() 44 | 45 | let ep = RevokeCertificateEndpoint( 46 | directory: self.client.directory, 47 | spec: .init(certificate: pemStr, reason: reason) 48 | ) 49 | let (_, _) = try await self.client.run(ep, privateKey: self.client.login!.key, accountURL: client.accountURL!) 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Sources/AcmeSwift/APIs/AcmeSwift+Certificates.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Crypto 3 | 4 | extension AcmeSwift { 5 | /// APIs related to ACMEv2 certificates management. 6 | public var certificates: CertificatesAPI { 7 | .init(client: self) 8 | } 9 | 10 | public struct CertificatesAPI { 11 | fileprivate var client: AcmeSwift 12 | 13 | /// Downloads the certificate chain for a finalized Order. 14 | /// The certificates are returned a a list of PEM strings. 15 | /// The first item is the final certificate for the domain. 16 | /// The second item, if any, is the issuer certificate. 17 | public func download(`for` order: AcmeOrderInfo) async throws -> [String] { 18 | try await self.client.ensureLoggedIn() 19 | 20 | guard order.status == .valid, let certURL = order.certificate else { 21 | throw AcmeError.certificateNotReady(order.status, "Order must have a `valid` status. Some challenges might not have been completed yet") 22 | } 23 | 24 | let separator = "-----END CERTIFICATE-----\n" 25 | let ep = DownloadCertificateEndpoint(certURL: certURL) 26 | let (certificateChain, _) = try await self.client.run(ep, privateKey: self.client.login!.key, accountURL: client.accountURL!) 27 | var certificates: [String] = [] 28 | for certificate in certificateChain.components(separatedBy: separator) { 29 | if certificate != "" { 30 | certificates.append("\(certificate)\(separator)".trimmingCharacters(in: .newlines)) 31 | } 32 | } 33 | return certificates 34 | } 35 | 36 | /// Revokes a previously issued certificate. 37 | /// - Parameters: 38 | /// - certificatePem: The Certificate **in PEM format**. 39 | public func revoke(certificatePem: String, reason: AcmeRevokeReason? = nil) async throws { 40 | try await self.client.ensureLoggedIn() 41 | 42 | let csrBytes = certificatePem.pemToData() 43 | let pemStr = csrBytes.toBase64UrlString() 44 | 45 | let ep = RevokeCertificateEndpoint( 46 | directory: self.client.directory, 47 | spec: .init(certificate: pemStr, reason: reason) 48 | ) 49 | let (_, _) = try await self.client.run(ep, privateKey: self.client.login!.key, accountURL: client.accountURL!) 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Tests/AcmeSwiftTests/AccountTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import AsyncHTTPClient 3 | import NIO 4 | import Logging 5 | 6 | @testable import AcmeSwift 7 | 8 | final class AccountTests: XCTestCase { 9 | var logger: Logger! 10 | var http: HTTPClient! 11 | 12 | override func setUp() async throws { 13 | self.logger = Logger.init(label: "acme-swift-tests") 14 | self.logger.logLevel = .trace 15 | 16 | let config = HTTPClient.Configuration(certificateVerification: .fullVerification, backgroundActivityLogger: self.logger) 17 | self.http = HTTPClient( 18 | eventLoopGroupProvider: .singleton, 19 | configuration: config 20 | ) 21 | } 22 | 23 | func testCreateAndDeactivateAccount() async throws { 24 | let acme = try await AcmeSwift(client: self.http, acmeEndpoint: .letsEncryptStaging, logger: logger) 25 | defer {try? acme.syncShutdown()} 26 | 27 | let account = try await acme.account.create(contacts: ["bonsouere3456@gmail.com", "bonsouere+299@gmail.com"], acceptTOS: true) 28 | try acme.account.use(account) 29 | try await acme.account.deactivate() 30 | } 31 | 32 | func testGetAccount() async throws { 33 | // TODO: pass the key as a secret/env var 34 | let privateKeyPem = """ 35 | -----BEGIN PRIVATE KEY----- 36 | MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQglxrdsu3lP83xzUej 37 | ytJ7zvy2uuW3Qt7SWGRiGx8dJJuhRANCAARcpivMPbQWA/T2h8YNQPgOF+8jhyaY 38 | iO6kepubzBqqgk/iub3w+ZBDfKi6RgGYX2yVRlHMS4ZhhSoFFLoP57eI 39 | -----END PRIVATE KEY----- 40 | """ 41 | let contacts = ["mailto:bonsouere3456@gmail.com"] 42 | 43 | let login = try AccountCredentials(contacts: contacts, pemKey: privateKeyPem) 44 | let acme = try await AcmeSwift(client: self.http, acmeEndpoint: .letsEncryptStaging, logger: logger) 45 | defer {try? acme.syncShutdown()} 46 | 47 | try acme.account.use(login) 48 | let account = try await acme.account.get() 49 | XCTAssert(account.privateKeyPem != "", "Ensure private key is set") 50 | XCTAssert(account.contact == contacts, "Ensure Account contacts are set") 51 | } 52 | 53 | func testGetNonce() async throws { 54 | let acme = try await AcmeSwift(client: self.http, acmeEndpoint: .letsEncryptStaging, logger: logger) 55 | defer {try? acme.syncShutdown()} 56 | let nonce = try await acme.getNonce() 57 | XCTAssert(nonce != "", "ensure Nonce is set") 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Sources/AcmeSwift/Helpers/String+base64Url.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension String { 4 | 5 | /// Decodes a Base64 string into a decoded string. 6 | func fromBase64Url() -> String? { 7 | var base64 = self 8 | base64 = base64.replacingOccurrences(of: "-", with: "+") 9 | base64 = base64.replacingOccurrences(of: "_", with: "/") 10 | while base64.count % 4 != 0 { 11 | base64 = base64.appending("=") 12 | } 13 | guard let data = Data(base64Encoded: base64) 14 | else { return nil } 15 | 16 | return String(decoding: data, as: UTF8.self) 17 | } 18 | 19 | /// Encodes the string as a Base64 string suitable for use as URL parameters. 20 | @usableFromInline 21 | func toBase64Url() -> String { 22 | return Data(self.utf8) 23 | .base64EncodedString() 24 | .base64ToBase64Url() 25 | } 26 | 27 | /// Converts a Base64 string to one suitable for use as URL parameters. 28 | @usableFromInline 29 | func base64ToBase64Url() -> String { 30 | return self 31 | .replacingOccurrences(of: "+", with: "-") 32 | .replacingOccurrences(of: "/", with: "_") 33 | .replacingOccurrences(of: "=", with: "") 34 | } 35 | 36 | /// Converts a PEM certificate or key into a Base64 string suitable for use as URL parameters. 37 | func pemToBase64Url() -> String { 38 | return self.replacingOccurrences(of: "-----BEGIN CERTIFICATE REQUEST-----", with: "") 39 | .replacingOccurrences(of: "-----BEGIN CERTIFICATE-----", with: "") 40 | .replacingOccurrences(of: "-----END CERTIFICATE REQUEST-----", with: "") 41 | .replacingOccurrences(of: "-----END CERTIFICATE-----", with: "") 42 | .replacingOccurrences(of: "\n", with: "") 43 | .trimmingCharacters(in: .whitespacesAndNewlines) 44 | .toBase64Url() 45 | } 46 | 47 | /// Converts a PEM certificate or key back to DER. 48 | func pemToData() -> Data { 49 | let rawData = self.replacingOccurrences(of: "-----BEGIN CERTIFICATE REQUEST-----", with: "") 50 | .replacingOccurrences(of: "-----BEGIN CERTIFICATE-----", with: "") 51 | .replacingOccurrences(of: "-----END CERTIFICATE REQUEST-----", with: "") 52 | .replacingOccurrences(of: "-----END CERTIFICATE-----", with: "") 53 | .replacingOccurrences(of: "\n", with: "") 54 | .trimmingCharacters(in: .whitespacesAndNewlines) 55 | return Data(base64Encoded: rawData)! 56 | } 57 | } 58 | 59 | extension Data { 60 | func toBase64UrlString() -> String { 61 | return self.base64EncodedString().base64ToBase64Url() 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Sources/AcmeSwift/Models/Order/AcmeAuthorization.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct AcmeAuthorization: Codable, Sendable { 4 | public let status: AuthorizationStatus 5 | 6 | /// The timestamp after which the server will consider this authorization invalid 7 | public let expires: Date? 8 | 9 | public let identifier: AcmeOrderSpec.Identifier 10 | 11 | public let challenges: [Challenge] 12 | 13 | /// Present and `true` if the current authorization is for a domain for which a wildcard certificate was requested. 14 | public let wildcard: Bool? 15 | 16 | public enum AuthorizationStatus: String, Codable, Sendable { 17 | /// Initial status when the authorization is created. 18 | case pending 19 | 20 | /// A challenge listed in the authorization was validated successfully. 21 | case valid 22 | 23 | case invalid 24 | 25 | /// Deactivated by the client. 26 | case deactivated 27 | 28 | case expired 29 | 30 | /// Revoked by the ACMEv2 server. 31 | case revoked 32 | } 33 | 34 | public struct Challenge: Codable, Sendable { 35 | /// The URL to which a response can be posted 36 | public let url: URL 37 | 38 | /// The type of challenge 39 | public let `type`: ChallengeType 40 | 41 | /// The status of this challenge 42 | public let status: ChallengeStatus 43 | 44 | 45 | public let token: String 46 | 47 | /// The time at which the server validated this challenge. 48 | public let validated: Date? 49 | 50 | /// Error that occurred while the server was validating the challenge 51 | public let error: AcmeResponseError? 52 | 53 | public enum ChallengeType: String, Codable, Sendable { 54 | //// A HTTP challenge that requires publishing the contents of a challenge at a specific URL to prove ownership of the domain record. 55 | case http = "http-01" 56 | 57 | /// A DNS challenge requiring the creation of TXT records to prove ownership of a domain or record. 58 | case dns = "dns-01" 59 | 60 | /// A TLS-ALPN-01 challenge. 61 | case alpn = "tls-alpn-01" 62 | 63 | /// A device attestation challenge, see https://datatracker.ietf.org/doc/draft-acme-device-attest/ 64 | case deviceAttest = "device-attest-01" 65 | } 66 | 67 | public enum ChallengeStatus: String, Codable, Sendable { 68 | case pending 69 | case processing 70 | case valid 71 | case invalid 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Sources/AcmeSwift/Helpers/AcmeRequestBody.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Crypto 3 | import JWTKit 4 | 5 | /* 6 | Example request body: 7 | { 8 | "protected": base64url({ 9 | "alg": "ES256", 10 | "jwk": {...}, 11 | "nonce": "6S8IqOGY7eL2lsGoTZYifg", 12 | "url": "https://example.com/acme/new-account" 13 | }), 14 | "payload": base64url({ 15 | "termsOfServiceAgreed": true, 16 | "contact": [ 17 | "mailto:cert-admin@example.org", 18 | "mailto:admin@example.org" 19 | ] 20 | }), 21 | "signature": "RZPOnYoPs1PhjszF...-nh6X1qtOFPB519I" 22 | } 23 | */ 24 | 25 | /// All requests to the ACMEv2 server must have their body wrapped into a custom JWS format 26 | struct AcmeRequestBody: Encodable { 27 | var protected: ProtectedHeader 28 | 29 | var payload: T.Body 30 | 31 | private var signature: String = "" 32 | 33 | private var privateKey: Crypto.P256.Signing.PrivateKey 34 | 35 | enum CodingKeys: String, CodingKey { 36 | case protected 37 | case payload 38 | case signature 39 | } 40 | 41 | struct ProtectedHeader: Codable { 42 | internal init(alg: Algorithm = .es256, jwk: JWK? = nil, kid: URL? = nil, nonce: String, url: URL) { 43 | self.alg = alg 44 | self.jwk = jwk 45 | self.kid = kid 46 | self.nonce = nonce 47 | self.url = url 48 | } 49 | 50 | var alg: Algorithm = .es256 51 | var jwk: JWK? 52 | var kid: URL? 53 | var nonce: String 54 | var url: URL 55 | 56 | enum Algorithm: String, Codable { 57 | case es256 = "ES256" 58 | } 59 | 60 | } 61 | 62 | init(accountURL: URL? = nil, privateKey: Crypto.P256.Signing.PrivateKey, nonce: String, payload: T) throws { 63 | self.privateKey = privateKey 64 | let publicKey = privateKey.publicKey.rawRepresentation 65 | 66 | self.protected = .init( 67 | alg: .es256, 68 | jwk: accountURL == nil ? JWK.ecdsa( 69 | nil, 70 | identifier: nil, 71 | x: publicKey.prefix(publicKey.count/2).toBase64UrlString(), 72 | y: publicKey.suffix(publicKey.count/2).toBase64UrlString(), 73 | curve: .p256 74 | ) : nil, 75 | kid: accountURL, 76 | nonce: nonce, 77 | url: payload.url 78 | ) 79 | self.payload = payload.body ?? (NoBody.init() as! T.Body) 80 | } 81 | 82 | /// Encode as a JWT as described in ACMEv2 (RFC 8555). 83 | func encode(to encoder: Encoder) throws { 84 | let jsonEncoder = JSONEncoder() 85 | jsonEncoder.dateEncodingStrategy = .iso8601 86 | jsonEncoder.outputFormatting = .sortedKeys 87 | 88 | var container = encoder.container(keyedBy: CodingKeys.self) 89 | 90 | let protectedData = try jsonEncoder.encode(self.protected) 91 | let protectedJSON = String(decoding: protectedData, as: UTF8.self) 92 | let protectedBase64 = protectedJSON.toBase64Url() 93 | try container.encode(protectedBase64, forKey: .protected) 94 | 95 | let payloadData = try jsonEncoder.encode(self.payload) 96 | let payloadJSON = String(decoding: payloadData, as: UTF8.self) 97 | 98 | // Empty payload is required most of the time for so-called POST-AS-GET ACMEv2 requests. 99 | let payloadBase64 = payloadJSON == "\"\"" ? "" : payloadJSON.toBase64Url() 100 | try container.encode(payloadBase64, forKey: .payload) 101 | 102 | let signedString = "\(protectedBase64).\(payloadBase64)" 103 | let signature = try self.privateKey.signature(for: Data(signedString.utf8)) 104 | let signatureData = signature.rawRepresentation 105 | 106 | let signatureBase64 = signatureData.toBase64UrlString() 107 | try container.encode(signatureBase64, forKey: .signature) 108 | } 109 | } 110 | 111 | /// For requests that have an empty body. 112 | struct NoBody: Codable { 113 | init(){} 114 | } 115 | -------------------------------------------------------------------------------- /Sources/AcmeSwift/APIs/AcmeSwift+Account.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Crypto 3 | 4 | extension AcmeSwift { 5 | 6 | /// APIs related to ACMEv2 account management. 7 | public var account: AccountAPI { 8 | .init(client: self) 9 | } 10 | 11 | public struct AccountAPI { 12 | fileprivate var client: AcmeSwift 13 | 14 | /// Gets an existing account on the ACMEv2 provider. 15 | /// - Parameters: 16 | /// - contacts: Email addresses of the contact points for this account. 17 | /// - Throws: Errors that can occur when executing the request. 18 | /// - Returns: Returns the `Account`. 19 | public func get() async throws -> AcmeAccountInfo { 20 | guard let login = self.client.login else { 21 | throw AcmeError.mustBeAuthenticated("\(AcmeSwift.self).init() must be called with an \(AccountCredentials.self)") 22 | } 23 | let ep = CreateAccountEndpoint( 24 | directory: self.client.directory, 25 | spec: .init( 26 | contact: login.contacts, 27 | termsOfServiceAgreed: true, 28 | onlyReturnExisting: true, 29 | externalAccountBinding: nil 30 | ) 31 | ) 32 | 33 | var (info, headers) = try await self.client.run(ep, privateKey: login.key) 34 | info.privateKeyPem = login.key.pemRepresentation 35 | info.url = URL(string: headers["Location"].first ?? "") 36 | return info 37 | } 38 | 39 | /// Creates a new account on the ACMEv2 provider. 40 | /// - Parameters: 41 | /// - contacts: Email addresses of the contact points for this account. 42 | /// - acceptTOS: Automatically accept the ACMEv2 provider Terms Of Service. 43 | /// - Throws: Errors that can occur when executing the request. 44 | /// - Returns: Returns an `Account` that can be saves for future connections. 45 | public func create(contacts: [String], acceptTOS: Bool) async throws -> AcmeAccountInfo { 46 | let contactsWithSchema = contacts.map { $0.contains(":") ? $0 : "mailto:\($0)" } 47 | 48 | // Create private key 49 | let privateKey = Crypto.P256.Signing.PrivateKey.init(compactRepresentable: true) 50 | 51 | let ep = CreateAccountEndpoint( 52 | directory: self.client.directory, 53 | spec: .init( 54 | contact: contactsWithSchema, 55 | termsOfServiceAgreed: acceptTOS, 56 | onlyReturnExisting: false, 57 | externalAccountBinding: nil 58 | ) 59 | ) 60 | 61 | var (info, headers) = try await self.client.run(ep, privateKey: privateKey) 62 | info.privateKeyPem = privateKey.pemRepresentation 63 | info.url = URL(string: headers["Location"].first ?? "") 64 | return info 65 | } 66 | 67 | /// Use an existing Account for the ACMEv2 provider 68 | public func use(_ account: AcmeAccountInfo) throws { 69 | guard let privateKey = account.privateKeyPem else { 70 | throw AcmeError.invalidAccountInfo 71 | } 72 | self.client.login = try .init( 73 | contacts: account.contact ?? [], 74 | pemKey: privateKey 75 | ) 76 | self.client.accountURL = account.url 77 | } 78 | 79 | /// Use an existing Account for the ACMEv2 provider 80 | public func use(_ credentials: AccountCredentials) throws { 81 | self.client.login = .init( 82 | contacts: credentials.contacts, 83 | key: credentials.key 84 | ) 85 | } 86 | 87 | /*public func update() async throws { 88 | guard let login = self.client.login else { 89 | throw AcmeError.mustBeAuthenticated("\(AcmeSwift.self).init() must be called with an \(AccountCredentials.self)") 90 | } 91 | }*/ 92 | 93 | /// Deactivate an ACME Account/. 94 | /// Certificates issued by the account prior to deactivation will normally not be revoked. 95 | /// WARNING: ACME does not provide a way to reactivate a deactivated account. 96 | public func deactivate() async throws { 97 | try await self.client.ensureLoggedIn() 98 | let ep = DeactivateAccountEndpoint(accountURL: client.accountURL!) 99 | let (info, _) = try await self.client.run(ep, privateKey: self.client.login!.key, accountURL: client.accountURL!) 100 | guard info.status == .deactivated else { 101 | throw AcmeError.deactivateFailed 102 | } 103 | } 104 | } 105 | 106 | } 107 | -------------------------------------------------------------------------------- /Sources/AcmeSwift/Models/AcmeError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum AcmeError: Error, Sendable { 4 | /// This Account information has no private key 5 | case invalidAccountInfo 6 | 7 | /// You need to call `account.use()` before performing this operation 8 | case mustBeAuthenticated(String) 9 | 10 | /// Deactivating the account failed, account still active 11 | case deactivateFailed 12 | 13 | /// The Order is not in a state that allows downloading the certificate. 14 | /// It can be invalid, or some challenges have not yet been completed 15 | case certificateNotReady(AcmeOrderInfo.OrderStatus, String) 16 | 17 | /// No nonce (anti-replay) value was returned by the endpoint 18 | case noNonceReturned 19 | 20 | case dataCorrupted(String) 21 | case errorCode(UInt, String) 22 | 23 | /// A resource should have a URL, returned in a response "Location" header, but couldn't find or parse the header. 24 | case noResourceUrl 25 | 26 | case noDomains(String) 27 | } 28 | 29 | public struct AcmeResponseError: Codable, Error, Sendable { 30 | public let type: AcmeErrorType 31 | 32 | public let title: String? 33 | 34 | public let detail: String 35 | 36 | public let instance: String? 37 | 38 | public let identifier: ErrorIdentifier? 39 | 40 | public let subproblems: [AcmeResponseError]? 41 | 42 | public enum AcmeErrorType: String, Codable, Error, Sendable { 43 | /// The request message was malformed 44 | case malformed = "urn:ietf:params:acme:error:malformed" 45 | 46 | /// The server will not issue certificates for the identifier 47 | case rejectedIdentifier = "urn:ietf:params:acme:error:rejectedIdentifier" 48 | 49 | /// The request specified an account that does not exist 50 | case accountDoesNotExist = "urn:ietf:params:acme:error:accountDoesNotExist" 51 | 52 | /// The request specified a certificate to be revoked that has already been revoked 53 | case alreadyRevoked = "urn:ietf:params:acme:error:alreadyRevoked" 54 | 55 | /// The CSR is unacceptable (e.g., due to a short key) 56 | case badCSR = "urn:ietf:params:acme:error:badCSR" 57 | 58 | /// The client sent an unacceptable anti-replay nonce 59 | case badNonce = "urn:ietf:params:acme:error:badNonce" 60 | 61 | /// The JWS was signed by a public key the server does not support 62 | case badPublicKey = "urn:ietf:params:acme:error:badPublicKey" 63 | 64 | /// The revocation reason provided is not allowed by the server 65 | case badRevocationReason = "urn:ietf:params:acme:error:badRevocationReason" 66 | 67 | /// The JWS was signed with an algorithm the server does not support 68 | case badSignatureAlgorithm = "urn:ietf:params:acme:error:badSignatureAlgorithm" 69 | 70 | /// Certification Authority Authorization (CAA) records forbid the CA from issuing a certificate 71 | case caa = "urn:ietf:params:acme:error:caa" 72 | 73 | /// Specific error conditions are indicated in the `subproblems` array 74 | case compound = "urn:ietf:params:acme:error:compound" 75 | 76 | /// The server could not connect to the validation target 77 | case connection = "urn:ietf:params:acme:error:connection" 78 | 79 | /// There was a problem with a DNS query during identifier validation 80 | case dns = "urn:ietf:params:acme:error:dns" 81 | 82 | /// The request must include a value for the "externalAccountBinding" field 83 | case externalAccountRequired = "urn:ietf:params:acme:error:externalAccountRequired" 84 | 85 | /// Response received didn't match the challenge's requirements 86 | case incorrectResponse = "urn:ietf:params:acme:error:incorrectResponse" 87 | 88 | /// A contact URL for an account was invalid 89 | case invalidContact = "urn:ietf:params:acme:error:invalidContact" 90 | 91 | /// A contact URL for an account was invalid. 92 | /// Specific to Let's Encrypt (Boulder) 93 | case invalidEmail = "urn:ietf:params:acme:error:invalidEmail" 94 | 95 | /// A contact URL for an account used an unsupported protocol scheme 96 | case unsupportedContact = "urn:ietf:params:acme:error:unsupportedContact" 97 | 98 | /// The request attempted to finalize an order that is not ready to be finalized 99 | case orderNotReady = "urn:ietf:params:acme:error:orderNotReady" 100 | 101 | /// The request exceeds a rate limit 102 | case rateLimited = "urn:ietf:params:acme:error:rateLimited" 103 | 104 | /// The server experienced an internal error 105 | case serverInternal = "urn:ietf:params:acme:error:serverInternal" 106 | 107 | /// The server received a TLS error during validation 108 | case tls = "urn:ietf:params:acme:error:tls" 109 | 110 | /// The client lacks sufficient authorization 111 | case unauthorized = "urn:ietf:params:acme:error:unauthorized" 112 | 113 | /// An identifier is of an unsupported type 114 | case unsupportedIdentifier = "urn:ietf:params:acme:error:unsupportedIdentifier" 115 | 116 | /// Visit the "instance" URL and take actions specified there 117 | case userActionRequired = "urn:ietf:params:acme:error:userActionRequired" 118 | } 119 | 120 | public struct ErrorIdentifier: Codable, Sendable { 121 | public let type: String 122 | public let value: String 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /Sources/AcmeSwift/APIs/AcmeSwift.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import NIO 3 | import NIOHTTP1 4 | import NIOSSL 5 | import AsyncHTTPClient 6 | import Logging 7 | import Crypto 8 | 9 | /// The entry point for Acmev2 client commands. 10 | public class AcmeSwift { 11 | /// Information about the endpoints of the ACMEv2 server 12 | public let directory: AcmeDirectory 13 | 14 | private let headers = HTTPHeaders([ 15 | ("User-Agent", "AcmeSwift (https://github.com/m-barthelemy/AcmeSwift)"), 16 | ("Content-Type", "application/jose+json") 17 | ]) 18 | 19 | internal var login: AccountCredentials? 20 | internal var accountURL: URL? 21 | 22 | internal let server: URL 23 | internal let client: HTTPClient 24 | private let logger: Logger 25 | private let decoder: JSONDecoder 26 | 27 | public init(client: HTTPClient = .init(eventLoopGroupProvider: .shared(MultiThreadedEventLoopGroup.singleton)), acmeEndpoint: AcmeEndpoint = .letsEncrypt, logger: Logger = Logger.init(label: "AcmeSwift")) async throws { 28 | self.client = client 29 | self.server = acmeEndpoint.value 30 | self.logger = logger 31 | 32 | let decoder = JSONDecoder() 33 | decoder.dateDecodingStrategy = .iso8601 34 | self.decoder = decoder 35 | 36 | var request = HTTPClientRequest(url: self.server.absoluteString) 37 | request.method = .GET 38 | self.directory = try await self.client.execute(request, deadline: .now() + TimeAmount.seconds(30), logger: self.logger) 39 | .decode(as: AcmeDirectory.self) 40 | } 41 | 42 | /// The client needs to be shutdown otherwise it can crash on exit. 43 | public func syncShutdown() throws { 44 | try client.syncShutdown() 45 | } 46 | 47 | /// Gets a Nonce (anti-replay) to include in an upcoming POST request 48 | internal func getNonce() async throws -> String { 49 | var nonce = HTTPClientRequest(url: self.directory.newNonce.absoluteString) 50 | nonce.method = .HEAD 51 | let response = try await self.client.execute(nonce, deadline: .now() + TimeAmount.seconds(15)) 52 | guard let nonce = response.headers["Replay-Nonce"].first else { 53 | throw AcmeError.noNonceReturned 54 | } 55 | return nonce 56 | } 57 | 58 | /// Ensure we have account credentials for actions that require it. 59 | internal func ensureLoggedIn() async throws { 60 | guard self.login != nil else { 61 | throw AcmeError.mustBeAuthenticated("Request requires credentials") 62 | } 63 | 64 | if self.accountURL == nil { 65 | let info = try await self.account.get() 66 | self.accountURL = info.url 67 | } 68 | } 69 | 70 | /// Returns the host header for the given URL, including port if not default. 71 | internal func getHostHeader(url: URL) -> String { 72 | var host = url.host ?? "localhost" 73 | if let port = url.port, port != 443 { 74 | host += ":\(port)" 75 | } 76 | return host 77 | } 78 | 79 | /// Executes a request to a specific endpoint. The `Endpoint` struct provides all necessary data and parameters for the request. 80 | /// - Parameter endpoint: `Endpoint` instance with all necessary data and parameters. 81 | /// - Throws: It can throw an error when encoding the body of the `Endpoint` request to JSON. 82 | /// - Returns: Returns the expected result defined by the `Endpoint`. 83 | @discardableResult 84 | internal func run(_ endpoint: T, privateKey: Crypto.P256.Signing.PrivateKey, accountURL: URL? = nil) async throws -> (result: T.Response, headers: HTTPHeaders) { 85 | logger.debug("\(Self.self) execute Endpoint \(T.self): \(endpoint.method) \(endpoint.url)") 86 | 87 | var finalHeaders: HTTPHeaders = .init() 88 | finalHeaders.add(name: "Host", value: getHostHeader(url: endpoint.url)) 89 | finalHeaders.add(contentsOf: self.headers) 90 | if let additionalHeaders = endpoint.headers { 91 | finalHeaders.add(contentsOf: additionalHeaders) 92 | } 93 | 94 | var request = HTTPClientRequest(url: endpoint.url.absoluteString) 95 | request.method = endpoint.method 96 | request.headers = finalHeaders 97 | 98 | let nonce = try await self.getNonce() 99 | 100 | let wrappedBody = try AcmeRequestBody(accountURL: accountURL, privateKey: privateKey, nonce: nonce, payload: endpoint) 101 | let body = try JSONEncoder().encode(wrappedBody) 102 | 103 | let bodyDebug = String(decoding: body, as: UTF8.self) 104 | logger.debug("\(Self.self) Endpoint: \(endpoint.method) \(endpoint.url) request body: \(bodyDebug)") 105 | 106 | request.body = .bytes(ByteBuffer(data: body)) 107 | 108 | let response = try await client.execute(request, deadline: .now() + TimeAmount.seconds(15), logger: self.logger) 109 | 110 | return (result: try await response.decode(as: T.Response.self, decoder: self.decoder), headers: response.headers) 111 | } 112 | } 113 | 114 | public enum AcmeEndpoint: Sendable { 115 | /// The default, production Let's Encrypt endpoint 116 | case letsEncrypt 117 | /// The staging Let's Encrypt endpoint, for tests. Issues certificate not recognized by clients/browsers 118 | case letsEncryptStaging 119 | /// A custom URL to a service compatible with the ACMEv2 protocol 120 | case custom(URL) 121 | 122 | public var value: URL { 123 | switch self { 124 | case .letsEncrypt: return URL(string: "https://acme-v02.api.letsencrypt.org/directory")! 125 | case .letsEncryptStaging: return URL(string: "https://acme-staging-v02.api.letsencrypt.org/directory")! 126 | case .custom(let url): return url 127 | } 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /Tests/AcmeSwiftTests/OrderTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import AsyncHTTPClient 3 | import NIO 4 | import Logging 5 | import SwiftASN1 6 | import X509 7 | import Crypto 8 | 9 | @testable import AcmeSwift 10 | 11 | final class OrderTests: XCTestCase { 12 | var logger: Logger! 13 | var http: HTTPClient! 14 | 15 | override func setUp() async throws { 16 | self.logger = Logger.init(label: "acme-swift-tests") 17 | self.logger.logLevel = .trace 18 | 19 | let config = HTTPClient.Configuration(certificateVerification: .fullVerification, backgroundActivityLogger: self.logger) 20 | self.http = HTTPClient( 21 | eventLoopGroupProvider: .singleton, 22 | configuration: config 23 | ) 24 | } 25 | 26 | func testCreateOrder() async throws { 27 | let acme = try await AcmeSwift(client: self.http, acmeEndpoint: .letsEncryptStaging, logger: logger) 28 | defer {try? acme.syncShutdown()} 29 | do { 30 | let privateKeyPem = """ 31 | -----BEGIN PRIVATE KEY----- 32 | MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQglxrdsu3lP83xzUej 33 | ytJ7zvy2uuW3Qt7SWGRiGx8dJJuhRANCAARcpivMPbQWA/T2h8YNQPgOF+8jhyaY 34 | iO6kepubzBqqgk/iub3w+ZBDfKi6RgGYX2yVRlHMS4ZhhSoFFLoP57eI 35 | -----END PRIVATE KEY----- 36 | """ 37 | let contacts = ["mailto:bonsouere3456@gmail.com"] 38 | 39 | let login = try AccountCredentials(contacts: contacts, pemKey: privateKeyPem) 40 | let acme = try await AcmeSwift(client: self.http, acmeEndpoint: .letsEncryptStaging, logger: logger) 41 | defer {try? acme.syncShutdown()} 42 | 43 | try acme.account.use(login) 44 | 45 | var order = try await acme.orders.create(domains: ["burrito.run", "www.burrito.run"]) 46 | XCTAssert(order.url != nil, "Ensure order has URL") 47 | XCTAssert(order.status == .pending, "Ensure order is pending (got \(order.status)") 48 | XCTAssert(order.expires > Date(), "Ensure order expiry is parsed (got \(order.expires)") 49 | XCTAssert(order.identifiers.count == 2, "Ensure identifiers match number of requested domains (expected 2, got \(order.identifiers.count)") 50 | 51 | let authorizations = try await acme.orders.getAuthorizations(from: order) 52 | XCTAssert(authorizations.count == 2, "Ensure we only have 1 authorization") 53 | 54 | let challengeDescriptions = try await acme.orders.describePendingChallenges(from: order, preferring: .dns) 55 | XCTAssert(challengeDescriptions.count == 2, "Ensure we have 1 pending challenge") 56 | XCTAssert(challengeDescriptions.filter({$0.type == .dns}).count == 2, "Ensure challenges are of the desired type") 57 | 58 | try await acme.orders.refresh(&order) 59 | 60 | } 61 | catch(let error) { 62 | print("\n•••• BOOM! \(error)") 63 | throw error 64 | } 65 | } 66 | 67 | func testWrapItUpLikeABurrito() async throws { 68 | let privateKeyPem = """ 69 | -----BEGIN PRIVATE KEY----- 70 | MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQglxrdsu3lP83xzUej 71 | ytJ7zvy2uuW3Qt7SWGRiGx8dJJuhRANCAARcpivMPbQWA/T2h8YNQPgOF+8jhyaY 72 | iO6kepubzBqqgk/iub3w+ZBDfKi6RgGYX2yVRlHMS4ZhhSoFFLoP57eI 73 | -----END PRIVATE KEY----- 74 | """ 75 | let contacts = ["mailto:bonsouere3456@gmail.com"] 76 | 77 | let login = try AccountCredentials(contacts: contacts, pemKey: privateKeyPem) 78 | let acme = try await AcmeSwift(client: self.http, acmeEndpoint: .letsEncryptStaging, logger: logger) 79 | defer {try? acme.syncShutdown()} 80 | 81 | try acme.account.use(login) 82 | let domains = ["www.nuw.run"] 83 | 84 | do { 85 | var order = try await acme.orders.create(domains: domains) 86 | //try await Task.sleep(nanoseconds: 60_000_000_000) 87 | for desc in try await acme.orders.describePendingChallenges(from: order, preferring: .dns) { 88 | if desc.type == .http { 89 | print("\n • The URL \(desc.endpoint) needs to return \(desc.value)") 90 | } 91 | else if desc.type == .dns { 92 | print("\n • Create the following DNS record: \(desc.endpoint) TXT \(desc.value)") 93 | } 94 | } 95 | print("\n =====> CREATE DNS CHALLENGES!!\n") 96 | 97 | try await Task.sleep(for: .seconds(20)) 98 | 99 | let failed = try await acme.orders.validateChallenges(from: order, preferring: .dns) 100 | guard failed.count == 0 else { 101 | fatalError("Some validations failed! \(failed)") 102 | } 103 | try await acme.orders.refresh(&order) 104 | print("\n => order: \(toJson(order))") 105 | 106 | let (key, _, finalized) = try await acme.orders.finalizeWithEcdsa(order: order, domains: domains) 107 | let certs = try await acme.certificates.download(for: finalized) 108 | try certs.joined(separator: "\n").write(to: URL(fileURLWithPath: "cert.pem"), atomically: true, encoding: .utf8) 109 | 110 | try key.serializeAsPEM().pemString.write(to: URL(fileURLWithPath: "key.pem"), atomically: true, encoding: .utf8) 111 | } 112 | catch(let error) { 113 | print("\n•••• BOOM! \(error)") 114 | throw error 115 | } 116 | } 117 | 118 | private func toJson(_ value: T) -> String { 119 | let encoder = JSONEncoder() 120 | encoder.outputFormatting = .prettyPrinted 121 | let data = try! encoder.encode(value) 122 | return String(decoding: data, as: UTF8.self) 123 | } 124 | 125 | } 126 | -------------------------------------------------------------------------------- /Examples/acme-da/AcmeDA.swift: -------------------------------------------------------------------------------- 1 | // The Swift Programming Language 2 | // https://docs.swift.org/swift-book 3 | 4 | import AcmeSwift 5 | import ArgumentParser 6 | import AsyncHTTPClient 7 | import Foundation 8 | import Logging 9 | import NIOSSL 10 | import X509 11 | 12 | let processName = ProcessInfo.processInfo.processName 13 | 14 | @main 15 | struct AcmeDA: AsyncParsableCommand { 16 | static var configuration: CommandConfiguration { 17 | CommandConfiguration( 18 | abstract: "An example of usage of ACME-DA with step-ca.", 19 | usage: "STTY=-icanon \(processName) --cacert ", 20 | discussion: """ 21 | To be able to run this example, we need to use a key that can be attested, 22 | "step-ca" [1], for example, supports attestation using YubiKey 5 Series. 23 | 24 | To configure "step-ca" with device-attest-01 support, you need to create an ACME 25 | provisioner with the device-attest-01 challenge enabled. In the ca.json the 26 | provisioner looks like this: 27 | 28 | { 29 | "type": "ACME", 30 | "name": "attestation", 31 | "challenges": [ "device-attest-01" ] 32 | } 33 | 34 | After configuring "step-ca" the first thing that we need is to create a key in 35 | one of the YubiKey slots. We're picking 82 in this example. To do this, we will 36 | use "step" [2] with the "step-kms-plugin" [3], and we will run the following: 37 | 38 | step kms create "yubikey:slot-id=82?pin-value=123456" 39 | 40 | Then we need to create a CSR signed by this new key. This CSR must include the 41 | serial number in the Permanent Identifier Subject Alternative Name extension. 42 | The serial number of a YubiKey is printed on the key, but it is also available 43 | in an attestation certificate. You can see it running: 44 | 45 | step kms attest "yubikey:slot-id=82?pin-value=123456" | \ 46 | step certificate inspect 47 | 48 | To add the permanent identifier, we will need to use the following template: 49 | 50 | { 51 | "subject": {{ toJson .Subject }}, 52 | "sans": [{ 53 | "type": "permanentIdentifier", 54 | "value": {{ toJson .Subject}} 55 | }] 56 | } 57 | 58 | Having the template in "attestation.tpl", and assuming the serial number is 59 | 123456789, we can get the proper CSR running: 60 | 61 | step certificate create --csr --template attestation.tpl \ 62 | --kms "yubikey:?pin-value=123456" --key "yubikey:slot-id=82" \ 63 | 123456789 att.csr 64 | 65 | With this we can run this program with the new CSR: 66 | 67 | STTY=-icanon \(processName) 123456789 att.csr https://localhost:9000/acme/attestation/directory 68 | 69 | The program will ask you to create an attestation of the ACME Key Authorization, 70 | running: 71 | 72 | echo -n | \ 73 | step kms attest --format step "yubikey:slot-id=82?pin-value=123456" 74 | 75 | Note that because the input that we need to paste is usually more than 1024 76 | characters, the "STTY=-icanon" environment variable is required. 77 | 78 | [1] step-ca - https://github.com/smallstep/certificates 79 | [2] step - https://github.com/smallstep/cli 80 | [3] step-kms-plugin - https://github.com/smallstep/step-kms-plugin 81 | """) 82 | } 83 | 84 | @Argument(help: "The permanent identifier to use.") 85 | public var permanentIdentifier: String 86 | 87 | @Argument(help: "The path of the CSR file to use.") 88 | public var csrFile: String 89 | 90 | @Argument(help: "The URL of the ACME directory to use.") 91 | public var directory: String 92 | 93 | @Option(help: "The path to the CA certificate to verify peer against.") 94 | public var cacert: String 95 | 96 | 97 | public func run() async throws { 98 | if ProcessInfo.processInfo.environment["STTY"] != "-icanon" { 99 | print("Please run this program with the environment variable STTY=-icanon") 100 | return 101 | } 102 | 103 | let logger = Logger.init(label: "acme-da") 104 | 105 | let directoryURL = URL(string: directory)! 106 | let csrFileURL = URL(fileURLWithPath: csrFile) 107 | 108 | // Parse CSR 109 | let csrPem = try String(contentsOf: csrFileURL, encoding: .utf8) 110 | let csr = try CertificateSigningRequest(pemEncoded: csrPem) 111 | 112 | // Initialize HTTP client with optional root 113 | var tlsConfiguration = TLSConfiguration.makeClientConfiguration() 114 | if cacert != "" { 115 | tlsConfiguration.trustRoots = .file(cacert) 116 | } 117 | var config = HTTPClient.Configuration( 118 | certificateVerification: .fullVerification, backgroundActivityLogger: logger) 119 | config.tlsConfiguration = tlsConfiguration 120 | let client = HTTPClient( 121 | eventLoopGroupProvider: .singleton, 122 | configuration: config, 123 | ) 124 | 125 | // Initialize ACME client 126 | let acme = try await AcmeSwift( 127 | client: client, acmeEndpoint: .custom(directoryURL), logger: logger) 128 | defer { try? acme.syncShutdown() } 129 | 130 | // Initialize ACME account 131 | let contacts: [String] = ["mailto:you@example.com"] 132 | let account = try await acme.account.create(contacts: contacts, acceptTOS: true) 133 | try acme.account.use(account) 134 | 135 | var attObj: String = "" 136 | var order = try await acme.orders.create(permanentIdentifier: permanentIdentifier) 137 | for desc in try await acme.orders.describePendingChallenges( 138 | from: order, preferring: .deviceAttest) 139 | { 140 | print("Now you need to sign following keyAuthorization:") 141 | print(desc.value) 142 | print() 143 | print("To do this you can use step-kms-plugin running:") 144 | print( 145 | "echo -n \(desc.value) | step kms attest --format step \"yubikey:slot-id=82?pin-value=123456\"" 146 | ) 147 | print() 148 | print("Please enter the base64 output and press Enter:") 149 | if let str = readLine() { 150 | attObj = str 151 | } else { 152 | print("No input was provided.") 153 | return 154 | } 155 | } 156 | 157 | let payload = acme.orders.createAttestationPayload(attObj: attObj) 158 | let updatedChallenges = try await acme.orders.validateChallenges( 159 | from: order, preferring: .deviceAttest, payload: payload) 160 | if updatedChallenges.count == 0 { 161 | fatalError("Challenged failed") 162 | } 163 | try await acme.orders.refresh(&order) 164 | 165 | let info = try await acme.orders.finalize(order: order, withCsr: csr) 166 | let certs = try await acme.certificates.download(for: info) 167 | print() 168 | for crt in certs { 169 | print(crt) 170 | } 171 | } 172 | } 173 | 174 | struct AcmeAttestationSpec: Codable { 175 | init(attObj: String) { 176 | self.attObj = attObj 177 | } 178 | 179 | var attObj: String 180 | } 181 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AcmeSwift 2 | [![Language](https://img.shields.io/badge/Swift-5.5-brightgreen.svg)](http://swift.org) 3 | [![Platforms](https://img.shields.io/badge/platform-linux--64%20%7C%20osx--64-blue)]() 4 | 5 | This is a **work in progress** Let's Encrypt (ACME v2) client written in Swift. 6 | 7 | It fully uses the Swift concurrency features introduced with Swift 5.5 (`async`/`await`). 8 | 9 | Although it _might_ work with other certificate providers implementing ACMEv2, this has not been tested at all. 10 | 11 | 12 | ## Note 13 | This library doesn't handle any ACME challenge at all by itself. 14 | Publishing the challenge, either by creating DNS record or exposing the value over HTTP, is your full responsibility. 15 | 16 | 17 | ## Installation 18 | ```swift 19 | import PackageDescription 20 | 21 | let package = Package( 22 | dependencies: [ 23 | ... 24 | .package(url: "https://github.com/m-barthelemy/AcmeSwift.git", from: "1.0.0-beta3"), 25 | ], 26 | targets: [ 27 | .target(name: "App", dependencies: [ 28 | ... 29 | .product(name: "AcmeSwift", package: "AcmeSwift") 30 | ]), 31 | ... 32 | ] 33 | ) 34 | ``` 35 | 36 | ## Usage 37 | 38 | Create an instance of the client: 39 | ```swift 40 | import AcmeSwift 41 | 42 | let acme = try await AcmeSwift() 43 | 44 | ``` 45 | 46 | When testing, preferably use the Let's Encrypt staging endpoint: 47 | ```swift 48 | import AcmeSwift 49 | 50 | let acme = try await AcmeSwift(acmeEndpoint: .letsEncryptStaging) 51 | 52 | ``` 53 | 54 |
55 | 56 | 57 | ### Account 58 | 59 | - Create a new Let's Encrypt account: 60 | 61 | ```swift 62 | let account = acme.account.create(contacts: ["my.email@domain.com"], validateTOS: true) 63 | ``` 64 | 65 | The information returned by this method is an `AcmeAccountInfo` object that can be directly reused for authentication. 66 | For example, you can encode it to JSON, save it somewhere and then decode it in order to log into your account later. 67 | 68 | > [!WARNING] 69 | > This Account information contains a private key and as such, **must** be stored securely. 70 | 71 | 72 |
73 | 74 | - Reuse a previously created account: 75 | 76 | Option 1: Directly use the object returned by `account.create(...)` 77 | ```swift 78 | try acme.account.use(account) 79 | ``` 80 | 81 | Option 2: Pass credentials "manually" 82 | ```swift 83 | let credentials = try AccountCredentials(contacts: ["my.email@domain.tld"], pemKey: "private key in PEM format") 84 | try acme.account.use(credentials) 85 | ``` 86 | 87 | If you created your account using AcmeSwift, the private key in PEM format is stored into the `AccountInfo.privateKeyPem` property. 88 | 89 |
90 | 91 | - Deactivate an existing account: 92 | 93 | > [!CAUTION] 94 | > Only use this if you are absolutely certain that the account needs to be permanently deactivated. There is no going back! 95 | 96 | ```swift 97 | 98 | try await acme.account.deactivate() 99 | ``` 100 | 101 |
102 | 103 | 104 | ### Orders (certificate requests) 105 | 106 | Fetch an Order by its URL: 107 | ```swift 108 | let latest = try await acme.orders.get(url: order.url!) 109 | ``` 110 | 111 |
112 | 113 | 114 | Refresh an Order instance with latest information from the server: 115 | ```swift 116 | try await acme.orders.refresh(&order) 117 | ``` 118 | 119 |
120 | 121 | 122 | Create an Order for a new certificate: 123 | ```swift 124 | 125 | let order = try await acme.orders.create(domains: ["mydomain.com", "www.mydomain.com"]) 126 | ``` 127 | 128 |
129 | 130 | Get the Order authorizations and challenges: 131 | ```swift 132 | let authorizations = try await acme.orders.getAuthorizations(from: order) 133 | ``` 134 | 135 |
136 | 137 | You will need to publish the challenges. AcmeSwift provides a way to list the pending HTTP or DNS challenges: 138 | ```swift 139 | let challengeDescs = try await acme.orders.describePendingChallenges(from: order, preferring: .http) 140 | for desc in challengeDescs { 141 | if desc.type == .http { 142 | print("\n • The URL \(desc.endpoint) needs to return \(desc.value)") 143 | } 144 | else if desc.type == .dns { 145 | print("\n • Create the following DNS record: \(desc.endpoint) TXT \(desc.value)") 146 | } 147 | } 148 | ``` 149 | Achieving this depends on your DNS provider and/or web hosting solution and is outside the scope of AcmeSwift. 150 | > 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. 151 | Let's Encrypt only allows DNS validation for wildcard certificates. 152 | 153 |
154 | 155 | Once the challenges are published, we can ask Let's Encrypt to validate them: 156 | ```swift 157 | let updatedChallenges = try await acme.orders.validateChallenges(from: order, preferring: .http) 158 | ``` 159 | 160 |
161 | 162 | Once all the authorizations/challenges are valid, we can finalize the Order by sending the CSR in PEM format. 163 | 164 | If you already have a CSR: 165 | ```swift 166 | let finalizedOrder = try await acme.orders.finalize(order: order, withPemCsr: "...") 167 | ``` 168 | 169 | 170 | If you want AcmeSwift to generate one for you: 171 | ```swift 172 | // ECDSA key and certificate 173 | let (privateKey, csr, finalizedOrder) = try await acme.orders.finalizeWithEcdsa(order: order, domains: ["mydomain.com", "www.mydomain.com"]) 174 | // .. or, good old RSA 175 | let (privateKey, csr, finalizedOrder) = try await acme.orders.finalizeWithRsa(order: order, domains: ["mydomain.com", "www.mydomain.com"]) 176 | 177 | // You can access the private key used to generate the CSR (and to use once you get the certificate) 178 | print("\n• Private key: \(try privateKey.serializeAsPEM().pemString)") 179 | ``` 180 | 181 |
182 | 183 | > [!NOTE] 184 | > The CSR must contain all the DNS names requested by the Order in its SAN (subjectAltName) field. 185 | 186 | 187 |
188 | 189 | ### Certificates 190 | 191 | - Download a certificate: 192 | 193 | > This assumes that the corresponding Order has been finalized successfully, meaning that the Order `status` field is `valid`. 194 | 195 | ```swift 196 | let certs = try await acme.certificates.download(for: finalizedOrder) 197 | for var cert in certs { 198 | print("\n • cert: \(cert)") 199 | } 200 | ``` 201 | 202 | This return a list of PEM-encoded certificates. The first item is the actual certificate for the requested domains. 203 | The following items are the other certificates required to establish the full certification chain (issuing CA, root CA...). 204 | 205 | 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: 206 | ```swift 207 | try certs.joined(separator: "\n") 208 | .write(to: URL(fileURLWithPath: "cert.pem"), atomically: true, encoding: .utf8) 209 | ``` 210 | 211 |
212 | 213 | - Revoke a certificate: 214 | ```swift 215 | try await acme.certificates.revoke(certificatePem: "....") 216 | ``` 217 | 218 | #### Validating Existing Certificates 219 | 220 | 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: 221 | 222 | ```swift 223 | import NIOSSL 224 | 225 | let certURL = URL(fileURLWithPath: "cert.pem").absoluteURL 226 | let domains = ["*.ponies.com", "ponies.com"] 227 | logger.notice("Refreshing certificate for \(domains.joined(separator: ", "))") 228 | 229 | do { 230 | let existingCerts = try NIOSSLCertificate.fromPEMFile(certURL.path(percentEncoded: false)) 231 | 232 | logger.notice("Found existing certificates: \(existingCerts)") 233 | if let certificate = existingCerts.first { 234 | let expirationDate = Date(timeIntervalSince1970: TimeInterval(certificate.notValidAfter)) 235 | 236 | /// Get the names gregistered in the current certificate to see if they changed 237 | let allNames = Set(certificate._subjectAlternativeNames().map { name -> String? in 238 | guard case .dnsName = name.nameType else { return nil } 239 | return String(decoding: name.contents, as: UTF8.self) 240 | }.compactMap { $0 }) 241 | 242 | /// If the expiration date is more than 2 months away and contains all the domains we are interested in, stop renewing. 243 | if expirationDate.timeIntervalSinceNow > 60*24*60*60 && allNames.isSuperset(of: domains) { 244 | logger.notice("Certificate for \(domains.joined(separator: ", ")) still valid. Expires on \(expirationDate). Renewing on \(expirationDate.advanced(by: -30*24*60*60))") 245 | return 246 | } 247 | } 248 | } catch { 249 | // Catch any errors here to log them, but otherwise continue 250 | logger.notice("An issue occured loading existing certificates: \(error)") 251 | } 252 | 253 | // ... Continue renewing certificate 254 | ``` 255 | 256 | ## Example 257 | 258 | Let's suppose that we own the `ponies.com` domain and that we want a wildcard certificate for it. 259 | We also assume that we have an existing Let's Encrypt account. 260 | 261 | ```swift 262 | import AcmeSwift 263 | 264 | // Create the client and load Let's Encrypt credentials 265 | let acme = try await AcmeSwift() 266 | let accountKey = try String(contentsOf: URL(fileURLWithPath: "letsEncryptAccountKey.pem"), encoding: .utf8) 267 | let credentials = try AccountCredentials(contacts: ["email@domain.tld"], pemKey: accountKey) 268 | try acme.account.use(credentials) 269 | 270 | let domains: [String] = ["*.ponies.com", "ponies.com"] 271 | 272 | // Create a certificate order for *.ponies.com 273 | let order = try await acme.orders.create(domains: domains) 274 | 275 | // ... after that, now we can fetch the challenges we need to complete 276 | for desc in try await acme.orders.describePendingChallenges(from: order, preferring: .dns) { 277 | if desc.type == .http { 278 | print("\n • The URL \(desc.endpoint) needs to return \(desc.value)") 279 | } 280 | else if desc.type == .dns { 281 | print("\n • Create the following DNS record: \(desc.endpoint) TXT \(desc.value)") 282 | } 283 | } 284 | 285 | // At this point, we could programmatically create the challenge DNS records using our DNS provider's API 286 | [.... publish the DNS challenge records ....] 287 | 288 | 289 | // Assuming the challenges have been published, we can now ask Let's Encrypt to validate them. 290 | // 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 291 | var remainingChallenges = try await acme.orders.validateChallenges(from: order, preferring: .dns) 292 | // 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). 293 | for timeout in [5, 10, 10, 10, 30] { 294 | guard !remainingChallenges.isEmpty else { break } 295 | try await Task.sleep(for: .seconds(timeout)) 296 | remainingChallenges = try await acme.orders.validateChallenges(from: order, preferring: .dns) 297 | } 298 | // Give up if we still haven't satisfied the request: 299 | guard remainingChallenges.isEmpty else { 300 | struct ChallengeValidationError: Error {} 301 | throw ChallengeValidationError() 302 | } 303 | 304 | // Let's create a private key and CSR using the rudimentary feature provided by AcmeSwift 305 | // If the validation didn't throw any error, we can now send our Certificate Signing Request... 306 | let (privateKey, csr, finalized) = try await acme.orders.finalizeWithRsa(order: order, domains: domains) 307 | 308 | // ... and the certificate is ready to download! 309 | let certs = try await acme.certificates.download(for: finalized) 310 | 311 | // Let's save the full certificates chain to a file 312 | try certs.joined(separator: "\n").write(to: URL(fileURLWithPath: "cert.pem"), atomically: true, encoding: .utf8) 313 | 314 | // Now we also need to export the private key, encoded as PEM 315 | // If your server doesn't accept it, append a line return to it. 316 | try privateKey.serializeAsPEM().pemString.write(to: URL(fileURLWithPath: "key.pem"), atomically: true, encoding: .utf8) 317 | ``` 318 | 319 | 320 | 321 | ## Credits 322 | Part of the CSR feature is inspired by and/or taken from the excellent Shield project (https://github.com/outfoxx/Shield) 323 | -------------------------------------------------------------------------------- /Sources/AcmeSwift/APIs/AcmeSwift+Orders.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Crypto 3 | import _CryptoExtras 4 | import JWTKit 5 | import SwiftASN1 6 | import X509 7 | 8 | extension AcmeSwift { 9 | 10 | /// APIs related to ACMEv2 orders management. 11 | public var orders: OrdersAPI { 12 | .init(client: self) 13 | } 14 | 15 | public struct OrdersAPI { 16 | fileprivate var client: AcmeSwift 17 | 18 | 19 | /// List pending orders for the Account. 20 | /// 21 | /// - Warning: No ACMEv2 provider seems to have this actually implemented. Doesn't work with Let's Encrypt. 22 | public func list() async throws -> [URL] { 23 | try await self.client.ensureLoggedIn() 24 | 25 | let account = try await self.client.account.get() 26 | var orders: [URL] = [] 27 | if let ordersURL = account.orders { 28 | let ep = ListOrdersEndpoint(url: ordersURL) 29 | let (orderInfo, _) = try await self.client.run(ep, privateKey: self.client.login!.key, accountURL: client.accountURL!) 30 | orders = orderInfo.orders 31 | } 32 | return orders 33 | } 34 | 35 | 36 | /// Fetches the latest status of an existing Order. 37 | /// - Parameters: 38 | /// - url: The URL of the Order. 39 | public func get(url: URL) async throws -> AcmeOrderInfo { 40 | try await self.client.ensureLoggedIn() 41 | 42 | let ep = GetOrderEndpoint(url: url) 43 | var (info, headers) = try await self.client.run(ep, privateKey: self.client.login!.key, accountURL: client.accountURL!) 44 | info.url = URL(string: headers["Location"].first ?? "") 45 | return info 46 | } 47 | 48 | /// Fetches the latest information about an existing Order. 49 | /// - Parameters: 50 | /// - order: an existing Order object to be updated. 51 | public func refresh(_ order: inout AcmeOrderInfo) async throws { 52 | try await self.client.ensureLoggedIn() 53 | 54 | guard let url = order.url else { 55 | throw AcmeError.noResourceUrl 56 | } 57 | order = try await get(url: url) 58 | } 59 | 60 | 61 | /// Creates an Order for obtaining a new certificate. 62 | /// - Parameters: 63 | /// - domains: The domains for which we want to create a certificate. Example: `["*.mydomain.com", "mydomain.com"]`. 64 | /// - notBefore: Minimum Date when the future certificate will start being valid. **Note:** Let's Encrypt does not support setting this. 65 | /// - notAfter: Desired expiration date of the future certificate. **Note:** Let's Encrypt does not support setting this. 66 | /// - Throws: Errors that can occur when executing the request. 67 | /// - Returns: Returns the `Account`. 68 | public func create(domains: [String], notBefore: Date? = nil, notAfter: Date? = nil) async throws -> AcmeOrderInfo { 69 | try await self.client.ensureLoggedIn() 70 | 71 | var identifiers: [AcmeOrderSpec.Identifier] = [] 72 | for domain in domains { 73 | identifiers.append(.init(value: domain)) 74 | } 75 | let ep = CreateOrderEndpoint( 76 | directory: self.client.directory, 77 | spec: .init( 78 | identifiers: identifiers, 79 | notBefore: notBefore, 80 | notAfter: notAfter 81 | ) 82 | ) 83 | 84 | var (info, headers) = try await self.client.run(ep, privateKey: self.client.login!.key, accountURL: client.accountURL!) 85 | info.url = URL(string: headers["Location"].first ?? "") 86 | return info 87 | } 88 | 89 | /// Creates an Order for obtaining a new certificate. 90 | /// - Parameters: 91 | /// - permanentIdentifier: The hardware permanent identifier for which we want to create a certificate. Example: `"123456789" or "urn:ek:sha256:p4y5IVB2fMIpdusxon+MUwYU4o/7/tgvKB9fyu8Idko="`. 92 | /// - notBefore: Minimum Date when the future certificate will start being valid. **Note:** Let's Encrypt does not support setting this. 93 | /// - notAfter: Desired expiration date of the future certificate. **Note:** Let's Encrypt does not support setting this. 94 | /// - Throws: Errors that can occur when executing the request. 95 | /// - Returns: Returns the `Account`. 96 | public func create(permanentIdentifier: String, notBefore: Date? = nil, notAfter: Date? = nil) async throws -> AcmeOrderInfo { 97 | try await self.client.ensureLoggedIn() 98 | 99 | var identifiers: [AcmeOrderSpec.Identifier] = [] 100 | identifiers.append(.init(type: .permanentIdentifier, value: permanentIdentifier)) 101 | let ep = CreateOrderEndpoint( 102 | directory: self.client.directory, 103 | spec: .init( 104 | identifiers: identifiers, 105 | notBefore: notBefore, 106 | notAfter: notAfter 107 | ) 108 | ) 109 | 110 | var (info, headers) = try await self.client.run(ep, privateKey: self.client.login!.key, accountURL: client.accountURL!) 111 | info.url = URL(string: headers["Location"].first ?? "") 112 | return info 113 | } 114 | 115 | /// Creates the attestation payload used device-attest-01 challenges 116 | /// - Parameters: 117 | /// - attObj: the base64url string with the WebAuthn attestation object. 118 | /// - Returns: returns the `AcmeAttestationSpec`. 119 | public func createAttestationPayload(attObj: String) -> AcmeAttestationSpec { 120 | return AcmeAttestationSpec(attObj: attObj) 121 | } 122 | 123 | /// Finalizes an Order and send the CSR. 124 | /// - Parameters: 125 | /// - order: The `AcmeOrderInfo` returned by the call to `.create()`. 126 | /// - withPemCsr: The CSR (Certificate Signing Request) **in PEM format**. 127 | /// - Throws: Errors that can occur when executing the request. 128 | /// - Returns: Returns the `Account`. 129 | public func finalize(order: AcmeOrderInfo, withPemCsr: String) async throws -> AcmeOrderInfo { 130 | try await self.client.ensureLoggedIn() 131 | 132 | let csrBytes = withPemCsr.pemToData() 133 | let pemStr = csrBytes.toBase64UrlString() 134 | let ep = FinalizeOrderEndpoint(orderURL: order.finalize, spec: .init(csr: pemStr)) 135 | 136 | let (info, _) = try await self.client.run(ep, privateKey: self.client.login!.key, accountURL: client.accountURL!) 137 | return info 138 | } 139 | 140 | /// Finalizes an Order and send the ECDSA CSR. 141 | /// - Parameters: 142 | /// - order: The `AcmeOrderInfo` returned by the call to `.create()`. 143 | /// - subject: Subject of certificate. 144 | /// - domains: Domains for certificate. 145 | /// - Throws: Errors that can occur when executing the request. 146 | /// - Returns: Returns `Certificate.PrivateKey`, `CertificateSigningRequest` and `Account`. 147 | public func finalizeWithEcdsa(order: AcmeOrderInfo, subject: String? = nil, domains: [String]) async throws -> (Certificate.PrivateKey, CertificateSigningRequest, AcmeOrderInfo) { 148 | guard domains.count > 0 else { 149 | throw AcmeError.noDomains("At least 1 DNS name is required") 150 | } 151 | 152 | let p256 = P256.Signing.PrivateKey() 153 | let privateKey = Certificate.PrivateKey(p256) 154 | let commonName = subject ?? domains[0] 155 | let name = try DistinguishedName { 156 | CommonName(commonName) 157 | } 158 | let extensions = try Certificate.Extensions { 159 | SubjectAlternativeNames(domains.map({ GeneralName.dnsName($0) })) 160 | } 161 | let extensionRequest = ExtensionRequest(extensions: extensions) 162 | let attributes = try CertificateSigningRequest.Attributes( 163 | [.init(extensionRequest)] 164 | ) 165 | let csr = try CertificateSigningRequest( 166 | version: .v1, 167 | subject: name, 168 | privateKey: privateKey, 169 | attributes: attributes, 170 | signatureAlgorithm: .ecdsaWithSHA256 171 | ) 172 | 173 | let account = try await finalize(order: order, withCsr: csr) 174 | 175 | return (privateKey, csr, account) 176 | } 177 | 178 | /// Finalizes an Order and send the RSA CSR. 179 | /// - Parameters: 180 | /// - order: The `AcmeOrderInfo` returned by the call to `.create()`. 181 | /// - subject: Subject of certificate. 182 | /// - domains: Domains for certificate. 183 | /// - Throws: Errors that can occur when executing the request. 184 | /// - Returns: Returns `Certificate.PrivateKey`, `CertificateSigningRequest` and `Account`. 185 | public func finalizeWithRsa(order: AcmeOrderInfo, subject: String? = nil, domains: [String]) async throws -> (Certificate.PrivateKey, CertificateSigningRequest, AcmeOrderInfo) { 186 | guard domains.count > 0 else { 187 | throw AcmeError.noDomains("At least 1 DNS name is required") 188 | } 189 | 190 | let p256 = try _CryptoExtras._RSA.Signing.PrivateKey(keySize: .bits2048) 191 | let privateKey = Certificate.PrivateKey(p256) 192 | let commonName = subject ?? domains[0] 193 | let name = try DistinguishedName { 194 | CommonName(commonName) 195 | } 196 | let extensions = try Certificate.Extensions { 197 | SubjectAlternativeNames(domains.map({ GeneralName.dnsName($0) })) 198 | } 199 | let extensionRequest = ExtensionRequest(extensions: extensions) 200 | let attributes = try CertificateSigningRequest.Attributes( 201 | [.init(extensionRequest)] 202 | ) 203 | let csr = try CertificateSigningRequest( 204 | version: .v1, 205 | subject: name, 206 | privateKey: privateKey, 207 | attributes: attributes, 208 | signatureAlgorithm: .sha256WithRSAEncryption 209 | ) 210 | 211 | let account = try await finalize(order: order, withCsr: csr) 212 | 213 | return (privateKey, csr, account) 214 | } 215 | 216 | /// Finalizes an Order and send the CSR. 217 | /// - Parameters: 218 | /// - order: The `AcmeOrderInfo` returned by the call to `.create()`. 219 | /// - withCsr: An instance of an `Certificate`. 220 | /// - Throws: Errors that can occur when executing the request. 221 | /// - Returns: Returns the `Account`. 222 | public func finalize(order: AcmeOrderInfo, withCsr csr: CertificateSigningRequest) async throws -> AcmeOrderInfo { 223 | try await self.client.ensureLoggedIn() 224 | 225 | var serializer = DER.Serializer() 226 | try serializer.serialize(csr) 227 | 228 | let csrBytes = Data(serializer.serializedBytes) 229 | let pemStr = csrBytes.toBase64UrlString() 230 | let ep = FinalizeOrderEndpoint(orderURL: order.finalize, spec: .init(csr: pemStr)) 231 | 232 | let (info, _) = try await self.client.run(ep, privateKey: self.client.login!.key, accountURL: client.accountURL!) 233 | return info 234 | } 235 | 236 | /// Get the authorizations containing the challenges for this Order. 237 | /// - Parameters: 238 | /// - from: The `AcmeOrderInfo` representing the certificates Order. 239 | /// - Throws: Errors that can occur when executing the request. 240 | /// - Returns: Returns the list of `AcmeAuthorization` for this Order. 241 | public func getAuthorizations(from order: AcmeOrderInfo) async throws -> [AcmeAuthorization] { 242 | try await self.client.ensureLoggedIn() 243 | 244 | var authorizations: [AcmeAuthorization] = [] 245 | for auth in order.authorizations { 246 | let ep = GetAuthorizationEndpoint(url: auth) 247 | let (authorization, _) = try await self.client.run(ep, privateKey: self.client.login!.key, accountURL: client.accountURL!) 248 | authorizations.append(authorization) 249 | } 250 | return authorizations 251 | } 252 | 253 | /// Gets a user-friendly list of the Order challenges that need to be published. 254 | /// 255 | /// These are the challenges that have a `pending` or `invalid` status. 256 | /// 257 | /// - Note: ALPN challenges are not returned. 258 | /// - Parameters: 259 | /// - from: The `AcmeOrderInfo` representing the certificates Order. 260 | /// - preferring: Your preferred challenge validation method. Note: when requesting a wildcard certificate, a challenge will have to be published over DNS regardless of your preferred method. 261 | /// - Throws: Errors that can occur when executing the request. 262 | /// - Returns: Returns a list of `ChallengeDescription` items that explain what information has to be published in order to validate the challenges. 263 | public func describePendingChallenges(from order: AcmeOrderInfo, preferring: AcmeAuthorization.Challenge.ChallengeType) async throws -> [ChallengeDescription] { 264 | 265 | let accountThumbprint = try getAccountThumbprint() 266 | let authorizations = try await getAuthorizations(from: order) 267 | var descs: [ChallengeDescription] = [] 268 | for auth in authorizations where auth.status == .pending { 269 | for challenge in auth.challenges where (challenge.type == preferring || auth.wildcard == true) && (challenge.status == .pending || challenge.status == .invalid) { 270 | let digest = "\(challenge.token).\(accountThumbprint.base64URLString)" 271 | 272 | if challenge.type == .dns { 273 | let challengeDesc = ChallengeDescription( 274 | type: challenge.type, 275 | endpoint: "_acme-challenge.\(auth.identifier.value)", 276 | value: Crypto.SHA256.hash(data: Array(digest.utf8)).base64URLString, 277 | url: challenge.url 278 | ) 279 | descs.append(challengeDesc) 280 | } 281 | else if challenge.type == .http { 282 | let challengeDesc = ChallengeDescription( 283 | type: challenge.type, 284 | endpoint: "http://\(auth.identifier.value)/.well-known/acme-challenge/\(challenge.token)", 285 | value: digest, 286 | url: challenge.url 287 | ) 288 | descs.append(challengeDesc) 289 | } else if challenge.type == .deviceAttest { 290 | let challengeDesc = ChallengeDescription( 291 | type: challenge.type, 292 | endpoint: "", 293 | value: digest, 294 | url: challenge.url 295 | ) 296 | descs.append(challengeDesc) 297 | } 298 | } 299 | } 300 | return descs 301 | } 302 | 303 | /// Call this to get the ACMEv2 provider to verify the pending challenges once you have published them over HTTP or DNS. 304 | /// 305 | /// Request challenges to be validated only after they have been published. 306 | /// - For DNS-based challenges, repeatedly wait and poll until the order expires or becomes invalid, or a timeout you define has been passed. 307 | /// - For HTTP-based challenges, request verification once, then wait for the endpoints to have been called before requesting again. Similarly, repeat the process until the order expires or becomes invalid, or a timeout you define has been passed. 308 | /// 309 | /// - SeeAlso: [ACME Section 7.5.1 - Responding to Challenges](https://www.rfc-editor.org/rfc/rfc8555.html#section-7.5.1) 310 | /// 311 | /// - Parameters: 312 | /// - from: The `AcmeOrderInfo` representing the certificates Order. 313 | /// - preferring: Your preferred challenge validation method. Note: when requesting a wildcard certificate, a challenge will have to be published over DNS regardless of your preferred method.. 314 | /// - Throws: Errors that can occur when executing the request. 315 | /// - Returns: Returns a list of `AcmeAuthorization` containing the challenges that were not validated yet and may be in the process of being validated, or have failed. 316 | @discardableResult 317 | public func validateChallenges(from order: AcmeOrderInfo, preferring: AcmeAuthorization.Challenge.ChallengeType, payload: Codable? = nil) async throws -> [AcmeAuthorization.Challenge] { 318 | // get pending challenges 319 | let pendingChallenges = try await describePendingChallenges(from: order, preferring: preferring) 320 | var updatedChallenges: [AcmeAuthorization.Challenge] = [] 321 | for challengeDesc in pendingChallenges { 322 | if challengeDesc.type == .deviceAttest { 323 | updatedChallenges.append(try await validateAttestationChallenge(url: challengeDesc.url, payload: payload as! AcmeAttestationSpec)) 324 | } else { 325 | updatedChallenges.append(try await validateChallenge(url: challengeDesc.url)) 326 | } 327 | } 328 | return updatedChallenges 329 | } 330 | 331 | /// Validates a single Challenge. 332 | public func validateChallenge(challenge: AcmeAuthorization.Challenge) async throws -> AcmeAuthorization.Challenge { 333 | try await self.client.ensureLoggedIn() 334 | 335 | let ep = ValidateChallengeEndpoint(challengeURL: challenge.url) 336 | let (updatedChallenge, _) = try await self.client.run(ep, privateKey: self.client.login!.key, accountURL: client.accountURL!) 337 | return updatedChallenge 338 | } 339 | 340 | 341 | /// Poll ACMEv2 provider for order status and return when challenges have been processed. 342 | /// - Parameters: 343 | /// - for: The `AcmeOrderInfo` representing the certificates Order. 344 | /// - timeout: Your preferred challenge validation method. Note: when requesting a wildcard certificate, a challenge will have to be published over DNS regardless of your preferred method.. 345 | /// - Throws: Errors that can occur when executing the request. 346 | /// - Returns: Returns a list of `AcmeAuthorization` that are not is a `valid` status. 347 | /*public func wait(`for` order: AcmeOrderInfo, timeout: TimeInterval) async throws -> [AcmeAuthorization] { 348 | let startDate = Date() 349 | let stopDate = startDate.addingTimeInterval(timeout) 350 | repeat { 351 | let authorizations = try await getAuthorizations(from: order) 352 | let pending = authorizations.filter({$0.status == .pending}) 353 | if pending.count == 0 { break } // nothing to wait for 354 | try await Task.sleep(nanoseconds: 5_000_000_000) 355 | } while stopDate > Date() 356 | 357 | let notReady = try await getAuthorizations(from: order) 358 | .filter({$0.status != .valid}) 359 | return notReady 360 | }*/ 361 | 362 | private func validateChallenge(url: URL) async throws -> AcmeAuthorization.Challenge { 363 | try await self.client.ensureLoggedIn() 364 | 365 | let ep = ValidateChallengeEndpoint(challengeURL: url) 366 | let (updatedChallenge, _) = try await self.client.run(ep, privateKey: self.client.login!.key, accountURL: client.accountURL!) 367 | return updatedChallenge 368 | } 369 | 370 | private func validateAttestationChallenge(url: URL, payload: AcmeAttestationSpec) async throws -> AcmeAuthorization.Challenge { 371 | try await self.client.ensureLoggedIn() 372 | 373 | let ep = ValidateAttestationChallengeEndpoint(challengeURL: url, spec: payload) 374 | let (updatedChallenge, _) = try await self.client.run(ep, privateKey: self.client.login!.key, accountURL: client.accountURL!) 375 | return updatedChallenge 376 | } 377 | 378 | /// Return the SHA256 digest of the ACMEv2 account public key's JWK JSON. 379 | /// 380 | /// This value has to be present in an HTTP challenge value. 381 | private func getAccountThumbprint() throws -> SHA256Digest { 382 | guard let login = self.client.login else { 383 | throw AcmeError.mustBeAuthenticated("\(AcmeSwift.self).init() must be called with an \(AccountCredentials.self)") 384 | } 385 | 386 | let publicKey = login.key.publicKey.rawRepresentation 387 | 388 | let jwk = JWK.ecdsa( 389 | nil, 390 | identifier: nil, 391 | x: publicKey.prefix(publicKey.count/2).toBase64UrlString(), 392 | y: publicKey.suffix(publicKey.count/2).toBase64UrlString(), 393 | curve: .p256 394 | ) 395 | let encoder = JSONEncoder() 396 | encoder.outputFormatting = .sortedKeys 397 | return Crypto.SHA256.hash(data: try encoder.encode(jwk)) 398 | } 399 | } 400 | } 401 | 402 | extension SHA256Digest { 403 | var base64URLString: String { 404 | Data(self).toBase64UrlString() 405 | } 406 | } 407 | --------------------------------------------------------------------------------