UNPKG

@small-tech/auto-encrypt

Version:

Automatically provisions and renews Let’s Encrypt TLS certificates on Node.js https servers (including Kitten, Polka, Express.js, etc.)

224 lines (186 loc) 9.15 kB
/////////////////////////////////////////////////////////////////////////////// // // Order // // (Please use async factory method Order.getInstanceAsync() to instantiate.) // // Represents a Let’s Encrypt order. // See RFC 8555 § 7.1.3 (Order Objects), 7.4 (Applying for Certificate Issuance) // // Copyright © 2020 Aral Balkan, Small Technology Foundation. // License: AGPLv3 or later. // //////////////////////////////////////////////////////////////////////////////// import fsPromises from 'fs/promises' import Authorisation from './Authorisation.js' import HttpServer from './HttpServer.js' import CertificateIdentity from './identities/CertificateIdentity.js' import acmeCsr from './acmeCsr.js' import asyncForEach from './util/async-foreach.js' import log from './util/log.js' import NewOrderRequest from './acme-requests/NewOrderRequest.js' import FinaliseOrderRequest from './acme-requests/FinaliseOrderRequest.js' import CheckOrderStatusRequest from './acme-requests/CheckOrderStatusRequest.js' import CertificateRequest from './acme-requests/CertificateRequest.js' import Throws from './util/Throws.js' import waitFor from './util/waitFor.js' const throws = new Throws() export default class Order { #data = null #headers = null #location = null #order = null #certificate = null #certificateIdentity = null #authorisations = [] // // Factory method (async). // static isBeingInstantiatedViaFactoryMethod = false static async getInstanceAsync (configuration = throws.ifMissing(), accountIdentity = throws.ifMissing()) { Order.isBeingInstantiatedViaFactoryMethod = true const instance = Order.instance = new Order(configuration, accountIdentity) await Order.instance.init() return instance } get certificate () { return this.#certificate } get certificateIdentity () { return this.#certificateIdentity } get authorisations () { return this.#authorisations } get finaliseUrl () { return this.#order ? this.#order.finalize : null } get identifiers () { return this.#order ? this.#order.identifiers : null } get status () { return this.#order ? this.#order.status : null } get expires () { return this.#order ? this.#order.expires : null } get certificateUrl () { return this.#order ? this.#order.certificate : null } get headers () { return this.#headers } // // Private. // get data () { return this.#data } set data (value) { this.#data = value // It seems that Let’s Encrypt are no longer sending the location // back on status checks, only on order finalisation, so let’s\ // make sure we don’t accidentally. if (this.#data.headers.location !== undefined) this.#location = this.#data.headers.location this.#headers = this.#data.headers this.#order = this.#data.body } set certificate (value) { throws.error(Symbol.for('ReadOnlyAccessorError', 'certificate')) } set certificateIdentity (value) { throws.error(Symbol.for('ReadOnlyAccessorError', 'certificateIdentity')) } set authorisations (value) { throws.error(Symbol.for('ReadOnlyAccessorError', 'authorisations')) } set finaliseUrl (value) { throws.error(Symbol.for('ReadOnlyAccessorError', 'finaliseUrl')) } set identifiers (value) { throws.error(Symbol.for('ReadOnlyAccessorError', 'identifiers')) } set authorisations (value) { throws.error(Symbol.for('ReadOnlyAccessorError', 'authorisations')) } set status (value) { throws.error(Symbol.for('ReadOnlyAccessorError', 'status')) } set expires (value) { throws.error(Symbol.for('ReadOnlyAccessorError', 'expires')) } set certificateUrl (value) { throws.error(Symbol.for('ReadOnlyAccessorError', 'certificateUrl')) } set headers (value) { throws.error(Symbol.for('ReadOnlyAccessorError', 'headers')) } // // Private. // /** * Creates an instance of Order. * * @param {Configuration} configuration (Required) Configuration instance. */ constructor (configuration = throws.ifMissing(), accountIdentity = throws.ifMissing()) { // Ensure singleton access. if (Order.isBeingInstantiatedViaFactoryMethod === false) { throw new Error('Order constructor is private. Please instantiate using :await Order.getInstanceAsync().') } this.configuration = configuration this.domains = configuration.domains this.accountIdentity = accountIdentity Order.isBeingInstantiatedViaFactoryMethod = false } async init () { try { this.data = await ((new NewOrderRequest()).execute(this.configuration)) } catch (error) { // TODO: Handle error. throw new Error(error) } this.#authorisations = [] let numberOfAuthorisationsValidated = 0 let numberOfAuthorisationsToValidate = this.domains.length log(` 📈 ❨auto-encrypt❩ Number of authorisations to validate: ${numberOfAuthorisationsToValidate}`) // We’ve got the order back. Download all the authorisations and // create Authorisation instances from them. The Authorisation // instances will handle settings up to answer their challenges themselves. await asyncForEach( this.data.body.authorizations, async authorisationUrl => { // An authorisation only returns when it is validated. // TODO: handle errors. const authorisation = await Authorisation.getInstanceAsync(authorisationUrl, this.accountIdentity) numberOfAuthorisationsValidated++ log(` 📝 ❨auto-encrypt❩ An authorisation was validated for the order! (${numberOfAuthorisationsValidated}/${numberOfAuthorisationsToValidate})`) this.#authorisations.push(authorisation) } ) // At this point, all authorisations have been validated. Now, finalise the order and send the CSR. // “Once the client believes it has fulfilled the server's requirements, // it should send a POST request to the order resource's finalize URL. // The POST body MUST include a CSR.” – RFC 8555 § 7.4 (Applying for Certificate Issuance). log(` 🎊 ❨auto-encrypt❩ All authorisations validated.`) // We no longer need the HTTP server in Challenge Server mode (as place in by the authorisations). // When we turn Challenge Server off, it will start redirecting any HTTP calls its receives to HTTPS. const httpServer = await HttpServer.getSharedInstance() httpServer.challengeServer = false log(` 💃 ❨auto-encrypt❩ Finalising order…`) // Generate and save certificate’s identity (private key). this.#certificateIdentity = new CertificateIdentity(this.configuration) // Generate a Certificate Signing Request in the unique format that ACME expects. const csr = await acmeCsr(this.domains, this.certificateIdentity.key) let numAttempts = 0 while (this.status !== 'valid' && this.status !== 'invalid') { numAttempts++ if (numAttempts > 5) { log(` ❌ ❨auto-encrypt❩ Timed out waiting for order validity. `) break; } try { if (numAttempts === 1) { // Finalise using CSR. log(' 📝 ❨auto-encrypt❩ Finalising using CSR.') this.data = await (new FinaliseOrderRequest()).execute(this.finaliseUrl, csr) } else { // Check for order status. log(' 👀 ❨auto-encrypt❩ Checking for order status.') this.data = await (new CheckOrderStatusRequest()).execute(this.#location) } } catch (error) { // TODO: Handle error. throw new Error(error) } if (this.status === 'valid') { log(' 🎁 ❨auto-encrypt❩ Order is valid.') // Download and cache the certificate. try { const certificateResponse = await ((new CertificateRequest)).execute(this.certificateUrl) this.#certificate = certificateResponse.body } catch (error) { throw new Error(error) } log(' 💅 ❨auto-encrypt❩ Got the certificate.') // Save the certificate. try { await fsPromises.writeFile(this.configuration.certificatePath, this.certificate, 'utf-8') } catch (error) { throw new Error(error) } log(' 💾 ❨auto-encrypt❩ Saved the certificate.') } else { log(` ℹ️ Order is not valid. Current status: (${this.status})`) if (this.status === 'invalid') { // To let renewal attempts naturally retry every day, we let this pass. log(` ❌ ❨auto-encrypt❩ Order is invalid. `) } else { log(` ⏳ ❨auto-encrypt❩ Waiting a second before checking again…`) await waitFor(1000) } } } } }